diff --git a/AmesimModels/component-model-authoring-spec-v1(1).md b/AmesimModels/component-model-authoring-spec-v1(1).md new file mode 100644 index 0000000..d81dd72 --- /dev/null +++ b/AmesimModels/component-model-authoring-spec-v1(1).md @@ -0,0 +1,680 @@ +# 组件模型建模规范 v1 + +状态:已在 `experimental` 临时组件库实施 +适用对象:人工开发者、代码生成工具和 AI 编程助手 +配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md) + +## 1. 文档目标 + +本文档规定一个 Python 仿真元件应如何创建、修改、测试和注册。完成后的模型必须 +同时满足四个使用方: + +1. 求解器能够实例化模型并调用方程。 +2. System XML 能够根据稳定类型找到模型。 +3. React Flow 能够自动显示图标、端口和参数。 +4. 结果页面能够根据结构化元数据展示变量。 + +本文档是模型代码的开发合同。若本文档与当前代码行为不一致,应把它视为缺陷: +先核对实际实现,再在同一次修改中同步代码、测试和文档,禁止让两套规则长期并存。 + +## 2. 开始前先判断任务类型 + +### 2.1 新增公开模型 + +公开模型会出现在前端组件库中,也能被 System XML 创建。必须: + +- 放入某个组件库的分类目录。 +- 实现完整模型契约。 +- 加入该库 `library.py` 的 `models` 清单。 +- 添加目录、契约、方程和最小仿真测试。 + +### 2.2 修改已有公开模型 + +必须先判断改动是否破坏已有工程: + +| 改动 | 版本建议 | 兼容性要求 | +| --- | --- | --- | +| 修复数值实现但不改变契约 | 修订版本 | 旧 XML 和工程继续可用 | +| 新增有默认值的参数或结果 | 次版本 | 旧工程缺少该字段时必须有迁移或默认值 | +| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 | +| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 | +| 删除、改名端口或参数 | 主版本 | 必须设计工程和 XML 迁移 | +| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 | + +### 2.3 新增内部模型 + +仅供固定算例或研究代码使用、不进入前端目录的模型,不加入 `library.py`。这类模型 +应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。 + +当前示例是 +[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。 + +### 2.4 新增物理域 + +仅新增模型类不足以支持新物理域。除了模型,还必须设计: + +- `PortDefinition` 和端口变量。 +- 变量角色与连接规则。 +- 网络兼容性检查。 +- 代数方程和 stream/signal 传播。 +- XML 端口协议。 +- 前端连线兼容规则。 +- 最小闭合系统与求解测试。 + +没有完成这些基础能力时,不得仅通过修改 `domain` 字符串宣称支持新物理域。 + +## 3. 开发前必须读取的文件 + +人工或 AI 在修改模型前,应按顺序读取: + +1. 本文档。 +2. 目标库的 `library.py`。 +3. 同分类中物理行为最接近的现有模型。 +4. [`core/base.py`](../app/simulation/core/base.py)。 +5. [`core/ports.py`](../app/simulation/core/ports.py)。 +6. [`core/metadata.py`](../app/simulation/core/metadata.py)。 +7. [`core/catalog.py`](../app/simulation/core/catalog.py)。 +8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。 +9. 与目标模型最接近的测试。 + +不要只根据文件名、前端图标或旧 XML 猜测模型语义。 + +## 4. 文件位置和命名 + +公开模型放在: + +```text +app/simulation/components///.py +``` + +例如: + +```text +app/simulation/components/experimental/storage/cylinder.py +app/simulation/components/experimental/flow/orifice.py +app/simulation/components/experimental/junctions/tee.py +``` + +规则: + +- 一个公开模型原则上对应一个文件和一个主要模型类。 +- 模块名、`MODEL_TYPE`、端口名和参数名使用稳定机器标识。 +- `MODEL_TYPE` 使用小写 `snake_case`。 +- 参数和结果变量允许保留已有热力学惯例,如 `T0`、`T`、`U`。 +- 中文名称只写入 `label`,不能代替机器标识。 +- 求解器、介质和网络通用逻辑不得复制到模型文件。 + +## 5. 公开模型完整契约 + +每个公开模型类必须在自身类体中显式声明: + +```python +MODEL_TYPE = "example_component" +MODEL_VERSION = "1.0.0" +PORTS = (...) +PARAMETERS = (...) +RESULT_VARIABLES = (...) +DISPLAY = ... +``` + +同时必须实现: + +```python +@classmethod +def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], +) -> Component: + ... +``` + +注册器要求这些字段直接存在于公开模型类中。不要依赖父类隐式提供 +`MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、 +`DISPLAY` 或 `create()`。 + +## 6. 基类选择 + +### 6.1 `AlgebraicComponent` + +适用于没有积分状态、由当前端口变量和参数直接决定残差的元件,例如: + +- 孔板 +- 阀门 +- 阻性管段 +- 理想三通 + +至少实现: + +- 构造函数和端口注册。 +- `create()`。 +- `pressure_flow_equation_residuals()`。 +- 需要传递 stream 变量时实现 `update_stream_outflows()`。 + +### 6.2 `ThermodynamicVolumeComponent` + +适用于包含质量和能量状态的气体容腔,例如: + +- 气瓶 +- 贮箱 +- 有容积的管段 + +至少实现: + +- `get_state_vector()`。 +- `set_state_vector()`。 +- `refresh_thermodynamic_ports()`。 +- `state_derivative_from_ports()`。 +- `pressure_flow_equation_residuals()`。 + +该基类已经提供标准热力学组件结果: + +```text +m, U, p, T, rho, u, h +``` + +除非物理含义不同,不要重新复制这组结果声明。 + +### 6.3 其他基类 + +如果现有基类不能表达模型,应先评估是否缺少一种通用组件能力。不要为了一个模型 +直接把专用判断塞入 `SimulationNetwork` 或求解器。 + +## 7. 端口建模规范 + +当前气动模型使用: + +```python +PortDefinition.pneumatic( + "port_a", + nominal_role="bidirectional", +) +``` + +气动端口包含: + +| 变量 | 角色 | 连接规则 | SI 单位 | +| --- | --- | --- | --- | +| `p` | `effort` | `equal` | `Pa` | +| `m_flow` | `flow` | `sumToZero` | `kg/s` | +| `h_outflow` | `stream` | `streamMix` | `J/kg` | + +必须遵守: + +- `m_flow > 0` 表示质量流入当前组件。 +- `nominal_role` 只用于界面和默认布局,不限制实际流向。 +- 物理连接是非因果的,连接线端点顺序不代表流向。 +- 所有声明端口必须使用 `register_declared_port()` 创建。 +- `DISPLAY.ports` 必须与 `PORTS` 名称集合完全一致。 +- 分支连接使用三通等连接元件,不能让一个物理端口直接连接多条边。 + +禁止: + +- 在模型内部根据画布左右方向判断流向。 +- 为了前端显示另造一套端口名。 +- 把 `port_a` 固定解释为真实入口、把 `port_b` 固定解释为真实出口。 +- 直接绕过端口状态读写其他组件对象。 + +## 8. 参数建模规范 + +所有用户可配置输入必须使用 `ParameterDefinition`: + +```python +ParameterDefinition( + name="volume", + label="容积", + quantity="volume", + unit="m3", + default=0.1, + minimum=0.0, + minimum_exclusive=True, +) +``` + +字段含义: + +| 字段 | 规则 | +| --- | --- | +| `name` | 稳定机器名,同时用于 XML、工程文件和 `create()` | +| `label` | 前端显示名称,不能为空 | +| `quantity` | 受控物理量标识 | +| `unit` | 后端 SI 基准单位 | +| `default` | 必须能够创建有效模型 | +| `minimum` / `maximum` | 必须反映方程有效范围 | +| `minimum_exclusive` | 用于直径、容积等严格大于零的量 | + +当前受控单位定义在 `SI_UNIT_BY_QUANTITY`: + +| quantity | SI 单位 | +| --- | --- | +| `dimensionless` | 空字符串 | +| `density` | `kg/m³` | +| `flow_coefficient` | `kg/(s*Pa^0.5)` | +| `internal_energy` | `J` | +| `length` | `m` | +| `mass` | `kg` | +| `mass_flow` | `kg/s` | +| `pressure` | `Pa` | +| `specific_enthalpy` | `J/kg` | +| `specific_internal_energy` | `J/kg` | +| `temperature` | `K` | +| `volume` | `m3` | + +新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在 +单个模型中私自拼写新的同义 `quantity`。 + +构造函数必须调用: + +```python +self.set_parameter_values( + { + "volume": volume, + "p0": p0, + "T0": T0, + } +) +``` + +保存值、方程计算和结果输出都使用 SI。前端显示单位变化不能改变后端参数语义。 + +## 9. 结果变量规范 + +### 9.1 组件级结果 + +组件自身状态或派生量使用 `ResultVariableDefinition`: + +```python +ResultVariableDefinition( + name="pressure_drop", + label="压降", + quantity="pressure", + unit="Pa", + category="derived", + order=10, +) +``` + +声明后必须在 `component_result_values()` 返回同名值: + +```python +def component_result_values(self) -> Mapping[str, float]: + return { + "pressure_drop": self.port_a.p - self.port_b.p, + } +``` + +声明集合和返回键必须一致。 + +### 9.2 端口结果 + +端口结果由 `PORTS` 的端口变量自动产生,不要在 `RESULT_VARIABLES` 中重复声明 +`port_a.p`、`port_a.m_flow` 等字段。 + +### 9.3 禁止暴露的内容 + +以下内容默认不能作为用户结果: + +- 非线性求解器内部未知量索引。 +- 缩放残差和迭代缓存。 +- 仅用于调试的临时中间值。 +- 可以由已有结果稳定推导、但没有明确工程用途的重复字段。 + +## 10. 显示声明规范 + +公开模型必须声明 `DISPLAY`: + +```python +DISPLAY = ComponentDisplaySpec( + label="示例阻力元件", + library_id="experimental", + category_id="flow", + symbol="generic", + ports=( + PortDisplaySpec("port_a", "left", order=10), + PortDisplaySpec("port_b", "right", order=20), + ), + order=90, +) +``` + +规则: + +- `library_id` 必须等于所属库 ID。 +- `category_id` 必须存在于所属库的 `categories`。 +- `symbol` 是前端图形键,不是模型类型。 +- 未实现专用图标时使用新的稳定键,前端会回退到通用图形。 +- 只有确实需要专用工程图标时才修改前端图标渲染器。 +- `side` 只允许 `left` 或 `right`。 +- 旋转和镜像不能改变端口名或物理语义。 + +## 11. 标准创建入口 + +`create()` 是注册器创建模型的唯一入口: + +```python +@classmethod +def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], +) -> ExampleComponent: + return cls( + name=name, + medium=medium, + coefficient=parameters["coefficient"], + ) +``` + +注册器会在调用前: + +1. 补齐默认参数。 +2. 拒绝未知参数。 +3. 检查有限值和边界。 + +调用后还会检查: + +1. 返回对象类型正确。 +2. 实例 `model_type` 与 `MODEL_TYPE` 一致。 +3. 实际端口与 `PORTS` 完全一致。 +4. 实例保存的参数与规范化参数完全一致。 + +`create()` 不应重复实现参数默认值和边界校验,也不能静默修改传入参数。 + +## 12. 方程实现要求 + +模型方程必须满足: + +- 残差形式统一为“期望等式左侧减右侧”。 +- 每条 `EquationResidual` 使用稳定、可定位的 `id`。 +- `variables` 列出该残差实际涉及的端口量或状态。 +- `role` 与方程主要约束的物理角色一致。 +- 对零压差、零流量和反向流动给出有限结果。 +- 必要正则化必须有物理解释,并通过边界测试保护。 +- 不得用画布坐标、连接线方向或组件名称决定方程。 + +动态模型还必须: + +- 状态向量长度稳定。 +- `get_state_vector()` 和 `set_state_vector()` 互为逆操作。 +- 状态导数满足质量和能量守恒约定。 +- 初始化默认值能够产生有限介质状态。 + +## 13. 可复制的代数模型模板 + +下面是一个符合当前规范的两端口代数阻力模板。复制后必须根据真实物理模型修改 +类型、参数、方程、名称和测试,不能只改类名就注册。 + +```python +from __future__ import annotations + +from collections.abc import Mapping +from math import 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 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class ExampleRestriction(AlgebraicComponent): + MODEL_TYPE = "example_restriction" + MODEL_VERSION = "1.0.0" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_b", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + name="K", + label="流量系数", + quantity="flow_coefficient", + unit="kg/(s*Pa^0.5)", + default=1e-5, + minimum=0.0, + ), + ) + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="示例阻力元件", + library_id="experimental", + category_id="flow", + symbol="generic", + ports=( + PortDisplaySpec("port_a", "left", order=10), + PortDisplaySpec("port_b", "right", order=20), + ), + order=90, + ) + + def __init__(self, name: str, K: float = 1e-5) -> None: + super().__init__(name) + self.set_parameter_values({"K": K}) + self.K = K + self.port_a = self.register_declared_port("port_a") + self.port_b = self.register_declared_port("port_b") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> ExampleRestriction: + return cls(name=name, K=parameters["K"]) + + def pressure_flow_equation_residuals( + self, + ) -> tuple[EquationResidual, ...]: + pressure_difference = self.port_a.p - self.port_b.p + expected_flow = ( + self.K + * sqrt(abs(pressure_difference)) + * (1.0 if pressure_difference > 0.0 else -1.0) + if pressure_difference != 0.0 + else 0.0 + ) + return ( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_a.m_flow", + f"{self.name}.port_b.m_flow", + ), + role="flow", + value=self.port_a.m_flow + self.port_b.m_flow, + ), + EquationResidual( + id=f"{self.name}:pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_a.p", + f"{self.name}.port_b.p", + f"{self.name}.port_a.m_flow", + ), + role="flow", + value=self.port_a.m_flow - expected_flow, + ), + ) + + def update_stream_outflows( + self, + connected_h: Mapping[str, float], + ) -> None: + self.port_a.h_outflow = connected_h["port_b"] + self.port_b.h_outflow = connected_h["port_a"] +``` + +真实现有模型可参考: + +- 储能元件: + [`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py) +- 阻性元件: + [`orifice.py`](../app/simulation/components/experimental/flow/orifice.py) +- 多端口连接元件: + [`tee.py`](../app/simulation/components/experimental/junctions/tee.py) + +## 14. 注册模型 + +模型文件完成后,只修改所属库的 `library.py`: + +```python +models=( + # 已有模型 + "app.simulation.components.experimental.flow.example_restriction:ExampleRestriction", +) +``` + +禁止: + +- 直接修改 `COMPONENT_MODEL_REGISTRY`。 +- 在前端复制参数和端口定义作为正式来源。 +- 递归扫描组件目录自动导入所有 `.py`。 +- 同时注册两个相同 `MODEL_TYPE`。 +- 把测试类、抽象基类或内部算例模型加入公开清单。 + +## 15. 测试要求 + +每个公开模型至少添加: + +1. 静态契约测试。 +2. 默认参数创建测试。 +3. 参数边界测试。 +4. 端口与显示布局一致性测试。 +5. 关键方程残差测试。 +6. 零流量或反向流动测试。 +7. 目录输出测试。 +8. 最小 XML 编译测试。 +9. 能进入通用求解器的模型,再添加短时仿真测试。 + +推荐先运行: + +```powershell +.\.venv-win\Scripts\python.exe -m unittest ` + tests.test_component_registry ` + tests.test_component_catalog ` + tests.test_component_metadata +``` + +然后运行完整回归: + +```powershell +.\.venv-win\Scripts\python.exe -m unittest discover -s tests +``` + +目录契约影响前端时还要运行: + +```powershell +cd frontend +$env:Path = 'F:\Master\SystemSimulationApp\.tools\node-v24.18.0-win-x64;' + $env:Path +npm.cmd run build +``` + +## 16. 修改已有模型的安全步骤 + +1. 找到 `MODEL_TYPE` 的所有 XML、工程和测试引用。 +2. 记录修改前的端口、参数、结果和默认行为。 +3. 判断版本级别和是否需要迁移。 +4. 先增加或修改测试,明确预期物理行为。 +5. 修改模型类,不在注册器和前端复制规则。 +6. 检查默认实例和旧参数是否仍能创建。 +7. 检查最小系统是否仍然闭合。 +8. 运行针对性测试和完整回归。 +9. 同步本文档或模型专属说明中的物理假设。 + +## 17. 人工或 AI 的任务输入卡 + +为了减少猜测,新增模型前建议先填写: + +```text +模型中文名称: +MODEL_TYPE: +所属 library_id: +所属 category_id: +物理域: +模型用途和边界: +端口列表及含义: +参数列表、SI 单位、默认值和范围: +状态变量: +代数方程或微分方程: +正流量约定: +需要显示的组件结果: +已知参考模型或工程公式: +最小测试系统: +允许的近似: +明确不实现的能力: +``` + +如果关键物理信息缺失,AI 应先通过现有模型、测试或用户提供的参考补齐;不能仅凭 +组件名称自行创造方程。 + +## 18. AI 修改协议 + +AI 创建或修改模型时必须遵守: + +### 修改前 + +1. 读取第 3 节列出的文件。 +2. 检查工作区已有改动,不能覆盖无关修改。 +3. 明确模型是公开模型还是内部模型。 +4. 明确端口物理域、状态、参数、方程和结果。 +5. 找到最接近的现有模型并沿用代码风格。 + +### 修改中 + +1. 将物理契约保存在模型类中。 +2. 只在库清单中登记公开模型。 +3. 不修改集中注册表来加入单个模型。 +4. 不为了让测试通过而放宽全局校验。 +5. 不改变现有模型标识,除非任务明确要求迁移。 +6. 不把前端拖拽方向当作物理流向。 +7. 不把求解器失败简单隐藏为默认结果。 + +### 修改后 + +1. 展示涉及的模型、清单和测试文件。 +2. 报告版本变化和兼容性影响。 +3. 运行针对性测试、完整后端测试和必要的前端构建。 +4. 检查 `GET /api/components/catalog` 中的模型、分类、端口和参数。 +5. 告知用户需要重启 FastAPI 才能加载新的 Python 模块。 +6. 未执行的校验必须明确说明原因。 + +## 19. 常见失败与处理 + +| 现象 | 常见原因 | 处理 | +| --- | --- | --- | +| FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 | +| 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI | +| 前端显示“内置兜底” | `/api/components/catalog` 不可用 | 检查 8000 端口和接口响应 | +| 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 | +| 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 | +| 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` | +| XML 报不支持模型 | XML `type` 与 `MODEL_TYPE` 不一致 | 修正类型或提供迁移 | +| 模型可显示但无法仿真 | 只完成目录元数据,方程或物理域求解未实现 | 补齐方程、网络和求解测试 | + +## 20. 完成定义 + +一个模型只有同时满足以下条件才算完成: + +- 模型契约完整且启动校验通过。 +- 默认参数和边界有效。 +- 端口、参数和结果具有稳定物理含义。 +- 方程覆盖零流量、正常流动和必要的反向流动。 +- 模型已加入正确库清单。 +- 目录接口能自动输出模型。 +- 前端无需复制参数和端口定义即可使用。 +- XML 能映射到正确模型。 +- 最小系统能够编译;声称可仿真的模型必须产生有限结果。 +- 针对性测试、完整回归和必要的前端构建通过。 +- 文档记录了模型假设、适用范围和已知限制。 diff --git a/AmesimModels/help/P4NODE2.pdf b/AmesimModels/help/P4NODE2.pdf new file mode 100644 index 0000000..2f3aa13 Binary files /dev/null and b/AmesimModels/help/P4NODE2.pdf differ diff --git a/AmesimModels/help/PN3NODE2.pdf b/AmesimModels/help/PN3NODE2.pdf new file mode 100644 index 0000000..cf5c414 Binary files /dev/null and b/AmesimModels/help/PN3NODE2.pdf differ diff --git a/AmesimModels/help/PNCH012.pdf b/AmesimModels/help/PNCH012.pdf new file mode 100644 index 0000000..92170cf Binary files /dev/null and b/AmesimModels/help/PNCH012.pdf differ diff --git a/AmesimModels/help/PNL0001.pdf b/AmesimModels/help/PNL0001.pdf new file mode 100644 index 0000000..ba4077a Binary files /dev/null and b/AmesimModels/help/PNL0001.pdf differ diff --git a/AmesimModels/help/PNL0002.pdf b/AmesimModels/help/PNL0002.pdf new file mode 100644 index 0000000..d2ad34b Binary files /dev/null and b/AmesimModels/help/PNL0002.pdf differ diff --git a/AmesimModels/help/PNL0003.pdf b/AmesimModels/help/PNL0003.pdf new file mode 100644 index 0000000..fc07a9a Binary files /dev/null and b/AmesimModels/help/PNL0003.pdf differ diff --git a/AmesimModels/help/PNL00R.pdf b/AmesimModels/help/PNL00R.pdf new file mode 100644 index 0000000..4cf7a74 Binary files /dev/null and b/AmesimModels/help/PNL00R.pdf differ diff --git a/AmesimModels/help/PNOR001.pdf b/AmesimModels/help/PNOR001.pdf new file mode 100644 index 0000000..70a418c Binary files /dev/null and b/AmesimModels/help/PNOR001.pdf differ diff --git a/AmesimModels/help/PNVO001.pdf b/AmesimModels/help/PNVO001.pdf new file mode 100644 index 0000000..1c347a0 Binary files /dev/null and b/AmesimModels/help/PNVO001.pdf differ diff --git a/AmesimModels/help/pneumatic_library_manual/03_gas_properties.pdf b/AmesimModels/help/pneumatic_library_manual/03_gas_properties.pdf new file mode 100644 index 0000000..150a8f3 Binary files /dev/null and b/AmesimModels/help/pneumatic_library_manual/03_gas_properties.pdf differ diff --git a/AmesimModels/help/pneumatic_library_manual/06_important_rules.pdf b/AmesimModels/help/pneumatic_library_manual/06_important_rules.pdf new file mode 100644 index 0000000..849e2fa Binary files /dev/null and b/AmesimModels/help/pneumatic_library_manual/06_important_rules.pdf differ diff --git a/AmesimModels/help/pneumatic_library_manual/07_review_pneumatic.pdf b/AmesimModels/help/pneumatic_library_manual/07_review_pneumatic.pdf new file mode 100644 index 0000000..6d7ec0d Binary files /dev/null and b/AmesimModels/help/pneumatic_library_manual/07_review_pneumatic.pdf differ diff --git a/AmesimModels/help/pneumatic_library_manual/08_pipes.pdf b/AmesimModels/help/pneumatic_library_manual/08_pipes.pdf new file mode 100644 index 0000000..b56ec4d Binary files /dev/null and b/AmesimModels/help/pneumatic_library_manual/08_pipes.pdf differ diff --git a/AmesimModels/help/pneumatic_library_manual/lib_pneumatic.pdf b/AmesimModels/help/pneumatic_library_manual/lib_pneumatic.pdf new file mode 100644 index 0000000..a9662c6 Binary files /dev/null and b/AmesimModels/help/pneumatic_library_manual/lib_pneumatic.pdf differ diff --git a/AmesimModels/help/source/PNCH012.c b/AmesimModels/help/source/PNCH012.c new file mode 100644 index 0000000..382bb31 --- /dev/null +++ b/AmesimModels/help/source/PNCH012.c @@ -0,0 +1,388 @@ +/* Submodel PNCH012 skeleton created by AME Submodel editing utility + mar. oct. 9 14:41:15 2018 */ + + + +#include +#include +#include +#include "ameutils.h" +/* ******************************************************************************* +TITLE : PNCH012 +-------------------------------------------------------------------------------- +DESCRIPTION : + This submodel represents a pneumatic chamber with a variable volume + and pressure dynamics. + + Each port receives a mass flow rate and an enthalpy flow rate as + input and gives the pressure and the temperature of the chamber as + output. Each port receives also the volume and volume variation as + input. The total volume is calculated by summing the four volume + inputs and a dead volume which is a parameter of PNCH012. + + The model takes into account heat exchange. It express the variation + of internal energy U using the first law of thermodynamics applied to + an open system. Therefore, this model should be preferred to the simple + polytropic chamber PNCH011. + + The total volume of the chamber is limited to a lower value equal to + the dead volume divided by 100. +-------------------------------------------------------------------------------- +USAGE : + Use this submodel to simulate a pneumatic chamber in a jack, spool + valve or any pneumatic chamber in which the volume can vary. + + This submodel can be directly connected to any pneumatic PCD + component or standard pneumatic component. + + The submodels PNGD001, PNGD002, PNGD003, PNGD004 or PNRGD00 should be + included in your circuit to define the characteristics of the gas. +-------------------------------------------------------------------------------- +PARAMETER SETTINGS: + The dead volume is the volume of the pneumatic fluid when all the input + volumes are zero. It is essential that this volume must be greater + than zero. +-------------------------------------------------------------------------------- +DATE OF CREATION / AUTHOR : + 2002 FS from PNCH12 +-------------------------------------------------------------------------------- +INDEX OF REVISIONS : + 2008 OBA - Real gas improvements : the mass and volume were considered as + internal state variable, they are now coded as internal basic + variable. The mass initialisation was removed as it was linked + to the perfect gas formulation. +-------------------------------------------------------------------------------- +LIST OF FUNCTIONS USED : + pn2getatp : get atmospheric pressure + firstc_ : checks if this is the first call to this submodel + pn2vol_ : pneumatic chamber with heat exchange + stepdn_ : reduce simulation step +-------------------------------------------------------------------------------- +SOURCE : + + This material contains trade secrets or otherwise confidential + information owned by Siemens Industry Software Inc. or its + affiliates (collectively, "Siemens"), or its licensors. Access to + and use of this information is strictly limited as set forth in the + Customer's applicable agreements with Siemens. + + Unpublished work. Copyright 2023 Siemens + +******************************************************************************* */ + +#define _SUBMODELNAME_ "PNCH012" + +/* >>>>>>>>>>>>Insert Private Code Here. */ +/* <<<<<<<<<<< m**3] + kth thermal exchange coefficient [J/m**2/K/s -> W/m**2/K] + sth thermal exchange area [m**2] + extemp external temperature [K] +*/ + + +/* There is 1 integer parameter: + + gi gas type index +*/ + +void pnch012in_(int *n, double rp[4], int ip[1], double c[2] + , int ic[2], double *temp, double *press, double *dvol1 + , double *vol1, double *dvol2, double *vol2, double *dvol3 + , double *vol3, double *dvol4, double *vol4) + +{ + int loop, error; +/* >>>>>>>>>>>>Extra Initialization Function Declarations Here. */ +/* <<<<<<<<<<<>>>>>>>>>>>Initialization Function Check Statements. */ + + pn2_valid_gas_(&gi, &error); + + if (cvol0 <= 0.0) + { + error = 2; + amefprintf(stderr, "\nVolume chamber must be strictly positive.\n"); + } + + if (kth < 0.0) + { + error = 2; + amefprintf(stderr, "\nthermal exchange coefficient must be positive.\n"); + } + + if (sth < 0.0) + { + error = 2; + amefprintf(stderr, "\nthermal exchange area must be positive.\n"); + } + + if (extemp <= 0.0) + { + error = 2; + amefprintf(stderr, "\nExternal temperature must be strictly positive.\n"); + } + + if (*temp <= 0.0) + { + error = 2; + amefprintf(stderr, "\nInitial temperature must be strictly positive.\n"); + } + + + +/* <<<<<<<<<<< 99) + { + amefprintf(stderr, "\ngas type index must be in range [1..99].\n"); + error = 2; + } + + if(error == 1) + { + amefprintf(stderr, "\nWarning in %s instance %d.\n", _SUBMODELNAME_, *n); + } + else if(error == 2) + { + amefprintf(stderr, "\nFatal error in %s instance %d.\n", _SUBMODELNAME_, *n); + amefprintf(stderr, "Terminating the program.\n"); + AmeExit(1); + } + +/* Common -> SI units conversions. */ + + rp[0] *= 1.00000000000000e-003; + cvol0 = rp[0]; + + +/* >>>>>>>>>>>>Initialization Function Executable Statements. */ + + c[0] = cvol0 / 100; + + /* Set initial value for the test of limited volume : + ic[1] = 1 when the chamber volume is limited to cvol0 / 100 else ic[1] = 0*/ + ic[1] = 0; + + /* set atmospheric pressure */ + c[1] = pn2getatp_(); + +/* <<<<<<<<<<< W] basic variable input + 4 dm1 mass flow rate at port 1 [g/s -> kg/s] basic variable input + 5 dvol1 derivative of volume at port 1 [L/min -> m**3/s] basic variable input with default 0.000000e+000 + 6 vol1 volume at port 1 [cm**3 -> m**3] basic variable input with default 0.000000e+000 + + Port 2 has 6 variables: + + 1 temp2 duplicate of temp + 2 press2 duplicate of press + 3 dh2 enthalpy flow rate at port 2 [J/s -> W] basic variable input + 4 dm2 mass flow rate at port 2 [g/s -> kg/s] basic variable input + 5 dvol2 derivative of volume at port 2 [L/min -> m**3/s] basic variable input with default 0.000000e+000 + 6 vol2 volume at port 2 [cm**3 -> m**3] basic variable input with default 0.000000e+000 + + Port 3 has 6 variables: + + 1 temp3 duplicate of temp + 2 press3 duplicate of press + 3 dh3 enthalpy flow rate at port 3 [J/s -> W] basic variable input + 4 dm3 mass flow rate at port 3 [g/s -> kg/s] basic variable input + 5 dvol3 derivative of volume at port 3 [L/min -> m**3/s] basic variable input with default 0.000000e+000 + 6 vol3 volume at port 3 [cm**3 -> m**3] basic variable input with default 0.000000e+000 + + Port 4 has 6 variables: + + 1 temp4 duplicate of temp + 2 press4 duplicate of press + 3 dh4 enthalpy flow rate at port 4 [J/s -> W] basic variable input + 4 dm4 mass flow rate at port 4 [g/s -> kg/s] basic variable input + 5 dvol4 derivative of volume at port 4 [L/min -> m**3/s] basic variable input with default 0.000000e+000 + 6 vol4 volume at port 4 [cm**3 -> m**3] basic variable input with default 0.000000e+000 +*/ + +/* There are 2 internal variables. + + 1 vol volume of pneumatic chamber [cm**3 -> m**3] basic variable + 2 mgas1 mass of gas in chamber [g -> kg] basic variable +*/ + +void pnch012_(int *n, double *temp, double *dtemp, double *press + , double *dpress, double *dh1, double *dm1, double *dvol1 + , double *vol1, double *dh2, double *dm2, double *dvol2 + , double *vol2, double *dh3, double *dm3, double *dvol3 + , double *vol3, double *dh4, double *dm4, double *dvol4 + , double *vol4, double *vol, double *mgas1, double rp[4] + , int ip[1], double c[2], int ic[2]) + +{ + int loop; +/* >>>>>>>>>>>>Extra Calculation Function Declarations Here. */ + double dvol; + double sdm, sdh; + double dq; + double pressa; +/* <<<<<<<<<<< SI units conversions. */ + + *dm1 *= 1.00000000000000e-003; + *dvol1 *= 1.66666666666667e-005; + *vol1 *= 1.00000000000000e-006; + *dm2 *= 1.00000000000000e-003; + *dvol2 *= 1.66666666666667e-005; + *vol2 *= 1.00000000000000e-006; + *dm3 *= 1.00000000000000e-003; + *dvol3 *= 1.66666666666667e-005; + *vol3 *= 1.00000000000000e-006; + *dm4 *= 1.00000000000000e-003; + *dvol4 *= 1.66666666666667e-005; + *vol4 *= 1.00000000000000e-006; + +/* + Set all submodel outputs below: + + *dtemp = ??; + *dpress = ??; + *vol = ??; + *mgas1 = ??; +*/ + + + +/* >>>>>>>>>>>>Calculation Function Executable Statements. */ + + /* set absolute pressure */ + pressa = *press + c[1]; + + /*** sum of the volume variation and volume ***/ + dvol = *dvol1 + *dvol2 + *dvol3 + *dvol4; + + /*** setup the initial mass of the gaz inside of the chamber ***/ + *vol = *vol1 + *vol2 + *vol3 + *vol4 + cvol0; + + /*** sum of the flows ***/ + sdm = *dm1 + *dm2 + *dm3 + *dm4; /* mass flow */ + sdh = *dh1 + *dh2 + *dh3 + *dh4; /* heat flow */ + + /*** V, M, T and P can not be lower than zero ***/ + *vol = llimit_(vol, &c[0], &ic[0]); + + if (ic[0] == -1) + { + dvol = 0.; + if (ic[1] == 0) + { + amefprintf(stderr, "\nWarning in %s instance %d chamber volume is limited by cvol0 / 100 = %g cm**3.\n", _SUBMODELNAME_, *n, c[0]*1E+6); + ic[1] = 1; + } + } + + if (*vol < c[0]/10) + { + *vol = c[0]/10; + } + + if ( (*mgas1 <= 1.0e-10) && (!firstc_()) ) + { + /* panic step reduction */ + stepdn_(); + *mgas1 = 1.0e-10; + } + + if (pressa <= 1.0e-10) + { + /* panic step reduction */ + stepdn_(); + *press = 1.0e-10 - c[1]; + } + + if (*temp <= 1.0e-10) + { + /* panic step reduction */ + stepdn_(); + *temp = 1.0e-10; + } + + /*** temperature & pressure variation ***/ + dq = kth*sth*(extemp-*temp); + + pn2vol_(dtemp, dpress, mgas1, temp, &pressa, + &sdm, &sdh, vol, &dvol, &dq, &gi); + +/* <<<<<<<<<<< Common units conversions. */ + + *dm1 /= 1.00000000000000e-003; + *dvol1 /= 1.66666666666667e-005; + *vol1 /= 1.00000000000000e-006; + *dm2 /= 1.00000000000000e-003; + *dvol2 /= 1.66666666666667e-005; + *vol2 /= 1.00000000000000e-006; + *dm3 /= 1.00000000000000e-003; + *dvol3 /= 1.66666666666667e-005; + *vol3 /= 1.00000000000000e-006; + *dm4 /= 1.00000000000000e-003; + *dvol4 /= 1.66666666666667e-005; + *vol4 /= 1.00000000000000e-006; + *vol /= 1.00000000000000e-006; + *mgas1 /= 1.00000000000000e-003; +} + diff --git a/AmesimModels/help/source/PNCH012.spe b/AmesimModels/help/source/PNCH012.spe new file mode 100644 index 0000000..0ad88a1 --- /dev/null +++ b/AmesimModels/help/source/PNCH012.spe @@ -0,0 +1,356 @@ + + + + + + 0 + 33 + pn_c1 + variable volume pneumatic chamber with heat exchange (preferred) + 0 + 2 + 2 + 1 + + + 27 + dead volume + cvol0 + True + 1.00000000000000e+00 + 1.00000000000000e+00 + 0.00000000000000e+000 + 1.00000000000000e+005 + L + + + 28 + thermal exchange coefficient + kth + True + 0.00000000000000e+00 + 0.00000000000000e+00 + 0.00000000000000e+000 + 1.00000000000000e+006 + J/m**2/K/s + + + 29 + thermal exchange area + sth + True + 1.00000000000000e-01 + 1.00000000000000e-01 + 0.00000000000000e+000 + 1.00000000000000e+002 + m**2 + + + 30 + external temperature + extemp + True + 2.93150000000000e+02 + 2.93150000000000e+02 + 1.00000000000000e+000 + 1.00000000000000e+003 + K + + + + + 31 + gas type index + gi + True + 1 + 1 + 1 + 99 + + + + + 32 + volume of pneumatic chamber + vol + True + 0 + 1 + cm**3 + + + 33 + mass of gas in chamber + mgas1 + True + 0 + 1 + g + + + + + + 1 + temperature + temp + dtemp + True + 1 + 1 + 2 + K + 0.00000000000000e+000 + 1.00000000000000e+003 + 2.93150000000000e+002 + 2.93150000000000e+002 + + + 2 + pressure + press + dpress + True + 1 + 1 + 2 + Pa + -1.01300000000000e+005 + 1.00000000000000e+012 + 0.00000000000000e+000 + 0.00000000000000e+000 + + + 3 + enthalpy flow rate at port 1 + dh1 + True + 0 + 1 + 1 + J/s + + + 4 + mass flow rate at port 1 + dm1 + True + 0 + 1 + 1 + g/s + + + 5 + derivative of volume at port 1 + dvol1 + True + 0 + 1 + 3 + L/min + 0 + + + 6 + volume at port 1 + vol1 + True + 0 + 1 + 3 + cm**3 + 0 + + + + + 7 + temp2 + True + 4 + 0 + 0 + 0 + + + 8 + press2 + True + 4 + 0 + 1 + 0 + + + 9 + enthalpy flow rate at port 2 + dh2 + True + 0 + 1 + 1 + J/s + + + 10 + mass flow rate at port 2 + dm2 + True + 0 + 1 + 1 + g/s + + + 11 + derivative of volume at port 2 + dvol2 + True + 0 + 1 + 3 + L/min + 0 + + + 12 + volume at port 2 + vol2 + True + 0 + 1 + 3 + cm**3 + 0 + + + + + 13 + temp3 + True + 4 + 0 + 0 + 0 + + + 14 + press3 + True + 4 + 0 + 1 + 0 + + + 15 + enthalpy flow rate at port 3 + dh3 + True + 0 + 1 + 1 + J/s + + + 16 + mass flow rate at port 3 + dm3 + True + 0 + 1 + 1 + g/s + + + 17 + derivative of volume at port 3 + dvol3 + True + 0 + 1 + 3 + L/min + 0 + + + 18 + volume at port 3 + vol3 + True + 0 + 1 + 3 + cm**3 + 0 + + + + + 19 + temp4 + True + 4 + 0 + 0 + 0 + + + 20 + press4 + True + 4 + 0 + 1 + 0 + + + 21 + enthalpy flow rate at port 4 + dh4 + True + 0 + 1 + 1 + J/s + + + 22 + mass flow rate at port 4 + dm4 + True + 0 + 1 + 1 + g/s + + + 23 + derivative of volume at port 4 + dvol4 + True + 0 + 1 + 3 + L/min + 0 + + + 24 + volume at port 4 + vol4 + True + 0 + 1 + 3 + cm**3 + 0 + + + + 0 + + diff --git a/AmesimModels/help/source/PNL0001.c b/AmesimModels/help/source/PNL0001.c new file mode 100644 index 0000000..e7954a0 --- /dev/null +++ b/AmesimModels/help/source/PNL0001.c @@ -0,0 +1,348 @@ +/* Submodel PNL0001 skeleton created by AME Submodel editing utility + mer. juin 20 14:20:39 2018 */ + + + +#include +#include +#include +#include "ameutils.h" +/* ******************************************************************************* +TITLE : PNL0001 (C-R) +------------------------------------------------------------------------------ +DESCRIPTION : + PNL0001 is a submodel of a pneumatic pipe with only compressibility + and friction effects taking into account heat exchange. + + The compressibility of the gas is taken into account by using a + simple polytropic model or a more complex one taking into account + heat exchange. + + The polytropic model is a simplified form of the general internal + energy model based on the first law of thermodynamics. The polytropic + approach is obtained by representing the thermal exchange phenomena + by a polytropic constant k. In that case, the temperature and + pressure are no more independent variables. + + The reduction of the complexity of the model implies a lack of + accuracy. For general studies, you'd better use the heat exchange + approach. + + Pipe friction is taken into account using a friction factor based on + the Reynolds number and the relative roughness. + + The temperature and pressure in the volume are state variables. +------------------------------------------------------------------------------ +USAGE : + Use this submodel to simulate a pneumatic pipe with compressibility + and friction effects, when the Mach number is low, ie gas velocity + < 0.3 * speed of sound . + + The submodels PNGD001 or PNGD002 should be included in your circuit to + define the characteristics of the gas. +------------------------------------------------------------------------------ +PARAMETER SETTINGS : +------------------------------------------------------------------------------ +DATE OF CREATION / AUTHOR : + 2002 FS from PNL01 SN. +------------------------------------------------------------------------------ +REVISIONS : +------------------------------------------------------------------------------ +LIST OF FUNCTIONS USED : + pn2getatp_() : get atmospheric pressure + pn2ri_() : get perfect gas constant + pn2vol1_() : polytropic model for chambers + pn2vol_() : heat exchange model for chambers + pn2pipefr_() : frictional coeffitient in pneumatic pipes +------------------------------------------------------------------------------ +SOURCE : + + This material contains trade secrets or otherwise confidential + information owned by Siemens Industry Software Inc. or its + affiliates (collectively, "Siemens"), or its licensors. Access to + and use of this information is strictly limited as set forth in the + Customer's applicable agreements with Siemens. + + Unpublished work. Copyright 2023 Siemens + +******************************************************************************* */ + +#define _SUBMODELNAME_ "PNL0001" + +/* >>>>>>>>>>>>Insert Private Code Here. */ +#define TABFR 0 /* real store 0, 1 & 2 are used by pn2pipefr */ +#define PATM 3 +#define AREA 4 +#define VOL 5 +#define AREAEX 6 + +#define SPL_FR 0 +/* <<<<<<<<<<< m] + le pipe length [m] + rr relative roughness [null] + k polytropic constant [null] + kth thermal exchange coefficient [J/m**2/K/s -> W/m**2/K] + extemp external temperature [K] +*/ + + +/* There are 2 integer parameters: + + gi gas type index + mode model +*/ + +void pnl0001in_(int *n, double rp[6], int ip[2], double c[7] + , int ic[1], double *t2, double *p2) + +{ + int loop, error; +/* >>>>>>>>>>>>Extra Initialization Function Declarations Here. */ +/* <<<<<<<<<<<>>>>>>>>>>>Initialization Function Check Statements. */ + + pn2_valid_gas_(&gi, &error); + + if (*p2 < -GPATMOS) + { + error = 2; + amefprintf(stderr, "\nInitial pressure at port 2 should be > 0 [barA].\n"); + } + + if (*t2 <= 0.0) + { + error = 2; + amefprintf(stderr, "\nInitial temperature at port 2 should be > 0 [K].\n"); + } + + if (diam <= 0.0) + { + error = 2; + amefprintf(stderr, "\nDiameter of pipe should be > 0 [mm].\n"); + } + + if (le <= 0.0) + { + error = 2; + amefprintf(stderr, "\nPipe length should be > 0 [m].\n"); + } + + if (rr < 0.0) + { + error = 2; + amefprintf(stderr, "\nRelative roughness should be >= 0.\n"); + } + + if (mode == 1) + { + if (k <= 0.) + { + error = 2; + amefprintf(stderr, "\nPolytropic constant should be > 0.\n"); + } + } + else + { + if (kth < 0.) + { + error = 2; + amefprintf(stderr, "\nThermal exchange coefficient should be >= 0 [J/m**2/K/s].\n"); + } + + if (extemp <= 0.) + { + error = 2; + amefprintf(stderr, "\nExternal temperature should be > 0 [K].\n"); + } + } + +/* <<<<<<<<<<< 99) + { + amefprintf(stderr, "\ngas type index must be in range [1..99].\n"); + error = 2; + } + if (mode < 1 || mode > 2) + { + amefprintf(stderr, "\nmodel must be in range [1..2].\n"); + error = 2; + } + + SUBMODEL_HANDLE_AND_RESET_ERROR(_SUBMODELNAME_, n, error) + +/* Common -> SI units conversions. */ + + rp[0] *= 1.00000000000000e-003; + diam = rp[0]; + + +/* >>>>>>>>>>>>Initialization Function Executable Statements. */ + + /* get atmospheric pressure */ + c[PATM] = pn2getatp_(); + + /* Compute the cross-sectional area of pipe. */ + c[AREA] = M_PI * (diam) * (diam) / 4.0; + + /* Compute volume of pipe. */ + c[VOL] = c[AREA] * le; + + /* Compute exchange area of pipe. */ + c[AREAEX] = M_PI * diam * le; + +/* <<<<<<<<<<< W] basic variable output + 2 dm1 mass flow rate at port 1 [g/s -> kg/s] basic variable output + 3 t1 temperature at port 1 [K] basic variable input + 4 p1 pressure at port 1 [Pa] basic variable input + + Port 2 has 4 variables: + + 1 t2 temperature at port 2 [K] explicit state (derivative `dt2') + 2 p2 pressure at port 2 [Pa] explicit state (derivative `dp2') + 3 dh2 enthalpy flow rate at port 2 [J/s -> W] basic variable input + 4 dm2 mass flow rate at port 2 [g/s -> kg/s] basic variable input +*/ + +/* There are 5 internal variables. + + 1 mgas mass of gas in pipe [g -> kg] basic variable + 2 re Reynolds number [null] basic variable + 3 cm mass flow parameter (cm) [(kg*K/J)**(1/2)] basic variable + 4 v mean gas velocity [m/s] basic variable + 5 ff friction factor [null] basic variable +*/ + +void pnl0001_(int *n, double *dh1, double *dm1, double *t1, double *p1 + , double *t2, double *dt2, double *p2, double *dp2, double *dh2 + , double *dm2, double *mgas, double *re, double *cm, double *v + , double *ff, double rp[6], int ip[2], double c[7], int ic[1]) + +{ + int loop; +/* >>>>>>>>>>>>Extra Calculation Function Declarations Here. */ + static double zero = 0.0; + double sdh; + double dh2i, dm2i; + double dq; + double pa1, pa2, dmgas; + double r; + int dummyreg; +/* <<<<<<<<<<< SI units conversions. */ + + *dm2 *= 1.00000000000000e-003; + +/* + Set all submodel outputs below: + + *dh1 = ??; + *dm1 = ??; + *dt2 = ??; + *dp2 = ??; + *mgas = ??; + *re = ??; + *cm = ??; + *v = ??; + *ff = ??; +*/ + + + +/* >>>>>>>>>>>>Calculation Function Executable Statements. */ + + /* set absolute pressures */ + pa1 = *p1 + c[PATM]; + pa2 = *p2 + c[PATM]; + + /* Compute flows through the pipe */ + pn2pipefr_(&pa1, t1, &pa2, t2, &diam, &rr, &le, &c[AREA], re, v,ff, + dh1, dm1, &dh2i, &dm2i, cm, &c[TABFR], &gi, &ic[SPL_FR], &dummyreg); + + /* Compute mass variation */ + dmgas = (*dm2) + dm2i; + + /* sum of enthalpy flows */ + sdh = (*dh2) + dh2i; + + /*** temperature & pressure variation ***/ + + if (mode == 1) /* Polytropic model. */ + { + r = pn2ri_(&gi); + /* Compute initial mass of gas inside the pipe */ + *mgas = (pa2) * c[VOL] / ((*t2) * r); + + pn2vol1_(dt2, dp2, t2, &pa2, + &dmgas, mgas, &zero, &c[VOL], &k, &gi); + } + else /* Heat exchange. */ + { + dq = kth * c[AREAEX] * (extemp - *t2); + + pn2vol_(dt2, dp2, mgas, t2, &pa2, + &dmgas, &sdh, &c[VOL], &zero, &dq, &gi); + } + + +/* <<<<<<<<<<< Common units conversions. */ + + *dm1 /= 1.00000000000000e-003; + *dm2 /= 1.00000000000000e-003; + *mgas /= 1.00000000000000e-003; +} + diff --git a/AmesimModels/help/source/PNL0001.spe b/AmesimModels/help/source/PNL0001.spe new file mode 100644 index 0000000..05729ed --- /dev/null +++ b/AmesimModels/help/source/PNL0001.spe @@ -0,0 +1,257 @@ + + + + + + 0 + 22 + p2port + Compressibility + friction submodel of pneumatic pipe (C-R) + 0 + 7 + 1 + 1 + + + 14 + diameter of pipe + diam + True + 1.00000000000000e+01 + 1.00000000000000e+01 + 1.00000000000000e-003 + 1.00000000000000e+007 + mm + + + 15 + pipe length + le + True + 1.00000000000000e+00 + 1.00000000000000e+00 + 1.00000000000000e-006 + 1.00000000000000e+004 + m + + + 16 + relative roughness + rr + True + 1.00000000000000e-05 + 1.00000000000000e-05 + 0.00000000000000e+000 + 1.00000000000000e-001 + null + + + 17 + polytropic constant + k + (mode == 1) + 1.35000000000000e+00 + 1.35000000000000e+00 + 5.00000000000000e-001 + 2.00000000000000e+000 + null + + + 18 + thermal exchange coefficient + kth + (mode == 2) + 0.00000000000000e+00 + 0.00000000000000e+00 + 0.00000000000000e+000 + 1.00000000000000e+006 + J/m**2/K/s + + + 19 + external temperature + extemp + (mode == 2) + 2.93150000000000e+02 + 2.93150000000000e+02 + 1.00000000000000e+000 + 1.00000000000000e+003 + K + + + + + 20 + gas type index + gi + True + 1 + 1 + 1 + 99 + + + 21 + model + mode + True + 2 + 2 + 1 + 2 + + + polytropic + + + with thermal exchange + + + + + + + 22 + mass of gas in pipe + mgas + True + 0 + 1 + g + + + 10 + Reynolds number + re + True + 0 + 1 + null + + + 11 + mass flow parameter (cm) + cm + True + 0 + 1 + (kg*K/J)**(1/2) + + + 12 + mean gas velocity + v + True + 0 + 1 + m/s + + + 13 + friction factor + ff + True + 0 + 1 + null + + + + + + 1 + enthalpy flow rate at port 1 + dh1 + True + 0 + 1 + 2 + J/s + + + 2 + mass flow rate at port 1 + dm1 + True + 0 + 1 + 2 + g/s + + + 3 + temperature at port 1 + t1 + True + 0 + 1 + 1 + K + + + 4 + pressure at port 1 + p1 + True + 0 + 1 + 1 + Pa + + + + + 5 + temperature at port 2 + t2 + dt2 + True + 1 + 1 + 2 + K + 0.00000000000000e+000 + 1.00000000000000e+004 + 2.93150000000000e+002 + 2.93150000000000e+002 + + + 6 + pressure at port 2 + p2 + dp2 + True + 1 + 1 + 2 + Pa + -1.01300000000000e+005 + 1.00000000000000e+012 + 0.00000000000000e+000 + 0.00000000000000e+000 + + + 7 + enthalpy flow rate at port 2 + dh2 + True + 0 + 1 + 1 + J/s + + + 8 + mass flow rate at port 2 + dm2 + True + 0 + 1 + 1 + g/s + + + + 0 + + diff --git a/AmesimModels/help/source/PNL0002.c b/AmesimModels/help/source/PNL0002.c new file mode 100644 index 0000000..ddf695e --- /dev/null +++ b/AmesimModels/help/source/PNL0002.c @@ -0,0 +1,368 @@ +/* Submodel PNL0002 skeleton created by AME Submodel editing utility + mer. juin 20 14:35:13 2018 */ + + + +#include +#include +#include +#include "ameutils.h" +/* ******************************************************************************* +TITLE : PNL0002 (R-C-R) +------------------------------------------------------------------------------ +DESCRIPTION : + PNL0002 is a submodel of a pneumatic pipe with only compressibility + and friction effects taking into account heat exchange. + + The compressibility of the gas is taken into account by using a + simple polytropic model or a more complex one taking into account + heat exchange. + + The polytropic model is a simplified form of the general internal + energy model based on the first law of thermodynamics. The polytropic + approach is obtained by representing the thermal exchange phenomena + by a polytropic constant k. In that case, the temperature and + pressure are no more independent variables. + + The reduction of the complexity of the model implies a lack of + accuracy. For general studies, you'd better use the heat exchange + approach. + + Pipe friction is taken into account using a friction factor based on + the Reynolds number and the relative roughness. + + The temperature and pressure in the middle volume are state variables. +------------------------------------------------------------------------------ +USAGE : + Use this submodel to simulate a pneumatic pipe with compressibility + and friction effects, when the Mach number is low, ie gas velocity + < 0.3 * speed of sound . + + PNL0002 is basically similar to PNL0001 and PNL0003 differing only in the + input and output requirements. + + The submodels PNGD01 or PNGD02 should be included in your circuit to + define the characteristics of the gas. +------------------------------------------------------------------------------ +PARAMETER SETTINGS : +------------------------------------------------------------------------------ +DATE OF CREATION / AUTHOR : + 2002 FS from PNL02 SN. +------------------------------------------------------------------------------ +REVISIONS : +------------------------------------------------------------------------------ +LIST OF FUNCTIONS USED : + pn2getatp_() : get atmospheric pressure + pn2ri_() : get perfect gas constant + pn2vol1_() : polytropic model for chambers + pn2vol_() : heat exchange model for chambers + pn2pipefr_() : frictional coeffitient in pneumatic pipes +------------------------------------------------------------------------------ +SOURCE : + + This material contains trade secrets or otherwise confidential + information owned by Siemens Industry Software Inc. or its + affiliates (collectively, "Siemens"), or its licensors. Access to + and use of this information is strictly limited as set forth in the + Customer's applicable agreements with Siemens. + + Unpublished work. Copyright 2023 Siemens + +******************************************************************************* */ + +#define _SUBMODELNAME_ "PNL0002" + +/* >>>>>>>>>>>>Insert Private Code Here. */ +#define TABFR 0 /* real store 0, 1 & 2 are used by pn2pipefr */ +#define PATM 3 +#define AREA 4 +#define VOL 5 +#define HALFLE 6 +#define AREAEX 7 + + +#define SPL_FR 0 +/* <<<<<<<<<<< m] + le pipe length [m] + rr relative roughness [null] + k polytropic constant [null] + kth thermal exchange coefficient [J/m**2/K/s -> W/m**2/K] + extemp external temperature [K] +*/ + + +/* There are 2 integer parameters: + + gi gas type index + mode model +*/ + +void pnl0002in_(int *n, double rp[6], int ip[2], double c[8] + , int ic[1], double *tctr, double *pctr) + +{ + int loop, error; +/* >>>>>>>>>>>>Extra Initialization Function Declarations Here. */ +/* <<<<<<<<<<<>>>>>>>>>>>Initialization Function Check Statements. */ + + pn2_valid_gas_(&gi, &error); + + if (*pctr < -GPATMOS) + { + error = 2; + amefprintf(stderr, "\nInitial pressure at center of pipe should be > 0 [barA].\n"); + } + + if (*tctr <= 0.0) + { + error = 2; + amefprintf(stderr, "\nInitial temperature at center of pipe should be > 0 [K].\n"); + } + + if (diam <= 0.0) + { + error = 2; + amefprintf(stderr, "\nDiameter of pipe should be > 0 [mm].\n"); + } + + if (le <= 0.0) + { + error = 2; + amefprintf(stderr, "\nPipe length should be > 0 [m].\n"); + } + + if (rr < 0.0) + { + error = 2; + amefprintf(stderr, "\nRelative roughness should be >= 0.\n"); + } + + if (mode == 1) + { + if (k <= 0.) + { + error = 2; + amefprintf(stderr, "\nPolytropic constant should be > 0.\n"); + } + } + else + { + if (kth < 0.) + { + error = 2; + amefprintf(stderr, "\nThermal exchange coefficient should be >= 0 [J/m**2/K/s].\n"); + } + + if (extemp <= 0.) + { + error = 2; + amefprintf(stderr, "\nExternal temperature should be > 0 [K].\n"); + } + } + +/* <<<<<<<<<<< 99) + { + amefprintf(stderr, "\ngas type index must be in range [1..99].\n"); + error = 2; + } + if (mode < 1 || mode > 2) + { + amefprintf(stderr, "\nmodel must be in range [1..2].\n"); + error = 2; + } + + SUBMODEL_HANDLE_AND_RESET_ERROR(_SUBMODELNAME_, n, error) + +/* Common -> SI units conversions. */ + + rp[0] *= 1.00000000000000e-003; + diam = rp[0]; + + +/* >>>>>>>>>>>>Initialization Function Executable Statements. */ + + /* get atmospheric pressure */ + c[PATM] = pn2getatp_(); + + /* Compute the cross-sectional area of pipe. */ + c[AREA] = M_PI * (diam) * (diam) / 4.0; + + /* Compute volume of pipe. */ + c[VOL] = c[AREA] * le; + + /* Divide the restriction in 2 identical restrictions */ + c[HALFLE] = 0.5 * le; + + /* Compute exchange area of pipe. */ + c[AREAEX] = M_PI * diam * le; + +/* <<<<<<<<<<< W] basic variable output + 2 dm1 mass flow rate at port 1 [g/s -> kg/s] basic variable output + 3 t1 temperature at port 1 [K] basic variable input + 4 p1 pressure at port 1 [Pa] basic variable input + + Port 2 has 4 variables: + + 1 dh2 enthalpy flow rate at port 2 [J/s -> W] basic variable output + 2 dm2 mass flow rate at port 2 [g/s -> kg/s] basic variable output + 3 t2 temperature at port 2 [K] basic variable input + 4 p2 pressure at port 2 [Pa] basic variable input +*/ + +/* There are 7 internal variables. + + 1 tctr temperature at center of pipe [K] explicit state (derivative `dtctr') + 2 pctr pressure at center of pipe [Pa] explicit state (derivative `dpctr') + 3 mgas mass of gas in pipe [g -> kg] basic variable + 4 re mean Reynolds number [null] basic variable + 5 cm mean mass flow parameter [(kg*K/J)**(1/2)] basic variable + 6 v mean gas velocity [m/s] basic variable + 7 ff mean friction factor [null] basic variable +*/ + +void pnl0002_(int *n, double *dh1, double *dm1, double *t1, double *p1 + , double *dh2, double *dm2, double *t2, double *p2, double *tctr + , double *dtctr, double *pctr, double *dpctr, double *mgas + , double *re, double *cm, double *v, double *ff, double rp[6] + , int ip[2], double c[8], int ic[1]) + +{ + int loop; +/* >>>>>>>>>>>>Extra Calculation Function Declarations Here. */ + static double zero = 0.0; + double sdh; + double dh1i, dm1i; + double dh2i, dm2i; + double ff1, ff2, re1, re2, cm1, cm2; + double dq; + double pa1, pa2, pactr; + double v1, v2; + double dmgas; + double r; + int dummyreg; +/* <<<<<<<<<<<>>>>>>>>>>>Calculation Function Executable Statements. */ + + /* set absolute pressure */ + pa1 = *p1 + c[PATM]; + pa2 = *p2 + c[PATM]; + pactr = *pctr + c[PATM]; + + /* Compute flows through the pipe */ + pn2pipefr_(&pa1, t1, &pactr, tctr, &diam, &rr, &c[HALFLE], &c[AREA], &re1, &v1, &ff1, + dh1, dm1, &dh1i, &dm1i, &cm1, &c[TABFR], &gi, &ic[SPL_FR], &dummyreg); + + pn2pipefr_(&pactr, tctr, &pa2, t2, &diam, &rr, &c[HALFLE], &c[AREA], &re2, &v2, &ff2, + &dh2i, &dm2i, dh2, dm2, &cm2, &c[TABFR], &gi, &ic[SPL_FR], &dummyreg); + + /* Mean variables */ + *ff = 0.5 * (ff1 + ff2); + *re = 0.5 * (re1 + re2); + *cm = 0.5 * (cm1 + cm2); + *v = 0.5 * (fabs(v1) + fabs(v2)); + + /* Compute mass variation */ + dmgas = dm1i + dm2i; + + /* sum of enthalpy flows */ + sdh = dh1i + dh2i; + + /*** temperature & pressure variation ***/ + if (mode == 1) /* Polytropic model. */ + { + r = pn2ri_(&gi); + *mgas = (pactr) * c[VOL] / ((*tctr) * r); + + pn2vol1_(dtctr, dpctr, tctr, &pactr, + &dmgas, mgas, &zero, &c[VOL], &k, &gi); + } + else /* Heat exchange. */ + { + dq = kth * c[AREAEX] * (extemp-*tctr); + + pn2vol_(dtctr, dpctr, mgas, tctr, &pactr, + &dmgas, &sdh, &c[VOL], &zero, &dq, &gi); + } + +/* <<<<<<<<<<< Common units conversions. */ + + *dm1 /= 1.00000000000000e-003; + *dm2 /= 1.00000000000000e-003; + *mgas /= 1.00000000000000e-003; +} + diff --git a/AmesimModels/help/source/PNL0002.spe b/AmesimModels/help/source/PNL0002.spe new file mode 100644 index 0000000..8c89f59 --- /dev/null +++ b/AmesimModels/help/source/PNL0002.spe @@ -0,0 +1,275 @@ + + + + + + 0 + 24 + p2port + Compressibility + friction submodel of pneumatic pipe (R-C-R) + 0 + 8 + 1 + 1 + + + 16 + diameter of pipe + diam + True + 1.00000000000000e+01 + 1.00000000000000e+01 + 1.00000000000000e-003 + 1.00000000000000e+007 + mm + + + 17 + pipe length + le + True + 1.00000000000000e+00 + 1.00000000000000e+00 + 1.00000000000000e-006 + 1.00000000000000e+004 + m + + + 18 + relative roughness + rr + True + 1.00000000000000e-05 + 1.00000000000000e-05 + 0.00000000000000e+000 + 1.00000000000000e-001 + null + + + 19 + polytropic constant + k + (mode == 1) + 1.35000000000000e+00 + 1.35000000000000e+00 + 5.00000000000000e-001 + 2.00000000000000e+000 + null + + + 20 + thermal exchange coefficient + kth + (mode == 2) + 0.00000000000000e+00 + 0.00000000000000e+00 + 0.00000000000000e+000 + 1.00000000000000e+006 + J/m**2/K/s + + + 21 + external temperature + extemp + (mode == 2) + 2.93150000000000e+02 + 2.93150000000000e+02 + 1.00000000000000e+000 + 1.00000000000000e+003 + K + + + + + 22 + gas type index + gi + True + 1 + 1 + 1 + 99 + + + 23 + model + mode + True + 2 + 2 + 1 + 2 + + + polytropic + + + with thermal exchange + + + + + + + 9 + temperature at center of pipe + tctr + dtctr + True + 1 + 1 + K + 0.00000000000000e+000 + 1.00000000000000e+006 + 2.931500e+02 + 2.931500e+02 + + + 10 + pressure at center of pipe + pctr + dpctr + True + 1 + 1 + Pa + 0.00000000000000e+000 + 1.00000000000000e+006 + 1.013000e+00 + 1.013000e+00 + + + 24 + mass of gas in pipe + mgas + True + 0 + 1 + g + + + 12 + mean Reynolds number + re + True + 0 + 1 + null + + + 13 + mean mass flow parameter + cm + True + 0 + 1 + (kg*K/J)**(1/2) + + + 14 + mean gas velocity + v + True + 0 + 1 + m/s + + + 15 + mean friction factor + ff + True + 0 + 1 + null + + + + + + 1 + enthalpy flow rate at port 1 + dh1 + True + 0 + 1 + 2 + J/s + + + 2 + mass flow rate at port 1 + dm1 + True + 0 + 1 + 2 + g/s + + + 3 + temperature at port 1 + t1 + True + 0 + 1 + 1 + K + + + 4 + pressure at port 1 + p1 + True + 0 + 1 + 1 + Pa + + + + + 5 + enthalpy flow rate at port 2 + dh2 + True + 0 + 1 + 2 + J/s + + + 6 + mass flow rate at port 2 + dm2 + True + 0 + 1 + 2 + g/s + + + 7 + temperature at port 2 + t2 + True + 0 + 1 + 1 + K + + + 8 + pressure at port 2 + p2 + True + 0 + 1 + 1 + Pa + + + + 0 + + diff --git a/AmesimModels/help/source/PNL0003.c b/AmesimModels/help/source/PNL0003.c new file mode 100644 index 0000000..67c2eba --- /dev/null +++ b/AmesimModels/help/source/PNL0003.c @@ -0,0 +1,399 @@ +/* Submodel PNL0003 skeleton created by AME Submodel editing utility + mer. juin 20 14:17:28 2018 */ + + + +#include +#include +#include +#include "ameutils.h" +/* ******************************************************************************* +TITLE : PNL0003 (C-R-C) +------------------------------------------------------------------------------ +DESCRIPTION : + PNL0003 is a submodel of a pneumatic pipe with only compressibility + and friction effects taking into account heat exchange. + + The compressibility of the gas is taken into account by using a + simple polytropic model or a more complex one taking into account + heat exchange. + + The polytropic model is a simplified form of the general internal + energy model based on the first law of thermodynamics. The polytropic + approach is obtained by representing the thermal exchange phenomena + by a polytropic constant k. In that case, the temperature and + pressure are no more independent variables. + + The reduction of the complexity of the model implies a lack of + accuracy. For general studies, you'd better use the heat exchange + approach. + + Pipe friction is taken into account using a friction factor based on + the Reynolds number and the relative roughness. + + The temperature and pressure in each two volumes are state variables. +------------------------------------------------------------------------------ +USAGE : + Use this submodel to simulate a pneumatic pipe with compressibility + and friction effects, when the Mach number is low, ie gas velocity + < 0.3 * speed of sound . + + PNL0003 is basically similar to PNL0001 and PNL0002 differing only in the + input and output requirements. + + The submodels PNGD01 or PNGD02 should be included in your circuit to + define the characteristics of the gas. +------------------------------------------------------------------------------ +PARAMETER SETTINGS : +------------------------------------------------------------------------------ +DATE OF CREATION / AUTHOR : + 2002 FS from PNL03 SN. +------------------------------------------------------------------------------ +REVISIONS : +------------------------------------------------------------------------------ +LIST OF FUNCTIONS USED : + pn2getatp_() : get atmospheric pressure + pn2ri_() : get perfect gas constant + pn2vol1_() : polytropic model for chambers + pn2vol_() : heat exchange model for chambers + pn2pipefr_() : frictional coefficient in pneumatic pipes +------------------------------------------------------------------------------ +SOURCE : + + This material contains trade secrets or otherwise confidential + information owned by Siemens Industry Software Inc. or its + affiliates (collectively, "Siemens"), or its licensors. Access to + and use of this information is strictly limited as set forth in the + Customer's applicable agreements with Siemens. + + Unpublished work. Copyright 2023 Siemens + +******************************************************************************* */ + +#define _SUBMODELNAME_ "PNL0003" + +/* >>>>>>>>>>>>Insert Private Code Here. */ +#define TABFR 0 /* real store 0, 1 & 2 are used by pn2pipefr */ +#define PATM 3 +#define AREA 4 +#define HALFVOL 5 +#define HALFAREAEX 6 + + +#define SPL_FR 0 +/* <<<<<<<<<<< m] + le pipe length [m] + rr relative roughness [null] + k polytropic constant [null] + kth thermal exchange coefficient [J/m**2/K/s -> W/m**2/K] + extemp external temperature [K] +*/ + + +/* There are 2 integer parameters: + + gi gas type index + mode model +*/ + +void pnl0003in_(int *n, double rp[6], int ip[2], double c[7] + , int ic[1], double *t1, double *p1, double *t2, double *p2) + +{ + int loop, error; +/* >>>>>>>>>>>>Extra Initialization Function Declarations Here. */ + double vol, areaex; +/* <<<<<<<<<<<>>>>>>>>>>>Initialization Function Check Statements. */ + + pn2_valid_gas_(&gi, &error); + + if (*p1 < -GPATMOS) + { + error = 2; + amefprintf(stderr, "\nInitial pressure at port 1 should be > 0 [barA].\n"); + } + + if (*t1 <= 0.0) + { + error = 2; + amefprintf(stderr, "\nInitial temperature at port 1 should be > 0 [K].\n"); + } + + if (*p2 < -GPATMOS) + { + error = 2; + amefprintf(stderr, "\nInitial pressure at port 2 should be > 0 [barA].\n"); + } + + if (*t2 <= 0.0) + { + error = 2; + amefprintf(stderr, "\nInitial temperature at port 2 should be > 0 [K].\n"); + } + + if (diam <= 0.0) + { + error = 2; + amefprintf(stderr, "\nDiameter of pipe should be > 0 [mm].\n"); + } + + if (le <= 0.0) + { + error = 2; + amefprintf(stderr, "\nPipe length should be > 0 [m].\n"); + } + + if (rr < 0.0) + { + error = 2; + amefprintf(stderr, "\nRelative roughness should be >= 0.\n"); + } + + if (mode == 1) + { + if (k <= 0.) + { + error = 2; + amefprintf(stderr, "\nPolytropic constant should be > 0.\n"); + } + } + else + { + if (kth < 0.) + { + error = 2; + amefprintf(stderr, "\nThermal exchange coefficient should be >= 0 [J/m**2/K/s].\n"); + } + + if (extemp <= 0.) + { + error = 2; + amefprintf(stderr, "\nExternal temperature should be > 0 [K].\n"); + } + } + +/* <<<<<<<<<<< 99) + { + amefprintf(stderr, "\ngas type index must be in range [1..99].\n"); + error = 2; + } + if (mode < 1 || mode > 2) + { + amefprintf(stderr, "\nmodel must be in range [1..2].\n"); + error = 2; + } + + SUBMODEL_HANDLE_AND_RESET_ERROR(_SUBMODELNAME_, n, error) + +/* Common -> SI units conversions. */ + + rp[0] *= 1.00000000000000e-003; + diam = rp[0]; + + +/* >>>>>>>>>>>>Initialization Function Executable Statements. */ + /* set atmospheric pressure */ + c[PATM] = pn2getatp_(); + + /* Compute the cross-sectional area of pipe. */ + c[AREA] = M_PI * (diam) * (diam) / 4.0; + + /* Compute volume of pipe. */ + vol = c[AREA] * le; + + /* Divide the volume in 2 identical volumes */ + c[HALFVOL] = 0.5 * vol; + + /* Compute exchange area of pipe. */ + areaex = M_PI * diam * le; + + /* Divide the exchange area of pipe in 2 identical areas */ + c[HALFAREAEX] = 0.5 * areaex; + +/* <<<<<<<<<<< W] basic variable input + 4 dm1 mass flow rate at port 1 [g/s -> kg/s] basic variable input + + Port 2 has 4 variables: + + 1 t2 temperature at port 2 [K] explicit state (derivative `dt2') + 2 p2 pressure at port 2 [Pa] explicit state (derivative `dp2') + 3 dh2 enthalpy flow rate at port 2 [J/s -> W] basic variable input + 4 dm2 mass flow rate at port 2 [g/s -> kg/s] basic variable input +*/ + +/* There are 7 internal variables. + + 1 dhctr enthalpy flow at center of pipe [J/s -> W] basic variable + 2 dmctr mass flow at center of pipe [g/s -> kg/s] basic variable + 3 mgas mass of gas in pipe [g -> kg] basic variable + 4 re Reynolds number [null] basic variable + 5 cm mass flow parameter (cm) [(kg*K/J)**(1/2)] basic variable + 6 v mean gas velocity [m/s] basic variable + 7 ff friction factor [null] basic variable +*/ + +void pnl0003_(int *n, double *t1, double *dt1, double *p1, double *dp1 + , double *dh1, double *dm1, double *t2, double *dt2, double *p2 + , double *dp2, double *dh2, double *dm2, double *dhctr + , double *dmctr, double *mgas, double *re, double *cm, double *v + , double *ff, double rp[6], int ip[2], double c[7], int ic[1]) + +{ + int loop; +/* >>>>>>>>>>>>Extra Calculation Function Declarations Here. */ + static double zero = 0.0; + double dh1i, dm1i; + double dh2i, dm2i; + double sdh1, sdm1; + double sdh2, sdm2; + double m1, m2; + double dq1, dq2; + double pa1, pa2; + double dmgas; + double r; + int dummyreg; +/* <<<<<<<<<<< SI units conversions. */ + + *dm1 *= 1.00000000000000e-003; + *dm2 *= 1.00000000000000e-003; + +/* + Set all submodel outputs below: + + *dt1 = ??; + *dp1 = ??; + *dt2 = ??; + *dp2 = ??; + *dhctr = ??; + *dmctr = ??; + *mgas = ??; + *re = ??; + *cm = ??; + *v = ??; + *ff = ??; +*/ + + + +/* >>>>>>>>>>>>Calculation Function Executable Statements. */ + + /* set absolute pressures */ + pa1 = *p1 + c[PATM]; + pa2 = *p2 + c[PATM]; + + /* Compute flow through the pipe */ + pn2pipefr_(&pa1, t1, &pa2, t2, &diam, &rr, &le, &c[AREA], re, v, ff, + &dh1i, &dm1i, &dh2i, &dm2i, cm, &c[TABFR], &gi, &ic[SPL_FR], &dummyreg); + + /* Enthalpy flow and mass flow at center of pipe */ + *dhctr = dh1i; /* = -dh2i */ + *dmctr = dm1i; /* = -dm2i */ + + /* Compute the sum of the flows inside each volume */ + sdm1 = *dm1 + dm1i; + sdh1 = *dh1 + dh1i; + sdm2 = *dm2 + dm2i; + sdh2 = *dh2 + dh2i; + + dmgas = sdm1 + sdm2; + + /*** temperature & pressure variation ***/ + if (mode == 1) /* Polytropic model. */ + { + r = pn2ri_(&gi); + + /* Current mass in each volume */ + m1 = pa1 * c[HALFVOL] / (*t1 * r); + m2 = pa2 * c[HALFVOL] / (*t2 * r); + + pn2vol1_(dt1, dp1, t1, &pa1, + &sdm1, &m1, &zero, &c[HALFVOL], &k,&gi); + + pn2vol1_(dt2, dp2, t2, &pa2, + &sdm2, &m2, &zero, &c[HALFVOL], &k,&gi); + } + else /* Heat exchange. */ + { + dq1 = kth * c[HALFAREAEX] * (extemp - *t1); + + pn2vol_(dt1, dp1, &m1, t1, &pa1, + &sdm1, &sdh1, &c[HALFVOL], &zero, &dq1, &gi); + + dq2 = kth * c[HALFAREAEX] * (extemp - *t2); + + pn2vol_(dt2, dp2, &m2, t2, &pa2, + &sdm2, &sdh2, &c[HALFVOL], &zero, &dq2, &gi); + } + + *mgas = m1 + m2; + +/* <<<<<<<<<<< Common units conversions. */ + + *dm1 /= 1.00000000000000e-003; + *dm2 /= 1.00000000000000e-003; + *dmctr /= 1.00000000000000e-003; + *mgas /= 1.00000000000000e-003; +} + diff --git a/AmesimModels/help/source/PNL0003.spe b/AmesimModels/help/source/PNL0003.spe new file mode 100644 index 0000000..45b3ec6 --- /dev/null +++ b/AmesimModels/help/source/PNL0003.spe @@ -0,0 +1,285 @@ + + + + + + 0 + 24 + p2port + Compressibility + friction submodel of pneumatic pipe (C-R-C) + 0 + 7 + 1 + 1 + + + 16 + diameter of pipe + diam + True + 1.00000000000000e+01 + 1.00000000000000e+01 + 1.00000000000000e-003 + 1.00000000000000e+007 + mm + + + 17 + pipe length + le + True + 1.00000000000000e+00 + 1.00000000000000e+00 + 1.00000000000000e-006 + 1.00000000000000e+004 + m + + + 18 + relative roughness + rr + True + 1.00000000000000e-05 + 1.00000000000000e-05 + 0.00000000000000e+000 + 1.00000000000000e-001 + null + + + 19 + polytropic constant + k + (mode == 1) + 1.35000000000000e+00 + 1.35000000000000e+00 + 5.00000000000000e-001 + 2.00000000000000e+000 + null + + + 20 + thermal exchange coefficient + kth + (mode == 2) + 0.00000000000000e+00 + 0.00000000000000e+00 + 0.00000000000000e+000 + 1.00000000000000e+006 + J/m**2/K/s + + + 21 + external temperature + extemp + (mode == 2) + 2.93150000000000e+02 + 2.93150000000000e+02 + 1.00000000000000e+000 + 1.00000000000000e+003 + K + + + + + 22 + gas type index + gi + True + 1 + 1 + 1 + 99 + + + 23 + model + mode + True + 2 + 2 + 1 + 2 + + + polytropic + + + with thermal exchange + + + + + + + 9 + enthalpy flow at center of pipe + dhctr + True + 0 + 1 + J/s + + + 10 + mass flow at center of pipe + dmctr + True + 0 + 1 + g/s + + + 24 + mass of gas in pipe + mgas + True + 0 + 1 + g + + + 12 + Reynolds number + re + True + 0 + 1 + null + + + 13 + mass flow parameter (cm) + cm + True + 0 + 1 + (kg*K/J)**(1/2) + + + 14 + mean gas velocity + v + True + 0 + 1 + m/s + + + 15 + friction factor + ff + True + 0 + 1 + null + + + + + + 1 + temperature at port 1 + t1 + dt1 + True + 1 + 1 + 2 + K + 0.00000000000000e+000 + 1.00000000000000e+006 + 2.93150000000000e+002 + 2.93150000000000e+002 + + + 2 + pressure at port 1 + p1 + dp1 + True + 1 + 1 + 2 + Pa + -1.01300000000000e+005 + 1.00000000000000e+012 + 0.00000000000000e+000 + 0.00000000000000e+000 + + + 3 + enthalpy flow rate at port 1 + dh1 + True + 0 + 1 + 1 + J/s + + + 4 + mass flow rate at port 1 + dm1 + True + 0 + 1 + 1 + g/s + + + + + 5 + temperature at port 2 + t2 + dt2 + True + 1 + 1 + 2 + K + 0.00000000000000e+000 + 1.00000000000000e+004 + 2.93150000000000e+002 + 2.93150000000000e+002 + + + 6 + pressure at port 2 + p2 + dp2 + True + 1 + 1 + 2 + Pa + -1.01300000000000e+005 + 1.00000000000000e+012 + 0.00000000000000e+000 + 0.00000000000000e+000 + + + 7 + enthalpy flow rate at port 2 + dh2 + True + 0 + 1 + 1 + J/s + + + 8 + mass flow rate at port 2 + dm2 + True + 0 + 1 + 1 + g/s + + + + 0 + + diff --git a/AmesimModels/help/source/PNL00R.c b/AmesimModels/help/source/PNL00R.c new file mode 100644 index 0000000..8bd124c --- /dev/null +++ b/AmesimModels/help/source/PNL00R.c @@ -0,0 +1,224 @@ +/* Submodel PNL00R skeleton created by AME Submodel editing utility + ven. 5. août 14:34:41 2016 */ + + + +#include +#include +#include +#include "ameutils.h" +/* ******************************************************************************* +TITLE : PNL00R +------------------------------------------------------------------------------ +DESCRIPTION : + PNL00R is a submodel of a pneumatic pipe with only friction effects. + + Pipe friction is taken into account using a friction factor based on + the Reynolds number and the relative roughness. +------------------------------------------------------------------------------ +USAGE : + Use this submodel to simulate a pneumatic pipe with friction effects, + when the Mach number is low, ie gas velocity < 0.3 * speed of sound . + + The submodels PNGD001 or PNGD002 should be included in your circuit to + define the characteristics of the gas. +------------------------------------------------------------------------------ +PARAMETER SETTINGS : +------------------------------------------------------------------------------ +DATE OF CREATION / AUTHOR : + 2002 FS from PNL0R SN +------------------------------------------------------------------------------ +REVISIONS : +------------------------------------------------------------------------------ +LIST OF FUNCTIONS USED : + pn2pipefr_() : frictional coefficient in pneumatic pipes + pn2getatp_() : get atmospheric pressure +------------------------------------------------------------------------------ +SOURCE : + + This material contains trade secrets or otherwise confidential + information owned by Siemens Industry Software Inc. or its + affiliates (collectively, "Siemens"), or its licensors. Access to + and use of this information is strictly limited as set forth in the + Customer's applicable agreements with Siemens. + + Unpublished work. Copyright 2023 Siemens + +******************************************************************************* */ + +#define _SUBMODELNAME_ "PNL00R" + +/* >>>>>>>>>>>>Insert Private Code Here. */ +#define TABFR 0 /* real store 0, 1 & 2 are used by pn2pipefr */ +#define PATM 3 +#define AREA 4 + + +#define SPL_FR 0 +/* <<<<<<<<<<< m] + le pipe length [m] + rr relative roughness [null] +*/ + + +/* There is 1 integer parameter: + + gi gas type index +*/ + +void pnl00rin_(int *n, double rp[3], int ip[1], double c[5], int ic[1]) + +{ + int loop, error; +/* >>>>>>>>>>>>Extra Initialization Function Declarations Here. */ +/* <<<<<<<<<<<>>>>>>>>>>>Initialization Function Check Statements. */ + + pn2_valid_gas_(&gi, &error); + + if (diam <= 0.0) + { + error = 2; + amefprintf(stderr, "\nDiameter of pipe should be > 0 [mm].\n"); + } + + if (le <= 0.0) + { + error = 2; + amefprintf(stderr, "\nPipe length should be > 0 [m].\n"); + } + + if (rr < 0.0) + { + error = 2; + amefprintf(stderr, "\nRelative roughness should be >= 0.\n"); + } + +/* <<<<<<<<<<< 99) + { + amefprintf(stderr, "\ngas type index must be in range [1..99].\n"); + error = 2; + } + + SUBMODEL_HANDLE_AND_RESET_ERROR(_SUBMODELNAME_, n, error) + +/* Common -> SI units conversions. */ + + rp[0] *= 1.00000000000000e-003; + diam = rp[0]; + + +/* >>>>>>>>>>>>Initialization Function Executable Statements. */ + + c[PATM] = pn2getatp_(); + + /* Compute the cross-sectional area of pipe. */ + c[AREA] = M_PI * (diam) * (diam) / 4.0; + +/* <<<<<<<<<<< W] basic variable output + 2 dm2 mass flow rate at port 2 [g/s -> kg/s] basic variable output + 3 t2 temperature at port 2 [K] basic variable input + 4 p2 pressure at port 2 [Pa] basic variable input +*/ + +/* There are 4 internal variables. + + 1 re Reynolds number [null] basic variable + 2 cm mass flow parameter (cm) [(kg*K/J)**(1/2)] basic variable + 3 v mean gas velocity [m/s] basic variable + 4 ff friction factor [null] basic variable +*/ + +void pnl00r_(int *n, double *t1, double *p1, double *dh2, double *dm2 + , double *t2, double *p2, double *re, double *cm, double *v + , double *ff, double rp[3], int ip[1], double c[5], int ic[1]) + +{ + int loop; +/* >>>>>>>>>>>>Extra Calculation Function Declarations Here. */ + double pa1, pa2; + double dh1loc, dm1loc; + int dummyreg; +/* <<<<<<<<<<<>>>>>>>>>>>Calculation Function Executable Statements. */ + + /* set absolute pressure */ + pa1 = *p1 + c[PATM]; + pa2 = *p2 + c[PATM]; + + /* Compute flow through the pipe */ + + pn2pipefr_(&pa2, t2, &pa1, t1, &diam, &rr, &le, &c[AREA], re, v, ff, + dh2, dm2, &dh1loc, &dm1loc, cm, &c[TABFR], &gi, &ic[SPL_FR], &dummyreg); + +/* <<<<<<<<<<< Common units conversions. */ + + *dm2 /= 1.00000000000000e-003; +} + diff --git a/AmesimModels/help/source/PNL00R.spe b/AmesimModels/help/source/PNL00R.spe new file mode 100644 index 0000000..48d20b3 --- /dev/null +++ b/AmesimModels/help/source/PNL00R.spe @@ -0,0 +1,185 @@ + + + + + + 0 + 18 + p2port + Friction submodel of pneumatic pipe (R) + 0 + 5 + 1 + 1 + + + 13 + diameter of pipe + diam + True + 1.00000000000000e+01 + 1.00000000000000e+01 + 1.00000000000000e-003 + 1.00000000000000e+007 + mm + + + 14 + pipe length + le + True + 1.00000000000000e+00 + 1.00000000000000e+00 + 1.00000000000000e-006 + 1.00000000000000e+004 + m + + + 15 + relative roughness + rr + True + 1.00000000000000e-05 + 1.00000000000000e-05 + 0.00000000000000e+000 + 1.00000000000000e-001 + null + + + + + 16 + gas type index + gi + True + 1 + 1 + 1 + 99 + + + + + 9 + Reynolds number + re + True + 0 + 1 + null + + + 10 + mass flow parameter (cm) + cm + True + 0 + 1 + (kg*K/J)**(1/2) + + + 11 + mean gas velocity + v + True + 0 + 1 + m/s + + + 12 + friction factor + ff + True + 0 + 1 + null + + + + + + 17 + dh1 + True + 4 + 1 + 0 + 1 + + + 18 + dm1 + True + 4 + 1 + 1 + 1 + + + 3 + temperature at port 1 + t1 + True + 0 + 1 + 1 + K + + + 4 + pressure at port 1 + p1 + True + 0 + 1 + 1 + Pa + + + + + 5 + enthalpy flow rate at port 2 + dh2 + True + 0 + 1 + 2 + J/s + + + 6 + mass flow rate at port 2 + dm2 + True + 0 + 1 + 2 + g/s + + + 7 + temperature at port 2 + t2 + True + 0 + 1 + 1 + K + + + 8 + pressure at port 2 + p2 + True + 0 + 1 + 1 + Pa + + + + 0 + + diff --git a/AmesimModels/help/source/PNOR001.c b/AmesimModels/help/source/PNOR001.c new file mode 100644 index 0000000..6dd3859 --- /dev/null +++ b/AmesimModels/help/source/PNOR001.c @@ -0,0 +1,240 @@ +/* Submodel PNOR001 skeleton created by AME Submodel editing utility + lun. 10. juil. 17:22:57 2017 */ + + + +#include +#include +#include +#include "ameutils.h" +/* ******************************************************************************* +TITLE : PNOR001 +------------------------------------------------------------------------------ +DATE OF CREATION / AUTHOR : + 2002 : Created by FS from PNOR01 +------------------------------------------------------------------------------ +SOURCE : + + This material contains trade secrets or otherwise confidential + information owned by Siemens Industry Software Inc. or its + affiliates (collectively, "Siemens"), or its licensors. Access to + and use of this information is strictly limited as set forth in the + Customer's applicable agreements with Siemens. + + Unpublished work. Copyright 2023 Siemens + +******************************************************************************* */ + +#define _SUBMODELNAME_ "PNOR001" + +/* >>>>>>>>>>>>Insert Private Code Here. */ + +/* real stores */ +#define PATM 0 +#define AREA 1 +#define CQ 2 + +/* integer stores */ +#define DISC_ORIF 0 + +/* <<<<<<<<<<< m**2] + Cv flow coefficient (Cv) [null] + Kv flow coefficient (Kv) [null] +*/ + + +/* There are 2 integer parameters: + + gi gas type index + flowset flow coefficient setting +*/ + +void pnor001in_(int *n, double rp[4], int ip[2], double c[3] + , int ic[1]) + +{ + int loop, error; +/* >>>>>>>>>>>>Extra Initialization Function Declarations Here. */ +/* <<<<<<<<<<<>>>>>>>>>>>Initialization Function Check Statements. */ + + pn2_valid_gas_(&gi, &error); + + if (flowset == 1) + { + if (area < 0.0) + { + error = 2; + amefprintf(stderr, "\nOrifice area should be positive.\n"); + } + + if (cq <= 0.0) + { + error = 2; + amefprintf(stderr, "\nFlow coefficient should be strictly positive.\n"); + } + } + else if (flowset == 2) + { + if (Cv < 0.0) + { + error = 2; + amefprintf(stderr, "\nFlow coefficient (Cv) should be positive (value is %g).\n", Cv); + } + } + else + { + if (Kv < 0.0) + { + error = 2; + amefprintf(stderr, "\nFlow coefficient (Kv) should be positive (value is %g).\n", Kv); + } + } + +/* <<<<<<<<<<< 99) + { + amefprintf(stderr, "\ngas type index must be in range [1..99].\n"); + error = 2; + } + if (flowset < 1 || flowset > 3) + { + amefprintf(stderr, "\nflow coefficient setting must be in range [1..3].\n"); + error = 2; + } + + SUBMODEL_HANDLE_AND_RESET_ERROR(_SUBMODELNAME_, n, error) + +/* Common -> SI units conversions. */ + + rp[1] *= 1.00000000000000e-006; + area = rp[1]; + + +/* >>>>>>>>>>>>Initialization Function Executable Statements. */ + + /* get atmospheric pressure */ + c[PATM] = pn2getatp_(); + + if (flowset == 1) + { + c[CQ] = cq; + c[AREA] = area; + } + else + { + /* calculation of equivalent area with Cv or Kv. + Default value of cq; the same value will be used in pn2rcqfix. */ + c[CQ] = 0.72; + + if (flowset == 2) /* Cv */ + orif_areafromcv_(&Cv, &c[CQ], &c[AREA]); + else + orif_areafromkv_(&Kv, &c[CQ], &c[AREA]); + } + +/* <<<<<<<<<<< W] basic variable output + 2 dm1 mass flow rate at port 1 [g/s -> kg/s] basic variable output + 3 temp1 temperature at port 1 [K] basic variable input + 4 press1 pressure at port 1 [Pa] basic variable input + + Port 2 has 4 variables: + + 1 dh2 duplicate of dh1 (sign reversed) + 2 dm2 duplicate of dm1 (sign reversed) + 3 temp2 temperature at port 2 [K] basic variable input + 4 press2 pressure at port 2 [Pa] basic variable input +*/ + +/* There are 2 internal variables. + + 1 cm mass flow parameter (cm) [(kg*K/J)**(1/2)] basic variable + 2 gasvel vena contracta gas velocity [m/s] basic variable +*/ + +void pnor001_(int *n, double *dh1, double *dm1, double *temp1 + , double *press1, double *temp2, double *press2, double *cm + , double *gasvel, double rp[4], int ip[2], double c[3] + , int ic[1]) + +{ + int loop; +/* >>>>>>>>>>>>Extra Calculation Function Declarations Here. */ + double pressa1, pressa2; +/* <<<<<<<<<<<>>>>>>>>>>>Calculation Function Executable Statements. */ + + /* set absolute pressures */ + pressa1 = *press1 + c[PATM]; + pressa2 = *press2 + c[PATM]; + + /* calculation of the flows */ + pn2rcqfix_( dh1, dm1, temp1, &pressa1, temp2, &pressa2, &c[AREA], &c[CQ], &gi, + cm, gasvel, &ic[DISC_ORIF]); + +/* <<<<<<<<<<< Common units conversions. */ + + *dm1 /= 1.00000000000000e-003; +} + diff --git a/AmesimModels/help/source/PNOR001.spe b/AmesimModels/help/source/PNOR001.spe new file mode 100644 index 0000000..fc8d153 --- /dev/null +++ b/AmesimModels/help/source/PNOR001.spe @@ -0,0 +1,199 @@ + + + + + + 0 + 18 + pn_orifice + pneumatic orifice (constant flow coefficient) + 0 + 3 + 1 + 1 + + + 12 + flow coefficient (Cq) + cq + flowset==1 + 7.20000000000000e-01 + 7.20000000000000e-01 + 1.00000000000000e-010 + 1.00000000000000e+000 + null + + + 11 + orifice area + area + flowset==1 + 5.00000000000000e+00 + 5.00000000000000e+00 + 0.00000000000000e+000 + 1.00000000000000e+006 + mm**2 + + + 17 + flow coefficient (Cv) + Cv + flowset==2 + 5.00000000000000e-01 + 5.00000000000000e-01 + 0.00000000000000e+000 + 1.00000000000000e+030 + null + + + 18 + flow coefficient (Kv) + Kv + flowset==3 + 4.00000000000000e-01 + 4.00000000000000e-01 + 0.00000000000000e+000 + 1.00000000000000e+030 + null + + + + + 13 + gas type index + gi + True + 1 + 1 + 1 + 99 + + + 14 + flow coefficient setting + flowset + True + 1 + 1 + 1 + 3 + + + Cq + + + Cv + + + Kv + + + + + + + 9 + mass flow parameter (cm) + cm + True + 0 + 1 + (kg*K/J)**(1/2) + + + 10 + vena contracta gas velocity + gasvel + True + 0 + 1 + m/s + + + + + + 1 + enthalpy flow rate at port 1 + dh1 + True + 0 + 1 + 2 + J/s + + + 2 + mass flow rate at port 1 + dm1 + True + 0 + 1 + 2 + g/s + + + 3 + temperature at port 1 + temp1 + True + 0 + 1 + 1 + K + + + 4 + pressure at port 1 + press1 + True + 0 + 1 + 1 + Pa + + + + + 5 + dh2 + True + 4 + 0 + 0 + 1 + + + 6 + dm2 + True + 4 + 0 + 1 + 1 + + + 7 + temperature at port 2 + temp2 + True + 0 + 1 + 1 + K + + + 8 + pressure at port 2 + press2 + True + 0 + 1 + 1 + Pa + + + + 0 + + diff --git a/AmesimModels/help/source/PNVO001.c b/AmesimModels/help/source/PNVO001.c new file mode 100644 index 0000000..1316872 --- /dev/null +++ b/AmesimModels/help/source/PNVO001.c @@ -0,0 +1,254 @@ +/* Submodel PNVO001 skeleton created by AME Submodel editing utility + ven. 6. oct. 11:10:58 2017 */ + + + +#include +#include +#include +#include "ameutils.h" +/* ******************************************************************************* +TITLE : PNVO001 +------------------------------------------------------------------------------ +DATE OF CREATION / AUTHOR : + 2002 : Created by FS from PNVO01 +------------------------------------------------------------------------------ +SOURCE : + + This material contains trade secrets or otherwise confidential + information owned by Siemens Industry Software Inc. or its + affiliates (collectively, "Siemens"), or its licensors. Access to + and use of this information is strictly limited as set forth in the + Customer's applicable agreements with Siemens. + + Unpublished work. Copyright 2023 Siemens + +******************************************************************************* */ + +#define _SUBMODELNAME_ "PNVO001" + +/* >>>>>>>>>>>>Insert Private Code Here. */ + +/* real stores */ +#define PATM 0 +#define AREAMAX 1 +#define CQ 2 + +/* integer stores */ +#define DISC_LIMIT 0 +#define DISC_ORIF 1 + +/* <<<<<<<<<<< m**2] + Cv maximum flow coefficient (Cv) [null] + Kv maximum flow coefficient (Kv) [null] +*/ + + +/* There are 2 integer parameters: + + gi gas type index + flowset flow coefficient setting +*/ + +void pnvo001in_(int *n, double rp[4], int ip[2], double c[3] + , int ic[2]) + +{ + int loop, error; +/* >>>>>>>>>>>>Extra Initialization Function Declarations Here. */ +/* <<<<<<<<<<<>>>>>>>>>>>Initialization Function Check Statements. */ + + pn2_valid_gas_(&gi, &error); + + if (flowset == 1) + { + if (area0 < 0.0) + { + error = 2; + amefprintf(stderr, "\nOrifice area at maximum opening should be positive.\n"); + } + + if (cq <= 0.0 ) + { + error = 2; + amefprintf(stderr, "\nFlow coefficient should be strictly positive.\n"); + } + } + else if (flowset == 2) + { + if (Cv < 0.0) + { + error = 2; + amefprintf(stderr, "\nMaximum flow coefficient (Cv) should be positive (value is %g).\n", Cv); + } + } + else + { + if (Kv < 0.0) + { + error = 2; + amefprintf(stderr, "\nMaximum flow coefficient (Kv) should be positive (value is %g).\n", Kv); + } + } + +/* <<<<<<<<<<< 99) + { + amefprintf(stderr, "\ngas type index must be in range [1..99].\n"); + error = 2; + } + if (flowset < 1 || flowset > 3) + { + amefprintf(stderr, "\nflow coefficient setting must be in range [1..3].\n"); + error = 2; + } + + SUBMODEL_HANDLE_AND_RESET_ERROR(_SUBMODELNAME_, n, error) + +/* Common -> SI units conversions. */ + + rp[1] *= 1.00000000000000e-006; + area0 = rp[1]; + + +/* >>>>>>>>>>>>Initialization Function Executable Statements. */ + + /* get atmospheric pressure */ + c[PATM] = pn2getatp_(); + + if (flowset == 1) + { + c[CQ] = cq; + c[AREAMAX] = area0; + } + else + { + /* calculation of equivalent maximal area with Cv or Kv. + Default value of cq; the same value will be used in pn2rcqfix. */ + c[CQ] = 0.72; + + if (flowset == 2) /* Cv */ + orif_areafromcv_(&Cv, &c[CQ], &c[AREAMAX]); + else + orif_areafromkv_(&Kv, &c[CQ], &c[AREAMAX]); + } + +/* <<<<<<<<<<< W] basic variable output + 2 dm2 mass flow rate at port 2 [g/s -> kg/s] basic variable output + 3 temp2 temperature at port 2 [K] basic variable input + 4 press2 pressure at port 2 [Pa] basic variable input + + Port 3 has 4 variables: + + 1 dh3 duplicate of dh2 (sign reversed) + 2 dm3 duplicate of dm2 (sign reversed) + 3 temp3 temperature at port 3 [K] basic variable input + 4 press3 pressure at port 3 [Pa] basic variable input +*/ + +/* There are 3 internal variables. + + 1 xv fractional opening [null] basic variable + 2 cm mass flow parameter (cm) [(kg*K/J)**(1/2)] basic variable + 3 gasvel vena contracta gas velocity [m/s] basic variable +*/ + +void pnvo001_(int *n, double *res, double *dh2, double *dm2 + , double *temp2, double *press2, double *temp3, double *press3 + , double *xv, double *cm, double *gasvel, double rp[4] + , int ip[2], double c[3], int ic[2]) + +{ + int loop; +/* >>>>>>>>>>>>Extra Calculation Function Declarations Here. */ + double marea; /* modulated area */ + double pressa2, pressa3; + static double zero = 0.0, one = 1.0; +/* <<<<<<<<<<<>>>>>>>>>>>Calculation Function Executable Statements. */ + + /* set absolute pressure */ + pressa2 = *press2 + c[PATM]; + pressa3 = *press3 + c[PATM]; + + *xv = dlimit_(res, &zero, &one, &ic[DISC_LIMIT]); + + /* limitation of the modulated area */ + marea = *xv * c[AREAMAX]; + + /*** calculation of the flows ***/ + pn2rcqfix_( dh2, dm2, temp2, &pressa2, temp3, &pressa3, &marea, &c[CQ], &gi, + cm, gasvel, &ic[DISC_ORIF] ); + +/* <<<<<<<<<<< Common units conversions. */ + + *dm2 /= 1.00000000000000e-003; +} + diff --git a/AmesimModels/help/source/PNVO001.spe b/AmesimModels/help/source/PNVO001.spe new file mode 100644 index 0000000..d81a91c --- /dev/null +++ b/AmesimModels/help/source/PNVO001.spe @@ -0,0 +1,220 @@ + + + + + + 0 + 20 + pn_morifice + modulated pneumatic orifice (constant flow coefficient) + 0 + 3 + 2 + 1 + + + 12 + flow coefficient (Cq) + cq + flowset==1 + 7.20000000000000e-01 + 7.20000000000000e-01 + 1.00000000000000e-010 + 1.00000000000000e+000 + null + + + 13 + orifice area at maximum opening + area0 + flowset==1 + 5.00000000000000e+00 + 5.00000000000000e+00 + 0.00000000000000e+000 + 1.00000000000000e+006 + mm**2 + + + 18 + maximum flow coefficient (Cv) + Cv + flowset==2 + 5.00000000000000e-01 + 5.00000000000000e-01 + 0.00000000000000e+000 + 1.00000000000000e+030 + null + + + 19 + maximum flow coefficient (Kv) + Kv + flowset==3 + 4.00000000000000e-01 + 4.00000000000000e-01 + 0.00000000000000e+000 + 1.00000000000000e+030 + null + + + + + 14 + gas type index + gi + True + 1 + 1 + 1 + 99 + + + 15 + flow coefficient setting + flowset + True + 1 + 1 + 1 + 3 + + + Cq + + + Cv + + + Kv + + + + + + + 20 + fractional opening + xv + True + 0 + 1 + null + + + 10 + mass flow parameter (cm) + cm + True + 0 + 1 + (kg*K/J)**(1/2) + + + 11 + vena contracta gas velocity + gasvel + True + 0 + 1 + m/s + + + + + + 1 + input signal + res + True + 0 + 1 + 1 + null + + + + + 2 + enthalpy flow rate at port 2 + dh2 + True + 0 + 1 + 2 + J/s + + + 3 + mass flow rate at port 2 + dm2 + True + 0 + 1 + 2 + g/s + + + 4 + temperature at port 2 + temp2 + True + 0 + 1 + 1 + K + + + 5 + pressure at port 2 + press2 + True + 0 + 1 + 1 + Pa + + + + + 6 + dh3 + True + 4 + 1 + 0 + 1 + + + 7 + dm3 + True + 4 + 1 + 1 + 1 + + + 8 + temperature at port 3 + temp3 + True + 0 + 1 + 1 + K + + + 9 + pressure at port 3 + press3 + True + 0 + 1 + 1 + Pa + + + + 0 + + diff --git a/AmesimModels/help/source/pn_public.h b/AmesimModels/help/source/pn_public.h new file mode 100644 index 0000000..15feacf --- /dev/null +++ b/AmesimModels/help/source/pn_public.h @@ -0,0 +1,1439 @@ +/****************************************************************************** +* This material contains trade secrets or otherwise confidential +* information owned by Siemens Industry Software Inc. or its +* affiliates (collectively, "Siemens"), or its licensors. Access to +* and use of this information is strictly limited as set forth in the +* Customer's applicable agreements with Siemens. +* +* Unpublished work. Copyright 2023 Siemens +******************************************************************************/ + +#ifndef PN_PUBLIC_H +#define PN_PUBLIC_H + +/**************************************************************************/ +/* */ +/* Generic Macros for the PNEUMATIC LIBRARY */ +/* */ +/**************************************************************************/ + +#define PN_SUBMODEL_WARNING(submodelInstance) \ + amefprintf(stderr, "\nWarning in %s instance %d.\n", _SUBMODELNAME_, *submodelInstance); + +#define PN_SUBMODEL_ERROR(submodelInstance) \ + amefprintf(stderr, "\nFatal error in %s instance %d.\n", _SUBMODELNAME_, *submodelInstance); \ + amefprintf(stderr, "Terminating the program.\n"); \ + AmeExit(1); + + /* Stores structures name */ +#define PN_STORESDEF2(x, y) x ## y +#define PN_STORESDEF(x, y) PN_STORESDEF2(x, y) +#define PN_SUBMODEL_IC_DEF PN_STORESDEF(PNSUBMODEL, _IC_DEF_) +#define PN_SUBMODEL_C_DEF PN_STORESDEF(PNSUBMODEL, _C_DEF_ ) +#define PN_SUBMODEL_PS_DEF PN_STORESDEF(PNSUBMODEL, _PS_DEF_) + +#define PN_NAMECHECK2(submodel, submodelname) \ + if (strcmp(submodelname, #submodel) != 0)\ + {\ + amefprintf(stderr, "\nPlease update \"PNSUBMODEL\" variable in %s code.\n", __FILE__);\ + PN_SUBMODEL_WARNING(n);\ + } +#define PN_NAMECHECK(submodel, submodelname) PN_NAMECHECK2(submodel, submodelname) + +/**************************************************************************/ +/* */ +/* Prototypes for the PNEUMATIC LIBRARY */ +/* */ +/**************************************************************************/ + +extern void pn2vol2_ +( + double *dtemp, + double *dpress, + double *temp, + double *press, + double *sdm, + double *mgas, + double *sdh, + double *vvolt, + double *volt, + double *kth, + double *sth, + double *extemp, + int *gi +); + + +/**************************************************************************/ +/* */ +/* Prototypes for the NEW PNEUMATIC LIBRARY */ +/* */ +/**************************************************************************/ + +/**************************** pn2atmos.c ******************************/ + +extern void pn2setatp_ +( + double *patmos +); + +extern double pn2getatp_(); + +/**************************** pn2lambda.c ******************************/ + +extern int pn2setlambda_ +( + double *lbda +); + +/**************************** pn2gasprops.c **************************/ + +extern void pn2CreateMixtureProp3_ +( + double *YiSpecies, + int *giSpecies, + int *nspec, + int *gi, + char *submodelName, + int *submodelInstance +); + +extern int pn2_valid_gas_(int *index, int *error); + +extern void pn2checkpt_ +( + double *p, + double *temp, + int *index +); + +extern double pn2cmpti_ +( + double *pr, + double *p, + double *temp, + int *index +); + +extern double pn2cppti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2cvpti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2gpti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2gspti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2hpti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2spti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2lampti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2mupti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2nupti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2pcpti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2rhopti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2cspti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2zpti_ +( + double *p, + double *temp, + int *index +); + +extern double pn2ri_ +( + int *index +); + +/*! pn2IsEosPerfect_: returns 1 if gas EoS is perfect. 0 otherwise */ +extern int pn2IsEosPerfect_ +( + int *index +); + +/**************************** pn2geta1b1.c **************************/ + +extern double pn2geta1b1_ +( + double *delta, + double *rc, + double *hdia, + double *A1, + double *B1 +); + +/**************************** pn2orif_zeta.c **************************/ + +extern void pn2orif_zeta_ +( + double *pa1, + double *t1, + double *pa2, + double *t2, + double *area, + double *diam, + int *n12, + int *n21, + double *dm1, + double *dmh1, + double *re, + double *v, + double *ff, + int *gi, + int *reg +); + +extern void pn2handledisc_ +( + double *p1, + double *p2, + int *handle_disc, + int *use_hyst, + double *hyst, + int *reg +); + +/**************************** pn2pipes.c **************************/ + +extern void pn2pipefr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *v, + double *ff, + double *dh1, + double *dm1, + double *dh2, + double *dm2, + double *cm, + double *c, + int *gi, + int *reg1, + int *reg2 +); + +extern void pn2pipifr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *dm1, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *v, + double *ff, + double *dm1dot, + double *dh1, + double *c, + int *reg, + int *gi +); + +extern void pn2pipefrber_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *diam, + double *rr, + double *l, + double *area, + double *v, + double *re, + double *ff, + double *dh1, + double *dm1, + double *dh2, + double *dm2, + double *c, + int *gi, + int *reg +); + + +/**************************** pn2rcqfix.c **************************/ + +extern void pn2rcqfix_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *area, + double *cq, + int *gi, + double *cm, + double *gasvel, + int *reg +); + + +/**************************** pn2rcqnor.c **************************/ + +extern void pn2rcqnor_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *b, + double *c, + int *gi, + double *cm, + double *gasvel, + int *reg +); + + +/**************************** pn2rcqper.c **************************/ + +extern void pn2rcqper_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *area, + double *cq, + int *gi, + double *cm, + double *gasvel, + int *reg +); + + +/**************************** pn2rtabprops.c **************************/ + +extern int pn2readeos_ +( + char *fileName, + double **eosValues +); + +extern int pn2readcphs_ +( + char *fileName, + double **cphsValues +); + +extern int pn2readmu_ +( + char *fileName, + double **muValues +); + +extern int pn2readlambda_ +( + char *fileName, + double **lambdaValues +); + + +/**************************** pn2init.c **************************/ +#define _PNGD00_RP_NUMBER_ 8 +#define _PNGD00_IP_NUMBER_ 4 +#define _PNGD00_TP_NUMBER_ 5 +#define _PNGD00_C_NUMBER_ 0 +#define _PNGD00_IC_NUMBER_ 1 +int preinit_pngd00_ +( + int *n, + double *rp, + int *ip, + char *tp[5], + double *c, + int *ic +); + + +/**************************** pnturb.c **************************/ + +extern double pnturbdmctable_ +( + int *index, double *ratio, double *Nc, double *ratiolimit, int *disc +); + +extern double pnturbpower_ +( + double *pressup, double *tempup, double *dhturbin, double *dhturbout, + double *dmturb, double *ratio, double *eff, int *gi +); + +extern double pnTurbineBackflowPower_( + double *pressDownBf, + double *tempDownBf, + double *pressUpBf, + double *tempUpBf, + double *BFarea, + double *cqbf, + int *gi, + double *dhTurbOut, + double *dhTurbIn, + double *dmc, + double *eff, + double *dmTurb, + int *reg1 +); + +extern double pnTurbineTorque_ +( + double *w, double *Pmec +); + +/**************************** pn2vol1.c **************************/ + +extern void pn2vol1_ +( + double *dtemp, + double *dpress, + double *temp, + double *press, + double *sdm, + double *mgas, + double *vvolt, + double *volt, + double *k, + int *gi +); + + +/**************************** pn2vol2.c **************************/ + +extern void pn2vol2_ +( + double *dtemp, + double *dpress, + double *temp, + double *press, + double *sdm, + double *mgas, + double *sdh, + double *vvolt, + double *volt, + double *kth, + double *sth, + double *extemp, + int *gi +); + + +/**************************** pn2vol.c **************************/ + +extern void pn2vol_ +( + double *dtemp, + double *dpress, + double *mgas, + double *temp, + double *press, + double *sdm, + double *sdh, + double *volt, + double *dvol, + double *dq, + int *gi +); + + +/************************** pnvalvearea.c ****************************/ + +/*! pnbuttvalvearea_: computes the cross-sectional area of a butterfly valve */ +extern void pnbuttvalvearea_ +( + double *phi, + double *d, + double *D, + double *area +); + +/*! pngatevalvearea_: computes the cross-sectional area of a gate valve */ +extern void pngatevalvearea_ +( + double *x, + double *d, + double *D, + double *area +); + + +/**************************************************************************/ +/* */ +/* Prototypes for the THERMAL-PNEUMATIC LIBRARY */ +/* */ +/**************************************************************************/ + +/**************************** tpzconv.c **************************/ + +extern double tpzconv_ +( + double *le, + double *diam, + double *alpha +); + +/**************************** tpzdiff.c **************************/ + +extern double tpzdiff_ +( + double *area1, + double *area2, + double *alpha +); + +/** Special heat exchanges **/ +extern void pn_Nu_extconv_free_plate_ +( + double *Pr, + double *Ra, + double *beta, + double *tFluid, + double *tPlate, + double *cdim_factor, + double *Nu +); + +extern void pn_Nu_extconv_forced_plate_ +( + double *Pr, + double *Re, + double *Nu +); + +extern void pn_Nu_extconv_free_cyl_ +( + double *Pr, + double *Ra, + double *Nu +); + +extern void pn_Nu_extconv_forced_cyl_ +( + double *Pr, + double *Re, + double *Nu +); + + +/* Obsolete utilities */ +extern double pngeta1b1_ +( + double *delta, double *rc, double *hdia, + double *A1, double *B1 +); + +extern double pngetcm_ +( + double *pr +); + +extern double pngetcp_(); + +extern double pngetcv_(); + +extern double pngetg_(); + +extern double pngetlcm_ +( + double *lambda, + double *pr +); + +extern double pngetmu_(); + +extern double pngetpc_(); + +extern double pngetr_(); + +extern double pngetrho_ +( + double *p, + double *temp +); + +extern double pnjetforc_ +( + double *dm, + double *v, + double *teta +); + +extern double pnsmooth_ +( + double *f1, + double *f2, + double *x, + double *x1, + double *x2 +); + +extern void pnchkgas_(); + +extern void pnpipefr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *v, + double *ff, + double *dh1, + double *dm1, + double *dh2, + double *dm2, + double *c, + int *reg +); + +extern void pnpipifr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *dm1, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *v, + double *ff, + double *dm1dot, + double *dh1, + double *dh2, + double *c, + int *reg +); + +extern void pnrcqfix2_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *dh2, + double *dm2, + double *temp2, + double *press2, + double *area, + double *cq, + double *cm, + double *gasvel, + int *reg1, + int *reg2 +); + +extern void pnrcqfix_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *dh2, + double *dm2, + double *temp2, + double *press2, + double *area, + double *cq, + double *cm, + int *reg1, + int *reg2 +); + +extern void pnrcqnor2_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *dh2, + double *dm2, + double *temp2, + double *press2, + double *b, + double *c, + double *cm, + double *gasvel, + int *reg1, + int *reg2 +); + +extern void pnrcqnor_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *dh2, + double *dm2, + double *temp2, + double *press2, + double *b, + double *c, + double *cm, + int *reg1, + int *reg2 +); + +extern void pnrcqper2_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *dh2, + double *dm2, + double *temp2, + double *press2, + double *area, + double *cq, + double *cm, + double *gasvel, + int *reg1, + int *reg2 +); + +extern void pnrcqper_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *dh2, + double *dm2, + double *temp2, + double *press2, + double *area, + double *cq, + double *cm, + int *reg1, + int *reg2 +); + +extern void pnsetcpv2_ +( + double *cp, + double *cv, + double *mu +); + +extern void pnsetcpv_ +( + double *cp, + double *cv +); + +extern void pnsetgas2_ +( + double *cp, + double *cv, + double *g, + double *r, + double *mu +); + +extern void pnsetgas_ +( + double *cp, + double *cv, + double *g, + double *r +); + +extern void pnvol_ +( + double *dtemp, + double *dpress, + double *temp, + double *press, + double *sdm, + double *mgas, + double *sdh, + double *vvolt, + double *volt, + double *k, + double *kth, + double *sth, + double *extemp, + int *mode +); + +extern void pnvol1_ +( + double *dtemp, + double *dpress, + double *temp, + double *press, + double *sdm, + double *mgas, + double *vvolt, + double *volt, + double *k +); + +extern void pnvol2_ +( + double *dtemp, + double *dpress, + double *temp, + double *press, + double *sdm, + double *mgas, + double *sdh, + double *vvolt, + double *volt, + double *kth, + double *sth, + double *extemp +); + +extern void pn2CreateMixtureProp_ +( + double *lrp, + int *lip +); + +extern void pn2CreateMixtureProp2_ +( + double *YiSpecies, + int *giSpecies, + double *pRefA, + int *nspec, + int *gi +); + +extern void pn2CreateGasProp_ +( + int index +); + +extern void pn2RegisterPerfectGasProp_ +( + double cp, + double cv, + double mu, + double lam, + int index +); + +extern void pn2RegisterRealGasProp_ +( + char *tp0, double *rp1, double *rp2, + double *rp3, int *eosind, int *index +); + +extern void pn2RegisterGenericGasProp_ +( +#ifndef _NO_PROTO + double *eosValues, + double *cphsValues, + double *muValues, + double *lambdaValues, + char *gasName, + int *index, + char *submodelName, + int *submodelInstance +#endif +); + +extern void pn2RegisterGasProp_ +( + double *rp, + char *tp, + int index +); + +extern int pn2SetGasAsPerfectAt_( + double *pressA, + double *tempK, + int *index +); + +extern void pn2checkgas_(int *index); + +extern double pn2orift_ +( + double *pa, + double *ta, + double *pb, + double *tb, + double *area, + double *hdia, + double *coefab, + double *coefba, + double *lamcab, + double *lamcba, + int *fi, + int *ic +); + +extern int pn2rtabprops_ +( + char *name, + double *xvalues, + int *num +); + +extern double pnturbbfpower_ +( + double *pressup, double *tempup, double *pressdown, double *tempdown, + double *dhturbin, double *dhturbout, double *dmc, double *eff, double *dmturb, + double *BFarea, double *cqbf, int *gi, int *reg1 +); + + +extern double pnturbtorque_ +( + double *w, double *Pmec +); + +extern void pn2seteosuserd_(int index); + +extern void tp2setatp_ +( + double *patmos +); + +extern double tp2getatp_(); + +extern void tp2setlambda_ +( + double *lbda +); + +extern void tp2RegisterPerfectGasProp_ +( + double cp, + double cv, + double mu, + double lam, + int index +); + +extern void tp2RegisterRealGasProp_ +( + char *tp0, double *rp1, double *rp2, + double *rp3, int *eosind, int *index +); + +extern void tp3RegisterPerfectGasProp_ +( + double cp, + double cv, + double mu, + double lam, + double tmin, + double tmax, + int index +); + +extern void RegisterTpGasProp_ +( + double *rp, + char *tp, + int index +); + +extern void tp2CreateMixtureProp_ +( + double *lrp, + int *lip +); + +extern void tpcheckgas_ +( + int *index +); + +extern void tpcheckpt_ +( + double *p, + double *temp, + int *index +); + +extern double tpcmpti_ +( + double *pr, + double *p, + double *temp, + int *index +); + +extern double tpcppti_ +( + double *p, + double *temp, + int *index +); + +extern double tpcvpti_ +( + double *p, + double *temp, + int *index +); + +extern double tpgpti_ +( + double *p, + double *temp, + int *index +); + +extern double tpgspti_ +( + double *p, + double *temp, + int *index +); + +extern double tphpti_ +( + double *p, + double *temp, + int *index +); + +extern double tplampti_ +( + double *p, + double *temp, + int *index +); + +extern double tpalphapti_ +( + double *p, + double *temp, + int *index +); + +extern double tpcspti_ +( + double *p, + double *temp, + int *index +); + +extern double tpmupti_ +( + double *p, + double *temp, + int *index +); + +extern double tpnupti_ +( + double *p, + double *temp, + int *index +); + +extern double tppcpti_ +( + double *p, + double *temp, + int *index +); + +extern double tprhopti_ +( + double *p, + double *temp, + int *index +); + +extern double tpri_ +( + int *index +); + +extern void tpsetuserd_ +( + int index +); + +extern double tpzpti_ +( + double *p, + double *temp, + int *index +); + +extern double tpgeta1b1_ +( + double *delta, + double *rc, + double *hdia, + double *A1, + double *B1 +); + +extern double tporift_ +( + double *pa, + double *ta, + double *pb, + double *tb, + double *area, + double *hdia, + double *coefab, + double *coefba, + double *lamcab, + double *lamcba, + int *fi, + int *ic +); + +extern void tp2pipefr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *v, + double *ff, + double *dh1, + double *dm1, + double *dh2, + double *dm2, + double *cm, + double *c, + int *gi, + int *reg1, + int *reg2 +); + +extern void tp2pipifr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *dm1, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *v, + double *ff, + double *dm1dot, + double *dh1, + double *c, + int *reg, + int *gi +); + +extern void tp2pipefrber_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *diam, + double *rr, + double *l, + double *area, + double *v, + double *re, + double *ff, + double *dh1, + double *dm1, + double *dh2, + double *dm2, + double *c, + int *gi, + int *reg +); + +extern void tp2rcqfix_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *area, + double *cqmax, + int *gi, + double *cm, + double *gasvel, + int *reg +); + +extern void tp2rcqnor_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *b, + double *c, + int *gi, + double *cm, + double *gasvel, + int *reg +); + + +extern void tp2rcqper_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *area, + double *cq, + int *gi, + double *cm, + double *gasvel, + int *reg +); + +extern void tppipefr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *ff, + double *dh1, + double *dm1, + double *dh2, + double *dm2, + double *cm, + double *c, + int *gi, + int *reg1, + int *reg2 +); + +extern void tppipifr_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *dm1, + double *diam, + double *rr, + double *l, + double *area, + double *re, + double *v, + double *ff, + double *dm1dot, + double *dh1, + double *c, + int *reg, + int *gi +); + +extern void tppipefrber_ +( + double *p1, + double *t1, + double *p2, + double *t2, + double *diam, + double *rr, + double *l, + double *area, + double *v, + double *re, + double *ff, + double *dh1, + double *dm1, + double *dh2, + double *dm2, + double *c, + int *gi, + int *reg +); + +extern void tprcqfix_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *area, + double *cq, + int *gi, + double *cm, + double *gasvel, + int *reg +); + +extern void tprcqnor_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *b, + double *c, + int *gi, + double *cm, + double *gasvel, + int *reg +); + +extern void tprcqper_ +( + double *dh1, + double *dm1, + double *temp1, + double *press1, + double *temp2, + double *press2, + double *area, + double *cq, + int *gi, + double *cm, + double *gasvel, + int *reg +); + +extern int tprtabprops_ +( + char *name, + double *xvalues, + int *num +); + +extern double tpsmooth_ +( + double *f1, + double *f2, + double *x, + double *x1, + double *x2 +); + +extern void tpvol_ +( + double *dtemp, + double *dpress, + double *mgas, + double *temp, + double *press, + double *sdm, + double *sdh, + double *volt, + double *dvol, + double *dq, + int *gi +); + +#endif /* PN_PUBLIC_H */ diff --git a/AmesimModels/help/utilities/pn2pipefr_.pdf b/AmesimModels/help/utilities/pn2pipefr_.pdf new file mode 100644 index 0000000..45f264d Binary files /dev/null and b/AmesimModels/help/utilities/pn2pipefr_.pdf differ diff --git a/AmesimModels/help/utilities/pn2rcqfix_.pdf b/AmesimModels/help/utilities/pn2rcqfix_.pdf new file mode 100644 index 0000000..8ca95ef Binary files /dev/null and b/AmesimModels/help/utilities/pn2rcqfix_.pdf differ diff --git a/AmesimModels/help/utilities/pn2vol.pdf b/AmesimModels/help/utilities/pn2vol.pdf new file mode 100644 index 0000000..bace2b0 Binary files /dev/null and b/AmesimModels/help/utilities/pn2vol.pdf differ diff --git a/AmesimModels/help/utilities/pn2vol1.pdf b/AmesimModels/help/utilities/pn2vol1.pdf new file mode 100644 index 0000000..b3b00a5 Binary files /dev/null and b/AmesimModels/help/utilities/pn2vol1.pdf differ diff --git a/AmesimModels/help/utilities/pn2vol2_.pdf b/AmesimModels/help/utilities/pn2vol2_.pdf new file mode 100644 index 0000000..7c541eb Binary files /dev/null and b/AmesimModels/help/utilities/pn2vol2_.pdf differ diff --git a/AmesimModels/test_mql.ame b/AmesimModels/test_mql.ame new file mode 100644 index 0000000..53419b0 Binary files /dev/null and b/AmesimModels/test_mql.ame differ diff --git a/AmesimModels/test_mql/README.md b/AmesimModels/test_mql/README.md new file mode 100644 index 0000000..0ba836e --- /dev/null +++ b/AmesimModels/test_mql/README.md @@ -0,0 +1,140 @@ +# test_mql + +本目录记录 AMESim 模型 `test_mql.ame` 向 `PythonModels` 迁移时使用的源模型信息、结果对齐约定和当前进度。 + +## 目标 + +迁移目标不是复制 AMESim `.results` 中的观测值,而是建立真实的 Python 仿真链路: + +`组件方程 -> SimulationNetwork/系统装配 -> closure -> snapshot/端口写回 -> RHS -> solver -> reporting/comparison` + +Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性依据。duplicate、反号或派生观测可以用于验证端口方向,但不能替代组件方程和网络闭合。 + +## 模型与源数据 + +- AMESim 源模型:`AmesimModels/test_mql.ame` +- Python 系统类:`PythonModels.systems.test_mql.TestMqlSystem` +- 结构运行入口:`PythonModels/scripts/run_test_mql.py` +- 132 状态比较入口:`PythonModels/scripts/run_test_mql_full_state_comparison.py` +- AMESim 组件数:117 +- LINE 连接数:84 +- 连续状态数:132 +- 离散状态数:24 +- 全局参数:`D1=20`、`D2=20`、`D3=14`、`P0=153`、`Pdq=1`、`V=15`、`cf=0.45` + +`.ame` 文件是 tar 包,迁移和校验主要使用其中的: + +- `test_mql_.cir`:组件、连接、参数表达式和生成代码线索。 +- `test_mql_.param` / `test_mql_.data`:参数和结果配套数据。 +- `test_mql_.modelinfo` / `test_mql_.sim`:状态数和仿真设置。 +- `test_mql_.var` / `test_mql_.results`:AMESim `Data_Path` 目录和 baseline 时序。 + +当前结果解析器可读取 1002 个时间点和 1116 个保存变量,无需先转换成 CSV。 + +## 当前实现 + +当前已经完成: + +- AMESim 组件、LINE 连接、直接组件接触、全局参数和变量目录解析。 +- 氦气 Peng-Robinson 物性;内部使用绝对压力,AMESim `press` 按相对 `101300 Pa` 的表压输出。 +- `PNCH023 / PNCH012 / PNOR001 / PNVO001` 气动组件。 +- `PNL0001 / PNL0002 / PNL0003 / PNL00R` 管路动态或阻性关系。 +- `PN3NODE2 / P4NODE2` 代数节点、真实邻接拓扑、canonical flow 和端口写回。 +- `PNRP17 / MECMAS21 / LSTP00A / LMECHN1 / UD00 / FORC` 当前工况可确认的机械行为。 +- 112 个气动状态和 20 个机械状态组成的 132 状态总闭包。 +- 活塞运动学、变容气室机械反馈、气动力、外力、端止动和质量约束耦合。 +- 关键 `Data_Path` 序列导出、schema validation、AMESim 插值 comparison、误差排序、端点诊断和变容气室 RHS 拆解。 + +主要实现位置: + +- `PythonModels/systems/test_mql.py` +- `PythonModels/systems/test_mql_closure.py` +- `PythonModels/systems/test_mql_pneumatic.py` +- `PythonModels/systems/test_mql_mechanical.py` +- `PythonModels/systems/test_mql_lines.py` +- `PythonModels/components/amesim_pneumatic.py` +- `PythonModels/components/amesim_mechanical.py` +- `PythonModels/reporting/amesim_results.py` +- `PythonModels/reporting/test_mql_comparison.py` +- `PythonModels/scripts/run_test_mql_full_state_comparison.py` + +## 当前对比结果 + +默认 comparison 已从区间内插值的 `t=1e-5 s` 改为与 AMESim +首个保存时刻精确对齐的 `t=0.01 s`,当前比较 13 个关键信号,并 +新增 `xv@pn_morifice_1`、`dm2@pn_morifice_1` 的结构化 PNVO 诊断。 + +事件前 `t=0.01 s` 对比: + +- `press@pn_c1_8`:Python `-1286.601221 Pa`,AMESim + `-1288.253185 Pa`,绝对误差约 `1.651964 Pa`。 +- `dm1@pneumatic_69`:Python `-0.002485112 g/s`,AMESim + `-0.002054255 g/s`;换算后的 canonical 流量绝对误差约 + `4.30857e-7 kg/s`。 +- `xv@pn_morifice_1` 和 `dm2@pn_morifice_1` 在两侧均为 0,确认 + STEP0 事件前 PNVO 保持关闭。 +- `vol1@pn_brp2_8` 绝对误差约 `8.76e-5`,机械位移、速度和加速度 + 仍保持较小误差;当前较明显的累计差异集中在 PNL0001 流量和 + PNCH012 压力/能量链路。 + +新增 `--pnvo-event-boundary` 诊断:先积分到 `0.04 s` 的左极限, +再按 STEP0 的右连续语义在事件时刻读取开度和流量。结果为: + +- `xv@pn_morifice_1`:Python/AMESim 均为 `1`。 +- `dm2@pn_morifice_1`:Python `502.945005 g/s`,AMESim + `497.823823 g/s`,绝对误差约 `5.121182 g/s`,相对约 `1.03%`。 +- `press@pn_c1_8` 绝对误差约 `55.4815 Pa`; + `dm1@pneumatic_69` 绝对误差约 `0.00281880 g/s`。 + +这说明 PNVO 开启瞬间的开度语义和主流量量级已经对齐,但事件前 +累积压力/支路流量仍有偏差。常规积分直接跨越事件到 `0.05 s` 时, +当前进程会被系统终止,尚未形成可信的事件后结果;不能据此声明 +完整 `0.04 -> 0.05 s` 窗口已经可运行。 + +## 下一步 + +1. 优先定位 `0 -> 0.04 s` 累积的 `pneumatic_69` 流量与 + `pn_c1_8` 压力偏差,区分 PNL0001 阻力和 PNCH012 能量方程。 +2. 采用显式事件分段或针对事件后的局部数值策略,解决跨越 + `t=0.04 s` 后积分进程被终止的问题,再验证 `t=0.05 s` 保存点。 +3. 保留 `press@pn_c1_8`、`dm1@pneumatic_69`、PNVO `xv/dm2` + 和 chamber RHS breakdown 作为同一条诊断链。 +4. 在完整开启窗口稳定后,再判断是否需要校准 PNVO 流量系数。 + +`pn_c1_8` 的直接主线是 `pneumatic_69`;`pneumatic_96` +属于另一条固定气室支路,不是该诊断对象。 + +## 运行与验证 + +运行默认短时域 comparison: + +```bash +python3 -m PythonModels.scripts.run_test_mql_full_state_comparison +``` + +运行 PNVO 事件边界诊断: + +```bash +python3 -m PythonModels.scripts.run_test_mql_full_state_comparison --pnvo-event-boundary +``` + +运行相关测试: + +```bash +python3 -m unittest tests.test_run_test_mql_full_state_comparison tests.test_test_mql_pnl0001_segment +``` + +运行全量测试: + +```bash +python3 -m unittest discover -s tests -t . +``` + +## 对齐约定 + +- 组件 alias 和输出名优先保持 AMESim 原名及 `Data_Path`。 +- `PortState.m_flow > 0` 表示流入当前组件。 +- closure 先定义 canonical flow,再按各组件端口方向写回 `m_flow`。 +- AMESim 管路质量流量通常以 `g/s` 保存,Python 内部统一使用 `kg/s`。 +- CSV 是人工检查和交换格式,不是读取 AMESim baseline 的前置条件。 +- 未完成真实 Python 输出对比前,不使用“与 AMESim 完全一致”之类结论。 diff --git a/PythonModels/.gitignore b/PythonModels/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/PythonModels/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/PythonModels/README.md b/PythonModels/README.md new file mode 100644 index 0000000..6152293 --- /dev/null +++ b/PythonModels/README.md @@ -0,0 +1,371 @@ +# PythonModels + +`PythonModels` 用于承接 Modelica 和 AMESim 模型的 Python 平台移植。 + +目标不是逐行翻译源模型,而是建立可运行、可测试、可导出,并能与 OpenModelica 或 AMESim baseline 对比的 Python 仿真框架。 + +当前包含两条模型线:`Testmodel` 已有可运行的 ODE 近似和 OpenModelica 对比能力;`test_mql` 已形成 132 状态气动机械总闭包,正在按 AMESim baseline 做数值校准。 + +## 当前目录 + +- `core/`: 通用基础设施 + 包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。 +- `components/`: 元件级 Python 实现 + 包含 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee`,以及 AMESim 气动和机械组件原语。 +- `systems/`: 系统级装配与闭合 + 包含旧 `TestModelSystem`,以及当前主线 `TestMqlSystem` 的配置、拓扑、closure、snapshot、端口写回和 RHS。 +- `reporting/`: 结果导出与对比 + 承接 OpenModelica 对比,以及 AMESim 结果读取、`Data_Path` schema validation、comparison 和诊断报告。 +- `scripts/`: 运行脚本 + 包含 `run_testmodel.py`、`run_test_mql.py` 和 `run_test_mql_full_state_comparison.py`。 +- `baselines/`: 提交进仓库的稳定基线 + 当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。 +- `runs/`: 每次实际运行的默认输出目录 + 当前脚本默认会在这里创建带时间戳的子目录,用来放这次运行生成的产物。 + +当前关键文件: + +- `core/medium.py`: 温度相关的理想气体近似介质 `IdealGasMedium` +- `core/peng_robinson.py`: `test_mql` 使用的氦气 Peng-Robinson 物性 +- `core/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装 +- `core/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回 +- `components/pipe.py`: 单阻容管道近似,入口压降 + 出口直连内容腔 +- `components/tee.py`: 三通的最小 stream 混合 helper +- `systems/testmodel.py`: `Testmodel` 的系统装配壳与外部运行入口 +- `systems/testmodel_closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写 +- `systems/test_mql.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射 +- `systems/test_mql_closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回 +- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出 +- `scripts/run_test_mql_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口 +- `scripts/run_testmodel.py`: 基线运行与程序化执行入口 +- `tests/test_pythonmodels_regression.py`: 当前 Python 基线回归测试 + +## 当前阶段进度 + +这一阶段原先有 4 件重点工作,现在的状态如下: + +1. `mytee1` 的 stream/焓传播语义:已完成当前阶段收紧 + 现在如果只有一条支路发生倒流,下游来流焓统一按 `tank.h` 处理,不再临时借另一条支路的焓来凑。 +2. 下游初始化/约束处理:已完成当前阶段收口 + 之前是“直接改对象状态再开始积分”,现在已经收成显式的 `consistent_initial_state_vector()` 初始化入口。当前这一步会在不改下游总质量、总内能的前提下,把几段直接相连的体积拉回同一个连接压力。 +3. 自动校验:已完成当前阶段首版 + 已经补了标准库 `unittest` 回归测试,先把初始化投影是否守恒、是否污染原始状态,以及 4 个主变量的提交基线锁住。 +4. 更严格介质模型:已完成当前阶段首版 + 已经从固定 `cp/cv` 的理想气体近似,推进到随温度变化的空气近似,并接上了内能反解和初始化求根。 + +如果只看结果,可以把这一阶段理解成: + +- 连接器语义:首轮收紧已完成 +- 初始化入口:首轮收口已完成 +- 基线验证:首轮保护已完成 +- 介质精化:首轮近似已完成 + +## 当前阶段收口 + +上一轮 `N0-N3` 已全部完成首版,当前可以简单理解为: + +1. `N0`:系统层里最明显的流向/焓判断已经继续下沉到组件 helper。 +2. `N1`:模型参数和运行参数已经收口到配置对象。 +3. `N2`:运行接口已经分成“准备请求”和“执行请求”两层。 +4. `N3`:结果导出和命令行报告格式化已经统一收口到 `reporting/`。 + +这一轮结束后,项目已经不缺“能不能跑”的能力,下一步更重要的是把后续开发最容易卡住的地方先处理掉。 + +## 本次推送更新 + +本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态: + +1. `M2`:已完成当前阶段首版 + - 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `systems/testmodel.py` 拆到新的 `systems/testmodel_closure.py` + - `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节 + +2. `M3`:已完成当前阶段首版 + - 已给两条支路入口流量固定点求解、下游公共压力投影补了显式诊断 + - 诊断内容至少包含 `converged / iterations / residual` + - 已支持严格模式;内部求解不收敛时可以直接抛错,而不是静默返回最后一个近似值 + - `run_testmodel()` 的结构化结果和 `testmodel_run_report.txt` 已能带出最后一次内部闭合求解诊断 + +3. `M4`:已完成当前阶段首版 + - 自动测试已不再只盯最终主变量结果 + - 现在已经覆盖: + - 改支路参数后,初始支路入口流量是否按预期变化 + - 更偏激配置下,初始化和内部闭合是否仍然收敛 + - 有无 Modelica 参考两种运行路径下,程序接口与产物行为是否一致 + +4. `M5`:已启动 + - 当前已经明确选择优先走“更容易扩展”的方向,而不是先追求更贴近 Modelica + - 已完成第一步:把闭合器内部原来大量写死的 `upper/lower` 双支路逻辑,收成可复用的 `BranchClosureComponents / BranchClosureState` 结构 + - 当前已继续推进到 `G1-G5` 的首轮兼容层改造:`snapshot` 已提供通用分支集合,系统层结果生成已拆成“通用键生成 + 旧键别名派生”两层,报告层已开始优先消费通用分支键,旧导出列名仍通过兼容映射保留,兼容测试已显式保护分支顺序和旧导出语义 + +## 下一阶段接手建议 + +如果继续往前推进,建议按下面顺序做,而不是再零散补功能: + +1. `G1`:已完成当前阶段首轮兼容接入 + - `TestModelSnapshot` 已新增 `branches` 集合 + - 每个分支当前至少带 `name / pipe / inlet_flow / outlet_flow / inlet_h / inlet_flow_diagnostics` + - `pipe_upper / pipe_lower / branch_inlet_flows / branch_outlet_flows` 目前仍保留为兼容属性,供旧调用方继续使用 + +2. `G2`:已完成当前阶段首轮内部迁移 + - `evaluate_solution()` 已改成从 `snapshot.branches` 读取数据,再通过显式分支名映射写回当前旧列名 + - `rhs()` 里的分支导数计算已改成通过通用 helper 按分支循环生成,再按当前状态向量顺序拼回 + - 当前外部导出列名仍保持兼容: + - `mypipe.p` + - `mypipe1.p` + - `branch_upper.in/out` + - `branch_lower.in/out` + +3. `G3`:已完成当前阶段首轮兼容测试 + - 当前测试已经显式保护: + - `branches` 顺序是否稳定 + - `snapshot` 新字段和兼容字段是否一致 + - 旧导出列名是否仍映射到正确分支语义 + - 参数变化后 `upper/lower` 的名字和顺序是否不会被打乱 + +4. `G4`:已完成当前阶段首轮兼容拆层 + - `evaluate_solution()` 现在会同时产出: + - 通用分支键:`branch..p/in/out` + - 旧兼容键:`mypipe.p`、`mypipe1.p`、`branch_upper.*`、`branch_lower.*` + - 报告层当前已开始优先读取通用分支键,旧键只作为兼容后备 + - 当前已经把“内部统一表达”和“旧接口兼容导出”拆成两层,但还没有把所有报告/导出逻辑都迁干净 + +5. `G5`:已完成当前阶段首轮兼容收口 + - `evaluate_solution()` 当前会先生成通用分支键,再统一派生旧兼容键 + - 报告层当前已支持“通用键优先、旧键兼容后备” + - 当前已经把系统层和 reporting 层的主要旧专名读取入口收口到少量 helper 上,后续继续迁移不会再到处散改 + +6. `P1`:下一阶段建议从这里接手 + 当前更合适的下一步,不是继续深挖内核通用化,而是切回结果导向主线: + - 定义一份稳定的外部输入参数 schema + - 明确这些结构化参数如何映射到 `TestModelConfig / TestModelRunConfig` + - 建立“结构化参数 -> 仿真执行 -> 结果产物/摘要”的稳定接口 + 这样可以直接服务后续文档解析、网页入口和报告生成,而不是继续在 `Testmodel` 内部做边际收益越来越低的抽象整理 + +7. `P2`:在 `P1` 完成后,再推进文档解析或报告生成链路 + 更现实的顺序应是: + - 先把结构化输入跑通 + - 再把结果摘要/产物组织成更接近最终产品的输出包 + - 最后再接 Word 解析或页面入口 + +如果后续继续推进,这个 README 也要一起更新,不要长期保留已经失效的路线描述。 + +## 当前实现了什么 + +当前代码已经实现: + +1. `m`、`U` 作为动态元件主状态,`p`、`T`、`rho`、`u`、`h` 作为派生量。 +2. `Cylinder`、`Tank`、`Pipe` 的刚性绝热容腔近似。 +3. `Orifice` 的压差开方流量关系。 +4. `Tee` 的简化混合焓处理。 +5. `Testmodel` 的系统级拓扑映射和一版可运行的 `rhs(t, x)`。 +6. 基于 `solve_ivp` 的积分入口,以及 SciPy 不可用时的 RK4 回退。 +7. 温度相关空气近似介质,包括 `cp(T)`、`h(T)`、`u(T)` 以及 `u -> T` 反解。 +8. 显式一致初值入口 `consistent_initial_state_vector()`,以及可迭代初始化器 `initialize_consistent_state()`。 +9. Python 主变量结果导出: + `mytank.p`、`mytank.T`、`mycylinder.p`、`mycylinder.T` +10. 贮箱温度曲线导出: + `testmodel_tank_temperature.csv` + `testmodel_tank_temperature.svg` +11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。 +12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。 + +当前没有实现: + +- 通用 DAE 初始化器 +- `Modelica.Media.Air.SimpleAir` 的严格复刻 +- 面向任意拓扑的通用 connector/stream 求解器 + +## 当前怎么运行 + +最小运行方式: + +```bash +python3 -m PythonModels.scripts.run_testmodel +``` + +如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如: + +```python +from PythonModels.core.solver import SolveIVPConfig +from PythonModels.scripts.run_testmodel import ( + TestModelRunConfig, + TestModelSamplingConfig, + run_testmodel, +) +from PythonModels.systems.testmodel import ( + BranchConfig, + CylinderConfig, + OrificeConfig, + PipeConfig, + TankConfig, + TestModelConfig, +) + +run_config = TestModelRunConfig( + model=TestModelConfig( + cylinder=CylinderConfig(p0=30e6), + upper_branch=BranchConfig( + orifice=OrificeConfig(K=8e-6), + pipe=PipeConfig(length=6.0, diameter=0.03), + ), + tank=TankConfig(volume=0.12), + ), + solver=SolveIVPConfig(t_start=0.0, t_stop=10.0, method="BDF"), + sampling=TestModelSamplingConfig(step=0.05), +) + +result = run_testmodel(run_config=run_config) +``` + +如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行: + +```python +from PythonModels.scripts.run_testmodel import ( + prepare_testmodel_run, + run_prepared_testmodel, + TestModelRunConfig, +) + +prepared = prepare_testmodel_run(run_config=TestModelRunConfig()) +print(prepared.output_dir) +print(prepared.t_eval) + +result = run_prepared_testmodel(prepared) +print(result.artifacts.primary_csv_path) +print(result.used_modelica_reference) +``` + +当前脚本会: + +1. 构建 `TestModelSystem` +2. 打印原始初值向量与约束一致后的初值向量 +3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s` +4. 将结果写入 `PythonModels/runs/` 下本次运行专属的时间戳目录 +5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果 + +当前脚本默认不会再把运行结果直接写到提交基线目录,而是会在 `PythonModels/runs/` 下创建一个带时间戳的子目录,例如: + +- `PythonModels/runs/testmodel_20260512_103000_123456/` + +该目录里通常会包含: + +- `testmodel_primary_series.csv` +- `testmodel_tank_temperature.csv` +- `testmodel_tank_temperature.svg` +- `testmodel_run_report.txt` +- `testmodel_modelica_comparison.csv` +- `testmodel_modelica_comparison_summary.txt` + +## 基线结果 + +当前基线对比摘要来自: +[testmodel_modelica_comparison_summary.txt](baselines/testmodel/testmodel_modelica_comparison_summary.txt) + +当前四个主变量的最大误差为: + +- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%` +- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%` +- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%` +- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%` + +这说明在当前基线工况下,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 当前架构判断 + +如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于: + +- 组件级:已完成首版 +- 系统闭合:已完成首版 +- 积分入口:已完成首版 +- 基线结果对齐:已具备初步能力 +- 去近似:仍在进行中 + +所以当前最准确的说法不是“已完成移植”,而是: + +`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。 + +## Testmodel 已知限制 + +当前最主要的限制可以直接理解成下面几条: + +- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。 +- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。 +- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。 +- 当前 `Tee` 的 stream 语义只覆盖了当前 `Testmodel` 需要的最小集合,还不是通用的 `inStream/actualStream` 框架。 +- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。 +- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。 +- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。 +- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。 + +所以,当前版本适合: + +- 架构验证 +- 组件接口验证 +- 基线工况对比 +- 结果导出与误差定位 + +但当前版本还不适合: + +- 直接宣称与 OpenModelica 严格等价 +- 作为最终工程结论的唯一依据 +- 直接扩展到更复杂拓扑而不补通用连接器语义 + +## Testmodel 文件级现状 + +按代码现状逐项看: + +- `core/base.py`: 正常 + 只提供最小抽象层,没有明显冗余。 +- `core/ports.py`: 正常 + `PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。 +- `core/state.py`: 正常 + `VolumeState` 只负责 `[m, U]` 状态打包。 +- `core/network.py`: 正常 + 负责状态向量拼装和连接摘要,不参与物理求解。 +- `core/solver.py`: 正常 + 已支持 SciPy、RK4 回退和零时长仿真。 +- `components/*.py`: 正常 + 都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。 +- `systems/testmodel.py`: 是当前最重要的技术债集中区 + 这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。 +- `scripts/run_testmodel.py`: 正常 + 已不是“最小打印脚本”,而是当前结果导出和对比入口。 +- `baselines/`: 是当前稳定基线,不应该随着日常运行频繁改动。 +- `runs/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。 + +## Testmodel 当前主技术债 + +目前最主要的技术债,可以直接理解成下面 4 件事: + +1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。 +2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。 +3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。 +4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。 diff --git a/PythonModels/__init__.py b/PythonModels/__init__.py new file mode 100644 index 0000000..a392a55 --- /dev/null +++ b/PythonModels/__init__.py @@ -0,0 +1,2 @@ +"""Python port scaffold for the Modelica-based pressurization system.""" + diff --git a/PythonModels/baselines/testmodel/testmodel_modelica_comparison_summary.txt b/PythonModels/baselines/testmodel/testmodel_modelica_comparison_summary.txt new file mode 100644 index 0000000..b8835bd --- /dev/null +++ b/PythonModels/baselines/testmodel/testmodel_modelica_comparison_summary.txt @@ -0,0 +1,4 @@ +mytank.p: max_abs_error=134.960858, max_rel_error=0.006798% +mytank.T: max_abs_error=0.035507, max_rel_error=0.009016% +mycylinder.p: max_abs_error=1391.986349, max_rel_error=0.009447% +mycylinder.T: max_abs_error=0.009069, max_rel_error=0.003870% diff --git a/PythonModels/baselines/testmodel/testmodel_primary_series.csv b/PythonModels/baselines/testmodel/testmodel_primary_series.csv new file mode 100644 index 0000000..f908440 --- /dev/null +++ b/PythonModels/baselines/testmodel/testmodel_primary_series.csv @@ -0,0 +1,202 @@ +time_s,mytank.p,mytank.T,mycylinder.p,mycylinder.T +0.0,99999.9999998181,299.9999999994543,35000000.0,300.0 +0.1,113767.94796194455,310.9637987101763,34857995.191961475,299.65190130981887 +0.2,127490.75529043324,320.1741038867186,34716455.97160761,299.3039342009557 +0.30000000000000004,141168.22832317703,327.9540327439692,34575384.336400226,298.9561065982938 +0.4,154800.2272891848,334.6028385113769,34434781.727959625,298.60842486666263 +0.5,168386.6833862148,340.3440160419025,34294648.85592305,298.26089368263627 +0.6000000000000001,181927.56886228317,345.34597934661645,34154986.006528884,297.9135169662631 +0.7000000000000001,195422.8867284584,349.7379443903134,34015793.14872051,297.5662980942661 +0.8,208872.67226727083,353.62071952544704,33877069.91858829,297.2192381374397 +0.9,222277.06297793236,357.0742683513725,33738814.897943415,296.8723276411647 +1.0,235635.98150118813,360.16219760966084,33601028.88468161,296.5255848498291 +1.1,248949.42783703818,362.9362624751423,33463711.878802836,296.1790180498007 +1.2000000000000002,262217.75812283135,365.43943715392686,33326860.20704978,295.8325994898035 +1.3,275440.80324992316,367.7064864598734,33190475.613635924,295.48635453607636 +1.4000000000000001,288618.55460473493,369.766650344722,33054558.187403098,295.14029246566224 +1.5,301751.3171281971,371.6448887937757,32919104.783141974,294.79439468638526 +1.6,314839.14341954247,373.36206261131525,32784114.858335685,294.44866485903736 +1.7000000000000002,327882.06907680153,374.93594217338176,32649588.04582047,294.10310819940065 +1.8,340880.20077679906,376.3818124575356,32515523.2453146,293.7577259967503 +1.9000000000000001,353833.6270518691,377.7128659999238,32381919.54368144,293.41251959012544 +2.0,366742.42291137256,378.94054912194076,32248776.16726251,293.06749036423287 +2.1,379606.6730674497,380.0748368029417,32116092.24232331,292.72263968200787 +2.2,392426.50707225554,381.12448095768656,31983866.432642274,292.37796667994184 +2.3000000000000003,405202.0671563814,382.0971825271288,31852097.271230437,292.03346983037886 +2.4000000000000004,417933.4155500976,382.9997115435689,31720784.11623488,291.6891515780112 +2.5,430620.6510589185,383.838081019396,31589925.948559783,291.3450125479824 +2.6,443263.883710829,384.61764092425494,31459521.633358993,291.0010529534012 +2.7,455863.2542884696,385.3431706778388,31329569.71857793,290.6572718762472 +2.8000000000000003,468418.84583467664,386.0189391423481,31200069.34769955,290.31367046787994 +2.9000000000000004,480930.76401047717,386.64878296917834,31071019.430919208,289.9702490545257 +3.0,493399.1147767602,387.2361564731079,30942418.8753394,289.627007973335 +3.1,505824.0120818939,387.7841699997202,30814266.50567852,289.283948167411 +3.2,518205.5585633492,388.29565044444143,30686561.263317347,288.9410696919777 +3.3000000000000003,530543.860941083,388.7731587646163,30559302.04752924,288.5983729053581 +3.4000000000000004,542839.025935053,389.21902406251183,30432487.75758757,288.2558581528899 +3.5,555091.1532282452,389.63535331051384,30306117.365346134,287.9135275289302 +3.6,567300.3523937837,390.0240928732544,30180189.74065028,287.5713806480376 +3.7,579466.730902255,390.3870196149787,30054703.77503127,287.2294176336731 +3.8000000000000003,591590.3951155862,390.7257605811333,29929658.371455234,286.88763887333175 +3.9000000000000004,603671.4513957056,391.0418105423231,29805052.432888325,286.5460447413264 +4.0,615710.0061045411,391.3365451815722,29680884.862296656,286.20463559857706 +4.1000000000000005,627706.1656040212,391.61123273483645,29557154.562646367,285.8634117923989 +4.2,639660.0356829006,391.86704273384044,29433860.4428154,285.5223738511199 +4.3,651571.716232906,392.1050410519721,29311001.472504564,285.1815242995298 +4.4,663441.3179459961,392.32624321858174,29188576.510019373,284.840861980108 +4.5,675268.9468771729,392.53157932311984,29066584.46149164,284.5003872946073 +4.6000000000000005,687054.7090814385,392.72191331429804,28945024.2330532,284.16010063101663 +4.7,698798.7106137947,392.8980487868277,28823894.730835862,283.8200023633402 +4.800000000000001,710501.057529244,393.06073417111315,28703194.86097143,283.48009285137505 +4.9,722161.8558827877,393.21066739655134,28582923.529591747,283.1403724404863 +5.0,733781.2088290841,393.3484951327475,28463079.672743235,282.8008422184954 +5.1000000000000005,745359.2175579917,393.47481852250473,28343662.24673756,282.4615037792963 +5.2,756895.9922909128,393.5902150621596,28224670.114733644,282.1223563449153 +5.300000000000001,768391.6386869826,393.6952183545109,28106102.186946325,281.78340031124986 +5.4,779846.2624053361,393.7903288312906,27987957.373590477,281.44463605959197 +5.5,791259.96910511,393.8760162992802,27870234.58488091,281.10606395639473 +5.6000000000000005,802632.8644454395,393.9527222554955,27752932.731032487,280.76768435303745 +5.7,813965.0540854601,394.02086199551263,27636050.72226007,280.4294975855874 +5.800000000000001,825256.6344365194,394.08081904264304,27519587.564161647,280.0915054160418 +5.9,836507.7093909897,394.13296077650165,27403542.18517475,279.75370843660113 +6.0,847718.3891245491,394.17763867488134,27287913.44892988,279.4161062418491 +6.1000000000000005,858888.7786008355,394.2151805066602,27172700.272815377,279.0786992215644 +6.2,870018.9827834871,394.2458959595685,27057901.57421954,278.7414877501898 +6.300000000000001,881109.1066361419,394.27007787647636,26943516.2705307,278.40447218658807 +6.4,892159.2551224378,394.2880033916485,26829543.279137183,278.0676528737956 +6.5,903169.5332060129,394.2999349762812,26715981.5174273,277.73103013877227 +6.6000000000000005,914139.9829839376,394.30609016406663,26602830.551205155,277.3946116451124 +6.7,925070.7482002805,394.30672506364067,26490088.897871558,277.0583932025842 +6.800000000000001,935961.9434794089,394.30206871636136,26377755.375172496,276.7223739769823 +6.9,946813.6729747849,394.2923336718555,26265828.9088526,276.3865543393284 +7.0,957626.040839869,394.2777219546483,26154308.424656466,276.05093464451556 +7.1000000000000005,968399.1512281233,394.2584257130283,26043192.84832872,275.7155152310525 +7.2,979133.1082930088,394.23462782052076,25932481.10561397,275.3802964208052 +7.300000000000001,989828.0161879869,394.2065024339623,25822172.12225682,275.04527851873627 +7.4,1000483.9790665191,394.17421551178546,25712264.824001882,274.71046181264023 +7.5,1011101.1010820667,394.13792529578427,25602758.136593778,274.3758465728772 +7.6000000000000005,1021679.4863880915,394.09778275932865,25493650.985777102,274.04143305210266 +7.7,1032219.239138054,394.0539320247227,25384942.297296483,273.707221484995 +7.800000000000001,1042720.4634854163,394.0065107521557,25276630.996896524,273.37321208797937 +7.9,1053183.2635836392,393.95565050247825,25168716.01032184,273.0394050589494 +8.0,1063607.7435861847,393.9014770758345,25061196.263317037,272.705800576985 +8.1,1073994.0076465139,393.8441108280058,24954070.681626726,272.3723988020679 +8.200000000000001,1084342.0075784405,393.78360959211744,24847339.76225091,272.03921728278755 +8.3,1094651.950514763,393.7201237653114,24741001.368788917,271.7062444960846 +8.4,1104923.9566483083,393.65376548392067,24635054.261552785,271.3734787513138 +8.5,1115158.1282653515,393.5846357175065,24529497.385545585,271.0409203804046 +8.6,1125354.567652169,393.5128313057523,24424329.685770374,270.7085696976142 +8.700000000000001,1135513.3770950353,393.43844517133164,24319550.10723021,270.37642699925055 +8.8,1145634.6588802272,393.3615665197664,24215157.594928175,270.0444925633909 +8.9,1155718.5152940191,393.2822810271899,24111151.093867306,269.712766649598 +9.0,1165765.0486226876,393.2006710168648,24007529.54905069,269.3812494986329 +9.1,1175774.361152508,393.11681562522904,23904291.90548136,269.04994133216485 +9.200000000000001,1185746.555169756,393.03079095819226,23801437.108162407,268.7188423524781 +9.3,1195681.732960707,392.94267023834027,23698964.10209688,268.3879527421748 +9.4,1205579.9968116365,392.8525239436615,23596871.83228785,268.05727266387584 +9.5,1215441.4490088206,392.7604199383583,23495159.243738372,267.72680225991735 +9.600000000000001,1225266.1918385345,392.66642359626576,23393825.281451505,267.39654165204473 +9.700000000000001,1235054.3275870536,392.57059791736026,23292868.89043032,267.0664909411029 +9.8,1244805.7424247153,392.47293675670085,23192291.244732097,266.7366751290975 +9.9,1254520.6963966596,392.3735487251935,23092089.662209835,266.40707625875064 +10.0,1264199.3049931854,392.2724947546991,22992262.951678198,266.07769289844276 +10.100000000000001,1273841.6679760527,392.1698289153906,22892810.0841785,265.74852540655036 +10.200000000000001,1283447.8851070204,392.0656034102887,22793730.030752078,265.41957412301247 +10.3,1293018.0561478487,391.9598686580462,22695021.76244025,265.0908393690368 +10.4,1302552.2808602974,391.8526733713811,22596684.250284337,264.76232144680336 +10.5,1312050.6590061258,391.7440646314241,22498716.465325654,264.4340206391643 +10.600000000000001,1321513.290347094,391.63408795822716,22401117.37860553,264.10593720934105 +10.700000000000001,1330940.2746449616,391.5227873776613,22303885.961165283,263.7780714006174 +10.8,1340331.711661488,391.41020548491883,22207021.184046242,263.4504234360302 +10.9,1349687.7011584332,391.29638350481804,22110522.01828973,263.1229935180553 +11.0,1359008.342897557,391.18136134909884,22014387.434937052,262.79578182829164 +11.100000000000001,1368293.7366406189,391.0651776708804,21918616.405029543,262.4687885271409 +11.200000000000001,1377543.9821493784,390.9478699164463,21823207.89960853,262.1420137534841 +11.3,1386759.1791855951,390.82947437450605,21728160.88971532,261.81545762435434 +11.4,1395939.2416852904,390.70997702085253,21633476.26302751,261.4891421257888 +11.5,1405084.4058089943,390.5894488404711,21539151.583747786,261.1630514204039 +11.600000000000001,1414194.7727817593,390.46792355037144,21445185.807824824,260.83718544108797 +11.700000000000001,1423270.4378832965,390.3454323238288,21351577.952528503,260.5115448028764 +11.8,1432311.4962235806,390.22200536933536,21258327.036879413,260.1861301247683 +11.9,1441318.042742851,390.0976719679259,21165432.0816488,259.86094202976824 +12.0,1450290.1722116095,389.97246050877925,21072892.1093586,259.5359811449295 +12.100000000000001,1459227.9792306225,389.84639852319333,20980706.14428146,259.2112481013979 +12.200000000000001,1468131.5582309205,389.71951271701596,20888873.212440677,258.88674353445447 +12.3,1477001.0034737969,389.59182900161403,20797392.34161027,258.5624680835606 +12.4,1485836.4090508097,389.46337252345796,20706262.561314918,258.23842239240224 +12.5,1494637.8688837802,389.3341676923915,20615482.902829994,257.9146071089349 +12.600000000000001,1503405.4767247946,389.20423820865625,20525052.399181567,257.5910228854296 +12.700000000000001,1512139.3261562001,389.07360708873284,20434970.085146382,257.26767037851835 +12.8,1520839.5105906113,388.94229669006046,20345234.997251876,256.9445502492407 +12.9,1529506.1233409408,388.81032876227204,20255846.173053782,256.62166315000906 +13.0,1538139.2687433015,388.67772882293264,20166802.53641284,256.2990076411302 +13.100000000000001,1546739.030251722,388.5445136335602,20078103.22657088,255.97658615427704 +13.200000000000001,1555305.5007360894,388.4107031827765,19989747.285653118,255.65439933992917 +13.3,1563838.7729005008,388.27631690939495,19901733.757494725,255.332447852161 +13.4,1572338.9392832702,388.14137372147235,19814061.687640797,255.0107323486784 +13.5,1580806.0922569225,388.0058920145713,19726730.123346385,254.68925349085504 +13.600000000000001,1589240.3240281995,387.86988968927403,19639738.113576483,254.36801194376957 +13.700000000000001,1597641.7266380545,387.73338416798015,19553084.709006038,254.04700837624307 +13.8,1606010.3919616563,387.59639241102605,19466768.962019928,253.72624346087662 +13.9,1614346.4117083861,387.458930932153,19380789.926712975,253.40571787408948 +14.0,1622649.8774218408,387.32101581335974,19295146.658889957,253.0854322961577 +14.100000000000001,1630920.8804798292,387.18266271916264,19209838.21606559,252.76538741125262 +14.200000000000001,1639159.5120943757,387.0438869102949,19124863.657464534,252.4455839074805 +14.3,1647365.8633117182,386.90470325686863,19040222.0440214,252.12602247692183 +14.4,1655540.0250123062,386.76512625102305,18955912.438380726,251.8067038156715 +14.5,1663682.087910807,386.6251700190859,18871933.904897016,251.48762862387906 +14.600000000000001,1671792.1425560997,386.4848483332645,18788285.509634707,251.16879760578948 +14.700000000000001,1679870.2793312764,386.3441746228917,18704966.320368182,250.85021146978437 +14.8,1687916.588453644,386.2031619852454,18621975.406581767,250.5318709284234 +14.9,1695931.1599747243,386.06182319595837,18539311.83946974,250.21377669848638 +15.0,1703914.0837802505,385.920170719039,18456974.691936314,249.8959295010154 +15.100000000000001,1711865.4494154856,385.77821670970445,18374963.040397402,249.57833006564294 +15.200000000000001,1719785.3379250832,385.6359727064743,18293276.048945524,249.26097933285033 +15.3,1727673.8459201432,385.4934505781353,18211912.721118417,248.94387786218365 +15.4,1735531.0625176337,385.3506616315768,18130872.137749474,248.6270263958575 +15.5,1743357.0766656764,385.2076169067951,18050153.381413613,248.31042568075102 +15.600000000000001,1751151.9771435438,385.06432718490083,17969755.53642727,247.99407646845765 +15.700000000000001,1758915.8525616606,384.9208029958382,17889677.688848406,247.67797951533504 +15.8,1766648.7913616048,384.77705462583015,17809918.926476505,247.36213558255577 +15.9,1774350.881816107,384.63309212455715,17730478.33885257,247.04654543615806 +16.0,1782022.2120290494,384.4889253120841,17651355.017259125,246.73120984709743 +16.1,1789662.8699354655,384.3445637855432,17572548.05472022,246.41612959129878 +16.2,1797272.9433015438,384.2000169255848,17494056.546001427,246.10130544970855 +16.3,1804852.5197246224,384.05529390260284,17415879.58760983,245.78673820834797 +16.400000000000002,1812401.6866331936,383.9104036827472,17338016.277794052,245.47242865836654 +16.5,1819920.5312869006,383.76535503372884,17260465.716544226,245.15837759609565 +16.6,1827409.1407765402,383.6201565304272,17183227.005592,244.84458582310356 +16.7,1834867.6020240602,383.47481656030743,17106299.24841057,244.53105414625009 +16.8,1842296.0017825624,383.32934332865534,17029681.550214626,244.2177833777421 +16.900000000000002,1849694.4266362996,383.1837448636361,16953373.017960392,243.9047743351897 +17.0,1857062.9630006768,383.03802902118434,16877372.76034562,243.59202784166294 +17.1,1864401.697122252,382.8922034897328,16801679.887809563,243.27954472574828 +17.2,1871710.7150787353,382.7462757947841,16726293.512533028,242.96732582160686 +17.3,1878990.1030524147,382.60025337999576,16651212.745618157,242.65537190477806 +17.400000000000002,1886239.954455942,382.4541455990818,16576436.623591991,242.343682012199 +17.5,1893460.3482686526,382.3079576538116,16501964.331849081,242.03225853722532 +17.6,1900651.3699906773,382.16169648335216,16427794.988527464,241.72110231102408 +17.7,1907813.104956132,382.0153688850827,16353927.713477474,241.41021416968596 +17.8,1914945.6383331183,381.86898151834066,16280361.628261749,241.09959495427398 +17.900000000000002,1922049.0551237254,381.7225409080495,16207095.856155202,240.78924551087363 +18.0,1929123.4401640275,381.57605344823065,16134129.522145053,240.47916669064318 +18.1,1936168.8781240864,381.4295254054061,16061461.752930798,240.16935934986387 +18.2,1943185.4535079484,381.28296292189384,15989091.67692425,239.8598243499916 +18.3,1950173.2506536485,381.1363720190009,15917018.424249483,239.55056255770802 +18.400000000000002,1957132.3537332045,380.98975860011734,15845241.126742886,239.24157484497272 +18.5,1964062.8467526236,380.84312845371574,15773758.917953137,238.93286208907583 +18.6,1970964.8135518986,380.6964872562572,15702570.933141202,238.6244251726908 +18.7,1977838.3378050062,380.54984057500997,15631676.309280336,238.31626498392774 +18.8,1984683.5030199126,380.4031938707821,15561074.185056096,238.00838241638746 +18.900000000000002,1991500.3925385692,380.25655250057133,15490763.700866321,237.7007783692156 +19.0,1998289.0895369116,380.10992172013556,15420743.998821149,237.39345374715774 +19.1,2005049.677024864,379.96330668648653,15351014.222743012,237.08640946061425 +19.200000000000003,2011782.237846337,379.81671246030885,15281573.518166626,236.77964642569634 +19.3,2018486.8546792252,379.67014400830794,15212421.032339014,236.47316556428217 +19.400000000000002,2025163.610035411,379.5236062054879,15143555.914219463,236.16696780407335 +19.5,2031812.5863479478,379.3771038539995,15074977.31358037,235.861054060844 +19.6,2038433.8680519294,379.2306420805889,15006684.359544702,235.55542481191287 +19.700000000000003,2045027.536485746,379.084225365391,14938676.213175347,235.25008113730536 +19.8,2051593.6739729291,378.9378583245209,14870952.025374293,234.9450238874766 +19.900000000000002,2058132.3627231135,378.79154550141357,14803510.948218277,234.64025390703284 +20.0,2064643.6848320398,378.64529136859073,14736352.134958768,234.33577203462033 diff --git a/PythonModels/components/__init__.py b/PythonModels/components/__init__.py new file mode 100644 index 0000000..d8e493c --- /dev/null +++ b/PythonModels/components/__init__.py @@ -0,0 +1,2 @@ +"""Component implementations for the Python system model.""" + diff --git a/PythonModels/components/amesim_mechanical.py b/PythonModels/components/amesim_mechanical.py new file mode 100644 index 0000000..2f3c4a4 --- /dev/null +++ b/PythonModels/components/amesim_mechanical.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import pi + + +MM_TO_M = 1.0e-3 +M_TO_MM = 1.0e3 +M3_TO_CM3 = 1.0e6 +M3_PER_S_TO_L_PER_MIN = 60_000.0 + + +def circular_area(diameter_m: float) -> float: + if diameter_m < 0.0: + raise ValueError("diameter_m must be non-negative.") + return pi * diameter_m * diameter_m / 4.0 + + +def mm_to_m(value: float) -> float: + return value * MM_TO_M + + +def m_to_mm(value: float) -> float: + return value * M_TO_MM + + +@dataclass(frozen=True) +class AmesimPistonGeometry: + """Geometry relations used by AMESim PNRP17 pneumatic piston variables.""" + + piston_diameter_m: float + rod_diameter_m: float = 0.0 + zero_length_m: float = 0.0 + + @property + def piston_area_m2(self) -> float: + return circular_area(self.piston_diameter_m) + + @property + def rod_area_m2(self) -> float: + return circular_area(self.rod_diameter_m) + + @property + def annulus_area_m2(self) -> float: + return self.piston_area_m2 - self.rod_area_m2 + + def chamber_length_m(self, port4_displacement_m: float, port5_displacement_m: float) -> float: + return self.zero_length_m + port5_displacement_m - port4_displacement_m + + def chamber_length_mm(self, port4_displacement_m: float, port5_displacement_m: float) -> float: + return m_to_mm(self.chamber_length_m(port4_displacement_m, port5_displacement_m)) + + @property + def chamber_area_m2(self) -> float: + return self.annulus_area_m2 + + def chamber_volume_m3(self, port4_displacement_m: float, port5_displacement_m: float) -> float: + return self.chamber_area_m2 * self.chamber_length_m( + port4_displacement_m, + port5_displacement_m, + ) + + def chamber_volume_cm3(self, port4_displacement_m: float, port5_displacement_m: float) -> float: + return self.chamber_volume_m3(port4_displacement_m, port5_displacement_m) * M3_TO_CM3 + + def chamber_volume_rate_m3_s(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float: + return self.chamber_area_m2 * (port5_velocity_m_s - port4_velocity_m_s) + + def chamber_volume_rate_l_min(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float: + return self.chamber_volume_rate_m3_s( + port4_velocity_m_s, + port5_velocity_m_s, + ) * M3_PER_S_TO_L_PER_MIN + + +@dataclass(frozen=True) +class AmesimElasticEndstop: + """Contact force part of AMESim LSTP00A elastic endstop.""" + + contact_stiffness_n_per_m: float + contact_damping_n_per_m_per_s: float = 0.0 + gap0_m: float = 0.0 + + def penetration_m_from_gap_mm(self, gap_mm: float) -> float: + return max(-(mm_to_m(gap_mm) - self.gap0_m), 0.0) + + def static_contact_force(self, gap_mm: float) -> float: + return self.contact_stiffness_n_per_m * self.penetration_m_from_gap_mm(gap_mm) + + def contact_force(self, gap_mm: float, penetration_velocity_m_s: float = 0.0) -> float: + if self.penetration_m_from_gap_mm(gap_mm) <= 0.0: + return 0.0 + damping_force = self.contact_damping_n_per_m_per_s * penetration_velocity_m_s + return max(self.static_contact_force(gap_mm) + damping_force, 0.0) + + +@dataclass(frozen=True) +class AmesimMassFrictionEndstops: + """Parameter and observable helpers for AMESim MECMAS21 translation masses.""" + + mass_kg: float + lower_limit_m: float + upper_limit_m: float + lower_stiffness_n_per_m: float + upper_stiffness_n_per_m: float + lower_damping_n_per_m_per_s: float = 0.0 + upper_damping_n_per_m_per_s: float = 0.0 + viscous_friction_n_per_m_per_s: float = 0.0 + coulomb_friction_n: float = 0.0 + stiction_force_n: float = 0.0 + windage_n_per_m2_per_s2: float = 0.0 + + def lower_penetration_m(self, displacement_m: float) -> float: + return max(self.lower_limit_m - displacement_m, 0.0) + + def upper_penetration_m(self, displacement_m: float) -> float: + return max(displacement_m - self.upper_limit_m, 0.0) + + def lower_static_force_magnitude(self, displacement_m: float) -> float: + return self.lower_stiffness_n_per_m * self.lower_penetration_m(displacement_m) + + def upper_static_force_magnitude(self, displacement_m: float) -> float: + return self.upper_stiffness_n_per_m * self.upper_penetration_m(displacement_m) + + def viscous_friction_force(self, velocity_m_s: float) -> float: + return -self.viscous_friction_n_per_m_per_s * velocity_m_s + + def windage_force(self, velocity_m_s: float) -> float: + return -self.windage_n_per_m2_per_s2 * velocity_m_s * abs(velocity_m_s) + + def dry_friction_force(self, velocity_m_s: float) -> float: + if velocity_m_s > 0.0: + return -self.coulomb_friction_n + if velocity_m_s < 0.0: + return self.coulomb_friction_n + return 0.0 + + def limit_contact_force(self, displacement_m: float, velocity_m_s: float) -> float: + lower_force = self.lower_static_force_magnitude(displacement_m) + if lower_force > 0.0: + lower_force += max(-self.lower_damping_n_per_m_per_s * velocity_m_s, 0.0) + + upper_force = self.upper_static_force_magnitude(displacement_m) + if upper_force > 0.0: + upper_force += max(self.upper_damping_n_per_m_per_s * velocity_m_s, 0.0) + + return lower_force - upper_force + + def derivatives( + self, + *, + velocity_m_s: float, + displacement_m: float, + port_1_force_n: float = 0.0, + port_2_force_n: float = 0.0, + external_force_n: float = 0.0, + ) -> tuple[float, float]: + total_force = ( + port_1_force_n + + port_2_force_n + + external_force_n + + self.viscous_friction_force(velocity_m_s) + + self.windage_force(velocity_m_s) + + self.dry_friction_force(velocity_m_s) + + self.limit_contact_force(displacement_m, velocity_m_s) + ) + return total_force / self.mass_kg, velocity_m_s + diff --git a/PythonModels/components/amesim_pneumatic.py b/PythonModels/components/amesim_pneumatic.py new file mode 100644 index 0000000..d705c0e --- /dev/null +++ b/PythonModels/components/amesim_pneumatic.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import pi, sqrt + +from PythonModels.core.base import AlgebraicComponent, DynamicComponent +from PythonModels.core.medium import ThermodynamicProperties +from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid +from PythonModels.core.ports import PortState +from PythonModels.core.state import VolumeState + + +@dataclass(frozen=True) +class AmesimPneumaticGas: + """Caloric constants plus Peng-Robinson EOS for AMESim pneumatic components.""" + + fluid: PengRobinsonFluid = HELIUM_PR + cp: float = 5193.0 + cv: float = 3116.0 + + @property + def gamma(self) -> float: + return self.cp / self.cv + + @property + def R_gas(self) -> float: + return self.fluid.specific_gas_constant + + def density(self, pressure: float, temperature: float) -> float: + return self.fluid.density(pressure, temperature) + + def pressure(self, density: float, temperature: float) -> float: + return self.fluid.pressure_from_density(temperature, density) + + def specific_internal_energy(self, temperature: float) -> float: + return self.cv * temperature + + def specific_enthalpy(self, temperature: float) -> float: + return self.cp * temperature + + def specific_reference_enthalpy( + self, + temperature: float, + reference_temperature: float = 298.15, + ) -> float: + return self.cp * (temperature - reference_temperature) + + def reference_temperature_from_specific_enthalpy( + self, + specific_enthalpy: float, + reference_temperature: float = 298.15, + ) -> float: + if self.cp <= 0.0: + raise ValueError("cp must be positive.") + return reference_temperature + specific_enthalpy / self.cp + + def pressure_reference_enthalpy( + self, + pressure: float, + temperature: float, + reference_pressure: float = 101_300.0, + reference_temperature: float = 298.15, + ) -> float: + return ( + self.specific_reference_enthalpy(temperature, reference_temperature) + + self.fluid.residual_specific_enthalpy(pressure, temperature) + - self.fluid.residual_specific_enthalpy( + reference_pressure, + reference_temperature, + ) + ) + + def pressure_transport_enthalpy( + self, + pressure: float, + temperature: float, + reference_pressure: float = 101_300.0, + reference_temperature: float = 298.15, + ) -> float: + """Convert AMESim reference enthalpy to the absolute-energy state basis.""" + return ( + self.pressure_reference_enthalpy( + pressure, + temperature, + reference_pressure, + reference_temperature, + ) + + self.cp * reference_temperature + ) + + def temperature_from_internal_energy(self, specific_internal_energy: float) -> float: + if self.cv <= 0.0: + raise ValueError("cv must be positive.") + return specific_internal_energy / self.cv + + +HELIUM_PNEUMATIC_GAS = AmesimPneumaticGas() + + +def liters_to_m3(value: float) -> float: + return value * 1.0e-3 + + +def m3_to_cm3(value: float) -> float: + return value * 1.0e6 + + +def cm3_to_m3(value: float) -> float: + return value * 1.0e-6 + + +def kg_to_g(value: float) -> float: + return value * 1.0e3 + + +def mm2_to_m2(value: float) -> float: + return value * 1.0e-6 + + +def diameter_mm_to_area_m2(diameter_mm: float) -> float: + diameter_m = diameter_mm * 1.0e-3 + return pi * diameter_m * diameter_m / 4.0 + + +class AmesimPneumaticVolume(DynamicComponent): + """First-pass AMESim pneumatic control volume using helium PR pressure closure.""" + + def __init__( + self, + name: str, + volume: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p0: float = 101_325.0, + T0: float = 293.15, + heat_transfer_coefficient: float = 0.0, + heat_transfer_area: float = 0.0, + external_temperature_k: float = 293.15, + ) -> None: + if volume <= 0.0: + raise ValueError("volume must be positive.") + if heat_transfer_coefficient < 0.0: + raise ValueError("heat_transfer_coefficient must be non-negative.") + if heat_transfer_area < 0.0: + raise ValueError("heat_transfer_area must be non-negative.") + if external_temperature_k <= 0.0: + raise ValueError("external_temperature_k must be positive.") + super().__init__(name=name) + self.volume = volume + self.gas = gas + self.heat_transfer_coefficient = heat_transfer_coefficient + self.heat_transfer_area = heat_transfer_area + self.external_temperature = external_temperature_k + rho0 = gas.density(p0, T0) + m0 = rho0 * volume + U0 = m0 * gas.specific_internal_energy(T0) + self.state = VolumeState(m=m0, U=U0) + self.port_a = PortState() + self.port_b = PortState() + + @classmethod + def from_liters( + cls, + name: str, + volume_liters: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p0: float = 101_325.0, + T0: float = 293.15, + heat_transfer_coefficient: float = 0.0, + heat_transfer_area: float = 0.0, + external_temperature_k: float = 293.15, + ) -> "AmesimPneumaticVolume": + return cls( + name=name, + volume=liters_to_m3(volume_liters), + gas=gas, + p0=p0, + T0=T0, + heat_transfer_coefficient=heat_transfer_coefficient, + heat_transfer_area=heat_transfer_area, + external_temperature_k=external_temperature_k, + ) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def volume_cm3(self) -> float: + return m3_to_cm3(self.volume) + + def volume_rate_m3_s(self) -> float: + return 0.0 + + def thermal_energy_flow_w(self, temperature_k: float | None = None) -> float: + temperature = self.properties().T if temperature_k is None else temperature_k + return ( + self.heat_transfer_coefficient + * self.heat_transfer_area + * (self.external_temperature - temperature) + ) + + def gas_mass_g(self) -> float: + return kg_to_g(self.state.m) + + def pressure_gauge_pa(self, reference_pressure_pa: float = 101_300.0) -> float: + return self.properties().p - reference_pressure_pa + + def properties(self) -> ThermodynamicProperties: + if self.state.m <= 0.0: + raise ValueError("volume mass must stay positive.") + T = self.gas.temperature_from_internal_energy(self.state.U / self.state.m) + rho = self.state.m / self.volume + p = self.gas.pressure(rho, T) + u = self.state.U / self.state.m + h = self.gas.specific_enthalpy(T) + self.port_a.p = p + self.port_a.h_outflow = h + self.port_b.p = p + self.port_b.h_outflow = h + return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h) + + def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: + return VolumeState( + m=m_flow, + U=m_flow * inlet_h + self.thermal_energy_flow_w(), + ) + + def derivatives_from_two_connections( + self, + *, + port_a_m_flow: float, + connected_h_a: float, + port_b_m_flow: float, + connected_h_b: float, + internal_h: float, + volume_rate_m3_s: float | None = None, + ) -> VolumeState: + properties = self.properties() + inlet_h_a = self.connection_inlet_enthalpy( + port_m_flow=port_a_m_flow, + connected_h=connected_h_a, + internal_h=internal_h, + ) + inlet_h_b = self.connection_inlet_enthalpy( + port_m_flow=port_b_m_flow, + connected_h=connected_h_b, + internal_h=internal_h, + ) + return VolumeState( + m=port_a_m_flow + port_b_m_flow, + U=( + port_a_m_flow * inlet_h_a + + port_b_m_flow * inlet_h_b + + self.thermal_energy_flow_w(properties.T) + - properties.p * ( + self.volume_rate_m3_s() + if volume_rate_m3_s is None + else volume_rate_m3_s + ) + ), + ) + + +class AmesimVariablePneumaticVolume(AmesimPneumaticVolume): + """PNCH012-style volume with a dead volume plus an external moving volume.""" + + def __init__( + self, + name: str, + dead_volume: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p0: float = 101_325.0, + T0: float = 293.15, + external_volume: float = 0.0, + heat_transfer_coefficient: float = 0.0, + heat_transfer_area: float = 0.0, + external_temperature_k: float = 293.15, + ) -> None: + if dead_volume <= 0.0: + raise ValueError("dead_volume must be positive.") + if dead_volume + external_volume <= 0.0: + raise ValueError("total volume must be positive.") + self.dead_volume = dead_volume + self.external_volume = external_volume + self.external_volume_rate = 0.0 + super().__init__( + name=name, + volume=dead_volume + external_volume, + gas=gas, + p0=p0, + T0=T0, + heat_transfer_coefficient=heat_transfer_coefficient, + heat_transfer_area=heat_transfer_area, + external_temperature_k=external_temperature_k, + ) + + @classmethod + def from_liters( + cls, + name: str, + dead_volume_liters: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p0: float = 101_325.0, + T0: float = 293.15, + external_volume_liters: float = 0.0, + heat_transfer_coefficient: float = 0.0, + heat_transfer_area: float = 0.0, + external_temperature_k: float = 293.15, + ) -> "AmesimVariablePneumaticVolume": + return cls( + name=name, + dead_volume=liters_to_m3(dead_volume_liters), + gas=gas, + p0=p0, + T0=T0, + external_volume=liters_to_m3(external_volume_liters), + heat_transfer_coefficient=heat_transfer_coefficient, + heat_transfer_area=heat_transfer_area, + external_temperature_k=external_temperature_k, + ) + + def volume_rate_m3_s(self) -> float: + return self.external_volume_rate + + def set_external_volume_m3( + self, + external_volume: float, + external_volume_rate_m3_s: float = 0.0, + ) -> None: + if self.dead_volume + external_volume <= 0.0: + raise ValueError("total volume must be positive.") + self.external_volume = external_volume + self.external_volume_rate = external_volume_rate_m3_s + self.volume = self.dead_volume + self.external_volume + + +class AmesimPneumaticOrifice(AlgebraicComponent): + """First-pass PNOR001/PNVO001-style compressible helium orifice. + + This is a calibrated placeholder boundary for the Python port. It preserves + AMESim-style area and coefficient inputs, but final parity must be checked + against AMESim CSV results before treating it as numerically equivalent. + """ + + def __init__( + self, + name: str, + area: float, + flow_coefficient: float = 1.0, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + opening: float = 1.0, + ) -> None: + if area < 0.0: + raise ValueError("area must be non-negative.") + if flow_coefficient < 0.0: + raise ValueError("flow_coefficient must be non-negative.") + super().__init__(name=name) + self.area = area + self.flow_coefficient = flow_coefficient + self.gas = gas + self.opening = opening + self.port_a = PortState() + self.port_b = PortState() + + @classmethod + def from_mm2( + cls, + name: str, + area_mm2: float, + flow_coefficient: float = 1.0, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + opening: float = 1.0, + ) -> "AmesimPneumaticOrifice": + return cls( + name=name, + area=mm2_to_m2(area_mm2), + flow_coefficient=flow_coefficient, + gas=gas, + opening=opening, + ) + + @property + def effective_area(self) -> float: + opening = min(max(self.opening, 0.0), 1.0) + return self.area * opening + + def mass_flow(self, p_a: float, p_b: float, upstream_temperature: float) -> float: + if p_a == p_b or self.effective_area == 0.0 or self.flow_coefficient == 0.0: + return 0.0 + if p_a > p_b: + return compressible_orifice_mass_flow( + upstream_pressure=p_a, + downstream_pressure=p_b, + upstream_temperature=upstream_temperature, + area=self.effective_area, + flow_coefficient=self.flow_coefficient, + gas=self.gas, + ) + return -compressible_orifice_mass_flow( + upstream_pressure=p_b, + downstream_pressure=p_a, + upstream_temperature=upstream_temperature, + area=self.effective_area, + flow_coefficient=self.flow_coefficient, + gas=self.gas, + ) + + +def compressible_orifice_mass_flow( + *, + upstream_pressure: float, + downstream_pressure: float, + upstream_temperature: float, + area: float, + flow_coefficient: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, +) -> float: + if upstream_pressure <= 0.0 or downstream_pressure < 0.0: + raise ValueError("pressures must be non-negative and upstream pressure must be positive.") + if upstream_temperature <= 0.0: + raise ValueError("upstream_temperature must be positive.") + if area < 0.0 or flow_coefficient < 0.0: + raise ValueError("area and flow_coefficient must be non-negative.") + if downstream_pressure >= upstream_pressure or area == 0.0 or flow_coefficient == 0.0: + return 0.0 + + gamma = gas.gamma + pressure_ratio = max(downstream_pressure / upstream_pressure, 0.0) + critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0)) + coefficient = flow_coefficient * area * upstream_pressure / sqrt(gas.R_gas * upstream_temperature) + if pressure_ratio <= critical_ratio: + flow_function = sqrt(gamma) * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0))) + else: + term = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** ((gamma + 1.0) / gamma) + flow_function = sqrt((2.0 * gamma / (gamma - 1.0)) * max(term, 0.0)) + return coefficient * flow_function diff --git a/PythonModels/components/amesim_pneumatic_line.py b/PythonModels/components/amesim_pneumatic_line.py new file mode 100644 index 0000000..7810d0d --- /dev/null +++ b/PythonModels/components/amesim_pneumatic_line.py @@ -0,0 +1,881 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import log10, pi, sqrt + +from PythonModels.components.amesim_pneumatic import ( + HELIUM_PNEUMATIC_GAS, + AmesimPneumaticGas, + compressible_orifice_mass_flow, + diameter_mm_to_area_m2, +) +from PythonModels.core.base import AlgebraicComponent, DynamicComponent +from PythonModels.core.medium import ThermodynamicProperties +from PythonModels.core.ports import PortState +from PythonModels.core.state import VolumeState + + +@dataclass(frozen=True) +class AmesimPnl0001Diagnostics: + mass_flow_kg_s: float + reynolds_number: float + gas_velocity_m_s: float + friction_factor: float + pressure_drop_pa: float + + +class _DarcyPipeResistanceMixin: + diameter: float + length: float + relative_roughness: float + area: float + + def _mass_flow_for_pressure_drop( + self, + pressure_drop_pa: float, + *, + density: float, + temperature: float, + ) -> float: + if pressure_drop_pa <= 0.0: + return 0.0 + upper = 1.0e-9 + while self._darcy_pressure_drop( + upper, + density=density, + temperature=temperature, + ) < pressure_drop_pa: + upper *= 10.0 + if upper > 1.0e3: + raise ValueError("unable to bracket pneumatic pipe resistance flow") + lower = 0.0 + for _ in range(48): + middle = 0.5 * (lower + upper) + if self._darcy_pressure_drop( + middle, + density=density, + temperature=temperature, + ) < pressure_drop_pa: + lower = middle + else: + upper = middle + return 0.5 * (lower + upper) + + def pn2pipefr_mass_flow( + self, + *, + port_1_pressure_pa: float, + port_1_temperature_k: float, + port_2_pressure_pa: float, + port_2_temperature_k: float, + length: float | None = None, + ) -> float: + pressure_difference = port_1_pressure_pa - port_2_pressure_pa + if pressure_difference == 0.0: + return 0.0 + upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa) + downstream_pressure = min(port_1_pressure_pa, port_2_pressure_pa) + upstream_temperature = ( + port_1_temperature_k + if pressure_difference > 0.0 + else port_2_temperature_k + ) + resistance_length = self.length if length is None else length + if resistance_length <= 0.0: + raise ValueError("length must be positive") + + def target_flow(mass_flow_kg_s: float) -> float: + reynolds = self._reynolds_number(mass_flow_kg_s, upstream_temperature) + friction_factor = self._friction_factor(reynolds) + flow_coefficient = sqrt( + self.diameter / (resistance_length * friction_factor) + ) + return compressible_orifice_mass_flow( + upstream_pressure=upstream_pressure, + downstream_pressure=downstream_pressure, + upstream_temperature=upstream_temperature, + area=self.area, + flow_coefficient=flow_coefficient, + gas=self.gas, + ) + + flow_coefficient = sqrt(self.diameter / (resistance_length * 0.02)) + magnitude = compressible_orifice_mass_flow( + upstream_pressure=upstream_pressure, + downstream_pressure=downstream_pressure, + upstream_temperature=upstream_temperature, + area=self.area, + flow_coefficient=flow_coefficient, + gas=self.gas, + ) + for _ in range(12): + next_magnitude = target_flow(magnitude) + if abs(next_magnitude - magnitude) <= max(1.0e-12, abs(magnitude) * 1.0e-9): + magnitude = next_magnitude + break + magnitude = 0.5 * (magnitude + next_magnitude) + return magnitude if pressure_difference > 0.0 else -magnitude + + def _darcy_pressure_drop( + self, + mass_flow_kg_s: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow_kg_s == 0.0: + return 0.0 + reynolds = self._reynolds_number(mass_flow_kg_s, temperature) + friction_factor = self._friction_factor(reynolds) + velocity = mass_flow_kg_s / (density * self.area) + magnitude = ( + friction_factor + * (self.length / self.diameter) + * density + * velocity + * velocity + / 2.0 + ) + return magnitude if mass_flow_kg_s > 0.0 else -magnitude + + def _reynolds_number(self, mass_flow_kg_s: float, temperature: float) -> float: + viscosity = helium_dynamic_viscosity(temperature) + return 4.0 * abs(mass_flow_kg_s) / (pi * self.diameter * viscosity) + + def _friction_factor(self, reynolds_number: float) -> float: + if reynolds_number <= 0.0: + return 64_000_000.0 + laminar = 64.0 / reynolds_number + if reynolds_number <= 2_300.0: + return laminar + turbulent = 1.0 / ( + -1.8 + * log10( + (self.relative_roughness / 3.7) ** 1.11 + + 6.9 / reynolds_number + ) + ) ** 2 + if reynolds_number >= 4_000.0: + return turbulent + fraction = (reynolds_number - 2_300.0) / 1_700.0 + return laminar + fraction * (turbulent - laminar) + + +class AmesimPnl0001Pipe(_DarcyPipeResistanceMixin, DynamicComponent): + """Physical first-pass implementation of AMESim ``PNL0001`` (C-R). + + Port 2 owns the lumped gas storage. Port 1 is connected through a Darcy + resistance. Both connection mass flows use the PythonModels convention: + positive values enter the pipe storage. + + AMESim's proprietary ``pn2pipefr`` utility is represented by an + optional calibrated linear conductance when a model-specific baseline + supports it; otherwise the component falls back to an auditable + Darcy-Weisbach law. Both paths preserve the real geometry, state count, + mass/energy balance, heat-transfer parameter, and observable diagnostics. + """ + + def __init__( + self, + name: str, + *, + diameter_mm: float, + length_m: float, + relative_roughness: float, + polytropic_constant: float = 1.35, + heat_transfer_coefficient: float = 0.0, + external_temperature_k: float = 293.15, + calibrated_linear_conductance: float | None = None, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p0: float = 101_325.0, + T0: float = 293.15, + ) -> None: + if diameter_mm <= 0.0: + raise ValueError("diameter_mm must be positive") + if length_m <= 0.0: + raise ValueError("length_m must be positive") + if relative_roughness < 0.0: + raise ValueError("relative_roughness must be non-negative") + if polytropic_constant <= 0.0: + raise ValueError("polytropic_constant must be positive") + if heat_transfer_coefficient < 0.0: + raise ValueError("heat_transfer_coefficient must be non-negative") + if external_temperature_k <= 0.0: + raise ValueError("external_temperature_k must be positive") + if ( + calibrated_linear_conductance is not None + and calibrated_linear_conductance <= 0.0 + ): + raise ValueError("calibrated_linear_conductance must be positive") + + super().__init__(name=name) + self.diameter = diameter_mm * 1.0e-3 + self.length = length_m + self.relative_roughness = relative_roughness + self.polytropic_constant = polytropic_constant + self.heat_transfer_coefficient = heat_transfer_coefficient + self.external_temperature = external_temperature_k + self.calibrated_linear_conductance = calibrated_linear_conductance + self.gas = gas + self.area = diameter_mm_to_area_m2(diameter_mm) + self.volume = self.area * self.length + self.heat_transfer_area = pi * self.diameter * self.length + + rho0 = gas.density(p0, T0) + mass0 = rho0 * self.volume + self.state = VolumeState( + m=mass0, + U=mass0 * gas.specific_internal_energy(T0), + ) + self.port_1 = PortState() + self.port_2 = PortState() + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + if self.state.m <= 0.0: + raise ValueError("pipe mass must stay positive") + temperature = self.gas.temperature_from_internal_energy( + self.state.U / self.state.m + ) + density = self.state.m / self.volume + pressure = self.gas.pressure(density, temperature) + properties = ThermodynamicProperties( + p=pressure, + T=temperature, + rho=density, + u=self.state.U / self.state.m, + h=self.gas.specific_enthalpy(temperature), + ) + self.port_2.p = pressure + self.port_2.h_outflow = properties.h + return properties + + def gas_mass_g(self) -> float: + return self.state.m * 1.0e3 + + def resistance_mass_flow( + self, + *, + port_1_pressure_pa: float, + port_1_temperature_k: float, + ) -> float: + """Return mass flow from port 1 into the port-2 storage in kg/s.""" + if port_1_pressure_pa <= 0.0: + raise ValueError("port_1_pressure_pa must be positive") + if port_1_temperature_k <= 0.0: + raise ValueError("port_1_temperature_k must be positive") + + internal = self.properties() + pressure_difference = port_1_pressure_pa - internal.p + if pressure_difference == 0.0: + return 0.0 + if self.calibrated_linear_conductance is not None: + return ( + self.calibrated_linear_conductance + * pressure_difference + / sqrt(internal.T) + ) + upstream_pressure = max(port_1_pressure_pa, internal.p) + upstream_temperature = ( + port_1_temperature_k if pressure_difference > 0.0 else internal.T + ) + density = self.gas.density(upstream_pressure, upstream_temperature) + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def diagnostics( + self, + *, + mass_flow_kg_s: float, + temperature_k: float | None = None, + ) -> AmesimPnl0001Diagnostics: + properties = self.properties() + temperature = temperature_k or properties.T + reynolds = self._reynolds_number(mass_flow_kg_s, temperature) + friction_factor = self._friction_factor(reynolds) + velocity = mass_flow_kg_s / (properties.rho * self.area) + pressure_drop = self._darcy_pressure_drop( + mass_flow_kg_s, + density=properties.rho, + temperature=temperature, + ) + return AmesimPnl0001Diagnostics( + mass_flow_kg_s=mass_flow_kg_s, + reynolds_number=reynolds, + gas_velocity_m_s=velocity, + friction_factor=friction_factor, + pressure_drop_pa=pressure_drop, + ) + + def darcy_pressure_drop_for_state( + self, + *, + mass_flow_kg_s: float, + pressure_pa: float, + temperature_k: float, + ) -> float: + if pressure_pa <= 0.0: + raise ValueError("pressure_pa must be positive") + if temperature_k <= 0.0: + raise ValueError("temperature_k must be positive") + density = self.gas.density(pressure_pa, temperature_k) + return self._darcy_pressure_drop( + mass_flow_kg_s, + density=density, + temperature=temperature_k, + ) + + def derivatives_from_connections( + self, + *, + port_1_m_flow: float, + connected_h_1: float, + port_2_m_flow: float, + connected_h_2: float, + ) -> VolumeState: + internal = self.properties() + # Default first-pass PNL0001 behavior uses the historical internal-energy + # approximation. AMESim-specific transport-enthalpy corrections are kept + # behind derivatives_from_transport_enthalpy_connections so they can be + # applied only where validated against baseline data. + inlet_u_1 = ( + connected_h_1 / self.gas.gamma + if port_1_m_flow > 0.0 + else internal.u + ) + inlet_u_2 = ( + connected_h_2 / self.gas.gamma + if port_2_m_flow > 0.0 + else internal.u + ) + heat_flow = ( + self.heat_transfer_coefficient + * self.heat_transfer_area + * (self.external_temperature - internal.T) + ) + return VolumeState( + m=port_1_m_flow + port_2_m_flow, + U=port_1_m_flow * inlet_u_1 + port_2_m_flow * inlet_u_2 + heat_flow, + ) + + def derivatives_from_transport_enthalpy_connections( + self, + *, + port_1_m_flow: float, + connected_h_1: float, + port_2_m_flow: float, + connected_h_2: float, + ) -> VolumeState: + internal = self.properties() + inlet_h_1 = connected_h_1 if port_1_m_flow > 0.0 else internal.h + inlet_h_2 = connected_h_2 if port_2_m_flow > 0.0 else internal.h + heat_flow = ( + self.heat_transfer_coefficient + * self.heat_transfer_area + * (self.external_temperature - internal.T) + ) + return VolumeState( + m=port_1_m_flow + port_2_m_flow, + U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow, + ) + + +class AmesimPnl0003Pipe(_DarcyPipeResistanceMixin, DynamicComponent): + """First-pass AMESim ``PNL0003`` (C-R-C) pipe. + + The two pipe-end compliances are represented as equal half-volume gas + stores connected by the same auditable Darcy resistance used for PNL0001. + Center flow is positive from port 1 storage to port 2 storage. + """ + + state_size = 4 + + def __init__( + self, + name: str, + *, + diameter_mm: float, + length_m: float, + relative_roughness: float, + polytropic_constant: float = 1.35, + heat_transfer_coefficient: float = 0.0, + external_temperature_k: float = 293.15, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p1_0: float = 101_325.0, + T1_0: float = 293.15, + p2_0: float = 101_325.0, + T2_0: float = 293.15, + ) -> None: + if diameter_mm <= 0.0: + raise ValueError("diameter_mm must be positive") + if length_m <= 0.0: + raise ValueError("length_m must be positive") + if relative_roughness < 0.0: + raise ValueError("relative_roughness must be non-negative") + if polytropic_constant <= 0.0: + raise ValueError("polytropic_constant must be positive") + if heat_transfer_coefficient < 0.0: + raise ValueError("heat_transfer_coefficient must be non-negative") + if external_temperature_k <= 0.0: + raise ValueError("external_temperature_k must be positive") + + super().__init__(name=name) + self.diameter = diameter_mm * 1.0e-3 + self.length = length_m + self.relative_roughness = relative_roughness + self.polytropic_constant = polytropic_constant + self.heat_transfer_coefficient = heat_transfer_coefficient + self.external_temperature = external_temperature_k + self.gas = gas + self.area = diameter_mm_to_area_m2(diameter_mm) + self.volume = self.area * self.length + self.compliance_volume = self.volume / 2.0 + self.heat_transfer_area = pi * self.diameter * self.length + + self.state_1 = self._initial_state(p1_0, T1_0) + self.state_2 = self._initial_state(p2_0, T2_0) + self.port_1 = PortState() + self.port_2 = PortState() + + def _initial_state(self, pressure: float, temperature: float) -> VolumeState: + rho = self.gas.density(pressure, temperature) + mass = rho * self.compliance_volume + return VolumeState( + m=mass, + U=mass * self.gas.specific_internal_energy(temperature), + ) + + def get_state_vector(self) -> list[float]: + return [*self.state_1.as_vector(), *self.state_2.as_vector()] + + def set_state_vector(self, values: list[float]) -> None: + if len(values) != 4: + raise ValueError("PNL0003 state vector requires four values") + self.state_1 = VolumeState.from_vector(values[:2]) + self.state_2 = VolumeState.from_vector(values[2:]) + + def properties_1(self) -> ThermodynamicProperties: + properties = self._properties(self.state_1) + self.port_1.p = properties.p + self.port_1.h_outflow = properties.h + return properties + + def properties_2(self) -> ThermodynamicProperties: + properties = self._properties(self.state_2) + self.port_2.p = properties.p + self.port_2.h_outflow = properties.h + return properties + + def _properties(self, state: VolumeState) -> ThermodynamicProperties: + if state.m <= 0.0: + raise ValueError("pipe mass must stay positive") + temperature = self.gas.temperature_from_internal_energy(state.U / state.m) + density = state.m / self.compliance_volume + pressure = self.gas.pressure(density, temperature) + return ThermodynamicProperties( + p=pressure, + T=temperature, + rho=density, + u=state.U / state.m, + h=self.gas.specific_enthalpy(temperature), + ) + + def gas_mass_g(self) -> float: + return (self.state_1.m + self.state_2.m) * 1.0e3 + + def resistance_mass_flow(self) -> float: + """Return center mass flow from port 1 storage to port 2 storage.""" + port_1 = self.properties_1() + port_2 = self.properties_2() + pressure_difference = port_1.p - port_2.p + if pressure_difference == 0.0: + return 0.0 + upstream = port_1 if pressure_difference > 0.0 else port_2 + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=upstream.rho, + temperature=upstream.T, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def diagnostics( + self, + *, + mass_flow_kg_s: float, + temperature_k: float | None = None, + ) -> AmesimPnl0001Diagnostics: + port_1 = self.properties_1() + port_2 = self.properties_2() + temperature = temperature_k or (port_1.T if mass_flow_kg_s >= 0.0 else port_2.T) + density = port_1.rho if mass_flow_kg_s >= 0.0 else port_2.rho + reynolds = self._reynolds_number(mass_flow_kg_s, temperature) + friction_factor = self._friction_factor(reynolds) + velocity = mass_flow_kg_s / (density * self.area) + pressure_drop = self._darcy_pressure_drop( + mass_flow_kg_s, + density=density, + temperature=temperature, + ) + return AmesimPnl0001Diagnostics( + mass_flow_kg_s=mass_flow_kg_s, + reynolds_number=reynolds, + gas_velocity_m_s=velocity, + friction_factor=friction_factor, + pressure_drop_pa=pressure_drop, + ) + + def derivatives_from_connections( + self, + *, + port_1_m_flow: float, + connected_h_1: float, + port_2_m_flow: float, + connected_h_2: float, + ) -> tuple[VolumeState, VolumeState]: + port_1 = self.properties_1() + port_2 = self.properties_2() + center_flow = self.resistance_mass_flow() + heat_flow_each = ( + self.heat_transfer_coefficient + * self.heat_transfer_area + * (self.external_temperature - 0.5 * (port_1.T + port_2.T)) + / 2.0 + ) + port_1_external_h = self.connection_inlet_enthalpy( + port_m_flow=port_1_m_flow, + connected_h=connected_h_1, + internal_h=port_1.h, + ) + port_2_external_h = self.connection_inlet_enthalpy( + port_m_flow=port_2_m_flow, + connected_h=connected_h_2, + internal_h=port_2.h, + ) + port_1_center_h = self.connection_inlet_enthalpy( + port_m_flow=-center_flow, + connected_h=port_2.h, + internal_h=port_1.h, + ) + port_2_center_h = self.connection_inlet_enthalpy( + port_m_flow=center_flow, + connected_h=port_1.h, + internal_h=port_2.h, + ) + return ( + VolumeState( + m=port_1_m_flow - center_flow, + U=( + port_1_m_flow * port_1_external_h + - center_flow * port_1_center_h + + heat_flow_each + ), + ), + VolumeState( + m=port_2_m_flow + center_flow, + U=( + port_2_m_flow * port_2_external_h + + center_flow * port_2_center_h + + heat_flow_each + ), + ), + ) + + +class AmesimPnl0002Pipe(_DarcyPipeResistanceMixin, DynamicComponent): + """First-pass AMESim ``PNL0002`` (R-C-R) pipe. + + The center compliance owns the gas state. Positive connection mass flows + enter that center storage from each external port. + """ + + state_size = 2 + + def __init__( + self, + name: str, + *, + diameter_mm: float, + length_m: float, + relative_roughness: float, + polytropic_constant: float = 1.35, + heat_transfer_coefficient: float = 0.0, + external_temperature_k: float = 293.15, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + pctr_0: float = 101_325.0, + Tctr_0: float = 293.15, + ) -> None: + if diameter_mm <= 0.0: + raise ValueError("diameter_mm must be positive") + if length_m <= 0.0: + raise ValueError("length_m must be positive") + if relative_roughness < 0.0: + raise ValueError("relative_roughness must be non-negative") + if polytropic_constant <= 0.0: + raise ValueError("polytropic_constant must be positive") + if heat_transfer_coefficient < 0.0: + raise ValueError("heat_transfer_coefficient must be non-negative") + if external_temperature_k <= 0.0: + raise ValueError("external_temperature_k must be positive") + + super().__init__(name=name) + self.diameter = diameter_mm * 1.0e-3 + self.length = length_m + self.relative_roughness = relative_roughness + self.polytropic_constant = polytropic_constant + self.heat_transfer_coefficient = heat_transfer_coefficient + self.external_temperature = external_temperature_k + self.gas = gas + self.area = diameter_mm_to_area_m2(diameter_mm) + self.volume = self.area * self.length + self.heat_transfer_area = pi * self.diameter * self.length + self._resistance_length = self.length / 2.0 + + rho0 = gas.density(pctr_0, Tctr_0) + mass0 = rho0 * self.volume + self.state = VolumeState( + m=mass0, + U=mass0 * gas.specific_internal_energy(Tctr_0), + ) + self.port_1 = PortState() + self.port_2 = PortState() + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + if self.state.m <= 0.0: + raise ValueError("pipe mass must stay positive") + temperature = self.gas.temperature_from_internal_energy( + self.state.U / self.state.m + ) + density = self.state.m / self.volume + pressure = self.gas.pressure(density, temperature) + properties = ThermodynamicProperties( + p=pressure, + T=temperature, + rho=density, + u=self.state.U / self.state.m, + h=self.gas.specific_enthalpy(temperature), + ) + self.port_1.p = pressure + self.port_1.h_outflow = properties.h + self.port_2.p = pressure + self.port_2.h_outflow = properties.h + return properties + + def gas_mass_g(self) -> float: + return self.state.m * 1.0e3 + + def port_mass_flow( + self, + *, + port_pressure_pa: float, + port_temperature_k: float, + ) -> float: + """Return mass flow from an external port into the center storage.""" + if port_pressure_pa <= 0.0: + raise ValueError("port_pressure_pa must be positive") + if port_temperature_k <= 0.0: + raise ValueError("port_temperature_k must be positive") + + center = self.properties() + pressure_difference = port_pressure_pa - center.p + if pressure_difference == 0.0: + return 0.0 + upstream_pressure = max(port_pressure_pa, center.p) + upstream_temperature = ( + port_temperature_k if pressure_difference > 0.0 else center.T + ) + density = self.gas.density(upstream_pressure, upstream_temperature) + magnitude = self._mass_flow_for_resistance_pressure_drop( + abs(pressure_difference), + density=density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def _mass_flow_for_resistance_pressure_drop( + self, + pressure_drop_pa: float, + *, + density: float, + temperature: float, + ) -> float: + original_length = self.length + self.length = self._resistance_length + try: + return self._mass_flow_for_pressure_drop( + pressure_drop_pa, + density=density, + temperature=temperature, + ) + finally: + self.length = original_length + + def diagnostics( + self, + *, + mass_flow_kg_s: float, + temperature_k: float | None = None, + ) -> AmesimPnl0001Diagnostics: + properties = self.properties() + temperature = temperature_k or properties.T + reynolds = self._reynolds_number(mass_flow_kg_s, temperature) + friction_factor = self._friction_factor(reynolds) + velocity = mass_flow_kg_s / (properties.rho * self.area) + original_length = self.length + self.length = self._resistance_length + try: + pressure_drop = self._darcy_pressure_drop( + mass_flow_kg_s, + density=properties.rho, + temperature=temperature, + ) + finally: + self.length = original_length + return AmesimPnl0001Diagnostics( + mass_flow_kg_s=mass_flow_kg_s, + reynolds_number=reynolds, + gas_velocity_m_s=velocity, + friction_factor=friction_factor, + pressure_drop_pa=pressure_drop, + ) + + def derivatives_from_connections( + self, + *, + port_1_m_flow: float, + connected_h_1: float, + port_2_m_flow: float, + connected_h_2: float, + ) -> VolumeState: + center = self.properties() + inlet_h_1 = self.connection_inlet_enthalpy( + port_m_flow=port_1_m_flow, + connected_h=connected_h_1, + internal_h=center.h, + ) + inlet_h_2 = self.connection_inlet_enthalpy( + port_m_flow=port_2_m_flow, + connected_h=connected_h_2, + internal_h=center.h, + ) + heat_flow = ( + self.heat_transfer_coefficient + * self.heat_transfer_area + * (self.external_temperature - center.T) + ) + return VolumeState( + m=port_1_m_flow + port_2_m_flow, + U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow, + ) + + +class AmesimPnl00rPipe(_DarcyPipeResistanceMixin, AlgebraicComponent): + """First-pass AMESim ``PNL00R`` (R) pipe resistance.""" + + def __init__( + self, + name: str, + *, + diameter_mm: float, + length_m: float, + relative_roughness: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + ) -> None: + if diameter_mm <= 0.0: + raise ValueError("diameter_mm must be positive") + if length_m <= 0.0: + raise ValueError("length_m must be positive") + if relative_roughness < 0.0: + raise ValueError("relative_roughness must be non-negative") + + super().__init__(name=name) + self.diameter = diameter_mm * 1.0e-3 + self.length = length_m + self.relative_roughness = relative_roughness + self.gas = gas + self.area = diameter_mm_to_area_m2(diameter_mm) + self.port_1 = PortState() + self.port_2 = PortState() + + def mass_flow( + self, + *, + port_1_pressure_pa: float, + port_1_temperature_k: float, + port_2_pressure_pa: float, + port_2_temperature_k: float, + ) -> float: + """Return mass flow from port 1 to port 2 in kg/s.""" + if port_1_pressure_pa <= 0.0 or port_2_pressure_pa <= 0.0: + raise ValueError("port pressures must be positive") + if port_1_temperature_k <= 0.0 or port_2_temperature_k <= 0.0: + raise ValueError("port temperatures must be positive") + pressure_difference = port_1_pressure_pa - port_2_pressure_pa + if pressure_difference == 0.0: + return 0.0 + upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa) + upstream_temperature = ( + port_1_temperature_k + if pressure_difference > 0.0 + else port_2_temperature_k + ) + density = self.gas.density(upstream_pressure, upstream_temperature) + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def diagnostics( + self, + *, + mass_flow_kg_s: float, + pressure_pa: float, + temperature_k: float, + ) -> AmesimPnl0001Diagnostics: + density = self.gas.density(pressure_pa, temperature_k) + reynolds = self._reynolds_number(mass_flow_kg_s, temperature_k) + friction_factor = self._friction_factor(reynolds) + velocity = mass_flow_kg_s / (density * self.area) + pressure_drop = self._darcy_pressure_drop( + mass_flow_kg_s, + density=density, + temperature=temperature_k, + ) + return AmesimPnl0001Diagnostics( + mass_flow_kg_s=mass_flow_kg_s, + reynolds_number=reynolds, + gas_velocity_m_s=velocity, + friction_factor=friction_factor, + pressure_drop_pa=pressure_drop, + ) + + +def helium_dynamic_viscosity(temperature_k: float) -> float: + """Sutherland approximation centered on the test_mql initial condition.""" + if temperature_k <= 0.0: + raise ValueError("temperature_k must be positive") + reference_temperature = 293.15 + reference_viscosity = 2.0e-5 + sutherland_constant = 79.4 + return ( + reference_viscosity + * (temperature_k / reference_temperature) ** 1.5 + * (reference_temperature + sutherland_constant) + / (temperature_k + sutherland_constant) + ) diff --git a/PythonModels/components/cylinder.py b/PythonModels/components/cylinder.py new file mode 100644 index 0000000..066fb27 --- /dev/null +++ b/PythonModels/components/cylinder.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from PythonModels.core.base import DynamicComponent +from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties +from PythonModels.core.ports import PortState +from PythonModels.core.state import VolumeState + + +class Cylinder(DynamicComponent): + """Python port of ModelicaModels.Mycylinder.""" + + def __init__( + self, + name: str, + medium: IdealGasMedium, + V: float = 0.01, + p0: float = 35e6, + T0: float = 300.0, + ) -> None: + super().__init__(name=name) + self.medium = medium + self.V = V + m0 = p0 * V / (medium.R_gas * T0) + U0 = m0 * medium.specific_internal_energy(T0) + self.state = VolumeState(m=m0, U=U0) + self.port_b = PortState() + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) + self.port_b.p = props.p + self.port_b.h_outflow = props.h + return props + + def derivatives_from_connection( + self, + *, + connected_h: float, + port_m_flow: float, + internal_h: float, + ) -> VolumeState: + inlet_h = self.connection_inlet_enthalpy( + port_m_flow=port_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + return self.derivatives(inlet_h, port_m_flow) + + def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: + return VolumeState(m=m_flow, U=m_flow * inlet_h) diff --git a/PythonModels/components/orifice.py b/PythonModels/components/orifice.py new file mode 100644 index 0000000..9da6282 --- /dev/null +++ b/PythonModels/components/orifice.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from math import sqrt + +from PythonModels.core.base import AlgebraicComponent +from PythonModels.core.ports import PortState + + +class Orifice(AlgebraicComponent): + """Python port of ModelicaModels.Myorifice.""" + + def __init__(self, name: str, opening: float = 1.0, K: float = 1e-7) -> None: + super().__init__(name=name) + self.opening = opening + self.K = K + self.port_a = PortState() + self.port_b = PortState() + + @property + def K_eff(self) -> float: + return self.K * max(self.opening, 0.001) + + def mass_flow(self, p_a: float, p_b: float) -> float: + dp = p_a - p_b + if dp == 0.0: + return 0.0 + return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0) + diff --git a/PythonModels/components/pipe.py b/PythonModels/components/pipe.py new file mode 100644 index 0000000..6fb9fe5 --- /dev/null +++ b/PythonModels/components/pipe.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from PythonModels.core.base import DynamicComponent +from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties +from PythonModels.core.ports import PortState +from PythonModels.core.state import VolumeState + + +class Pipe(DynamicComponent): + """Python port of ModelicaModels.Mypipe.""" + + def __init__( + self, + name: str, + medium: IdealGasMedium, + L: float = 5.0, + D: float = 0.02, + lambda_darcy: float = 0.02, + p0: float = 1e5, + T0: float = 300.0, + ) -> None: + super().__init__(name=name) + self.medium = medium + self.L = L + self.D = D + self.lambda_darcy = lambda_darcy + self.area = 3.141592653589793 * D * D / 4.0 + self.V = self.area * L + m0 = p0 * self.V / (medium.R_gas * T0) + U0 = m0 * medium.specific_internal_energy(T0) + self.state = VolumeState(m=m0, U=U0) + self.port_a = PortState() + self.port_b = PortState() + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) + self.port_b.p = props.p + self.port_a.h_outflow = props.h + self.port_b.h_outflow = props.h + return props + + def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float: + resistance = self.lambda_darcy * (self.L / self.D) + dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area) + return core_pressure + resistance * dynamic_term + + def port_a_inlet_enthalpy( + self, + *, + port_a_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + return self.connection_inlet_enthalpy( + port_m_flow=port_a_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + + def port_b_inlet_enthalpy( + self, + *, + port_b_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + return self.connection_inlet_enthalpy( + port_m_flow=port_b_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + + def connection_inlet_enthalpies( + self, + *, + port_a_m_flow: float, + connected_h_a: float, + port_b_m_flow: float, + connected_h_b: float, + internal_h: float, + ) -> tuple[float, float]: + return ( + self.port_a_inlet_enthalpy( + port_a_m_flow=port_a_m_flow, + connected_h=connected_h_a, + internal_h=internal_h, + ), + self.port_b_inlet_enthalpy( + port_b_m_flow=port_b_m_flow, + connected_h=connected_h_b, + internal_h=internal_h, + ), + ) + + def derivatives_from_connections( + self, + *, + port_a_m_flow: float, + connected_h_a: float, + port_b_m_flow: float, + connected_h_b: float, + internal_h: float, + ) -> VolumeState: + inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies( + port_a_m_flow=port_a_m_flow, + connected_h_a=connected_h_a, + port_b_m_flow=port_b_m_flow, + connected_h_b=connected_h_b, + internal_h=internal_h, + ) + return self.derivatives( + inlet_h_a=inlet_h_a, + inlet_h_b=inlet_h_b, + m_flow_a=port_a_m_flow, + m_flow_b=port_b_m_flow, + ) + + def derivatives( + self, + inlet_h_a: float, + inlet_h_b: float, + m_flow_a: float, + m_flow_b: float, + ) -> VolumeState: + dm_dt = m_flow_a + m_flow_b + dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b + return VolumeState(m=dm_dt, U=dU_dt) diff --git a/PythonModels/components/tank.py b/PythonModels/components/tank.py new file mode 100644 index 0000000..8f51d69 --- /dev/null +++ b/PythonModels/components/tank.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from PythonModels.core.base import DynamicComponent +from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties +from PythonModels.core.ports import PortState +from PythonModels.core.state import VolumeState + + +class Tank(DynamicComponent): + """Python port of ModelicaModels.Mytank.""" + + def __init__( + self, + name: str, + medium: IdealGasMedium, + V: float = 0.1, + p0: float = 1e5, + T0: float = 300.0, + ) -> None: + super().__init__(name=name) + self.medium = medium + self.V = V + m0 = p0 * V / (medium.R_gas * T0) + U0 = m0 * medium.specific_internal_energy(T0) + self.state = VolumeState(m=m0, U=U0) + self.port_a = PortState() + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) + self.port_a.p = props.p + self.port_a.h_outflow = props.h + return props + + def derivatives_from_connection( + self, + *, + connected_h: float, + port_m_flow: float, + internal_h: float, + ) -> VolumeState: + inlet_h = self.connection_inlet_enthalpy( + port_m_flow=port_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + return self.derivatives(inlet_h, port_m_flow) + + def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: + return VolumeState(m=m_flow, U=m_flow * inlet_h) diff --git a/PythonModels/components/tee.py b/PythonModels/components/tee.py new file mode 100644 index 0000000..6602435 --- /dev/null +++ b/PythonModels/components/tee.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from PythonModels.core.base import AlgebraicComponent +from PythonModels.core.ports import PortState + + +class Tee(AlgebraicComponent): + """Python port of ModelicaModels.Mytee.""" + + def __init__(self, name: str) -> None: + super().__init__(name=name) + self.port_in = PortState() + self.port_out1 = PortState() + self.port_out2 = PortState() + + def mixed_inlet_enthalpy( + self, + branch1_m_flow: float, + branch1_h: float, + branch2_m_flow: float, + branch2_h: float, + fallback_h: float = 0.0, + ) -> float: + positive_1 = max(branch1_m_flow, 0.0) + positive_2 = max(branch2_m_flow, 0.0) + total = positive_1 + positive_2 + if total <= 1e-9: + return fallback_h + return (positive_1 * branch1_h + positive_2 * branch2_h) / total + + def inlet_stream_enthalpy( + self, + branch1_m_flow: float, + branch1_h: float, + branch2_m_flow: float, + branch2_h: float, + fallback_h: float, + ) -> float: + """Approximate `inStream(port_in.h_outflow)` for the current tee topology.""" + + return self.mixed_inlet_enthalpy( + branch1_m_flow, + branch1_h, + branch2_m_flow, + branch2_h, + fallback_h=fallback_h, + ) + + def branch_actual_stream_enthalpy( + self, + branch_m_flow: float, + branch_h: float, + inlet_h: float, + ) -> float: + """Approximate `actualStream(branch.h_outflow)` for a tee branch port.""" + + return inlet_h if branch_m_flow > 0.0 else branch_h + + @staticmethod + def _solve_linear_2x2( + a11: float, + a12: float, + a21: float, + a22: float, + b1: float, + b2: float, + ) -> tuple[float, float] | None: + determinant = a11 * a22 - a12 * a21 + if abs(determinant) <= 1e-12: + return None + x1 = (b1 * a22 - b2 * a12) / determinant + x2 = (a11 * b2 - a21 * b1) / determinant + return x1, x2 + + def solve_branch_outlet_flows_from_energy_balance( + self, + *, + ratio_branch1: float, + ratio_branch2: float, + inlet_h_branch1: float, + inlet_h_branch2: float, + branch1_h: float, + branch2_h: float, + inlet_h: float, + q_in_branch1: float, + q_in_branch2: float, + tolerance: float = 1e-12, + ) -> tuple[float, float]: + """Solve branch outlet flows for the current three-port downstream tee use-case.""" + + rhs_branch1 = q_in_branch1 * inlet_h_branch1 + rhs_branch2 = q_in_branch2 * inlet_h_branch2 + + def solve_both_forward() -> tuple[float, float] | None: + return self._solve_linear_2x2( + (1.0 + ratio_branch1) * branch1_h, + ratio_branch1 * branch2_h, + ratio_branch2 * branch1_h, + (1.0 + ratio_branch2) * branch2_h, + rhs_branch1, + rhs_branch2, + ) + + def solve_one_reverse( + *, + branch1_reverse: bool, + ) -> tuple[float, float] | None: + if branch1_reverse: + return self._solve_linear_2x2( + inlet_h * (1.0 + ratio_branch1), + ratio_branch1 * inlet_h, + ratio_branch2 * inlet_h, + branch2_h + ratio_branch2 * inlet_h, + rhs_branch1, + rhs_branch2, + ) + + return self._solve_linear_2x2( + branch1_h + ratio_branch1 * inlet_h, + ratio_branch1 * inlet_h, + ratio_branch2 * inlet_h, + inlet_h * (1.0 + ratio_branch2), + rhs_branch1, + rhs_branch2, + ) + + def solve_both_reverse() -> tuple[float, float] | None: + return self._solve_linear_2x2( + inlet_h * (1.0 + ratio_branch1), + ratio_branch1 * inlet_h, + ratio_branch2 * inlet_h, + inlet_h * (1.0 + ratio_branch2), + rhs_branch1, + rhs_branch2, + ) + + candidate_solvers = ( + ( + solve_both_forward, + lambda q1, q2: q1 >= -tolerance and q2 >= -tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=True), + lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 > tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=True), + lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 <= tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=False), + lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 > tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=False), + lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 <= tolerance, + ), + ( + solve_both_reverse, + lambda q1, q2: q1 < -tolerance and q2 < -tolerance, + ), + ) + + for solver, predicate in candidate_solvers: + candidate = solver() + if candidate is None: + continue + q_out_branch1, q_out_branch2 = candidate + if predicate(q_out_branch1, q_out_branch2): + return q_out_branch1, q_out_branch2 + + return solve_both_forward() or (0.0, 0.0) diff --git a/PythonModels/core/__init__.py b/PythonModels/core/__init__.py new file mode 100644 index 0000000..ae12d41 --- /dev/null +++ b/PythonModels/core/__init__.py @@ -0,0 +1,2 @@ +"""Core abstractions for the Python system model.""" + diff --git a/PythonModels/core/base.py b/PythonModels/core/base.py new file mode 100644 index 0000000..bac666e --- /dev/null +++ b/PythonModels/core/base.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Component(ABC): + def __init__(self, name: str) -> None: + self.name = name + + +class DynamicComponent(Component): + state_size = 2 + + @staticmethod + def actual_stream_enthalpy( + port_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + """Approximate `actualStream(port.h_outflow)` for a mixed control volume port.""" + + return connected_h if port_m_flow > 0.0 else internal_h + + def connection_inlet_enthalpy( + self, + port_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + """Resolve the enthalpy convected into this control volume through one port.""" + + return self.actual_stream_enthalpy( + port_m_flow=port_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + + @abstractmethod + def get_state_vector(self) -> list[float]: + raise NotImplementedError + + @abstractmethod + def set_state_vector(self, values: list[float]) -> None: + raise NotImplementedError + + +class AlgebraicComponent(Component): + """Stateless element described by algebraic constraints only.""" diff --git a/PythonModels/core/medium.py b/PythonModels/core/medium.py new file mode 100644 index 0000000..3410590 --- /dev/null +++ b/PythonModels/core/medium.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ThermodynamicProperties: + p: float + T: float + rho: float + u: float + h: float + + +@dataclass(frozen=True) +class IdealGasMedium: + """Temperature-dependent ideal-gas air approximation. + + This is still not a strict clone of `Modelica.Media.Air.SimpleAir`. + The small linear `cp(T)` term is kept configurable for calibration, but the + current default is calibrated against the committed Testmodel baseline and + therefore falls back to the constant-heat-capacity limit. + """ + + name: str = "SimpleAirApprox" + R_gas: float = 287.0 + cp_ref: float = 1005.0 + T_ref: float = 300.0 + cp_slope: float = 0.0 + + @property + def cv(self) -> float: + return self.cv_at_temperature(self.T_ref) + + @property + def gamma(self) -> float: + return self.cp_at_temperature(self.T_ref) / self.cv + + def cp_at_temperature(self, T: float) -> float: + return self.cp_ref + self.cp_slope * (T - self.T_ref) + + def cv_at_temperature(self, T: float) -> float: + return self.cp_at_temperature(T) - self.R_gas + + def density(self, p: float, T: float) -> float: + return p / (self.R_gas * T) + + def specific_internal_energy(self, T: float) -> float: + delta_T = T - self.T_ref + return ( + self.cv * self.T_ref + + self.cv * delta_T + + 0.5 * self.cp_slope * delta_T * delta_T + ) + + def specific_enthalpy(self, T: float) -> float: + delta_T = T - self.T_ref + return ( + self.cp_ref * self.T_ref + + self.cp_ref * delta_T + + 0.5 * self.cp_slope * delta_T * delta_T + ) + + def temperature_from_internal_energy(self, u: float) -> float: + reference_internal_energy = self.cv * self.T_ref + delta_u = u - reference_internal_energy + + if abs(self.cp_slope) <= 1e-15: + return self.T_ref + delta_u / self.cv + + a = 0.5 * self.cp_slope + b = self.cv + c = -delta_u + discriminant = max(b * b - 4.0 * a * c, 0.0) + positive_root = (-b + discriminant**0.5) / (2.0 * a) + negative_root = (-b - discriminant**0.5) / (2.0 * a) + delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root + return self.T_ref + delta_T + + def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: + if m <= 0.0: + raise ValueError("Mass must stay positive when recovering temperature.") + return self.temperature_from_internal_energy(U / m) + + def pressure(self, m: float, T: float, V: float) -> float: + if V <= 0.0: + raise ValueError("Volume must stay positive.") + return m * self.R_gas * T / V + + def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties: + T = self.temperature_from_mass_internal_energy(m, U) + p = self.pressure(m, T, V) + rho = m / V + u = U / m + h = self.specific_enthalpy(T) + return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h) diff --git a/PythonModels/core/network.py b/PythonModels/core/network.py new file mode 100644 index 0000000..e912867 --- /dev/null +++ b/PythonModels/core/network.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from PythonModels.core.base import Component, DynamicComponent + + +@dataclass(frozen=True) +class Connection: + source_component: str + source_port: str + target_component: str + target_port: str + + +class SimulationNetwork: + """Container for components, topology, and state-vector bookkeeping.""" + + def __init__(self, name: str) -> None: + self.name = name + self.components: dict[str, Component] = {} + self.connections: list[Connection] = [] + + def add_component(self, component: Component) -> None: + if component.name in self.components: + raise ValueError(f"Duplicate component name: {component.name}") + self.components[component.name] = component + + def connect( + self, + source_component: str, + source_port: str, + target_component: str, + target_port: str, + ) -> None: + self.connections.append( + Connection( + source_component=source_component, + source_port=source_port, + target_component=target_component, + target_port=target_port, + ) + ) + + def dynamic_components(self) -> list[DynamicComponent]: + return [ + component + for component in self.components.values() + if isinstance(component, DynamicComponent) + ] + + def initial_state_vector(self) -> list[float]: + values: list[float] = [] + for component in self.dynamic_components(): + values.extend(component.get_state_vector()) + return values + + def apply_state_vector(self, values: list[float]) -> None: + cursor = 0 + for component in self.dynamic_components(): + next_cursor = cursor + component.state_size + component.set_state_vector(values[cursor:next_cursor]) + cursor = next_cursor + if cursor != len(values): + raise ValueError("State vector length does not match dynamic components.") + + def summary(self) -> str: + lines = [f"Network: {self.name}", "Components:"] + for name, component in self.components.items(): + lines.append(f" - {name}: {component.__class__.__name__}") + lines.append("Connections:") + for conn in self.connections: + lines.append( + f" - {conn.source_component}.{conn.source_port}" + f" -> {conn.target_component}.{conn.target_port}" + ) + return "\n".join(lines) + diff --git a/PythonModels/core/peng_robinson.py b/PythonModels/core/peng_robinson.py new file mode 100644 index 0000000..f822189 --- /dev/null +++ b/PythonModels/core/peng_robinson.py @@ -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) diff --git a/PythonModels/core/ports.py b/PythonModels/core/ports.py new file mode 100644 index 0000000..d340db2 --- /dev/null +++ b/PythonModels/core/ports.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class PortState: + """Python-side analogue of a Modelica fluid port.""" + + p: float = 0.0 + m_flow: float = 0.0 + h_outflow: float = 0.0 + diff --git a/PythonModels/core/solver.py b/PythonModels/core/solver.py new file mode 100644 index 0000000..681886c --- /dev/null +++ b/PythonModels/core/solver.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Literal + + +CancellationCheck = Callable[[], bool] +AcceptedStepCallback = Callable[[float], None] +IntegrationStatus = Literal["completed", "cancelled", "failed"] + + +class _IntegrationCancelled(Exception): + pass + + +@dataclass(frozen=True) +class SolveIVPConfig: + t_start: float = 0.0 + t_stop: float = 20.0 + method: str = "BDF" + rtol: float = 1e-6 + atol: float = 1e-10 + max_step: float = 1e-3 + first_step: float | None = None + + +@dataclass(frozen=True) +class ODESolution: + t: list[float] + y: list[list[float]] + success: bool + message: str + status: IntegrationStatus = "completed" + error: Exception | None = None + + +def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]: + return [x + scale * y for x, y in zip(a, b)] + + +def _append_solution_sample( + times: list[float], + states: list[list[float]], + time: float, + state: list[float], +) -> None: + if times and time <= times[-1] + 1e-12: + return + times.append(float(time)) + for index, value in enumerate(state): + states[index].append(float(value)) + + +def _runge_kutta_4( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, +) -> ODESolution: + if t_eval is None: + point_count = max( + 2, + int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1, + ) + step = (config.t_stop - config.t_start) / (point_count - 1) + t_eval = [config.t_start + index * step for index in range(point_count)] + + state = list(initial_state) + states = [[value] for value in state] + times = [float(t_eval[0])] + current_time = float(t_eval[0]) + status: IntegrationStatus = "completed" + message = "Integrated with built-in RK4 fallback because SciPy is unavailable." + error: Exception | None = None + + try: + for target_time in t_eval[1:]: + while current_time < target_time - 1e-15: + if cancel_check is not None and cancel_check(): + raise _IntegrationCancelled + dt = min(config.max_step, target_time - current_time) + k1 = rhs(current_time, state) + k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt)) + k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt)) + k4 = rhs(current_time + dt, _vector_add(state, k3, dt)) + state = [ + value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d) + for value, a, b, c, d in zip(state, k1, k2, k3, k4) + ] + current_time += dt + if accepted_step_callback is not None: + accepted_step_callback(current_time) + + _append_solution_sample(times, states, target_time, state) + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + _append_solution_sample(times, states, current_time, state) + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + _append_solution_sample(times, states, current_time, state) + + return ODESolution( + t=times, + y=states, + success=status == "completed", + message=message, + status=status, + error=error, + ) + + +def _integrate_scipy_stepwise( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None, + cancel_check: CancellationCheck, + accepted_step_callback: AcceptedStepCallback | None, +) -> ODESolution: + import numpy as np + from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau + + solver_types = { + "BDF": BDF, + "DOP853": DOP853, + "LSODA": LSODA, + "RK23": RK23, + "RK45": RK45, + "Radau": Radau, + } + solver_type = solver_types.get(config.method) + if solver_type is None: + raise ValueError(f"Unsupported integration method: {config.method}") + + times = [float(config.t_start)] + states = [[float(value)] for value in initial_state] + last_accepted_time = float(config.t_start) + last_accepted_state = [float(value) for value in initial_state] + sample_times = list(t_eval or []) + sample_index = 0 + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= config.t_start + 1e-12 + ): + sample_index += 1 + + def cancellable_rhs(time, state): + if cancel_check(): + raise _IntegrationCancelled + return rhs(float(time), [float(value) for value in state]) + + if cancel_check(): + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + + solver_options = { + "rtol": config.rtol, + "atol": config.atol, + "max_step": config.max_step, + } + if config.first_step is not None: + solver_options["first_step"] = config.first_step + + try: + solver = solver_type( + cancellable_rhs, + config.t_start, + np.asarray(initial_state, dtype=float), + config.t_stop, + **solver_options, + ) + except _IntegrationCancelled: + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + except Exception as exc: + return ODESolution( + t=times, + y=states, + success=False, + message=str(exc), + status="failed", + error=exc, + ) + + status: IntegrationStatus = "completed" + message = "The solver successfully reached the end of the integration interval." + error: Exception | None = None + + while solver.status == "running": + if cancel_check(): + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + try: + step_message = solver.step() + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + break + + if solver.status == "failed": + status = "failed" + message = str(step_message or "Integration step failed.") + break + + last_accepted_time = float(solver.t) + last_accepted_state = [float(value) for value in solver.y] + if sample_times: + dense_output = solver.dense_output() + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= last_accepted_time + 1e-12 + ): + sample_time = float(sample_times[sample_index]) + sample_state = [float(value) for value in dense_output(sample_time)] + _append_solution_sample(times, states, sample_time, sample_state) + sample_index += 1 + else: + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + if accepted_step_callback is not None: + accepted_step_callback(last_accepted_time) + + if status != "completed": + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + + return ODESolution( + t=times, + y=states, + success=status == "completed", + message=message, + status=status, + error=error, + ) + + +def integrate_ode( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None = None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, +): + """Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback.""" + + if abs(config.t_stop - config.t_start) <= 1e-15: + return ODESolution( + t=[float(config.t_start)], + y=[[value] for value in initial_state], + success=True, + message="Skipped integration because t_start equals t_stop.", + ) + + try: + from scipy.integrate import solve_ivp + except ImportError: + return _runge_kutta_4( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) + + if cancel_check is not None: + return _integrate_scipy_stepwise( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) + + solve_options = { + "fun": rhs, + "t_span": (config.t_start, config.t_stop), + "y0": initial_state, + "method": config.method, + "rtol": config.rtol, + "atol": config.atol, + "max_step": config.max_step, + "t_eval": t_eval, + } + if config.first_step is not None: + solve_options["first_step"] = config.first_step + return solve_ivp(**solve_options) diff --git a/PythonModels/core/state.py b/PythonModels/core/state.py new file mode 100644 index 0000000..e4cf3b3 --- /dev/null +++ b/PythonModels/core/state.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class VolumeState: + """Primary dynamic state for rigid adiabatic control volumes.""" + + m: float + U: float + + def as_vector(self) -> list[float]: + return [self.m, self.U] + + @classmethod + def from_vector(cls, values: list[float]) -> "VolumeState": + if len(values) != 2: + raise ValueError("VolumeState requires exactly two values: [m, U].") + return cls(m=values[0], U=values[1]) + diff --git a/PythonModels/reporting/__init__.py b/PythonModels/reporting/__init__.py new file mode 100644 index 0000000..9ca6880 --- /dev/null +++ b/PythonModels/reporting/__init__.py @@ -0,0 +1,23 @@ +from PythonModels.reporting.testmodel_outputs import ( + COMPARISON_KEYS, + MODELICA_COMPARISON_COLUMNS, + PRIMARY_KEYS, + TestModelArtifacts, + export_testmodel_artifacts, + format_testmodel_run_report, + load_modelica_series, + write_testmodel_run_report, + write_modelica_comparison, +) + +__all__ = [ + "COMPARISON_KEYS", + "MODELICA_COMPARISON_COLUMNS", + "PRIMARY_KEYS", + "TestModelArtifacts", + "export_testmodel_artifacts", + "format_testmodel_run_report", + "load_modelica_series", + "write_testmodel_run_report", + "write_modelica_comparison", +] diff --git a/PythonModels/reporting/amesim_results.py b/PythonModels/reporting/amesim_results.py new file mode 100644 index 0000000..449b839 --- /dev/null +++ b/PythonModels/reporting/amesim_results.py @@ -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(" 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, + ) diff --git a/PythonModels/reporting/test_mql_chamber_observations.py b/PythonModels/reporting/test_mql_chamber_observations.py new file mode 100644 index 0000000..fd60209 --- /dev/null +++ b/PythonModels/reporting/test_mql_chamber_observations.py @@ -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}." + ) diff --git a/PythonModels/reporting/test_mql_comparison.py b/PythonModels/reporting/test_mql_comparison.py new file mode 100644 index 0000000..3d65cf2 --- /dev/null +++ b/PythonModels/reporting/test_mql_comparison.py @@ -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 diff --git a/PythonModels/reporting/test_mql_line_observations.py b/PythonModels/reporting/test_mql_line_observations.py new file mode 100644 index 0000000..0a85c8a --- /dev/null +++ b/PythonModels/reporting/test_mql_line_observations.py @@ -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}." + ) diff --git a/PythonModels/reporting/test_mql_mechanical_observations.py b/PythonModels/reporting/test_mql_mechanical_observations.py new file mode 100644 index 0000000..645be29 --- /dev/null +++ b/PythonModels/reporting/test_mql_mechanical_observations.py @@ -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}." + ) diff --git a/PythonModels/reporting/test_mql_observations.py b/PythonModels/reporting/test_mql_observations.py new file mode 100644 index 0000000..fe48070 --- /dev/null +++ b/PythonModels/reporting/test_mql_observations.py @@ -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))) diff --git a/PythonModels/reporting/test_mql_orifice_observations.py b/PythonModels/reporting/test_mql_orifice_observations.py new file mode 100644 index 0000000..defe414 --- /dev/null +++ b/PythonModels/reporting/test_mql_orifice_observations.py @@ -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] diff --git a/PythonModels/reporting/test_mql_output_schema.py b/PythonModels/reporting/test_mql_output_schema.py new file mode 100644 index 0000000..3a5661c --- /dev/null +++ b/PythonModels/reporting/test_mql_output_schema.py @@ -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, + ) diff --git a/PythonModels/reporting/test_mql_output_validation.py b/PythonModels/reporting/test_mql_output_validation.py new file mode 100644 index 0000000..39333e2 --- /dev/null +++ b/PythonModels/reporting/test_mql_output_validation.py @@ -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 diff --git a/PythonModels/reporting/test_mql_variables.py b/PythonModels/reporting/test_mql_variables.py new file mode 100644 index 0000000..c453928 --- /dev/null +++ b/PythonModels/reporting/test_mql_variables.py @@ -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) diff --git a/PythonModels/reporting/testmodel_outputs.py b/PythonModels/reporting/testmodel_outputs.py new file mode 100644 index 0000000..9338b1c --- /dev/null +++ b/PythonModels/reporting/testmodel_outputs.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +from bisect import bisect_left +import csv +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +PRIMARY_KEYS = ( + "mytank.p", + "mytank.T", + "mycylinder.p", + "mycylinder.T", +) + +MODELICA_COMPARISON_COLUMNS = { + "mytank.p": "mytank.p", + "mytank.T": "mytank.T", + "mycylinder.p": "mycylinder.p", + "mycylinder.T": "mycylinder.T", + "branch.upper_branch.p": "mypipe.p", + "branch.upper_branch.in": "myorifice.port_a.m_flow", + "branch.upper_branch.out": "mytee1.port_out2.m_flow", + "branch.lower_branch.p": "mypipe1.p", + "branch.lower_branch.in": "myorifice1.port_a.m_flow", + "branch.lower_branch.out": "mytee1.port_out1.m_flow", +} + +COMPARISON_KEYS = tuple(MODELICA_COMPARISON_COLUMNS.keys()) + + +def _branch_series_values( + series: dict[str, list[float]], + branch_name: str, + legacy_key: str, +) -> list[float]: + generic_key = f"branch.{branch_name}.{legacy_key.split('.')[-1]}" + if generic_key in series: + return series[generic_key] + return series[legacy_key] + + +@dataclass(frozen=True) +class TestModelArtifacts: + primary_csv_path: Path + temperature_csv_path: Path + temperature_svg_path: Path + run_report_path: Path + comparison_csv_path: Path | None = None + comparison_summary_path: Path | None = None + + +def format_testmodel_run_report( + *, + network_summary: str, + initialization: Any, + raw_initial_state: tuple[float, ...], + consistent_initial_state: tuple[float, ...], + solution: Any, + series: dict[str, list[float]], + solve_diagnostics: Any, + artifacts: TestModelArtifacts, + comparison_summary: dict[str, tuple[float, float]] | None, +) -> str: + lines = [ + network_summary, + "", + f"Initialization converged: {initialization.converged}", + f"Initialization iterations: {initialization.iterations}", + f"Initialization max state delta: {initialization.max_state_delta:.6e}", + f"Initialization max flow delta: {initialization.max_flow_delta:.6e}", + f"Initialization max enthalpy delta: {initialization.max_enthalpy_delta:.6e}", + ( + "Initialization downstream pressure spread: " + f"{initialization.downstream_pressure_spread:.6e}" + ), + "", + "Raw initial state vector:", + str(list(raw_initial_state)), + "", + "Constraint-consistent initial state vector:", + str(list(consistent_initial_state)), + "", + f"Solver success: {solution.success}", + f"Solver message: {solution.message}", + f"Final time: {solution.t[-1]:.2f} s", + f"Final tank pressure: {series['mytank.p'][-1]:.3f} Pa", + f"Final tank temperature: {series['mytank.T'][-1]:.3f} K", + f"Final cylinder pressure: {series['mycylinder.p'][-1]:.3f} Pa", + ( + "Final branch inflow: " + f"{_branch_series_values(series, 'upper_branch', 'branch_upper.in')[-1] + _branch_series_values(series, 'lower_branch', 'branch_lower.in')[-1]:.6f} kg/s" + ), + ] + + if solve_diagnostics is not None: + lines.extend( + [ + "", + "Final closure solve diagnostics:", + ( + "Upper branch inlet solve: " + f"converged={solve_diagnostics.upper_branch_inlet.converged}, " + f"iterations={solve_diagnostics.upper_branch_inlet.iterations}, " + f"residual={solve_diagnostics.upper_branch_inlet.residual:.6e}" + ), + ( + "Lower branch inlet solve: " + f"converged={solve_diagnostics.lower_branch_inlet.converged}, " + f"iterations={solve_diagnostics.lower_branch_inlet.iterations}, " + f"residual={solve_diagnostics.lower_branch_inlet.residual:.6e}" + ), + ] + ) + if solve_diagnostics.downstream_pressure_projection is not None: + lines.append( + "Downstream pressure projection: " + f"converged={solve_diagnostics.downstream_pressure_projection.converged}, " + f"iterations={solve_diagnostics.downstream_pressure_projection.iterations}, " + f"residual={solve_diagnostics.downstream_pressure_projection.residual:.6e}" + ) + + lines.extend( + [ + f"Primary series CSV: {artifacts.primary_csv_path}", + f"Temperature CSV: {artifacts.temperature_csv_path}", + f"Temperature plot: {artifacts.temperature_svg_path}", + f"Run report TXT: {artifacts.run_report_path}", + ] + ) + + if ( + artifacts.comparison_csv_path is not None + and artifacts.comparison_summary_path is not None + ): + lines.extend( + [ + f"Modelica comparison CSV: {artifacts.comparison_csv_path}", + f"Modelica comparison summary: {artifacts.comparison_summary_path}", + ] + ) + if comparison_summary is not None: + for key, (max_abs_error, max_rel_error) in comparison_summary.items(): + lines.append( + f"{key} max abs error: {max_abs_error:.6f}, " + f"max rel error: {max_rel_error:.6%}" + ) + + return "\n".join(lines) + "\n" + + +def write_testmodel_run_report(output_dir: Path, report_text: str) -> Path: + report_path = output_dir / "testmodel_run_report.txt" + report_path.write_text(report_text, encoding="utf-8") + return report_path + + +def _write_primary_series_csv(output_dir: Path, series: dict[str, list[float]]) -> Path: + csv_path = output_dir / "testmodel_primary_series.csv" + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(["time_s", *PRIMARY_KEYS]) + for index, time_value in enumerate(series["time"]): + writer.writerow([time_value, *(series[key][index] for key in PRIMARY_KEYS)]) + return csv_path + + +def _write_temperature_csv(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path: + csv_path = output_dir / "testmodel_tank_temperature.csv" + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(["time_s", "mytank_T_K"]) + writer.writerows(zip(time_values, temperatures)) + return csv_path + + +def _write_temperature_svg(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path: + svg_path = output_dir / "testmodel_tank_temperature.svg" + + width = 900 + height = 520 + left = 90 + right = 40 + top = 60 + bottom = 70 + plot_width = width - left - right + plot_height = height - top - bottom + + min_time = min(time_values) + max_time = max(time_values) + min_temp = min(temperatures) + max_temp = max(temperatures) + temp_padding = max(1.0, (max_temp - min_temp) * 0.08) + min_temp -= temp_padding + max_temp += temp_padding + + def scale_x(value: float) -> float: + return left + (value - min_time) / max(max_time - min_time, 1e-12) * plot_width + + def scale_y(value: float) -> float: + return top + (max_temp - value) / max(max_temp - min_temp, 1e-12) * plot_height + + points = " ".join( + f"{scale_x(time_value):.2f},{scale_y(temperature):.2f}" + for time_value, temperature in zip(time_values, temperatures) + ) + + x_ticks = 5 + y_ticks = 5 + x_tick_markup = [] + y_tick_markup = [] + + for index in range(x_ticks + 1): + fraction = index / x_ticks + time_value = min_time + fraction * (max_time - min_time) + x = left + fraction * plot_width + x_tick_markup.append( + f'' + ) + x_tick_markup.append( + f'' + f"{time_value:.1f}" + ) + + for index in range(y_ticks + 1): + fraction = index / y_ticks + temp_value = min_temp + fraction * (max_temp - min_temp) + y = top + plot_height - fraction * plot_height + y_tick_markup.append( + f'' + ) + y_tick_markup.append( + f'' + f"{temp_value:.1f}" + ) + + svg_content = f""" + + Python Testmodel Tank Temperature + Time (s) + Temperature (K) + + {''.join(x_tick_markup)} + {''.join(y_tick_markup)} + + +""" + svg_path.write_text(svg_content, encoding="utf-8") + return svg_path + + +def load_modelica_series(csv_path: Path, variable_names: tuple[str, ...]) -> dict[str, list[float]]: + series = {"time": []} + for variable_name in variable_names: + series[variable_name] = [] + + with csv_path.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + available_variable_names = tuple( + variable_name + for variable_name in variable_names + if MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) in (reader.fieldnames or ()) + ) + for row in reader: + series["time"].append(float(row["time"])) + for variable_name in available_variable_names: + modelica_column = MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) + series[variable_name].append(float(row[modelica_column])) + + return series + + +def _interpolate_series_value(time_values: list[float], values: list[float], target_time: float) -> float: + if target_time <= time_values[0]: + return values[0] + if target_time >= time_values[-1]: + return values[-1] + + right_index = bisect_left(time_values, target_time) + if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1e-12: + return values[right_index] + + left_index = right_index - 1 + left_time = time_values[left_index] + right_time = time_values[right_index] + fraction = (target_time - left_time) / (right_time - left_time) + return values[left_index] + fraction * (values[right_index] - values[left_index]) + + +def write_modelica_comparison( + output_dir: Path, + python_series: dict[str, list[float]], + modelica_series: dict[str, list[float]], +) -> tuple[Path, Path, dict[str, tuple[float, float]]]: + comparison_csv_path = output_dir / "testmodel_modelica_comparison.csv" + summary_path = output_dir / "testmodel_modelica_comparison_summary.txt" + summary: dict[str, tuple[float, float]] = {} + + with comparison_csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + header = ["time_s"] + comparison_keys = tuple( + key + for key in COMPARISON_KEYS + if key in python_series and key in modelica_series and modelica_series[key] + ) + for key in comparison_keys: + header.extend( + [ + f"python.{key}", + f"modelica.{key}", + f"abs_error.{key}", + f"rel_error.{key}", + ] + ) + writer.writerow(header) + + max_abs_errors = {key: 0.0 for key in comparison_keys} + max_rel_errors = {key: 0.0 for key in comparison_keys} + + for index, time_value in enumerate(python_series["time"]): + row = [time_value] + for key in comparison_keys: + python_value = python_series[key][index] + modelica_value = _interpolate_series_value( + modelica_series["time"], + modelica_series[key], + time_value, + ) + abs_error = abs(python_value - modelica_value) + rel_error = abs_error / max(abs(modelica_value), 1e-9) + max_abs_errors[key] = max(max_abs_errors[key], abs_error) + max_rel_errors[key] = max(max_rel_errors[key], rel_error) + row.extend([python_value, modelica_value, abs_error, rel_error]) + writer.writerow(row) + + summary_lines = [] + for key in comparison_keys: + summary[key] = (max_abs_errors[key], max_rel_errors[key]) + summary_lines.append( + f"{key}: max_abs_error={max_abs_errors[key]:.6f}, " + f"max_rel_error={max_rel_errors[key]:.6%}" + ) + summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8") + return comparison_csv_path, summary_path, summary + + +def export_testmodel_artifacts( + *, + output_dir: Path, + series: dict[str, list[float]], + modelica_series: dict[str, list[float]] | None = None, +) -> tuple[TestModelArtifacts, dict[str, tuple[float, float]] | None]: + output_dir.mkdir(parents=True, exist_ok=True) + + primary_csv_path = _write_primary_series_csv(output_dir, series) + temperature_csv_path = _write_temperature_csv( + output_dir, + series["time"], + series["mytank.T"], + ) + temperature_svg_path = _write_temperature_svg( + output_dir, + series["time"], + series["mytank.T"], + ) + + comparison_csv_path = None + comparison_summary_path = None + comparison_summary = None + if modelica_series is not None: + ( + comparison_csv_path, + comparison_summary_path, + comparison_summary, + ) = write_modelica_comparison(output_dir, series, modelica_series) + + return ( + TestModelArtifacts( + primary_csv_path=primary_csv_path, + temperature_csv_path=temperature_csv_path, + temperature_svg_path=temperature_svg_path, + run_report_path=output_dir / "testmodel_run_report.txt", + comparison_csv_path=comparison_csv_path, + comparison_summary_path=comparison_summary_path, + ), + comparison_summary, + ) diff --git a/PythonModels/runs/test_mql_20260715_085526_745726/test_mql_model_summary.txt b/PythonModels/runs/test_mql_20260715_085526_745726/test_mql_model_summary.txt new file mode 100644 index 0000000..1ad5ff1 --- /dev/null +++ b/PythonModels/runs/test_mql_20260715_085526_745726/test_mql_model_summary.txt @@ -0,0 +1,31 @@ +Model: test_mql +Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame +Components: 117 +Connections: 84 +Continuous states in AMESim modelinfo: 132 +Discrete states in AMESim modelinfo: 24 +Global parameters: + - D1: 20 + - D2: 20 + - D3: 14 + - P0: 153 + - Pdq: 1 + - V: 15 + - cf: 0.45 +Component submodels: + - F000: 16 + - FORC: 2 + - LMECHN1: 2 + - LSTP00A: 8 + - MECMAS21: 10 + - P4NODE2: 8 + - PN3NODE2: 8 + - PNCH012: 8 + - PNCH023: 4 + - PNGD00: 1 + - PNOR001: 8 + - PNPL01: 16 + - PNRP17: 8 + - PNVO001: 8 + - STEP0: 8 + - UD00: 2 diff --git a/PythonModels/runs/test_mql_20260715_085712_158379/test_mql_model_summary.txt b/PythonModels/runs/test_mql_20260715_085712_158379/test_mql_model_summary.txt new file mode 100644 index 0000000..1ad5ff1 --- /dev/null +++ b/PythonModels/runs/test_mql_20260715_085712_158379/test_mql_model_summary.txt @@ -0,0 +1,31 @@ +Model: test_mql +Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame +Components: 117 +Connections: 84 +Continuous states in AMESim modelinfo: 132 +Discrete states in AMESim modelinfo: 24 +Global parameters: + - D1: 20 + - D2: 20 + - D3: 14 + - P0: 153 + - Pdq: 1 + - V: 15 + - cf: 0.45 +Component submodels: + - F000: 16 + - FORC: 2 + - LMECHN1: 2 + - LSTP00A: 8 + - MECMAS21: 10 + - P4NODE2: 8 + - PN3NODE2: 8 + - PNCH012: 8 + - PNCH023: 4 + - PNGD00: 1 + - PNOR001: 8 + - PNPL01: 16 + - PNRP17: 8 + - PNVO001: 8 + - STEP0: 8 + - UD00: 2 diff --git a/PythonModels/runs/test_mql_20260715_091524_614886/test_mql_model_summary.txt b/PythonModels/runs/test_mql_20260715_091524_614886/test_mql_model_summary.txt new file mode 100644 index 0000000..1ad5ff1 --- /dev/null +++ b/PythonModels/runs/test_mql_20260715_091524_614886/test_mql_model_summary.txt @@ -0,0 +1,31 @@ +Model: test_mql +Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame +Components: 117 +Connections: 84 +Continuous states in AMESim modelinfo: 132 +Discrete states in AMESim modelinfo: 24 +Global parameters: + - D1: 20 + - D2: 20 + - D3: 14 + - P0: 153 + - Pdq: 1 + - V: 15 + - cf: 0.45 +Component submodels: + - F000: 16 + - FORC: 2 + - LMECHN1: 2 + - LSTP00A: 8 + - MECMAS21: 10 + - P4NODE2: 8 + - PN3NODE2: 8 + - PNCH012: 8 + - PNCH023: 4 + - PNGD00: 1 + - PNOR001: 8 + - PNPL01: 16 + - PNRP17: 8 + - PNVO001: 8 + - STEP0: 8 + - UD00: 2 diff --git a/PythonModels/runs/test_mql_20260720_084308_403642/test_mql_model_summary.txt b/PythonModels/runs/test_mql_20260720_084308_403642/test_mql_model_summary.txt new file mode 100644 index 0000000..1ad5ff1 --- /dev/null +++ b/PythonModels/runs/test_mql_20260720_084308_403642/test_mql_model_summary.txt @@ -0,0 +1,31 @@ +Model: test_mql +Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame +Components: 117 +Connections: 84 +Continuous states in AMESim modelinfo: 132 +Discrete states in AMESim modelinfo: 24 +Global parameters: + - D1: 20 + - D2: 20 + - D3: 14 + - P0: 153 + - Pdq: 1 + - V: 15 + - cf: 0.45 +Component submodels: + - F000: 16 + - FORC: 2 + - LMECHN1: 2 + - LSTP00A: 8 + - MECMAS21: 10 + - P4NODE2: 8 + - PN3NODE2: 8 + - PNCH012: 8 + - PNCH023: 4 + - PNGD00: 1 + - PNOR001: 8 + - PNPL01: 16 + - PNRP17: 8 + - PNVO001: 8 + - STEP0: 8 + - UD00: 2 diff --git a/PythonModels/runs/test_mql_20260720_085120_858664/test_mql_model_summary.txt b/PythonModels/runs/test_mql_20260720_085120_858664/test_mql_model_summary.txt new file mode 100644 index 0000000..1ad5ff1 --- /dev/null +++ b/PythonModels/runs/test_mql_20260720_085120_858664/test_mql_model_summary.txt @@ -0,0 +1,31 @@ +Model: test_mql +Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame +Components: 117 +Connections: 84 +Continuous states in AMESim modelinfo: 132 +Discrete states in AMESim modelinfo: 24 +Global parameters: + - D1: 20 + - D2: 20 + - D3: 14 + - P0: 153 + - Pdq: 1 + - V: 15 + - cf: 0.45 +Component submodels: + - F000: 16 + - FORC: 2 + - LMECHN1: 2 + - LSTP00A: 8 + - MECMAS21: 10 + - P4NODE2: 8 + - PN3NODE2: 8 + - PNCH012: 8 + - PNCH023: 4 + - PNGD00: 1 + - PNOR001: 8 + - PNPL01: 16 + - PNRP17: 8 + - PNVO001: 8 + - STEP0: 8 + - UD00: 2 diff --git a/PythonModels/runs/test_mql_baseline_20260716_024434_287016/test_mql_baseline_summary.txt b/PythonModels/runs/test_mql_baseline_20260716_024434_287016/test_mql_baseline_summary.txt new file mode 100644 index 0000000..e48fb2a --- /dev/null +++ b/PythonModels/runs/test_mql_baseline_20260716_024434_287016/test_mql_baseline_summary.txt @@ -0,0 +1,8 @@ +Model: test_mql +Mode: AMESim baseline passthrough +Samples: 1002 +Output schema signals: 858 +Compared signals: 858 +Observation bindings: 114 +Max absolute error: 0.0 +Max relative error: 0.0 diff --git a/PythonModels/runs/test_mql_baseline_20260720_084318_747036/test_mql_baseline_summary.txt b/PythonModels/runs/test_mql_baseline_20260720_084318_747036/test_mql_baseline_summary.txt new file mode 100644 index 0000000..e48fb2a --- /dev/null +++ b/PythonModels/runs/test_mql_baseline_20260720_084318_747036/test_mql_baseline_summary.txt @@ -0,0 +1,8 @@ +Model: test_mql +Mode: AMESim baseline passthrough +Samples: 1002 +Output schema signals: 858 +Compared signals: 858 +Observation bindings: 114 +Max absolute error: 0.0 +Max relative error: 0.0 diff --git a/PythonModels/scripts/run_test_mql.py b/PythonModels/scripts/run_test_mql.py new file mode 100644 index 0000000..0fd7977 --- /dev/null +++ b/PythonModels/scripts/run_test_mql.py @@ -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() diff --git a/PythonModels/scripts/run_test_mql_baseline.py b/PythonModels/scripts/run_test_mql_baseline.py new file mode 100644 index 0000000..eb37505 --- /dev/null +++ b/PythonModels/scripts/run_test_mql_baseline.py @@ -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() diff --git a/PythonModels/scripts/run_test_mql_full_state_comparison.py b/PythonModels/scripts/run_test_mql_full_state_comparison.py new file mode 100644 index 0000000..1b3c8d4 --- /dev/null +++ b/PythonModels/scripts/run_test_mql_full_state_comparison.py @@ -0,0 +1,4620 @@ +from __future__ import annotations + +import argparse +from bisect import bisect_left +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime +from math import nextafter, sqrt +from pathlib import Path + +from PythonModels.components.amesim_pneumatic import AmesimPneumaticGas +from PythonModels.core.solver import SolveIVPConfig, integrate_ode +from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results +from PythonModels.reporting.test_mql_comparison import ( + TestMqlComparisonResult, + interpolate_series_value, + write_test_mql_comparison_csv, +) +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.systems.test_mql import ( + TestMqlPnl0001LineRhsDiagnostic, + TestMqlSimulationResult, + TestMqlSystem, + TestMqlVariableChamberRhsDiagnostic, +) +from PythonModels.systems.test_mql_pneumatic import AMESIM_REFERENCE_PRESSURE_PA + + +DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS = ( + "press@pn_c1_8", + "vol@pn_c1_8", + "vol1@pn_brp2_8", + "vvol1@pn_brp2_8", + "x1@mass_friction_endstops_10", + "v1@mass_friction_endstops_10", + "acc1@mass_friction_endstops_10", + "x1@mass_friction_endstops_18", + "v1@mass_friction_endstops_18", + "acc1@mass_friction_endstops_18", + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", +) + + +@dataclass(frozen=True) +class TestMqlFullStateSignalDiagnostic: + data_path: str + initial_time_s: float + final_time_s: float + initial_python_value: float + initial_amesim_value: float + initial_abs_error: float + final_python_value: float + final_amesim_value: float + final_abs_error: float + + +@dataclass(frozen=True) +class TestMqlFullStateFlowDiagnostic: + data_path: str + initial_time_s: float + final_time_s: float + initial_python_dm1_g_s: float + initial_amesim_dm1_g_s: float + initial_python_canonical_kg_s: float + initial_amesim_canonical_kg_s: float + initial_canonical_abs_error_kg_s: float + final_python_dm1_g_s: float + final_amesim_dm1_g_s: float + final_python_canonical_kg_s: float + final_amesim_canonical_kg_s: float + final_canonical_abs_error_kg_s: float + + +@dataclass(frozen=True) +class TestMqlFullStatePnvoDiagnostic: + alias: str + initial_time_s: float + final_time_s: float + initial_python_opening: float + initial_amesim_opening: float + final_python_opening: float + final_amesim_opening: float + initial_python_mass_flow_kg_s: float + initial_amesim_mass_flow_kg_s: float + final_python_mass_flow_kg_s: float + final_amesim_mass_flow_kg_s: float + final_mass_flow_abs_error_kg_s: float + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonRun: + system: TestMqlSystem + closure: object + amesim_results: AmesimResults + output_schema: TestMqlOutputSchema + result: TestMqlSimulationResult + 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 metrics_by_max_abs_error(self): + return tuple( + sorted( + self.comparison.metrics, + key=lambda metric: metric.max_abs_error, + reverse=True, + ) + ) + + @property + def largest_abs_error_metric(self): + metrics = self.metrics_by_max_abs_error() + return metrics[0] if metrics else None + + def signal_diagnostic(self, data_path: str) -> TestMqlFullStateSignalDiagnostic: + times = self.output.times + python_values = self.output.series_by_data_path[data_path] + amesim_values = self.amesim_results.series(data_path) + initial_time = float(times[0]) + final_time = float(times[-1]) + initial_python = float(python_values[0]) + final_python = float(python_values[-1]) + initial_amesim = interpolate_series_value( + self.amesim_results.times, + amesim_values, + initial_time, + ) + final_amesim = interpolate_series_value( + self.amesim_results.times, + amesim_values, + final_time, + ) + return TestMqlFullStateSignalDiagnostic( + data_path=data_path, + initial_time_s=initial_time, + final_time_s=final_time, + initial_python_value=initial_python, + initial_amesim_value=initial_amesim, + initial_abs_error=abs(initial_python - initial_amesim), + final_python_value=final_python, + final_amesim_value=final_amesim, + final_abs_error=abs(final_python - final_amesim), + ) + + def pnl0001_mass_flow_diagnostic( + self, + data_path: str = "dm1@pneumatic_69", + ) -> TestMqlFullStateFlowDiagnostic: + diagnostic = self.signal_diagnostic(data_path) + initial_python_canonical = -diagnostic.initial_python_value * 1.0e-3 + initial_amesim_canonical = -diagnostic.initial_amesim_value * 1.0e-3 + final_python_canonical = -diagnostic.final_python_value * 1.0e-3 + final_amesim_canonical = -diagnostic.final_amesim_value * 1.0e-3 + return TestMqlFullStateFlowDiagnostic( + data_path=data_path, + initial_time_s=diagnostic.initial_time_s, + final_time_s=diagnostic.final_time_s, + initial_python_dm1_g_s=diagnostic.initial_python_value, + initial_amesim_dm1_g_s=diagnostic.initial_amesim_value, + initial_python_canonical_kg_s=initial_python_canonical, + initial_amesim_canonical_kg_s=initial_amesim_canonical, + initial_canonical_abs_error_kg_s=abs( + initial_python_canonical - initial_amesim_canonical + ), + final_python_dm1_g_s=diagnostic.final_python_value, + final_amesim_dm1_g_s=diagnostic.final_amesim_value, + final_python_canonical_kg_s=final_python_canonical, + final_amesim_canonical_kg_s=final_amesim_canonical, + final_canonical_abs_error_kg_s=abs( + final_python_canonical - final_amesim_canonical + ), + ) + + def pnvo_diagnostic( + self, + alias: str = "pn_morifice_1", + ) -> TestMqlFullStatePnvoDiagnostic: + opening = self.signal_diagnostic(f"xv@{alias}") + mass_flow = self.signal_diagnostic(f"dm2@{alias}") + initial_python_mass_flow = mass_flow.initial_python_value * 1.0e-3 + initial_amesim_mass_flow = mass_flow.initial_amesim_value * 1.0e-3 + final_python_mass_flow = mass_flow.final_python_value * 1.0e-3 + final_amesim_mass_flow = mass_flow.final_amesim_value * 1.0e-3 + return TestMqlFullStatePnvoDiagnostic( + alias=alias, + initial_time_s=opening.initial_time_s, + final_time_s=opening.final_time_s, + initial_python_opening=opening.initial_python_value, + initial_amesim_opening=opening.initial_amesim_value, + final_python_opening=opening.final_python_value, + final_amesim_opening=opening.final_amesim_value, + initial_python_mass_flow_kg_s=initial_python_mass_flow, + initial_amesim_mass_flow_kg_s=initial_amesim_mass_flow, + final_python_mass_flow_kg_s=final_python_mass_flow, + final_amesim_mass_flow_kg_s=final_amesim_mass_flow, + final_mass_flow_abs_error_kg_s=abs( + final_python_mass_flow - final_amesim_mass_flow + ), + ) + + def diagnostics_by_final_abs_error(self): + return tuple( + sorted( + ( + self.signal_diagnostic(data_path) + for data_path in self.output.data_paths + ), + key=lambda diagnostic: diagnostic.final_abs_error, + reverse=True, + ) + ) + + @property + def largest_final_abs_error_diagnostic(self): + diagnostics = self.diagnostics_by_final_abs_error() + return diagnostics[0] if diagnostics else None + + def chamber_rhs_diagnostic(self, chamber_alias: str, sample_index: int = -1): + state_vector = [row[sample_index] for row in self.result.y] + return self.closure.variable_chamber_rhs_diagnostic( + chamber_alias=chamber_alias, + state_vector=state_vector, + time_s=float(self.result.t[sample_index]), + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventBoundaryDiagnostic: + orifice_alias: str + event_time_s: float + integration_stop_time_s: float + data_paths: tuple[str, ...] + python_values_by_data_path: dict[str, float] + amesim_values_by_data_path: dict[str, float] + + def abs_error(self, data_path: str) -> float: + return abs( + self.python_values_by_data_path[data_path] + - self.amesim_values_by_data_path[data_path] + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowSegmentDiagnostic: + t_start: float + t_stop: float + method: str + rtol: float + atol: float + max_step: float + rhs_evaluations: int + success: bool + message: str + + +@dataclass(frozen=True) +class TestMqlPnl0001PressureLossCalibrationDiagnostic: + line_alias: str + chamber_alias: str + time_s: float + amesim_cm: float + amesim_dm1_g_s: float + amesim_mass_flow_magnitude_kg_s: float + amesim_line_gauge_pressure_pa: float + amesim_chamber_gauge_pressure_pa: float + amesim_line_temperature_k: float + amesim_pressure_drop_pa: float + current_darcy_pressure_drop_pa: float + pressure_drop_multiplier: float + candidate_pn2pipefr_dm1_g_s: float + candidate_pn2pipefr_to_amesim_dm1_ratio: float + amesim_linear_conductance_kg_s_sqrt_k_per_pa: float + python_linear_conductance_kg_s_sqrt_k_per_pa: float + python_to_amesim_linear_conductance_ratio: float + + +@dataclass(frozen=True) +class TestMqlPnvoFlowParameterDiagnostic: + orifice_alias: str + time_s: float + amesim_cm: float + amesim_dm2_g_s: float + amesim_opening: float + amesim_gas_velocity_m_s: float + python_opening: float + python_flow_coefficient: float + python_effective_area_m2: float + python_line_pressure_pa: float + python_boundary_pressure_pa: float + python_upstream_pressure_pa: float + python_upstream_temperature_k: float + python_mass_flow_kg_s: float + python_cm: float + python_to_amesim_cm_ratio: float + + +@dataclass(frozen=True) +class TestMqlPnl0001EnergyFlowDiagnostic: + line_alias: str + chamber_alias: str + node_alias: str + time_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_port_1_mass_flow_g_s: float + amesim_node_port_2_enthalpy_flow_w: float + amesim_node_port_2_mass_flow_g_s: float + amesim_observed_port_enthalpy_sum_w: float + python_port_1_internal_energy_flow_w: float + python_port_2_internal_energy_flow_w: float + python_current_energy_derivative_w: float + python_direct_enthalpy_port_1_flow_w: float + python_direct_enthalpy_port_2_flow_w: float + python_direct_enthalpy_energy_derivative_w: float + python_direct_minus_current_energy_derivative_w: float + python_p4_node_port_2_enthalpy_flow_w: float + python_p4_node_port_2_mass_flow_g_s: float + python_p4_node_port_2_connected_h_j_kg: float + python_p4_node_port_2_connected_temperature_k: float + python_p4_node_port_2_connected_h_delta_to_line_h_j_kg: float + python_p4_node_port_2_counterfactual_dtemp_k_s: float + reference_temperature_k: float + amesim_port_1_reference_enthalpy_estimate_w: float + amesim_port_1_reference_enthalpy_error_w: float + amesim_candidate_pn2pipefr_dm2i_g_s: float + amesim_candidate_pn2pipefr_dh2i_w: float + amesim_candidate_storage_enthalpy_sum_w: float + amesim_candidate_pn2pipefr_dtemp_k_s: float + amesim_candidate_pn2pipefr_dtemp_residual_k_s: float + python_reference_port_1_enthalpy_flow_w: float + python_reference_node_port_2_enthalpy_flow_w: float + python_reference_observed_port_enthalpy_sum_w: float + python_reference_port_1_to_amesim_error_w: float + python_reference_node_port_2_to_amesim_error_w: float + python_reference_sum_to_amesim_error_w: float + python_current_line_temperature_derivative_k_s: float + amesim_line_temperature_derivative_fd_k_s: float + amesim_pn2vol2_dtemp_node_plus_dh1_k_s: float + amesim_pn2vol2_dtemp_node_minus_dh1_k_s: float + python_reference_pn2vol2_dtemp_node_minus_dh1_k_s: float + + +@dataclass(frozen=True) +class TestMqlP4NodeEnthalpyBreakdownDiagnostic: + node_alias: str + time_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_port_1_mass_flow_g_s: float + amesim_port_3_enthalpy_flow_w: float + amesim_port_3_mass_flow_g_s: float + amesim_port_4_enthalpy_flow_w: float + amesim_port_4_mass_flow_g_s: float + amesim_port_2_enthalpy_flow_w: float + amesim_port_2_mass_flow_g_s: float + python_port_1_enthalpy_flow_w: float + python_port_1_mass_flow_g_s: float + python_port_3_enthalpy_flow_w: float + python_port_3_mass_flow_g_s: float + python_port_4_enthalpy_flow_w: float + python_port_4_mass_flow_g_s: float + python_port_2_enthalpy_flow_w: float + python_port_2_mass_flow_g_s: float + python_reference_port_1_enthalpy_flow_w: float + python_reference_port_3_enthalpy_flow_w: float + python_reference_port_4_enthalpy_flow_w: float + python_reference_port_2_enthalpy_flow_w: float + python_real_gas_reference_port_1_enthalpy_flow_w: float + python_real_gas_reference_port_3_enthalpy_flow_w: float + python_real_gas_reference_port_4_enthalpy_flow_w: float + python_real_gas_reference_port_2_enthalpy_flow_w: float + python_port_2_enthalpy_error_w: float + python_reference_port_2_enthalpy_error_w: float + python_real_gas_reference_port_2_enthalpy_error_w: float + + +@dataclass(frozen=True) +class TestMqlPnvoUpstreamEnthalpyDiagnostic: + orifice_alias: str + upstream_line_alias: str + time_s: float + amesim_orifice_port_2_enthalpy_flow_w: float + amesim_orifice_port_2_mass_flow_g_s: float + amesim_orifice_port_3_enthalpy_flow_w: float + amesim_orifice_port_3_mass_flow_g_s: float + amesim_upstream_line_center_enthalpy_flow_w: float + amesim_upstream_line_center_mass_flow_g_s: float + amesim_upstream_line_port_2_temperature_k: float + amesim_upstream_line_port_2_pressure_pa: float + amesim_orifice_port_2_implied_reference_temperature_k: float + amesim_orifice_port_2_implied_temperature_delta_to_line_k: float + amesim_upstream_line_port_2_reference_enthalpy_flow_w: float + amesim_upstream_line_port_2_reference_enthalpy_error_w: float + amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w: float + amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w: float + python_orifice_to_node_flow_kg_s: float + python_upstream_line_port_2_temperature_k: float + python_upstream_line_port_2_pressure_pa: float + python_current_port_2_enthalpy_flow_w: float + python_reference_port_2_enthalpy_flow_w: float + python_real_gas_reference_port_2_enthalpy_flow_w: float + python_current_port_2_enthalpy_error_w: float + python_reference_port_2_enthalpy_error_w: float + python_real_gas_reference_port_2_enthalpy_error_w: float + +@dataclass(frozen=True) +class TestMqlPnl0003EnergyDiagnostic: + line_alias: str + time_s: float + amesim_port_1_temperature_k: float + amesim_port_2_temperature_k: float + amesim_port_1_pressure_pa: float + amesim_port_2_pressure_pa: float + amesim_port_1_mass_flow_g_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_center_mass_flow_g_s: float + amesim_center_enthalpy_flow_w: float + amesim_port_2_mass_flow_g_s: float + amesim_port_2_enthalpy_flow_w: float + amesim_port_1_storage_mass_derivative_g_s: float + amesim_port_1_storage_enthalpy_sum_w: float + amesim_port_2_storage_mass_derivative_g_s: float + amesim_port_2_storage_enthalpy_sum_w: float + python_pn3_node_port_1_mass_flow_g_s: float + python_pn3_node_port_3_mass_flow_g_s: float + python_pn3_node_to_line_mass_flow_g_s: float + python_pn3_node_to_line_mass_flow_error_g_s: float + amesim_port_1_mass_estimate_g: float + amesim_port_2_mass_estimate_g: float + python_port_1_mass_g: float + python_port_2_mass_g: float + python_port_1_mass_error_to_amesim_g: float + python_port_2_mass_error_to_amesim_g: float + amesim_total_mass_derivative_fd_g_s: float + amesim_total_mass_derivative_backward_g_s: float + amesim_total_mass_derivative_forward_g_s: float + amesim_total_storage_mass_derivative_g_s: float + amesim_total_storage_mass_derivative_residual_g_s: float + amesim_center_flow_python_sign_equivalent_g_s: float + amesim_center_flow_python_sign_error_g_s: float + python_center_mass_flow_g_s: float + amesim_port_1_center_mass_derivative_g_s: float + amesim_port_2_center_mass_derivative_g_s: float + python_port_1_external_mass_flow_g_s: float + python_port_1_external_mass_flow_error_g_s: float + python_port_1_center_mass_derivative_g_s: float + python_port_1_center_mass_derivative_error_g_s: float + python_port_1_storage_mass_derivative_g_s: float + python_port_1_storage_mass_derivative_error_g_s: float + python_port_2_external_mass_flow_g_s: float + python_port_2_external_mass_flow_error_g_s: float + python_port_2_center_mass_derivative_g_s: float + python_port_2_center_mass_derivative_error_g_s: float + python_port_2_storage_mass_derivative_g_s: float + python_port_2_storage_mass_derivative_error_g_s: float + python_amesim_state_darcy_center_mass_flow_g_s: float + python_amesim_state_darcy_center_mass_flow_error_g_s: float + python_amesim_state_pn2pipefr_center_mass_flow_g_s: float + python_amesim_state_pn2pipefr_center_mass_flow_error_g_s: float + amesim_center_real_gas_reference_port_1_estimate_w: float + amesim_center_real_gas_reference_port_1_error_w: float + python_center_ideal_reference_port_1_estimate_w: float + python_center_ideal_reference_port_1_error_w: float + python_center_ideal_reference_port_2_estimate_w: float + python_center_ideal_reference_port_2_error_w: float + python_center_real_gas_reference_port_1_estimate_w: float + python_center_real_gas_reference_port_1_error_w: float + python_center_real_gas_reference_port_2_estimate_w: float + python_center_real_gas_reference_port_2_error_w: float + python_total_mass_derivative_kg_s: float + python_port_1_reference_external_enthalpy_flow_w: float + python_port_1_reference_center_enthalpy_flow_w: float + python_port_1_reference_storage_enthalpy_sum_w: float + python_port_1_reference_storage_enthalpy_error_w: float + python_port_2_reference_external_enthalpy_flow_w: float + python_port_2_reference_center_enthalpy_flow_w: float + python_port_2_reference_storage_enthalpy_sum_w: float + python_port_2_reference_storage_enthalpy_error_w: float + amesim_port_1_external_enthalpy_dtemp_component_k_s: float + amesim_port_1_center_enthalpy_dtemp_component_k_s: float + amesim_port_1_mass_offset_dtemp_component_k_s: float + amesim_port_1_heat_dtemp_component_k_s: float + python_port_1_external_enthalpy_dtemp_component_k_s: float + python_port_1_center_enthalpy_dtemp_component_k_s: float + python_port_1_mass_offset_dtemp_component_k_s: float + python_port_1_heat_dtemp_component_k_s: float + amesim_port_2_external_enthalpy_dtemp_component_k_s: float + amesim_port_2_center_enthalpy_dtemp_component_k_s: float + amesim_port_2_mass_offset_dtemp_component_k_s: float + amesim_port_2_heat_dtemp_component_k_s: float + python_port_2_external_enthalpy_dtemp_component_k_s: float + python_port_2_center_enthalpy_dtemp_component_k_s: float + python_port_2_mass_offset_dtemp_component_k_s: float + python_port_2_heat_dtemp_component_k_s: float + amesim_port_1_temperature_derivative_fd_k_s: float + amesim_port_1_temperature_derivative_backward_k_s: float + amesim_port_1_temperature_derivative_forward_k_s: float + amesim_port_2_temperature_derivative_fd_k_s: float + amesim_port_2_temperature_derivative_backward_k_s: float + amesim_port_2_temperature_derivative_forward_k_s: float + amesim_port_1_pn2vol_dtemp_k_s: float + amesim_port_2_pn2vol_dtemp_k_s: float + amesim_port_1_pn2vol_dtemp_residual_k_s: float + amesim_port_2_pn2vol_dtemp_residual_k_s: float + amesim_port_1_pressure_derivative_fd_pa_s: float + amesim_port_2_pressure_derivative_fd_pa_s: float + amesim_port_1_pressure_derivative_mass_component_pa_s: float + amesim_port_1_pressure_derivative_temperature_component_pa_s: float + amesim_port_1_pressure_derivative_eos_sum_pa_s: float + amesim_port_1_pressure_derivative_eos_residual_pa_s: float + amesim_port_2_pressure_derivative_mass_component_pa_s: float + amesim_port_2_pressure_derivative_temperature_component_pa_s: float + amesim_port_2_pressure_derivative_eos_sum_pa_s: float + amesim_port_2_pressure_derivative_eos_residual_pa_s: float + python_port_1_pressure_derivative_mass_component_pa_s: float + python_port_1_pressure_derivative_temperature_component_pa_s: float + python_port_1_pressure_derivative_eos_sum_pa_s: float + python_port_2_pressure_derivative_mass_component_pa_s: float + python_port_2_pressure_derivative_temperature_component_pa_s: float + python_port_2_pressure_derivative_eos_sum_pa_s: float + python_port_1_temperature_k: float + python_port_2_temperature_k: float + python_port_1_pressure_pa: float + python_port_2_pressure_pa: float + python_port_1_mass_derivative_kg_s: float + python_port_2_mass_derivative_kg_s: float + python_port_1_current_dtemp_k_s: float + python_port_2_current_dtemp_k_s: float + python_port_1_real_gas_reference_dtemp_k_s: float + python_port_2_real_gas_reference_dtemp_k_s: float + python_port_2_amesim_center_counterfactual_dtemp_k_s: float + python_port_2_amesim_external_counterfactual_dtemp_k_s: float + python_port_2_amesim_external_center_counterfactual_dtemp_k_s: float + python_port_2_temperature_error_to_amesim_k: float + + +@dataclass(frozen=True) +class TestMqlPnch012EnergyEquationDiagnostic: + chamber_alias: str + piston_alias: str + line_alias: str + time_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_port_1_mass_flow_g_s: float + amesim_chamber_mass_derivative_fd_g_s: float + amesim_chamber_mass_derivative_residual_g_s: float + amesim_chamber_temperature_derivative_fd_k_s: float + amesim_chamber_volume_rate_fd_m3_s: float + amesim_piston_volume_rate_m3_s: float + amesim_heat_flow_w: float + amesim_boundary_work_fd_volume_w: float + amesim_pn2vol_reference_dtemp_fd_volume_k_s: float + amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s: float + amesim_pn2vol_reference_dtemp_piston_volume_k_s: float + amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s: float + python_port_a_mass_flow_kg_s: float + python_current_energy_derivative_w: float + python_current_chamber_temperature_derivative_k_s: float + reference_temperature_k: float + + +@dataclass(frozen=True) +class TestMqlPneumatic96InletLineDiagnostic: + line_alias: str + node_alias: str + orifice_alias: str + chamber_alias: str + time_s: float + amesim_line_pressure_pa: float + amesim_line_temperature_k: float + amesim_line_mass_g: float + amesim_line_mass_derivative_fd_g_s: float + amesim_line_to_node_mass_flow_g_s: float + amesim_orifice_to_line_mass_flow_g_s: float + amesim_storage_mass_derivative_from_ports_g_s: float + amesim_storage_mass_derivative_residual_g_s: float + amesim_chamber_pressure_pa: float + amesim_chamber_temperature_k: float + amesim_node_pressure_pa: float + amesim_node_temperature_k: float + amesim_line_temperature_derivative_fd_k_s: float + amesim_line_pressure_derivative_fd_pa_s: float + amesim_port_1_enthalpy_dtemp_component_k_s: float + amesim_port_2_enthalpy_dtemp_component_k_s: float + amesim_mass_offset_dtemp_component_k_s: float + amesim_reference_temperature_derivative_sum_k_s: float + amesim_reference_temperature_derivative_residual_k_s: float + amesim_pressure_derivative_mass_component_pa_s: float + amesim_pressure_derivative_temperature_component_pa_s: float + amesim_pressure_derivative_eos_sum_pa_s: float + amesim_pressure_derivative_eos_residual_pa_s: float + python_line_pressure_pa: float + python_line_temperature_k: float + python_line_mass_g: float + python_line_pressure_error_pa: float + python_line_temperature_error_k: float + python_line_mass_error_g: float + python_line_to_node_mass_flow_g_s: float + python_line_to_node_mass_flow_error_g_s: float + python_orifice_to_line_mass_flow_g_s: float + python_orifice_to_line_mass_flow_error_g_s: float + python_storage_mass_derivative_g_s: float + python_storage_mass_derivative_error_g_s: float + python_line_to_node_amesim_node_mass_flow_g_s: float + python_line_to_node_amesim_node_mass_flow_error_g_s: float + python_line_to_node_amesim_line_mass_flow_g_s: float + python_line_to_node_amesim_line_mass_flow_error_g_s: float + python_line_to_node_amesim_node_line_mass_flow_g_s: float + python_line_to_node_amesim_node_line_mass_flow_error_g_s: float + python_orifice_to_line_amesim_line_mass_flow_g_s: float + python_orifice_to_line_amesim_line_mass_flow_error_g_s: float + python_orifice_to_line_amesim_chamber_mass_flow_g_s: float + python_orifice_to_line_amesim_chamber_mass_flow_error_g_s: float + python_orifice_to_line_amesim_line_chamber_mass_flow_g_s: float + python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s: float + python_storage_amesim_boundary_states_mass_derivative_g_s: float + python_storage_amesim_boundary_states_mass_derivative_error_g_s: float + python_chamber_pressure_pa: float + python_chamber_temperature_k: float + python_current_temperature_derivative_k_s: float + python_port_1_current_dtemp_component_k_s: float + python_port_2_current_dtemp_component_k_s: float + python_heat_dtemp_component_k_s: float + python_temperature_derivative_error_k_s: float + python_pressure_derivative_mass_component_pa_s: float + python_pressure_derivative_temperature_component_pa_s: float + python_pressure_derivative_eos_sum_pa_s: float + python_pressure_derivative_eos_error_pa_s: float + python_reference_port_1_enthalpy_flow_w: float + python_reference_port_2_enthalpy_flow_w: float + python_reference_port_1_dtemp_component_k_s: float + python_reference_port_2_dtemp_component_k_s: float + python_reference_mass_offset_dtemp_component_k_s: float + python_reference_temperature_derivative_k_s: float + python_reference_temperature_derivative_error_k_s: float + python_reference_pressure_derivative_mass_component_pa_s: float + python_reference_pressure_derivative_temperature_component_pa_s: float + python_reference_pressure_derivative_eos_sum_pa_s: float + python_reference_pressure_derivative_eos_error_pa_s: float + python_reference_amesim_storage_mass_offset_dtemp_component_k_s: float + python_reference_amesim_storage_temperature_derivative_k_s: float + python_reference_amesim_storage_temperature_derivative_error_k_s: float + python_reference_amesim_storage_pressure_derivative_mass_component_pa_s: float + python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s: float + python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s: float + python_reference_amesim_storage_pressure_derivative_eos_error_pa_s: float + python_active_temperature_derivative_k_s: float + python_active_temperature_derivative_error_k_s: float + python_active_pressure_derivative_eos_sum_pa_s: float + python_active_pressure_derivative_eos_error_pa_s: float + python_chamber_pressure_error_pa: float + python_chamber_temperature_error_k: float + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowSampleDiagnostic: + time_s: float + data_paths: tuple[str, ...] + python_values_by_data_path: dict[str, float] + amesim_values_by_data_path: dict[str, float] + amesim_sample_left_time_s: float + amesim_sample_right_time_s: float + pnl0001_rhs_diagnostics: tuple[TestMqlPnl0001LineRhsDiagnostic, ...] = field( + default_factory=tuple + ) + pnl0001_pressure_loss_diagnostics: tuple[ + TestMqlPnl0001PressureLossCalibrationDiagnostic, ... + ] = field(default_factory=tuple) + pnvo_flow_parameter_diagnostics: tuple[ + TestMqlPnvoFlowParameterDiagnostic, ... + ] = field(default_factory=tuple) + pnl0001_energy_flow_diagnostics: tuple[ + TestMqlPnl0001EnergyFlowDiagnostic, ... + ] = field(default_factory=tuple) + p4_node_enthalpy_breakdown_diagnostics: tuple[ + TestMqlP4NodeEnthalpyBreakdownDiagnostic, ... + ] = field(default_factory=tuple) + pnvo_upstream_enthalpy_diagnostics: tuple[ + TestMqlPnvoUpstreamEnthalpyDiagnostic, ... + ] = field(default_factory=tuple) + pnl0003_energy_diagnostics: tuple[ + TestMqlPnl0003EnergyDiagnostic, ... + ] = field(default_factory=tuple) + pneumatic_96_inlet_line_diagnostics: tuple[ + TestMqlPneumatic96InletLineDiagnostic, ... + ] = field(default_factory=tuple) + pnch012_energy_equation_diagnostics: tuple[ + TestMqlPnch012EnergyEquationDiagnostic, ... + ] = field(default_factory=tuple) + + @property + def amesim_values_are_interpolated(self) -> bool: + return self.amesim_sample_left_time_s != self.amesim_sample_right_time_s + + def abs_error(self, data_path: str) -> float: + return abs( + self.python_values_by_data_path[data_path] + - self.amesim_values_by_data_path[data_path] + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowDiagnostic: + orifice_alias: str + event_time_s: float + final_time_s: float + data_paths: tuple[str, ...] + segment_diagnostics: tuple[TestMqlPnvoEventWindowSegmentDiagnostic, ...] + sample_diagnostics: tuple[TestMqlPnvoEventWindowSampleDiagnostic, ...] + python_values_by_data_path: dict[str, float] + amesim_values_by_data_path: dict[str, float] + + def abs_error(self, data_path: str) -> float: + return abs( + self.python_values_by_data_path[data_path] + - self.amesim_values_by_data_path[data_path] + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowCandidateMetricComparison: + time_s: float + metric_name: str + default_abs_error: float + candidate_abs_error: float + + @property + def abs_error_delta(self) -> float: + return self.candidate_abs_error - self.default_abs_error + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowCandidateComparisonDiagnostic: + orifice_alias: str + event_time_s: float + final_time_s: float + default_diagnostic: TestMqlPnvoEventWindowDiagnostic + candidate_diagnostic: TestMqlPnvoEventWindowDiagnostic + metric_comparisons: tuple[ + TestMqlPnvoEventWindowCandidateMetricComparison, ... + ] + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonPathConfig: + archive_path: Path = field( + default_factory=lambda: Path(__file__).resolve().parents[2] + / "AmesimModels" + / "test_mql.ame" + ) + amesim_results_archive_path: Path | None = None + output_dir: Path | None = None + + @property + def resolved_amesim_results_archive_path(self) -> Path: + return self.amesim_results_archive_path or self.archive_path + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonExecutionConfig: + write_summary: bool = True + write_comparison_csv: bool = True + data_paths: tuple[str, ...] | None = DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS + solver: SolveIVPConfig = field( + default_factory=lambda: SolveIVPConfig(t_stop=1.0e-2, max_step=1.0e-3) + ) + t_eval: tuple[float, ...] | None = (0.0, 1.0e-2) + inlet_node_pressure_pa: float = 15.31e6 + resistance_boundary_pressure_pa: float = 15.29e6 + inlet_node_temperature_k: float = 293.15 + resistance_boundary_temperature_k: float = 293.15 + use_pneumatic_96_reference_rhs: bool = False + use_pneumatic_69_reference_rhs: bool = False + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonScriptConfig: + paths: TestMqlFullStateComparisonPathConfig = field( + default_factory=TestMqlFullStateComparisonPathConfig + ) + execution: TestMqlFullStateComparisonExecutionConfig = field( + default_factory=TestMqlFullStateComparisonExecutionConfig + ) + + +def _default_output_dir() -> Path: + pythonmodels_root = Path(__file__).resolve().parents[1] + timestamp = datetime.now(UTC).strftime("test_mql_full_state_%Y%m%d_%H%M%S_%f") + return pythonmodels_root / "runs" / timestamp + + +def run_test_mql_full_state_comparison( + config: TestMqlFullStateComparisonScriptConfig | None = None, +) -> tuple[TestMqlFullStateComparisonRun, Path]: + config = config or TestMqlFullStateComparisonScriptConfig() + system = TestMqlSystem(archive_path=config.paths.archive_path) + amesim_results = load_test_mql_amesim_results( + config.paths.resolved_amesim_results_archive_path, + time_stop_s=config.execution.solver.t_stop, + ) + output_schema = build_test_mql_output_schema(amesim_results) + selected_paths = config.execution.data_paths + spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + result = system.simulate_full_state_series_from_spec( + spec, + inlet_node_pressure_pa=config.execution.inlet_node_pressure_pa, + resistance_boundary_pressure_pa=( + config.execution.resistance_boundary_pressure_pa + ), + inlet_node_temperature_k=config.execution.inlet_node_temperature_k, + resistance_boundary_temperature_k=( + config.execution.resistance_boundary_temperature_k + ), + use_pneumatic_96_reference_rhs=( + config.execution.use_pneumatic_96_reference_rhs + ), + use_pneumatic_69_reference_rhs=( + config.execution.use_pneumatic_69_reference_rhs + ), + config=config.execution.solver, + t_eval=list(config.execution.t_eval) if config.execution.t_eval is not None else None, + data_paths=selected_paths, + ) + closure = system.full_state_closure_from_spec( + spec, + inlet_node_pressure_pa=config.execution.inlet_node_pressure_pa, + resistance_boundary_pressure_pa=( + config.execution.resistance_boundary_pressure_pa + ), + inlet_node_temperature_k=config.execution.inlet_node_temperature_k, + resistance_boundary_temperature_k=( + config.execution.resistance_boundary_temperature_k + ), + use_pneumatic_96_reference_rhs=( + config.execution.use_pneumatic_96_reference_rhs + ), + use_pneumatic_69_reference_rhs=( + config.execution.use_pneumatic_69_reference_rhs + ), + ) + series_by_data_path = { + data_path: result.series[data_path] + for data_path in result.series + if data_path != "time" + } + output = validate_test_mql_output( + times=result.t, + series_by_data_path=series_by_data_path, + schema=output_schema, + data_paths=selected_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, + ) + run = TestMqlFullStateComparisonRun( + system=system, + closure=closure, + amesim_results=amesim_results, + output_schema=output_schema, + result=result, + output=output, + comparison=comparison, + ) + output_dir = config.paths.output_dir or _default_output_dir() + if config.execution.write_summary or config.execution.write_comparison_csv: + output_dir.mkdir(parents=True, exist_ok=True) + if config.execution.write_summary: + (output_dir / "test_mql_full_state_comparison_summary.txt").write_text( + format_test_mql_full_state_comparison_summary(run), + encoding="utf-8", + ) + if config.execution.write_comparison_csv: + write_test_mql_comparison_csv( + output_dir=output_dir, + python_times=output.times, + python_series_by_data_path=output.series_by_data_path, + amesim_results=amesim_results, + data_paths=output.data_paths, + ) + return run, output_dir + + +def run_test_mql_pnvo_event_boundary_diagnostic( + config: TestMqlFullStateComparisonScriptConfig | None = None, + *, + orifice_alias: str = "pn_morifice_1", +) -> TestMqlPnvoEventBoundaryDiagnostic: + config = config or TestMqlFullStateComparisonScriptConfig() + system = TestMqlSystem(archive_path=config.paths.archive_path) + control = system.pneumatic_assembly.variable_orifice_controls[orifice_alias] + event_time_s = control.step.step_time_s + integration_stop_time_s = nextafter(event_time_s, 0.0) + solver_template = config.execution.solver + solver = SolveIVPConfig( + t_start=solver_template.t_start, + t_stop=integration_stop_time_s, + method=solver_template.method, + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=solver_template.max_step, + ) + spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + closure_kwargs = { + "inlet_node_pressure_pa": config.execution.inlet_node_pressure_pa, + "resistance_boundary_pressure_pa": ( + config.execution.resistance_boundary_pressure_pa + ), + "inlet_node_temperature_k": config.execution.inlet_node_temperature_k, + "resistance_boundary_temperature_k": ( + config.execution.resistance_boundary_temperature_k + ), + "use_pneumatic_96_reference_rhs": ( + config.execution.use_pneumatic_96_reference_rhs + ), + "use_pneumatic_69_reference_rhs": ( + config.execution.use_pneumatic_69_reference_rhs + ), + } + solution = system.simulate_full_state_from_spec( + spec, + config=solver, + t_eval=[solver.t_start, integration_stop_time_s], + **closure_kwargs, + ) + state_vector = [row[-1] for row in solution.y] + closure = system.full_state_closure_from_spec(spec, **closure_kwargs) + data_paths = ( + "press@pn_c1_8", + "dm1@pneumatic_69", + f"xv@{orifice_alias}", + f"dm2@{orifice_alias}", + ) + python_values = closure.data_path_values( + time_s=event_time_s, + state_vector=state_vector, + data_paths=data_paths, + ) + amesim_results = load_test_mql_amesim_results( + config.paths.resolved_amesim_results_archive_path, + time_stop_s=event_time_s, + ) + amesim_values = { + data_path: interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + event_time_s, + ) + for data_path in data_paths + } + return TestMqlPnvoEventBoundaryDiagnostic( + orifice_alias=orifice_alias, + event_time_s=event_time_s, + integration_stop_time_s=integration_stop_time_s, + data_paths=data_paths, + python_values_by_data_path=python_values, + amesim_values_by_data_path=amesim_values, + ) + + +def format_test_mql_pnvo_event_boundary_summary( + diagnostic: TestMqlPnvoEventBoundaryDiagnostic, +) -> str: + lines = [ + "Model: test_mql", + f"Mode: PNVO event boundary diagnostic ({diagnostic.orifice_alias})", + f"Event time: {diagnostic.event_time_s}", + f"Integrated left limit: {diagnostic.integration_stop_time_s}", + "Observation side: right-continuous STEP0 opening", + ] + for data_path in diagnostic.data_paths: + lines.append( + f" - {data_path}: " + f"python={diagnostic.python_values_by_data_path[data_path]}, " + f"amesim={diagnostic.amesim_values_by_data_path[data_path]}, " + f"abs_error={diagnostic.abs_error(data_path)}" + ) + return "\n".join(lines) + "\n" + + +def _pnl0001_pressure_loss_calibration_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + python_values_by_data_path: dict[str, float], + line_alias: str, + chamber_alias: str, + time_s: float, +) -> TestMqlPnl0001PressureLossCalibrationDiagnostic: + if line_alias != "pneumatic_69" or chamber_alias != "pn_c1_8": + raise KeyError(f"Unsupported PNL0001 pressure-loss diagnostic: {line_alias}") + line = closure.pneumatic_closure.components.p4_port3_remote_primary_line + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_cm = amesim_value(f"cm@{line_alias}") + amesim_dm1_g_s = amesim_value(f"dm1@{line_alias}") + amesim_line_gauge_pressure_pa = amesim_value(f"p2@{line_alias}") + amesim_chamber_gauge_pressure_pa = amesim_value(f"press@{chamber_alias}") + amesim_line_temperature_k = amesim_value(f"t2@{line_alias}") + amesim_chamber_temperature_k = amesim_value(f"temp@{chamber_alias}") + mass_flow_magnitude_kg_s = abs(amesim_dm1_g_s) * 1.0e-3 + amesim_pressure_drop_pa = abs( + amesim_line_gauge_pressure_pa - amesim_chamber_gauge_pressure_pa + ) + current_darcy_pressure_drop_pa = abs( + line.darcy_pressure_drop_for_state( + mass_flow_kg_s=mass_flow_magnitude_kg_s, + pressure_pa=( + amesim_line_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA + ), + temperature_k=amesim_line_temperature_k, + ) + ) + if current_darcy_pressure_drop_pa > 0.0: + pressure_drop_multiplier = ( + amesim_pressure_drop_pa / current_darcy_pressure_drop_pa + ) + else: + pressure_drop_multiplier = ( + float("inf") if amesim_pressure_drop_pa > 0.0 else 1.0 + ) + candidate_port1_to_port2_kg_s = line.pn2pipefr_mass_flow( + port_1_pressure_pa=( + amesim_chamber_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA + ), + port_1_temperature_k=amesim_chamber_temperature_k, + port_2_pressure_pa=( + amesim_line_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA + ), + port_2_temperature_k=amesim_line_temperature_k, + ) + candidate_pn2pipefr_dm1_g_s = -candidate_port1_to_port2_kg_s * 1.0e3 + if amesim_dm1_g_s != 0.0: + candidate_pn2pipefr_to_amesim_dm1_ratio = ( + candidate_pn2pipefr_dm1_g_s / amesim_dm1_g_s + ) + else: + candidate_pn2pipefr_to_amesim_dm1_ratio = ( + float("inf") if candidate_pn2pipefr_dm1_g_s != 0.0 else 1.0 + ) + amesim_linear_conductance = _pnl0001_linear_conductance( + dm1_g_s=amesim_dm1_g_s, + temperature_k=amesim_line_temperature_k, + pressure_drop_pa=amesim_pressure_drop_pa, + ) + python_pressure_drop_pa = abs( + python_values_by_data_path[f"p2@{line_alias}"] + - python_values_by_data_path[f"press@{chamber_alias}"] + ) + python_linear_conductance = _pnl0001_linear_conductance( + dm1_g_s=python_values_by_data_path[f"dm1@{line_alias}"], + temperature_k=python_values_by_data_path[f"t2@{line_alias}"], + pressure_drop_pa=python_pressure_drop_pa, + ) + if amesim_linear_conductance > 0.0: + conductance_ratio = python_linear_conductance / amesim_linear_conductance + else: + conductance_ratio = ( + float("inf") if python_linear_conductance > 0.0 else 1.0 + ) + return TestMqlPnl0001PressureLossCalibrationDiagnostic( + line_alias=line_alias, + chamber_alias=chamber_alias, + time_s=time_s, + amesim_cm=amesim_cm, + amesim_dm1_g_s=amesim_dm1_g_s, + amesim_mass_flow_magnitude_kg_s=mass_flow_magnitude_kg_s, + amesim_line_gauge_pressure_pa=amesim_line_gauge_pressure_pa, + amesim_chamber_gauge_pressure_pa=amesim_chamber_gauge_pressure_pa, + amesim_line_temperature_k=amesim_line_temperature_k, + amesim_pressure_drop_pa=amesim_pressure_drop_pa, + current_darcy_pressure_drop_pa=current_darcy_pressure_drop_pa, + pressure_drop_multiplier=pressure_drop_multiplier, + candidate_pn2pipefr_dm1_g_s=candidate_pn2pipefr_dm1_g_s, + candidate_pn2pipefr_to_amesim_dm1_ratio=( + candidate_pn2pipefr_to_amesim_dm1_ratio + ), + amesim_linear_conductance_kg_s_sqrt_k_per_pa=( + amesim_linear_conductance + ), + python_linear_conductance_kg_s_sqrt_k_per_pa=( + python_linear_conductance + ), + python_to_amesim_linear_conductance_ratio=conductance_ratio, + ) + + +def _pnl0001_linear_conductance( + *, + dm1_g_s: float, + temperature_k: float, + pressure_drop_pa: float, +) -> float: + if temperature_k <= 0.0: + raise ValueError("temperature_k must be positive") + if pressure_drop_pa <= 0.0: + return 0.0 + return abs(dm1_g_s) * 1.0e-3 * sqrt(temperature_k) / pressure_drop_pa + + +def _ideal_pn2vol_reference_dtemp( + *, + gas: AmesimPneumaticGas, + mass_kg: float, + temperature_k: float, + mass_derivative_kg_s: float, + enthalpy_flow_w: float, + heat_flow_w: float, + reference_temperature_k: float = 298.15, +) -> float: + reference_offset_flow_w = ( + gas.cp * reference_temperature_k - gas.cv * temperature_k + ) * mass_derivative_kg_s + return ( + enthalpy_flow_w + reference_offset_flow_w + heat_flow_w + ) / (mass_kg * gas.cv) + + +def _series_finite_difference_at( + *, + times: tuple[float, ...] | list[float], + values: tuple[float, ...] | list[float], + time_s: float, +) -> float: + if len(times) != len(values): + raise ValueError("times and values must have equal length") + if len(times) < 2: + raise ValueError("at least two samples are required") + index = min(range(len(times)), key=lambda idx: abs(times[idx] - time_s)) + if index == 0: + left = 0 + right = 1 + elif index == len(times) - 1: + left = len(times) - 2 + right = len(times) - 1 + else: + left = index - 1 + right = index + 1 + dt = times[right] - times[left] + if dt == 0.0: + raise ValueError("finite difference time interval must be non-zero") + return (values[right] - values[left]) / dt + + +def _series_one_sided_differences_at( + *, + times: tuple[float, ...] | list[float], + values: tuple[float, ...] | list[float], + time_s: float, +) -> tuple[float, float]: + if len(times) != len(values): + raise ValueError("times and values must have equal length") + if len(times) < 2: + raise ValueError("at least two samples are required") + index = min(range(len(times)), key=lambda idx: abs(times[idx] - time_s)) + left = max(index - 1, 0) + right = min(index + 1, len(times) - 1) + backward_dt = times[index] - times[left] + forward_dt = times[right] - times[index] + if backward_dt == 0.0: + backward = (values[right] - values[index]) / forward_dt + else: + backward = (values[index] - values[left]) / backward_dt + if forward_dt == 0.0: + forward = (values[index] - values[left]) / backward_dt + else: + forward = (values[right] - values[index]) / forward_dt + return backward, forward + + +def _series_sample_bracket( + *, + times: tuple[float, ...] | list[float], + time_s: float, +) -> tuple[float, float]: + if not times: + raise ValueError("at least one sample time is required") + index = bisect_left(times, time_s) + tolerance = 1.0e-12 * max(1.0, abs(time_s)) + for candidate_index in (index - 1, index): + if 0 <= candidate_index < len(times): + candidate_time = times[candidate_index] + if abs(candidate_time - time_s) <= tolerance: + return candidate_time, candidate_time + if index <= 0: + return times[0], times[0] + if index >= len(times): + return times[-1], times[-1] + return times[index - 1], times[index] + + +def _reference_enthalpy_flow_for_node_port( + *, + gas: AmesimPneumaticGas, + reference_temperature_k: float, + flow_kg_s: float, + node_temperature_k: float, + connected_temperature_k: float, + port_mass_flow_kg_s: float, +) -> float: + stream_temperature_k = ( + node_temperature_k if flow_kg_s >= 0.0 else connected_temperature_k + ) + reference_h = gas.specific_reference_enthalpy( + stream_temperature_k, + reference_temperature_k, + ) + return port_mass_flow_kg_s * reference_h + + +def _real_gas_reference_enthalpy_flow_for_node_port( + *, + gas: AmesimPneumaticGas, + reference_temperature_k: float, + flow_kg_s: float, + node_pressure_pa: float, + node_temperature_k: float, + connected_pressure_pa: float, + connected_temperature_k: float, + port_mass_flow_kg_s: float, +) -> float: + stream_pressure_pa = node_pressure_pa if flow_kg_s >= 0.0 else connected_pressure_pa + stream_temperature_k = ( + node_temperature_k if flow_kg_s >= 0.0 else connected_temperature_k + ) + reference_h = gas.pressure_reference_enthalpy( + stream_pressure_pa, + stream_temperature_k, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + return port_mass_flow_kg_s * reference_h + + +def _pnl0001_energy_flow_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + rhs_diagnostic: TestMqlPnl0001LineRhsDiagnostic, + line_alias: str, + chamber_alias: str, + node_alias: str, + time_s: float, +) -> TestMqlPnl0001EnergyFlowDiagnostic: + if line_alias != "pneumatic_69" or chamber_alias != "pn_c1_8": + raise KeyError(f"Unsupported PNL0001 energy diagnostic: {line_alias}") + if node_alias != "pnnode4_16": + raise KeyError(f"Unsupported PNL0001 node diagnostic: {node_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_primary_line + line_properties = snapshot.p4_port3_remote_primary_line + chamber_properties = snapshot.p4_port3_remote_primary_chamber + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_dh1 = amesim_value(f"dh1@{line_alias}") + amesim_dm1 = amesim_value(f"dm1@{line_alias}") + amesim_node_dh2 = amesim_value(f"dh2@{node_alias}") + amesim_node_dm2 = amesim_value(f"dm2@{node_alias}") + direct_port_1 = rhs_diagnostic.chamber_to_line_flow_kg_s * ( + chamber_properties.h + if rhs_diagnostic.chamber_to_line_flow_kg_s > 0.0 + else line_properties.h + ) + direct_port_2 = rhs_diagnostic.node_to_line_flow_kg_s * line_properties.h + direct_energy_derivative = ( + direct_port_1 + direct_port_2 + rhs_diagnostic.thermal_energy_flow_w + ) + p4_node_port_2_mass_flow_kg_s = ( + snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + if abs(p4_node_port_2_mass_flow_kg_s) <= 1.0e-12: + p4_node_port_2_connected_h = line_properties.h + else: + p4_node_port_2_connected_h = ( + snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w + / p4_node_port_2_mass_flow_kg_s + ) + p4_node_port_2_inlet_h = ( + p4_node_port_2_connected_h + if rhs_diagnostic.node_to_line_flow_kg_s > 0.0 + else line_properties.h + ) + p4_node_port_2_counterfactual_energy_derivative = ( + rhs_diagnostic.port_1_energy_flow_w + + rhs_diagnostic.node_to_line_flow_kg_s * p4_node_port_2_inlet_h + + rhs_diagnostic.thermal_energy_flow_w + ) + p4_node_port_2_counterfactual_dtemp = ( + p4_node_port_2_counterfactual_energy_derivative + - line_properties.u * rhs_diagnostic.mass_derivative_kg_s + ) / (line.state.m * line.gas.cv) + reference_temperature_k = 298.15 + + def reference_h(temperature_k: float) -> float: + return line.gas.specific_reference_enthalpy( + temperature_k, + reference_temperature_k, + ) + + amesim_stream_temperature = ( + amesim_value(f"t2@{line_alias}") + if amesim_dm1 >= 0.0 + else amesim_value(f"temp@{chamber_alias}") + ) + reference_enthalpy_estimate = ( + amesim_dm1 * 1.0e-3 * reference_h(amesim_stream_temperature) + ) + python_dm1_kg_s = -rhs_diagnostic.chamber_to_line_flow_kg_s + python_port_1_stream_temperature_k = ( + line_properties.T if python_dm1_kg_s >= 0.0 else chamber_properties.T + ) + python_reference_port_1 = python_dm1_kg_s * reference_h( + python_port_1_stream_temperature_k + ) + python_reference_node_port_2 = sum( + ( + _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + ), + _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + ), + _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, + connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + ), + ) + ) + python_reference_sum = python_reference_port_1 + python_reference_node_port_2 + + amesim_mgas_kg = amesim_value(f"mgas@{line_alias}") * 1.0e-3 + amesim_line_temperature = amesim_value(f"t2@{line_alias}") + amesim_mass_derivative = (amesim_node_dm2 - amesim_dm1) * 1.0e-3 + amesim_heat_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - amesim_line_temperature) + ) + current_line_dtemp = ( + rhs_diagnostic.energy_derivative_w + - line_properties.u * rhs_diagnostic.mass_derivative_kg_s + ) / (line.state.m * line.gas.cv) + amesim_line_dtemp_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"t2@{line_alias}"), + time_s=time_s, + ) + candidate_pn2pipefr_dm2i_g_s = -amesim_dm1 + candidate_pn2pipefr_dh2i = -reference_enthalpy_estimate + candidate_storage_sdh = amesim_node_dh2 + candidate_pn2pipefr_dh2i + candidate_pn2pipefr_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=amesim_mgas_kg, + temperature_k=amesim_line_temperature, + mass_derivative_kg_s=amesim_mass_derivative, + enthalpy_flow_w=candidate_storage_sdh, + heat_flow_w=amesim_heat_flow, + ) + amesim_node_plus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=amesim_mgas_kg, + temperature_k=amesim_line_temperature, + mass_derivative_kg_s=amesim_mass_derivative, + enthalpy_flow_w=amesim_node_dh2 + amesim_dh1, + heat_flow_w=amesim_heat_flow, + ) + amesim_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=amesim_mgas_kg, + temperature_k=amesim_line_temperature, + mass_derivative_kg_s=amesim_mass_derivative, + enthalpy_flow_w=amesim_node_dh2 - amesim_dh1, + heat_flow_w=amesim_heat_flow, + ) + python_reference_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=line.state.m, + temperature_k=line_properties.T, + mass_derivative_kg_s=rhs_diagnostic.mass_derivative_kg_s, + enthalpy_flow_w=python_reference_node_port_2 - python_reference_port_1, + heat_flow_w=rhs_diagnostic.thermal_energy_flow_w, + ) + return TestMqlPnl0001EnergyFlowDiagnostic( + line_alias=line_alias, + chamber_alias=chamber_alias, + node_alias=node_alias, + time_s=time_s, + amesim_port_1_enthalpy_flow_w=amesim_dh1, + amesim_port_1_mass_flow_g_s=amesim_dm1, + amesim_node_port_2_enthalpy_flow_w=amesim_node_dh2, + amesim_node_port_2_mass_flow_g_s=amesim_node_dm2, + amesim_observed_port_enthalpy_sum_w=amesim_dh1 + amesim_node_dh2, + python_port_1_internal_energy_flow_w=rhs_diagnostic.port_1_energy_flow_w, + python_port_2_internal_energy_flow_w=rhs_diagnostic.port_2_energy_flow_w, + python_current_energy_derivative_w=rhs_diagnostic.energy_derivative_w, + python_direct_enthalpy_port_1_flow_w=direct_port_1, + python_direct_enthalpy_port_2_flow_w=direct_port_2, + python_direct_enthalpy_energy_derivative_w=direct_energy_derivative, + python_direct_minus_current_energy_derivative_w=( + direct_energy_derivative - rhs_diagnostic.energy_derivative_w + ), + python_p4_node_port_2_enthalpy_flow_w=( + snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w + ), + python_p4_node_port_2_mass_flow_g_s=( + snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s + ), + python_p4_node_port_2_connected_h_j_kg=p4_node_port_2_connected_h, + python_p4_node_port_2_connected_temperature_k=( + p4_node_port_2_connected_h / line.gas.cp + ), + python_p4_node_port_2_connected_h_delta_to_line_h_j_kg=( + p4_node_port_2_connected_h - line_properties.h + ), + python_p4_node_port_2_counterfactual_dtemp_k_s=( + p4_node_port_2_counterfactual_dtemp + ), + reference_temperature_k=reference_temperature_k, + amesim_port_1_reference_enthalpy_estimate_w=reference_enthalpy_estimate, + amesim_port_1_reference_enthalpy_error_w=( + reference_enthalpy_estimate - amesim_dh1 + ), + amesim_candidate_pn2pipefr_dm2i_g_s=candidate_pn2pipefr_dm2i_g_s, + amesim_candidate_pn2pipefr_dh2i_w=candidate_pn2pipefr_dh2i, + amesim_candidate_storage_enthalpy_sum_w=candidate_storage_sdh, + amesim_candidate_pn2pipefr_dtemp_k_s=candidate_pn2pipefr_dtemp, + amesim_candidate_pn2pipefr_dtemp_residual_k_s=( + candidate_pn2pipefr_dtemp - amesim_line_dtemp_fd + ), + python_reference_port_1_enthalpy_flow_w=python_reference_port_1, + python_reference_node_port_2_enthalpy_flow_w=python_reference_node_port_2, + python_reference_observed_port_enthalpy_sum_w=python_reference_sum, + python_reference_port_1_to_amesim_error_w=( + python_reference_port_1 - amesim_dh1 + ), + python_reference_node_port_2_to_amesim_error_w=( + python_reference_node_port_2 - amesim_node_dh2 + ), + python_reference_sum_to_amesim_error_w=( + python_reference_sum - (amesim_dh1 + amesim_node_dh2) + ), + python_current_line_temperature_derivative_k_s=current_line_dtemp, + amesim_line_temperature_derivative_fd_k_s=amesim_line_dtemp_fd, + amesim_pn2vol2_dtemp_node_plus_dh1_k_s=amesim_node_plus_dh1_dtemp, + amesim_pn2vol2_dtemp_node_minus_dh1_k_s=amesim_node_minus_dh1_dtemp, + python_reference_pn2vol2_dtemp_node_minus_dh1_k_s=( + python_reference_node_minus_dh1_dtemp + ), + ) + +def _p4_node_enthalpy_breakdown_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + node_alias: str, + time_s: float, +) -> TestMqlP4NodeEnthalpyBreakdownDiagnostic: + if node_alias != "pnnode4_16": + raise KeyError(f"Unsupported P4 node enthalpy diagnostic: {node_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_primary_line + balance = snapshot.p4_port3_remote_balance + reference_temperature_k = 298.15 + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_port_1_enthalpy = amesim_value("dh1@pneumatic_83") + amesim_port_1_mass = amesim_value("dm1@pneumatic_83") + amesim_port_3_enthalpy = amesim_value("dh2@pneumatic_95") + amesim_port_3_mass = amesim_value("dm2@pneumatic_95") + amesim_port_4_enthalpy = amesim_value("dh2@pn_morifice_1") + amesim_port_4_mass = amesim_value("dm2@pn_morifice_1") + amesim_port_2_enthalpy = amesim_value(f"dh2@{node_alias}") + amesim_port_2_mass = amesim_value(f"dm2@{node_alias}") + + reference_port_1 = _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + ) + reference_port_3 = _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + ) + reference_port_4 = _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, + connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + ) + reference_port_2 = reference_port_1 + reference_port_3 + reference_port_4 + real_gas_reference_port_1 = _real_gas_reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + node_pressure_pa=snapshot.p4_port3_remote_primary_line.p, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_pressure_pa=snapshot.p4_port3_line.p, + connected_temperature_k=snapshot.p4_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + ) + real_gas_reference_port_3 = _real_gas_reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + node_pressure_pa=snapshot.p4_port3_remote_primary_line.p, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_pressure_pa=snapshot.p4_port3_remote_port3_line.p, + connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + ) + real_gas_reference_port_4 = _real_gas_reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + node_pressure_pa=snapshot.p4_port3_remote_orifice_line_port_2.p, + node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, + connected_pressure_pa=snapshot.p4_port3_remote_primary_line.p, + connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + ) + real_gas_reference_port_2 = ( + real_gas_reference_port_1 + + real_gas_reference_port_3 + + real_gas_reference_port_4 + ) + + return TestMqlP4NodeEnthalpyBreakdownDiagnostic( + node_alias=node_alias, + time_s=time_s, + amesim_port_1_enthalpy_flow_w=amesim_port_1_enthalpy, + amesim_port_1_mass_flow_g_s=amesim_port_1_mass, + amesim_port_3_enthalpy_flow_w=amesim_port_3_enthalpy, + amesim_port_3_mass_flow_g_s=amesim_port_3_mass, + amesim_port_4_enthalpy_flow_w=amesim_port_4_enthalpy, + amesim_port_4_mass_flow_g_s=amesim_port_4_mass, + amesim_port_2_enthalpy_flow_w=amesim_port_2_enthalpy, + amesim_port_2_mass_flow_g_s=amesim_port_2_mass, + python_port_1_enthalpy_flow_w=balance.port_1_enthalpy_flow_w, + python_port_1_mass_flow_g_s=balance.port_1_mass_flow_g_s, + python_port_3_enthalpy_flow_w=balance.port_3_enthalpy_flow_w, + python_port_3_mass_flow_g_s=balance.port_3_mass_flow_g_s, + python_port_4_enthalpy_flow_w=balance.port_4_enthalpy_flow_w, + python_port_4_mass_flow_g_s=balance.port_4_mass_flow_g_s, + python_port_2_enthalpy_flow_w=balance.port_2_enthalpy_flow_w, + python_port_2_mass_flow_g_s=balance.port_2_mass_flow_g_s, + python_reference_port_1_enthalpy_flow_w=reference_port_1, + python_reference_port_3_enthalpy_flow_w=reference_port_3, + python_reference_port_4_enthalpy_flow_w=reference_port_4, + python_reference_port_2_enthalpy_flow_w=reference_port_2, + python_real_gas_reference_port_1_enthalpy_flow_w=real_gas_reference_port_1, + python_real_gas_reference_port_3_enthalpy_flow_w=real_gas_reference_port_3, + python_real_gas_reference_port_4_enthalpy_flow_w=real_gas_reference_port_4, + python_real_gas_reference_port_2_enthalpy_flow_w=real_gas_reference_port_2, + python_port_2_enthalpy_error_w=( + balance.port_2_enthalpy_flow_w - amesim_port_2_enthalpy + ), + python_reference_port_2_enthalpy_error_w=( + reference_port_2 - amesim_port_2_enthalpy + ), + python_real_gas_reference_port_2_enthalpy_error_w=( + real_gas_reference_port_2 - amesim_port_2_enthalpy + ), + ) + + +def _pnvo_upstream_enthalpy_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + orifice_alias: str, + upstream_line_alias: str, + downstream_node_alias: str, + time_s: float, +) -> TestMqlPnvoUpstreamEnthalpyDiagnostic: + if orifice_alias != "pn_morifice_1": + raise KeyError(f"Unsupported PNVO upstream diagnostic: {orifice_alias}") + if upstream_line_alias != "pneumatic_87": + raise KeyError(f"Unsupported PNVO upstream line diagnostic: {upstream_line_alias}") + if downstream_node_alias != "pnnode4_16": + raise KeyError(f"Unsupported PNVO downstream node diagnostic: {downstream_node_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_orifice_line + flow_kg_s = snapshot.p4_port3_remote_orifice_to_node_flow + reference_temperature_k = 298.15 + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_orifice_dh2 = amesim_value(f"dh2@{orifice_alias}") + amesim_orifice_dm2 = amesim_value(f"dm2@{orifice_alias}") + amesim_line_t2 = amesim_value(f"t2@{upstream_line_alias}") + if amesim_orifice_dm2 >= 0.0: + amesim_stream_temperature = amesim_line_t2 + amesim_stream_pressure = ( + amesim_value(f"p2@{upstream_line_alias}") + + AMESIM_REFERENCE_PRESSURE_PA + ) + else: + amesim_stream_temperature = amesim_value(f"temp1@{downstream_node_alias}") + amesim_stream_pressure = ( + amesim_value(f"press1@{downstream_node_alias}") + + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_dm2_kg_s = amesim_orifice_dm2 * 1.0e-3 + amesim_reference_dh2 = amesim_dm2_kg_s * line.gas.specific_reference_enthalpy( + amesim_stream_temperature, + reference_temperature_k, + ) + amesim_real_gas_reference_dh2 = ( + amesim_dm2_kg_s + * line.gas.pressure_reference_enthalpy( + amesim_stream_pressure, + amesim_stream_temperature, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + ) + if abs(amesim_orifice_dm2) <= 1.0e-12: + amesim_implied_temperature = amesim_stream_temperature + else: + amesim_implied_temperature = line.gas.reference_temperature_from_specific_enthalpy( + amesim_orifice_dh2 / (amesim_orifice_dm2 * 1.0e-3), + reference_temperature_k, + ) + + python_stream_properties = ( + snapshot.p4_port3_remote_orifice_line_port_2 + if flow_kg_s >= 0.0 + else snapshot.p4_port3_remote_primary_line + ) + python_current_dh2 = flow_kg_s * python_stream_properties.h + python_reference_dh2 = flow_kg_s * line.gas.specific_reference_enthalpy( + python_stream_properties.T, + reference_temperature_k, + ) + python_real_gas_reference_dh2 = flow_kg_s * line.gas.pressure_reference_enthalpy( + python_stream_properties.p, + python_stream_properties.T, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + + return TestMqlPnvoUpstreamEnthalpyDiagnostic( + orifice_alias=orifice_alias, + upstream_line_alias=upstream_line_alias, + time_s=time_s, + amesim_orifice_port_2_enthalpy_flow_w=amesim_orifice_dh2, + amesim_orifice_port_2_mass_flow_g_s=amesim_orifice_dm2, + amesim_orifice_port_3_enthalpy_flow_w=amesim_value( + f"dh3@{orifice_alias}" + ), + amesim_orifice_port_3_mass_flow_g_s=amesim_value( + f"dm3@{orifice_alias}" + ), + amesim_upstream_line_center_enthalpy_flow_w=amesim_value( + f"dhctr@{upstream_line_alias}" + ), + amesim_upstream_line_center_mass_flow_g_s=amesim_value( + f"dmctr@{upstream_line_alias}" + ), + amesim_upstream_line_port_2_temperature_k=amesim_line_t2, + amesim_upstream_line_port_2_pressure_pa=amesim_stream_pressure, + amesim_orifice_port_2_implied_reference_temperature_k=( + amesim_implied_temperature + ), + amesim_orifice_port_2_implied_temperature_delta_to_line_k=( + amesim_implied_temperature - amesim_line_t2 + ), + amesim_upstream_line_port_2_reference_enthalpy_flow_w=amesim_reference_dh2, + amesim_upstream_line_port_2_reference_enthalpy_error_w=( + amesim_reference_dh2 - amesim_orifice_dh2 + ), + amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w=( + amesim_real_gas_reference_dh2 + ), + amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w=( + amesim_real_gas_reference_dh2 - amesim_orifice_dh2 + ), + python_orifice_to_node_flow_kg_s=flow_kg_s, + python_upstream_line_port_2_temperature_k=( + snapshot.p4_port3_remote_orifice_line_port_2.T + ), + python_upstream_line_port_2_pressure_pa=python_stream_properties.p, + python_current_port_2_enthalpy_flow_w=python_current_dh2, + python_reference_port_2_enthalpy_flow_w=python_reference_dh2, + python_real_gas_reference_port_2_enthalpy_flow_w=( + python_real_gas_reference_dh2 + ), + python_current_port_2_enthalpy_error_w=( + python_current_dh2 - amesim_orifice_dh2 + ), + python_reference_port_2_enthalpy_error_w=( + python_reference_dh2 - amesim_orifice_dh2 + ), + python_real_gas_reference_port_2_enthalpy_error_w=( + python_real_gas_reference_dh2 - amesim_orifice_dh2 + ), + ) + + +def _pn2vol_reference_dtemp( + *, + gas: AmesimPneumaticGas, + temperature_k: float, + mass_kg: float, + mass_derivative_kg_s: float, + enthalpy_flow_w: float, + heat_flow_w: float, + reference_temperature_k: float = 298.15, +) -> float: + reference_offset_flow_w = ( + gas.cp * reference_temperature_k - gas.cv * temperature_k + ) * mass_derivative_kg_s + return (enthalpy_flow_w + reference_offset_flow_w + heat_flow_w) / ( + mass_kg * gas.cv + ) + + +def _actual_reference_enthalpy_flow( + *, + gas: AmesimPneumaticGas, + port_m_flow: float, + connected_pressure_pa: float, + connected_temperature_k: float, + internal_pressure_pa: float, + internal_temperature_k: float, + reference_temperature_k: float = 298.15, +) -> float: + if port_m_flow > 0.0: + pressure = connected_pressure_pa + temperature = connected_temperature_k + else: + pressure = internal_pressure_pa + temperature = internal_temperature_k + return port_m_flow * gas.pressure_reference_enthalpy( + pressure, + temperature, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + + +def _pressure_derivative_components_from_density_temperature( + *, + gas: AmesimPneumaticGas, + density_kg_m3: float, + temperature_k: float, + density_derivative_kg_m3_s: float, + temperature_derivative_k_s: float, +) -> tuple[float, float, float]: + density_step = max(abs(density_kg_m3) * 1.0e-6, 1.0e-9) + if density_kg_m3 <= density_step: + density_step = density_kg_m3 * 0.5 + temperature_step = max(abs(temperature_k) * 1.0e-6, 1.0e-4) + dp_drho = ( + gas.pressure(density_kg_m3 + density_step, temperature_k) + - gas.pressure(density_kg_m3 - density_step, temperature_k) + ) / (2.0 * density_step) + dp_dtemp = ( + gas.pressure(density_kg_m3, temperature_k + temperature_step) + - gas.pressure(density_kg_m3, temperature_k - temperature_step) + ) / (2.0 * temperature_step) + mass_component = dp_drho * density_derivative_kg_m3_s + temperature_component = dp_dtemp * temperature_derivative_k_s + return mass_component, temperature_component, mass_component + temperature_component + + +def _pnl0003_energy_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + line_alias: str, + time_s: float, +) -> TestMqlPnl0003EnergyDiagnostic: + if line_alias != "pneumatic_87": + raise KeyError(f"Unsupported PNL0003 energy diagnostic: {line_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_orifice_line + port_1 = snapshot.p4_port3_remote_orifice_line_port_1 + port_2 = snapshot.p4_port3_remote_orifice_line_port_2 + connected_port_2 = snapshot.p4_port3_remote_primary_line + center_flow = snapshot.p4_port3_remote_orifice_line_center_flow + port_1_flow = snapshot.p4_port3_remote_node_to_orifice_line_flow + port_2_flow = -snapshot.p4_port3_remote_orifice_to_node_flow + reference_temperature_k = 298.15 + pn3_port_2_mass_flow_kg_s = ( + snapshot.p4_port3_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + if abs(pn3_port_2_mass_flow_kg_s) <= 1.0e-12: + port_1_connected_h = port_1.h + else: + port_1_connected_h = ( + snapshot.p4_port3_remote_orifice_node_balance.port_2_enthalpy_flow_w + / pn3_port_2_mass_flow_kg_s + ) + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_t1 = amesim_value(f"t1@{line_alias}") + amesim_t2 = amesim_value(f"t2@{line_alias}") + amesim_p1 = amesim_value(f"p1@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA + amesim_p2 = amesim_value(f"p2@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA + amesim_dm1 = amesim_value("dm2@pn_node3_8") + amesim_dh1 = amesim_value("dh2@pn_node3_8") + python_node_dm1 = snapshot.p4_port3_remote_orifice_node_balance.port_1_mass_flow_g_s + python_node_dm3 = snapshot.p4_port3_remote_orifice_node_balance.port_3_mass_flow_g_s + python_node_dm2_sum = python_node_dm1 + python_node_dm3 + python_node_to_line_dm = -python_node_dm2_sum + amesim_dm2 = amesim_value("dm3@pn_morifice_1") + amesim_dh2 = amesim_value("dh3@pn_morifice_1") + amesim_dmctr = amesim_value(f"dmctr@{line_alias}") + amesim_dhctr = amesim_value(f"dhctr@{line_alias}") + amesim_sdm1_kg_s = (amesim_dm1 + amesim_dmctr) * 1.0e-3 + amesim_sdm2_kg_s = (amesim_dm2 - amesim_dmctr) * 1.0e-3 + amesim_sdh1 = amesim_dh1 + amesim_dhctr + amesim_sdh2 = amesim_dh2 - amesim_dhctr + amesim_mgas_series = amesim_results.series(f"mgas@{line_alias}") + amesim_total_mass_derivative_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_mgas_series, + time_s=time_s, + ) + ( + amesim_total_mass_derivative_backward, + amesim_total_mass_derivative_forward, + ) = _series_one_sided_differences_at( + times=amesim_results.times, + values=amesim_mgas_series, + time_s=time_s, + ) + amesim_total_storage_mass_derivative = ( + amesim_sdm1_kg_s + amesim_sdm2_kg_s + ) * 1.0e3 + python_center_mass_flow_g_s = center_flow * 1.0e3 + amesim_center_python_sign_equivalent_g_s = -amesim_dmctr + amesim_center_mass_flow_python_sign_kg_s = ( + amesim_center_python_sign_equivalent_g_s * 1.0e-3 + ) + amesim_port_1_center_mass_derivative_g_s = amesim_dmctr + amesim_port_2_center_mass_derivative_g_s = -amesim_dmctr + python_port_1_external_mass_flow_g_s = port_1_flow * 1.0e3 + python_port_1_center_mass_derivative_g_s = -center_flow * 1.0e3 + python_port_1_storage_mass_derivative_g_s = ( + python_port_1_external_mass_flow_g_s + + python_port_1_center_mass_derivative_g_s + ) + python_port_2_external_mass_flow_g_s = port_2_flow * 1.0e3 + python_port_2_center_mass_derivative_g_s = center_flow * 1.0e3 + python_port_2_storage_mass_derivative_g_s = ( + python_port_2_external_mass_flow_g_s + + python_port_2_center_mass_derivative_g_s + ) + + def current_darcy_center_flow_for_state( + port_1_pressure_pa: float, + port_1_temperature_k: float, + port_2_pressure_pa: float, + port_2_temperature_k: float, + ) -> float: + pressure_difference = port_1_pressure_pa - port_2_pressure_pa + if pressure_difference == 0.0: + return 0.0 + upstream_pressure = ( + port_1_pressure_pa if pressure_difference > 0.0 else port_2_pressure_pa + ) + upstream_temperature = ( + port_1_temperature_k if pressure_difference > 0.0 else port_2_temperature_k + ) + upstream_density = line.gas.density(upstream_pressure, upstream_temperature) + magnitude = line._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=upstream_density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + amesim_state_darcy_center_flow = current_darcy_center_flow_for_state( + amesim_p1, + amesim_t1, + amesim_p2, + amesim_t2, + ) + amesim_state_pn2pipefr_center_flow = line.pn2pipefr_mass_flow( + port_1_pressure_pa=amesim_p1, + port_1_temperature_k=amesim_t1, + port_2_pressure_pa=amesim_p2, + port_2_temperature_k=amesim_t2, + ) + amesim_center_real_port_1 = ( + -amesim_center_mass_flow_python_sign_kg_s + * line.gas.pressure_reference_enthalpy( + amesim_p1, + amesim_t1, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + ) + amesim_mass_1 = line.gas.density(amesim_p1, amesim_t1) * line.compliance_volume + amesim_mass_2 = line.gas.density(amesim_p2, amesim_t2) * line.compliance_volume + heat_flow_1 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - amesim_t1) + ) + heat_flow_2 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - amesim_t2) + ) + amesim_pn2vol_dtemp_1 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=amesim_t1, + mass_kg=amesim_mass_1, + mass_derivative_kg_s=amesim_sdm1_kg_s, + enthalpy_flow_w=amesim_sdh1, + heat_flow_w=heat_flow_1, + reference_temperature_k=reference_temperature_k, + ) + amesim_pn2vol_dtemp_2 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=amesim_t2, + mass_kg=amesim_mass_2, + mass_derivative_kg_s=amesim_sdm2_kg_s, + enthalpy_flow_w=amesim_sdh2, + heat_flow_w=heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + amesim_t1_series = amesim_results.series(f"t1@{line_alias}") + amesim_t2_series = amesim_results.series(f"t2@{line_alias}") + amesim_fd_dtemp_1 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_t1_series, + time_s=time_s, + ) + amesim_fd_dtemp_2 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_t2_series, + time_s=time_s, + ) + amesim_backward_dtemp_1, amesim_forward_dtemp_1 = ( + _series_one_sided_differences_at( + times=amesim_results.times, + values=amesim_t1_series, + time_s=time_s, + ) + ) + amesim_backward_dtemp_2, amesim_forward_dtemp_2 = ( + _series_one_sided_differences_at( + times=amesim_results.times, + values=amesim_t2_series, + time_s=time_s, + ) + ) + amesim_p1_series = amesim_results.series(f"p1@{line_alias}") + amesim_p2_series = amesim_results.series(f"p2@{line_alias}") + amesim_fd_dpressure_1 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_p1_series, + time_s=time_s, + ) + amesim_fd_dpressure_2 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_p2_series, + time_s=time_s, + ) + ( + amesim_dpressure_1_mass, + amesim_dpressure_1_temperature, + amesim_dpressure_1_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=amesim_mass_1 / line.compliance_volume, + temperature_k=amesim_t1, + density_derivative_kg_m3_s=amesim_sdm1_kg_s / line.compliance_volume, + temperature_derivative_k_s=amesim_fd_dtemp_1, + ) + ( + amesim_dpressure_2_mass, + amesim_dpressure_2_temperature, + amesim_dpressure_2_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=amesim_mass_2 / line.compliance_volume, + temperature_k=amesim_t2, + density_derivative_kg_m3_s=amesim_sdm2_kg_s / line.compliance_volume, + temperature_derivative_k_s=amesim_fd_dtemp_2, + ) + + current_derivative_1, current_derivative_2 = line.derivatives_from_connections( + port_1_m_flow=port_1_flow, + connected_h_1=port_1_connected_h, + port_2_m_flow=port_2_flow, + connected_h_2=connected_port_2.h, + ) + python_current_dtemp_1 = ( + current_derivative_1.U - port_1.u * current_derivative_1.m + ) / (line.state_1.m * line.gas.cv) + python_current_dtemp_2 = ( + current_derivative_2.U - port_2.u * current_derivative_2.m + ) / (line.state_2.m * line.gas.cv) + ( + python_dpressure_1_mass, + python_dpressure_1_temperature, + python_dpressure_1_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state_1.m / line.compliance_volume, + temperature_k=port_1.T, + density_derivative_kg_m3_s=current_derivative_1.m / line.compliance_volume, + temperature_derivative_k_s=python_current_dtemp_1, + ) + ( + python_dpressure_2_mass, + python_dpressure_2_temperature, + python_dpressure_2_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state_2.m / line.compliance_volume, + temperature_k=port_2.T, + density_derivative_kg_m3_s=current_derivative_2.m / line.compliance_volume, + temperature_derivative_k_s=python_current_dtemp_2, + ) + + python_center_dh_1 = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=-center_flow, + connected_pressure_pa=port_2.p, + connected_temperature_k=port_2.T, + internal_pressure_pa=port_1.p, + internal_temperature_k=port_1.T, + reference_temperature_k=reference_temperature_k, + ) + python_center_dh_2 = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=center_flow, + connected_pressure_pa=port_1.p, + connected_temperature_k=port_1.T, + internal_pressure_pa=port_2.p, + internal_temperature_k=port_2.T, + reference_temperature_k=reference_temperature_k, + ) + if port_1_flow > 0.0: + python_port_1_dh = port_1_flow * ( + port_1_connected_h - line.gas.cp * reference_temperature_k + ) + else: + python_port_1_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=port_1_flow, + connected_pressure_pa=port_1.p, + connected_temperature_k=port_1.T, + internal_pressure_pa=port_1.p, + internal_temperature_k=port_1.T, + reference_temperature_k=reference_temperature_k, + ) + python_port_2_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=port_2_flow, + connected_pressure_pa=connected_port_2.p, + connected_temperature_k=connected_port_2.T, + internal_pressure_pa=port_2.p, + internal_temperature_k=port_2.T, + reference_temperature_k=reference_temperature_k, + ) + python_center_ideal_port_1 = -center_flow * line.gas.specific_reference_enthalpy( + port_1.T, + reference_temperature_k, + ) + python_center_ideal_port_2 = -center_flow * line.gas.specific_reference_enthalpy( + port_2.T, + reference_temperature_k, + ) + python_center_real_port_1 = -center_flow * line.gas.pressure_reference_enthalpy( + port_1.p, + port_1.T, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + python_center_real_port_2 = -center_flow * line.gas.pressure_reference_enthalpy( + port_2.p, + port_2.T, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + + python_heat_flow_1 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - port_1.T) + ) + python_heat_flow_2 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - port_2.T) + ) + python_ref_dtemp_1 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_1.T, + mass_kg=line.state_1.m, + mass_derivative_kg_s=port_1_flow - center_flow, + enthalpy_flow_w=python_port_1_dh + python_center_dh_1, + heat_flow_w=python_heat_flow_1, + reference_temperature_k=reference_temperature_k, + ) + python_ref_dtemp_2 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=port_2_flow + center_flow, + enthalpy_flow_w=python_port_2_dh + python_center_dh_2, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + + def dtemp_component(flow_w: float, mass_kg: float) -> float: + return flow_w / (mass_kg * line.gas.cv) + + amesim_port_1_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * amesim_t1 + ) * amesim_sdm1_kg_s + amesim_port_2_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * amesim_t2 + ) * amesim_sdm2_kg_s + python_port_1_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * port_1.T + ) * (port_1_flow - center_flow) + python_port_2_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * port_2.T + ) * (port_2_flow + center_flow) + amesim_port_1_external_dtemp_component = dtemp_component( + amesim_dh1, + amesim_mass_1, + ) + amesim_port_1_center_dtemp_component = dtemp_component( + amesim_dhctr, + amesim_mass_1, + ) + amesim_port_1_mass_offset_dtemp_component = dtemp_component( + amesim_port_1_mass_offset_flow_w, + amesim_mass_1, + ) + amesim_port_1_heat_dtemp_component = dtemp_component( + heat_flow_1, + amesim_mass_1, + ) + amesim_port_2_external_dtemp_component = dtemp_component( + amesim_dh2, + amesim_mass_2, + ) + amesim_port_2_center_dtemp_component = dtemp_component( + -amesim_dhctr, + amesim_mass_2, + ) + amesim_port_2_mass_offset_dtemp_component = dtemp_component( + amesim_port_2_mass_offset_flow_w, + amesim_mass_2, + ) + amesim_port_2_heat_dtemp_component = dtemp_component( + heat_flow_2, + amesim_mass_2, + ) + python_port_1_external_dtemp_component = dtemp_component( + python_port_1_dh, + line.state_1.m, + ) + python_port_1_center_dtemp_component = dtemp_component( + python_center_dh_1, + line.state_1.m, + ) + python_port_1_mass_offset_dtemp_component = dtemp_component( + python_port_1_mass_offset_flow_w, + line.state_1.m, + ) + python_port_1_heat_dtemp_component = dtemp_component( + python_heat_flow_1, + line.state_1.m, + ) + python_port_2_external_dtemp_component = dtemp_component( + python_port_2_dh, + line.state_2.m, + ) + python_port_2_center_dtemp_component = dtemp_component( + python_center_dh_2, + line.state_2.m, + ) + python_port_2_mass_offset_dtemp_component = dtemp_component( + python_port_2_mass_offset_flow_w, + line.state_2.m, + ) + python_port_2_heat_dtemp_component = dtemp_component( + python_heat_flow_2, + line.state_2.m, + ) + + amesim_center_flow_python_sign_kg_s = ( + amesim_center_python_sign_equivalent_g_s * 1.0e-3 + ) + amesim_external_flow_kg_s = amesim_dm2 * 1.0e-3 + python_dtemp_2_amesim_center = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=port_2_flow + amesim_center_flow_python_sign_kg_s, + enthalpy_flow_w=python_port_2_dh - amesim_dhctr, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + python_dtemp_2_amesim_external = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=amesim_external_flow_kg_s + center_flow, + enthalpy_flow_w=amesim_dh2 + python_center_dh_2, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + python_dtemp_2_amesim_external_center = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=( + amesim_external_flow_kg_s + amesim_center_flow_python_sign_kg_s + ), + enthalpy_flow_w=amesim_dh2 - amesim_dhctr, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + + return TestMqlPnl0003EnergyDiagnostic( + line_alias=line_alias, + time_s=time_s, + amesim_port_1_temperature_k=amesim_t1, + amesim_port_2_temperature_k=amesim_t2, + amesim_port_1_pressure_pa=amesim_p1, + amesim_port_2_pressure_pa=amesim_p2, + amesim_port_1_mass_flow_g_s=amesim_dm1, + amesim_port_1_enthalpy_flow_w=amesim_dh1, + amesim_center_mass_flow_g_s=amesim_dmctr, + amesim_center_enthalpy_flow_w=amesim_dhctr, + amesim_port_2_mass_flow_g_s=amesim_dm2, + amesim_port_2_enthalpy_flow_w=amesim_dh2, + amesim_port_1_storage_mass_derivative_g_s=amesim_sdm1_kg_s * 1.0e3, + amesim_port_1_storage_enthalpy_sum_w=amesim_sdh1, + amesim_port_2_storage_mass_derivative_g_s=amesim_sdm2_kg_s * 1.0e3, + amesim_port_2_storage_enthalpy_sum_w=amesim_sdh2, + python_pn3_node_port_1_mass_flow_g_s=python_node_dm1, + python_pn3_node_port_3_mass_flow_g_s=python_node_dm3, + python_pn3_node_to_line_mass_flow_g_s=python_node_to_line_dm, + python_pn3_node_to_line_mass_flow_error_g_s=( + python_node_to_line_dm - amesim_dm1 + ), + amesim_port_1_mass_estimate_g=amesim_mass_1 * 1.0e3, + amesim_port_2_mass_estimate_g=amesim_mass_2 * 1.0e3, + python_port_1_mass_g=line.state_1.m * 1.0e3, + python_port_2_mass_g=line.state_2.m * 1.0e3, + python_port_1_mass_error_to_amesim_g=( + line.state_1.m * 1.0e3 - amesim_mass_1 * 1.0e3 + ), + python_port_2_mass_error_to_amesim_g=( + line.state_2.m * 1.0e3 - amesim_mass_2 * 1.0e3 + ), + amesim_total_mass_derivative_fd_g_s=amesim_total_mass_derivative_fd, + amesim_total_mass_derivative_backward_g_s=( + amesim_total_mass_derivative_backward + ), + amesim_total_mass_derivative_forward_g_s=( + amesim_total_mass_derivative_forward + ), + amesim_total_storage_mass_derivative_g_s=( + amesim_total_storage_mass_derivative + ), + amesim_total_storage_mass_derivative_residual_g_s=( + amesim_total_storage_mass_derivative - amesim_total_mass_derivative_fd + ), + amesim_center_flow_python_sign_equivalent_g_s=( + amesim_center_python_sign_equivalent_g_s + ), + amesim_center_flow_python_sign_error_g_s=( + python_center_mass_flow_g_s - amesim_center_python_sign_equivalent_g_s + ), + python_center_mass_flow_g_s=python_center_mass_flow_g_s, + amesim_port_1_center_mass_derivative_g_s=( + amesim_port_1_center_mass_derivative_g_s + ), + amesim_port_2_center_mass_derivative_g_s=( + amesim_port_2_center_mass_derivative_g_s + ), + python_port_1_external_mass_flow_g_s=python_port_1_external_mass_flow_g_s, + python_port_1_external_mass_flow_error_g_s=( + python_port_1_external_mass_flow_g_s - amesim_dm1 + ), + python_port_1_center_mass_derivative_g_s=( + python_port_1_center_mass_derivative_g_s + ), + python_port_1_center_mass_derivative_error_g_s=( + python_port_1_center_mass_derivative_g_s + - amesim_port_1_center_mass_derivative_g_s + ), + python_port_1_storage_mass_derivative_g_s=( + python_port_1_storage_mass_derivative_g_s + ), + python_port_1_storage_mass_derivative_error_g_s=( + python_port_1_storage_mass_derivative_g_s - amesim_sdm1_kg_s * 1.0e3 + ), + python_port_2_external_mass_flow_g_s=python_port_2_external_mass_flow_g_s, + python_port_2_external_mass_flow_error_g_s=( + python_port_2_external_mass_flow_g_s - amesim_dm2 + ), + python_port_2_center_mass_derivative_g_s=( + python_port_2_center_mass_derivative_g_s + ), + python_port_2_center_mass_derivative_error_g_s=( + python_port_2_center_mass_derivative_g_s + - amesim_port_2_center_mass_derivative_g_s + ), + python_port_2_storage_mass_derivative_g_s=( + python_port_2_storage_mass_derivative_g_s + ), + python_port_2_storage_mass_derivative_error_g_s=( + python_port_2_storage_mass_derivative_g_s - amesim_sdm2_kg_s * 1.0e3 + ), + python_amesim_state_darcy_center_mass_flow_g_s=( + amesim_state_darcy_center_flow * 1.0e3 + ), + python_amesim_state_darcy_center_mass_flow_error_g_s=( + amesim_state_darcy_center_flow * 1.0e3 + - amesim_center_python_sign_equivalent_g_s + ), + python_amesim_state_pn2pipefr_center_mass_flow_g_s=( + amesim_state_pn2pipefr_center_flow * 1.0e3 + ), + python_amesim_state_pn2pipefr_center_mass_flow_error_g_s=( + amesim_state_pn2pipefr_center_flow * 1.0e3 + - amesim_center_python_sign_equivalent_g_s + ), + amesim_center_real_gas_reference_port_1_estimate_w=amesim_center_real_port_1, + amesim_center_real_gas_reference_port_1_error_w=( + amesim_center_real_port_1 - amesim_dhctr + ), + python_center_ideal_reference_port_1_estimate_w=python_center_ideal_port_1, + python_center_ideal_reference_port_1_error_w=( + python_center_ideal_port_1 - amesim_dhctr + ), + python_center_ideal_reference_port_2_estimate_w=python_center_ideal_port_2, + python_center_ideal_reference_port_2_error_w=( + python_center_ideal_port_2 - amesim_dhctr + ), + python_center_real_gas_reference_port_1_estimate_w=python_center_real_port_1, + python_center_real_gas_reference_port_1_error_w=( + python_center_real_port_1 - amesim_dhctr + ), + python_center_real_gas_reference_port_2_estimate_w=python_center_real_port_2, + python_center_real_gas_reference_port_2_error_w=( + python_center_real_port_2 - amesim_dhctr + ), + python_total_mass_derivative_kg_s=( + current_derivative_1.m + current_derivative_2.m + ), + python_port_1_reference_external_enthalpy_flow_w=python_port_1_dh, + python_port_1_reference_center_enthalpy_flow_w=python_center_dh_1, + python_port_1_reference_storage_enthalpy_sum_w=( + python_port_1_dh + python_center_dh_1 + ), + python_port_1_reference_storage_enthalpy_error_w=( + python_port_1_dh + python_center_dh_1 - amesim_sdh1 + ), + python_port_2_reference_external_enthalpy_flow_w=python_port_2_dh, + python_port_2_reference_center_enthalpy_flow_w=python_center_dh_2, + python_port_2_reference_storage_enthalpy_sum_w=( + python_port_2_dh + python_center_dh_2 + ), + python_port_2_reference_storage_enthalpy_error_w=( + python_port_2_dh + python_center_dh_2 - amesim_sdh2 + ), + amesim_port_1_external_enthalpy_dtemp_component_k_s=( + amesim_port_1_external_dtemp_component + ), + amesim_port_1_center_enthalpy_dtemp_component_k_s=( + amesim_port_1_center_dtemp_component + ), + amesim_port_1_mass_offset_dtemp_component_k_s=( + amesim_port_1_mass_offset_dtemp_component + ), + amesim_port_1_heat_dtemp_component_k_s=amesim_port_1_heat_dtemp_component, + python_port_1_external_enthalpy_dtemp_component_k_s=( + python_port_1_external_dtemp_component + ), + python_port_1_center_enthalpy_dtemp_component_k_s=( + python_port_1_center_dtemp_component + ), + python_port_1_mass_offset_dtemp_component_k_s=( + python_port_1_mass_offset_dtemp_component + ), + python_port_1_heat_dtemp_component_k_s=python_port_1_heat_dtemp_component, + amesim_port_2_external_enthalpy_dtemp_component_k_s=( + amesim_port_2_external_dtemp_component + ), + amesim_port_2_center_enthalpy_dtemp_component_k_s=( + amesim_port_2_center_dtemp_component + ), + amesim_port_2_mass_offset_dtemp_component_k_s=( + amesim_port_2_mass_offset_dtemp_component + ), + amesim_port_2_heat_dtemp_component_k_s=amesim_port_2_heat_dtemp_component, + python_port_2_external_enthalpy_dtemp_component_k_s=( + python_port_2_external_dtemp_component + ), + python_port_2_center_enthalpy_dtemp_component_k_s=( + python_port_2_center_dtemp_component + ), + python_port_2_mass_offset_dtemp_component_k_s=( + python_port_2_mass_offset_dtemp_component + ), + python_port_2_heat_dtemp_component_k_s=python_port_2_heat_dtemp_component, + amesim_port_1_temperature_derivative_fd_k_s=amesim_fd_dtemp_1, + amesim_port_1_temperature_derivative_backward_k_s=amesim_backward_dtemp_1, + amesim_port_1_temperature_derivative_forward_k_s=amesim_forward_dtemp_1, + amesim_port_2_temperature_derivative_fd_k_s=amesim_fd_dtemp_2, + amesim_port_2_temperature_derivative_backward_k_s=amesim_backward_dtemp_2, + amesim_port_2_temperature_derivative_forward_k_s=amesim_forward_dtemp_2, + amesim_port_1_pn2vol_dtemp_k_s=amesim_pn2vol_dtemp_1, + amesim_port_2_pn2vol_dtemp_k_s=amesim_pn2vol_dtemp_2, + amesim_port_1_pn2vol_dtemp_residual_k_s=( + amesim_pn2vol_dtemp_1 - amesim_fd_dtemp_1 + ), + amesim_port_2_pn2vol_dtemp_residual_k_s=( + amesim_pn2vol_dtemp_2 - amesim_fd_dtemp_2 + ), + amesim_port_1_pressure_derivative_fd_pa_s=amesim_fd_dpressure_1, + amesim_port_2_pressure_derivative_fd_pa_s=amesim_fd_dpressure_2, + amesim_port_1_pressure_derivative_mass_component_pa_s=( + amesim_dpressure_1_mass + ), + amesim_port_1_pressure_derivative_temperature_component_pa_s=( + amesim_dpressure_1_temperature + ), + amesim_port_1_pressure_derivative_eos_sum_pa_s=amesim_dpressure_1_sum, + amesim_port_1_pressure_derivative_eos_residual_pa_s=( + amesim_dpressure_1_sum - amesim_fd_dpressure_1 + ), + amesim_port_2_pressure_derivative_mass_component_pa_s=( + amesim_dpressure_2_mass + ), + amesim_port_2_pressure_derivative_temperature_component_pa_s=( + amesim_dpressure_2_temperature + ), + amesim_port_2_pressure_derivative_eos_sum_pa_s=amesim_dpressure_2_sum, + amesim_port_2_pressure_derivative_eos_residual_pa_s=( + amesim_dpressure_2_sum - amesim_fd_dpressure_2 + ), + python_port_1_pressure_derivative_mass_component_pa_s=( + python_dpressure_1_mass + ), + python_port_1_pressure_derivative_temperature_component_pa_s=( + python_dpressure_1_temperature + ), + python_port_1_pressure_derivative_eos_sum_pa_s=python_dpressure_1_sum, + python_port_2_pressure_derivative_mass_component_pa_s=( + python_dpressure_2_mass + ), + python_port_2_pressure_derivative_temperature_component_pa_s=( + python_dpressure_2_temperature + ), + python_port_2_pressure_derivative_eos_sum_pa_s=python_dpressure_2_sum, + python_port_1_temperature_k=port_1.T, + python_port_2_temperature_k=port_2.T, + python_port_1_pressure_pa=port_1.p, + python_port_2_pressure_pa=port_2.p, + python_port_1_mass_derivative_kg_s=current_derivative_1.m, + python_port_2_mass_derivative_kg_s=current_derivative_2.m, + python_port_1_current_dtemp_k_s=python_current_dtemp_1, + python_port_2_current_dtemp_k_s=python_current_dtemp_2, + python_port_1_real_gas_reference_dtemp_k_s=python_ref_dtemp_1, + python_port_2_real_gas_reference_dtemp_k_s=python_ref_dtemp_2, + python_port_2_amesim_center_counterfactual_dtemp_k_s=( + python_dtemp_2_amesim_center + ), + python_port_2_amesim_external_counterfactual_dtemp_k_s=( + python_dtemp_2_amesim_external + ), + python_port_2_amesim_external_center_counterfactual_dtemp_k_s=( + python_dtemp_2_amesim_external_center + ), + python_port_2_temperature_error_to_amesim_k=port_2.T - amesim_t2, + ) + + +def _pneumatic_96_inlet_line_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + line_alias: str, + node_alias: str, + orifice_alias: str, + chamber_alias: str, + time_s: float, +) -> TestMqlPneumatic96InletLineDiagnostic: + if line_alias != "pneumatic_96": + raise KeyError(f"Unsupported inlet line diagnostic: {line_alias}") + if node_alias != "pn_node3_8" or orifice_alias != "pn_orifice_18": + raise KeyError( + f"Unsupported inlet path diagnostic: {node_alias}/{orifice_alias}" + ) + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.inlet_line + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_line_pressure = ( + amesim_value(f"p2@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_line_temperature = amesim_value(f"t2@{line_alias}") + amesim_line_mass = amesim_value(f"mgas@{line_alias}") + amesim_line_mass_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"mgas@{line_alias}"), + time_s=time_s, + ) + amesim_line_to_node_flow = amesim_value(f"dm1@{line_alias}") + amesim_orifice_to_line_flow = amesim_value(f"dm2@{orifice_alias}") + amesim_storage_from_ports = ( + amesim_orifice_to_line_flow - amesim_line_to_node_flow + ) + amesim_chamber_pressure = ( + amesim_value(f"press@{chamber_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_chamber_temperature = amesim_value(f"temp@{chamber_alias}") + amesim_node_pressure = ( + amesim_value(f"press1@{node_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_node_temperature = amesim_value(f"temp1@{node_alias}") + + python_line_to_node_flow = -snapshot.inlet_node_to_line_flow * 1.0e3 + python_orifice_to_line_flow = -snapshot.inlet_line_to_chamber_flow * 1.0e3 + python_storage_derivative = ( + snapshot.inlet_node_to_line_flow - snapshot.inlet_line_to_chamber_flow + ) * 1.0e3 + + orifice = closure.pneumatic_closure.components.inlet_orifice + + def node_to_line_flow_from_states( + *, + node_pressure_pa: float, + node_temperature_k: float, + line_pressure_pa: float, + line_temperature_k: float, + ) -> float: + pressure_difference = node_pressure_pa - line_pressure_pa + if pressure_difference == 0.0: + return 0.0 + if line.calibrated_linear_conductance is not None: + return ( + line.calibrated_linear_conductance + * pressure_difference + / sqrt(line_temperature_k) + ) + upstream_pressure = max(node_pressure_pa, line_pressure_pa) + upstream_temperature = ( + node_temperature_k if pressure_difference > 0.0 else line_temperature_k + ) + density = line.gas.density(upstream_pressure, upstream_temperature) + magnitude = line._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def line_to_node_flow_from_states_g_s( + *, + node_pressure_pa: float, + node_temperature_k: float, + line_pressure_pa: float, + line_temperature_k: float, + ) -> float: + return -1.0e3 * node_to_line_flow_from_states( + node_pressure_pa=node_pressure_pa, + node_temperature_k=node_temperature_k, + line_pressure_pa=line_pressure_pa, + line_temperature_k=line_temperature_k, + ) + + def orifice_to_line_flow_from_states_g_s( + *, + line_pressure_pa: float, + line_temperature_k: float, + chamber_pressure_pa: float, + chamber_temperature_k: float, + ) -> float: + upstream_temperature = ( + line_temperature_k + if line_pressure_pa >= chamber_pressure_pa + else chamber_temperature_k + ) + line_to_chamber = orifice.mass_flow( + line_pressure_pa, + chamber_pressure_pa, + upstream_temperature, + ) + return -1.0e3 * line_to_chamber + + python_line_to_node_amesim_node_flow = line_to_node_flow_from_states_g_s( + node_pressure_pa=amesim_node_pressure, + node_temperature_k=amesim_node_temperature, + line_pressure_pa=snapshot.inlet_line.p, + line_temperature_k=snapshot.inlet_line.T, + ) + python_line_to_node_amesim_line_flow = line_to_node_flow_from_states_g_s( + node_pressure_pa=snapshot.inlet_node.p, + node_temperature_k=snapshot.inlet_node.T, + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + ) + python_line_to_node_amesim_node_line_flow = line_to_node_flow_from_states_g_s( + node_pressure_pa=amesim_node_pressure, + node_temperature_k=amesim_node_temperature, + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + ) + python_orifice_to_line_amesim_line_flow = orifice_to_line_flow_from_states_g_s( + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + chamber_pressure_pa=snapshot.chamber.p, + chamber_temperature_k=snapshot.chamber.T, + ) + python_orifice_to_line_amesim_chamber_flow = orifice_to_line_flow_from_states_g_s( + line_pressure_pa=snapshot.inlet_line.p, + line_temperature_k=snapshot.inlet_line.T, + chamber_pressure_pa=amesim_chamber_pressure, + chamber_temperature_k=amesim_chamber_temperature, + ) + python_orifice_to_line_amesim_line_chamber_flow = ( + orifice_to_line_flow_from_states_g_s( + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + chamber_pressure_pa=amesim_chamber_pressure, + chamber_temperature_k=amesim_chamber_temperature, + ) + ) + python_storage_amesim_boundary_states = ( + python_orifice_to_line_amesim_line_chamber_flow + - python_line_to_node_amesim_node_line_flow + ) + + amesim_line_temperature_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"t2@{line_alias}"), + time_s=time_s, + ) + amesim_line_pressure_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"p2@{line_alias}"), + time_s=time_s, + ) + amesim_dh1 = amesim_value(f"dh1@{line_alias}") + amesim_dh2 = amesim_value(f"dh2@{orifice_alias}") + amesim_line_mass_kg = amesim_line_mass * 1.0e-3 + amesim_storage_kg_s = amesim_storage_from_ports * 1.0e-3 + amesim_reference_temperature_k = 298.15 + amesim_mass_offset_flow = ( + line.gas.cp * amesim_reference_temperature_k + - line.gas.cv * amesim_line_temperature + ) * amesim_storage_kg_s + + def amesim_dtemp_component(flow_w: float) -> float: + return flow_w / (amesim_line_mass_kg * line.gas.cv) + + amesim_port_1_dtemp = amesim_dtemp_component(-amesim_dh1) + amesim_port_2_dtemp = amesim_dtemp_component(amesim_dh2) + amesim_mass_offset_dtemp = amesim_dtemp_component(amesim_mass_offset_flow) + amesim_reference_dtemp_sum = ( + amesim_port_1_dtemp + amesim_port_2_dtemp + amesim_mass_offset_dtemp + ) + ( + amesim_pressure_mass_component, + amesim_pressure_temperature_component, + amesim_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=amesim_line_mass_kg / line.volume, + temperature_k=amesim_line_temperature, + density_derivative_kg_m3_s=amesim_storage_kg_s / line.volume, + temperature_derivative_k_s=amesim_line_temperature_fd, + ) + + python_port_1_flow = snapshot.inlet_node_to_line_flow + python_port_2_flow = -snapshot.inlet_line_to_chamber_flow + python_heat_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - snapshot.inlet_line.T) + ) + python_port_1_u = ( + snapshot.inlet_node.h / line.gas.gamma + if python_port_1_flow > 0.0 + else snapshot.inlet_line.u + ) + python_port_2_u = ( + snapshot.chamber.h / line.gas.gamma + if python_port_2_flow > 0.0 + else snapshot.inlet_line.u + ) + + def python_current_dtemp_component( + *, + mass_flow_kg_s: float, + inlet_u_j_kg: float, + ) -> float: + return ( + mass_flow_kg_s + * (inlet_u_j_kg - snapshot.inlet_line.u) + / (line.state.m * line.gas.cv) + ) + + python_port_1_dtemp = python_current_dtemp_component( + mass_flow_kg_s=python_port_1_flow, + inlet_u_j_kg=python_port_1_u, + ) + python_port_2_dtemp = python_current_dtemp_component( + mass_flow_kg_s=python_port_2_flow, + inlet_u_j_kg=python_port_2_u, + ) + python_heat_dtemp = python_heat_flow / (line.state.m * line.gas.cv) + python_current_dtemp = ( + python_port_1_dtemp + python_port_2_dtemp + python_heat_dtemp + ) + python_density_derivative = ( + (snapshot.inlet_node_to_line_flow - snapshot.inlet_line_to_chamber_flow) + / line.volume + ) + ( + python_pressure_mass_component, + python_pressure_temperature_component, + python_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state.m / line.volume, + temperature_k=snapshot.inlet_line.T, + density_derivative_kg_m3_s=python_density_derivative, + temperature_derivative_k_s=python_current_dtemp, + ) + + python_reference_port_1_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=python_port_1_flow, + connected_pressure_pa=snapshot.inlet_node.p, + connected_temperature_k=snapshot.inlet_node.T, + internal_pressure_pa=snapshot.inlet_line.p, + internal_temperature_k=snapshot.inlet_line.T, + reference_temperature_k=amesim_reference_temperature_k, + ) + python_reference_port_2_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=python_port_2_flow, + connected_pressure_pa=snapshot.chamber.p, + connected_temperature_k=snapshot.chamber.T, + internal_pressure_pa=snapshot.inlet_line.p, + internal_temperature_k=snapshot.inlet_line.T, + reference_temperature_k=amesim_reference_temperature_k, + ) + python_reference_mass_offset_flow = ( + line.gas.cp * amesim_reference_temperature_k + - line.gas.cv * snapshot.inlet_line.T + ) * (python_port_1_flow + python_port_2_flow) + + def python_reference_dtemp_component(flow_w: float) -> float: + return flow_w / (line.state.m * line.gas.cv) + + python_reference_port_1_dtemp = python_reference_dtemp_component( + python_reference_port_1_dh + ) + python_reference_port_2_dtemp = python_reference_dtemp_component( + python_reference_port_2_dh + ) + python_reference_mass_offset_dtemp = python_reference_dtemp_component( + python_reference_mass_offset_flow + ) + python_reference_dtemp = ( + python_reference_port_1_dtemp + + python_reference_port_2_dtemp + + python_reference_mass_offset_dtemp + + python_heat_dtemp + ) + ( + python_reference_pressure_mass_component, + python_reference_pressure_temperature_component, + python_reference_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state.m / line.volume, + temperature_k=snapshot.inlet_line.T, + density_derivative_kg_m3_s=python_density_derivative, + temperature_derivative_k_s=python_reference_dtemp, + ) + + python_reference_amesim_storage_mass_offset_flow = ( + line.gas.cp * amesim_reference_temperature_k + - line.gas.cv * snapshot.inlet_line.T + ) * amesim_storage_kg_s + python_reference_amesim_storage_mass_offset_dtemp = ( + python_reference_dtemp_component( + python_reference_amesim_storage_mass_offset_flow + ) + ) + python_reference_amesim_storage_dtemp = ( + python_reference_port_1_dtemp + + python_reference_port_2_dtemp + + python_reference_amesim_storage_mass_offset_dtemp + + python_heat_dtemp + ) + use_reference_rhs = bool( + getattr(closure.pneumatic_closure, "use_inlet_line_reference_rhs", False) + ) + python_active_dtemp = ( + python_reference_dtemp if use_reference_rhs else python_current_dtemp + ) + ( + python_reference_amesim_storage_pressure_mass_component, + python_reference_amesim_storage_pressure_temperature_component, + python_reference_amesim_storage_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state.m / line.volume, + temperature_k=snapshot.inlet_line.T, + density_derivative_kg_m3_s=amesim_storage_kg_s / line.volume, + temperature_derivative_k_s=python_reference_amesim_storage_dtemp, + ) + + return TestMqlPneumatic96InletLineDiagnostic( + line_alias=line_alias, + node_alias=node_alias, + orifice_alias=orifice_alias, + chamber_alias=chamber_alias, + time_s=time_s, + amesim_line_pressure_pa=amesim_line_pressure, + amesim_line_temperature_k=amesim_line_temperature, + amesim_line_mass_g=amesim_line_mass, + amesim_line_mass_derivative_fd_g_s=amesim_line_mass_fd, + amesim_line_to_node_mass_flow_g_s=amesim_line_to_node_flow, + amesim_orifice_to_line_mass_flow_g_s=amesim_orifice_to_line_flow, + amesim_storage_mass_derivative_from_ports_g_s=amesim_storage_from_ports, + amesim_storage_mass_derivative_residual_g_s=( + amesim_storage_from_ports - amesim_line_mass_fd + ), + amesim_chamber_pressure_pa=amesim_chamber_pressure, + amesim_chamber_temperature_k=amesim_chamber_temperature, + amesim_node_pressure_pa=amesim_node_pressure, + amesim_node_temperature_k=amesim_node_temperature, + amesim_line_temperature_derivative_fd_k_s=amesim_line_temperature_fd, + amesim_line_pressure_derivative_fd_pa_s=amesim_line_pressure_fd, + amesim_port_1_enthalpy_dtemp_component_k_s=amesim_port_1_dtemp, + amesim_port_2_enthalpy_dtemp_component_k_s=amesim_port_2_dtemp, + amesim_mass_offset_dtemp_component_k_s=amesim_mass_offset_dtemp, + amesim_reference_temperature_derivative_sum_k_s=( + amesim_reference_dtemp_sum + ), + amesim_reference_temperature_derivative_residual_k_s=( + amesim_reference_dtemp_sum - amesim_line_temperature_fd + ), + amesim_pressure_derivative_mass_component_pa_s=( + amesim_pressure_mass_component + ), + amesim_pressure_derivative_temperature_component_pa_s=( + amesim_pressure_temperature_component + ), + amesim_pressure_derivative_eos_sum_pa_s=amesim_pressure_eos_sum, + amesim_pressure_derivative_eos_residual_pa_s=( + amesim_pressure_eos_sum - amesim_line_pressure_fd + ), + python_line_pressure_pa=snapshot.inlet_line.p, + python_line_temperature_k=snapshot.inlet_line.T, + python_line_mass_g=line.state.m * 1.0e3, + python_line_pressure_error_pa=snapshot.inlet_line.p - amesim_line_pressure, + python_line_temperature_error_k=( + snapshot.inlet_line.T - amesim_line_temperature + ), + python_line_mass_error_g=line.state.m * 1.0e3 - amesim_line_mass, + python_line_to_node_mass_flow_g_s=python_line_to_node_flow, + python_line_to_node_mass_flow_error_g_s=( + python_line_to_node_flow - amesim_line_to_node_flow + ), + python_orifice_to_line_mass_flow_g_s=python_orifice_to_line_flow, + python_orifice_to_line_mass_flow_error_g_s=( + python_orifice_to_line_flow - amesim_orifice_to_line_flow + ), + python_storage_mass_derivative_g_s=python_storage_derivative, + python_storage_mass_derivative_error_g_s=( + python_storage_derivative - amesim_storage_from_ports + ), + python_line_to_node_amesim_node_mass_flow_g_s=( + python_line_to_node_amesim_node_flow + ), + python_line_to_node_amesim_node_mass_flow_error_g_s=( + python_line_to_node_amesim_node_flow - amesim_line_to_node_flow + ), + python_line_to_node_amesim_line_mass_flow_g_s=( + python_line_to_node_amesim_line_flow + ), + python_line_to_node_amesim_line_mass_flow_error_g_s=( + python_line_to_node_amesim_line_flow - amesim_line_to_node_flow + ), + python_line_to_node_amesim_node_line_mass_flow_g_s=( + python_line_to_node_amesim_node_line_flow + ), + python_line_to_node_amesim_node_line_mass_flow_error_g_s=( + python_line_to_node_amesim_node_line_flow - amesim_line_to_node_flow + ), + python_orifice_to_line_amesim_line_mass_flow_g_s=( + python_orifice_to_line_amesim_line_flow + ), + python_orifice_to_line_amesim_line_mass_flow_error_g_s=( + python_orifice_to_line_amesim_line_flow - amesim_orifice_to_line_flow + ), + python_orifice_to_line_amesim_chamber_mass_flow_g_s=( + python_orifice_to_line_amesim_chamber_flow + ), + python_orifice_to_line_amesim_chamber_mass_flow_error_g_s=( + python_orifice_to_line_amesim_chamber_flow - amesim_orifice_to_line_flow + ), + python_orifice_to_line_amesim_line_chamber_mass_flow_g_s=( + python_orifice_to_line_amesim_line_chamber_flow + ), + python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s=( + python_orifice_to_line_amesim_line_chamber_flow + - amesim_orifice_to_line_flow + ), + python_storage_amesim_boundary_states_mass_derivative_g_s=( + python_storage_amesim_boundary_states + ), + python_storage_amesim_boundary_states_mass_derivative_error_g_s=( + python_storage_amesim_boundary_states - amesim_storage_from_ports + ), + python_chamber_pressure_pa=snapshot.chamber.p, + python_chamber_temperature_k=snapshot.chamber.T, + python_current_temperature_derivative_k_s=python_current_dtemp, + python_port_1_current_dtemp_component_k_s=python_port_1_dtemp, + python_port_2_current_dtemp_component_k_s=python_port_2_dtemp, + python_heat_dtemp_component_k_s=python_heat_dtemp, + python_temperature_derivative_error_k_s=( + python_current_dtemp - amesim_line_temperature_fd + ), + python_pressure_derivative_mass_component_pa_s=( + python_pressure_mass_component + ), + python_pressure_derivative_temperature_component_pa_s=( + python_pressure_temperature_component + ), + python_pressure_derivative_eos_sum_pa_s=python_pressure_eos_sum, + python_pressure_derivative_eos_error_pa_s=( + python_pressure_eos_sum - amesim_line_pressure_fd + ), + python_reference_port_1_enthalpy_flow_w=python_reference_port_1_dh, + python_reference_port_2_enthalpy_flow_w=python_reference_port_2_dh, + python_reference_port_1_dtemp_component_k_s=( + python_reference_port_1_dtemp + ), + python_reference_port_2_dtemp_component_k_s=( + python_reference_port_2_dtemp + ), + python_reference_mass_offset_dtemp_component_k_s=( + python_reference_mass_offset_dtemp + ), + python_reference_temperature_derivative_k_s=python_reference_dtemp, + python_reference_temperature_derivative_error_k_s=( + python_reference_dtemp - amesim_line_temperature_fd + ), + python_reference_pressure_derivative_mass_component_pa_s=( + python_reference_pressure_mass_component + ), + python_reference_pressure_derivative_temperature_component_pa_s=( + python_reference_pressure_temperature_component + ), + python_reference_pressure_derivative_eos_sum_pa_s=( + python_reference_pressure_eos_sum + ), + python_reference_pressure_derivative_eos_error_pa_s=( + python_reference_pressure_eos_sum - amesim_line_pressure_fd + ), + python_reference_amesim_storage_mass_offset_dtemp_component_k_s=( + python_reference_amesim_storage_mass_offset_dtemp + ), + python_reference_amesim_storage_temperature_derivative_k_s=( + python_reference_amesim_storage_dtemp + ), + python_reference_amesim_storage_temperature_derivative_error_k_s=( + python_reference_amesim_storage_dtemp - amesim_line_temperature_fd + ), + python_reference_amesim_storage_pressure_derivative_mass_component_pa_s=( + python_reference_amesim_storage_pressure_mass_component + ), + python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s=( + python_reference_amesim_storage_pressure_temperature_component + ), + python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s=( + python_reference_amesim_storage_pressure_eos_sum + ), + python_reference_amesim_storage_pressure_derivative_eos_error_pa_s=( + python_reference_amesim_storage_pressure_eos_sum - amesim_line_pressure_fd + ), + python_active_temperature_derivative_k_s=python_active_dtemp, + python_active_temperature_derivative_error_k_s=( + python_active_dtemp - amesim_line_temperature_fd + ), + python_active_pressure_derivative_eos_sum_pa_s=( + python_reference_pressure_eos_sum + if use_reference_rhs + else python_pressure_eos_sum + ), + python_active_pressure_derivative_eos_error_pa_s=( + (python_reference_pressure_eos_sum if use_reference_rhs else python_pressure_eos_sum) + - amesim_line_pressure_fd + ), + python_chamber_pressure_error_pa=( + snapshot.chamber.p - amesim_chamber_pressure + ), + python_chamber_temperature_error_k=( + snapshot.chamber.T - amesim_chamber_temperature + ), + ) + + +def _pnch012_energy_equation_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + rhs_diagnostic: TestMqlVariableChamberRhsDiagnostic, + chamber_alias: str, + piston_alias: str, + line_alias: str, + time_s: float, +) -> TestMqlPnch012EnergyEquationDiagnostic: + if chamber_alias != "pn_c1_8" or piston_alias != "pn_brp2_8": + raise KeyError(f"Unsupported PNCH012 energy diagnostic: {chamber_alias}") + if line_alias != "pneumatic_69": + raise KeyError(f"Unsupported PNCH012 line diagnostic: {line_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + chamber = closure.pneumatic_closure.components.p4_port3_remote_primary_chamber + chamber_properties = snapshot.p4_port3_remote_primary_chamber + reference_temperature_k = 298.15 + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_dh1 = amesim_value(f"dh1@{line_alias}") + amesim_dm1_g_s = amesim_value(f"dm1@{line_alias}") + amesim_chamber_temperature = amesim_value(f"temp@{chamber_alias}") + amesim_chamber_pressure_abs = ( + amesim_value(f"press@{chamber_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_mgas_kg = amesim_value(f"mgas1@{chamber_alias}") * 1.0e-3 + amesim_mass_derivative_fd_g_s = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"mgas1@{chamber_alias}"), + time_s=time_s, + ) + amesim_temperature_derivative_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"temp@{chamber_alias}"), + time_s=time_s, + ) + amesim_volume_rate_fd = ( + _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"vol@{chamber_alias}"), + time_s=time_s, + ) + * 1.0e-6 + ) + amesim_piston_volume_rate = amesim_value(f"vvol1@{piston_alias}") * (1.0 / 60000.0) + amesim_heat_flow = ( + chamber.heat_transfer_coefficient + * chamber.heat_transfer_area + * (chamber.external_temperature - amesim_chamber_temperature) + ) + + def reference_pn2vol_dtemp(volume_rate_m3_s: float) -> float: + mass_flow_kg_s = amesim_dm1_g_s * 1.0e-3 + reference_offset_flow_w = ( + chamber.gas.cp * reference_temperature_k + - chamber.gas.cv * amesim_chamber_temperature + ) * mass_flow_kg_s + return ( + amesim_dh1 + + reference_offset_flow_w + + amesim_heat_flow + - amesim_chamber_pressure_abs * volume_rate_m3_s + ) / (amesim_mgas_kg * chamber.gas.cv) + + dtemp_from_fd_volume = reference_pn2vol_dtemp(amesim_volume_rate_fd) + dtemp_from_piston_volume = reference_pn2vol_dtemp(amesim_piston_volume_rate) + python_current_dtemp = ( + rhs_diagnostic.energy_derivative_w + - chamber_properties.u * rhs_diagnostic.mass_derivative_kg_s + ) / (chamber.state.m * chamber.gas.cv) + + return TestMqlPnch012EnergyEquationDiagnostic( + chamber_alias=chamber_alias, + piston_alias=piston_alias, + line_alias=line_alias, + time_s=time_s, + amesim_port_1_enthalpy_flow_w=amesim_dh1, + amesim_port_1_mass_flow_g_s=amesim_dm1_g_s, + amesim_chamber_mass_derivative_fd_g_s=amesim_mass_derivative_fd_g_s, + amesim_chamber_mass_derivative_residual_g_s=( + amesim_dm1_g_s - amesim_mass_derivative_fd_g_s + ), + amesim_chamber_temperature_derivative_fd_k_s=amesim_temperature_derivative_fd, + amesim_chamber_volume_rate_fd_m3_s=amesim_volume_rate_fd, + amesim_piston_volume_rate_m3_s=amesim_piston_volume_rate, + amesim_heat_flow_w=amesim_heat_flow, + amesim_boundary_work_fd_volume_w=( + -amesim_chamber_pressure_abs * amesim_volume_rate_fd + ), + amesim_pn2vol_reference_dtemp_fd_volume_k_s=dtemp_from_fd_volume, + amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s=( + dtemp_from_fd_volume - amesim_temperature_derivative_fd + ), + amesim_pn2vol_reference_dtemp_piston_volume_k_s=dtemp_from_piston_volume, + amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s=( + dtemp_from_piston_volume - amesim_temperature_derivative_fd + ), + python_port_a_mass_flow_kg_s=rhs_diagnostic.port_a_mass_flow_kg_s, + python_current_energy_derivative_w=rhs_diagnostic.energy_derivative_w, + python_current_chamber_temperature_derivative_k_s=python_current_dtemp, + reference_temperature_k=reference_temperature_k, + ) + +def _pnvo_flow_parameter_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + orifice_alias: str, + time_s: float, +) -> TestMqlPnvoFlowParameterDiagnostic: + if orifice_alias != "pn_morifice_1": + raise KeyError(f"Unsupported PNVO flow parameter diagnostic: {orifice_alias}") + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + orifice = closure.pneumatic_closure.components.p4_port3_remote_orifice + if orifice.name != orifice_alias: + raise KeyError(f"Unexpected PNVO diagnostic orifice: {orifice.name}") + line_properties = snapshot.p4_port3_remote_orifice_line_port_2 + boundary_properties = snapshot.p4_port3_remote_primary_line + upstream_properties = ( + line_properties if line_properties.p >= boundary_properties.p else boundary_properties + ) + python_mass_flow_kg_s = snapshot.p4_port3_remote_orifice_to_node_flow + denominator = ( + orifice.flow_coefficient * orifice.effective_area * upstream_properties.p + ) + python_cm = ( + abs(python_mass_flow_kg_s) * sqrt(upstream_properties.T) / denominator + if denominator > 0.0 and upstream_properties.T > 0.0 + else 0.0 + ) + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_cm = amesim_value(f"cm@{orifice_alias}") + cm_ratio = python_cm / amesim_cm if amesim_cm != 0.0 else 0.0 + return TestMqlPnvoFlowParameterDiagnostic( + orifice_alias=orifice_alias, + time_s=time_s, + amesim_cm=amesim_cm, + amesim_dm2_g_s=amesim_value(f"dm2@{orifice_alias}"), + amesim_opening=amesim_value(f"xv@{orifice_alias}"), + amesim_gas_velocity_m_s=amesim_value(f"gasvel@{orifice_alias}"), + python_opening=orifice.opening, + python_flow_coefficient=orifice.flow_coefficient, + python_effective_area_m2=orifice.effective_area, + python_line_pressure_pa=line_properties.p, + python_boundary_pressure_pa=boundary_properties.p, + python_upstream_pressure_pa=upstream_properties.p, + python_upstream_temperature_k=upstream_properties.T, + python_mass_flow_kg_s=python_mass_flow_kg_s, + python_cm=python_cm, + python_to_amesim_cm_ratio=cm_ratio, + ) + + +def run_test_mql_pnvo_event_window_diagnostic( + config: TestMqlFullStateComparisonScriptConfig | None = None, + *, + orifice_alias: str = "pn_morifice_1", + final_time_s: float = 0.05, +) -> TestMqlPnvoEventWindowDiagnostic: + config = config or TestMqlFullStateComparisonScriptConfig() + system = TestMqlSystem(archive_path=config.paths.archive_path) + control = system.pneumatic_assembly.variable_orifice_controls[orifice_alias] + event_time_s = control.step.step_time_s + if final_time_s <= event_time_s: + raise ValueError("final_time_s must be greater than the PNVO event time") + spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + closure_kwargs = { + "inlet_node_pressure_pa": config.execution.inlet_node_pressure_pa, + "resistance_boundary_pressure_pa": ( + config.execution.resistance_boundary_pressure_pa + ), + "inlet_node_temperature_k": config.execution.inlet_node_temperature_k, + "resistance_boundary_temperature_k": ( + config.execution.resistance_boundary_temperature_k + ), + "use_pneumatic_96_reference_rhs": ( + config.execution.use_pneumatic_96_reference_rhs + ), + "use_pneumatic_69_reference_rhs": ( + config.execution.use_pneumatic_69_reference_rhs + ), + } + closure = system.full_state_closure_from_spec(spec, **closure_kwargs) + state_vector = closure.initial_state_vector() + solver_template = config.execution.solver + segment_templates = ( + SolveIVPConfig( + t_start=solver_template.t_start, + t_stop=nextafter(event_time_s, 0.0), + method=solver_template.method, + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=solver_template.max_step, + ), + SolveIVPConfig( + t_start=event_time_s, + t_stop=0.040001, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-7, + first_step=1.0e-10, + ), + SolveIVPConfig( + t_start=0.040001, + t_stop=0.04001, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-6, + ), + SolveIVPConfig( + t_start=0.04001, + t_stop=0.0401, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-5, + ), + SolveIVPConfig( + t_start=0.0401, + t_stop=0.041, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-5, + ), + SolveIVPConfig( + t_start=0.041, + t_stop=0.048, + method="BDF", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-5, + ), + SolveIVPConfig( + t_start=0.048, + t_stop=0.05, + method="BDF", + rtol=1.0e-5, + atol=1.0e-8, + max_step=1.0e-5, + ), + ) + segments = tuple( + replace(segment, t_stop=min(segment.t_stop, final_time_s)) + for segment in segment_templates + if segment.t_start < final_time_s + ) + data_paths = ( + "press@pn_c1_8", + "temp@pn_c1_8", + "vol@pn_c1_8", + "mgas1@pn_c1_8", + "p2@pneumatic_69", + "t2@pneumatic_69", + "mgas@pneumatic_69", + "re@pneumatic_69", + "v@pneumatic_69", + "ff@pneumatic_69", + "dm1@pneumatic_69", + f"xv@{orifice_alias}", + f"dm2@{orifice_alias}", + ) + sample_times = tuple( + sorted( + { + sample_time + for sample_time in ( + nextafter(event_time_s, 0.0), + 0.0401, + 0.0402, + 0.041, + 0.042, + 0.045, + 0.048, + final_time_s, + ) + if sample_time <= final_time_s + } + ) + ) + state_vector_by_sample_time: dict[float, list[float]] = {} + segment_diagnostics: list[TestMqlPnvoEventWindowSegmentDiagnostic] = [] + for segment in segments: + rhs_evaluations = 0 + segment_sample_times = tuple( + sample_time + for sample_time in sample_times + if segment.t_start <= sample_time <= segment.t_stop + ) + t_eval = tuple( + sorted({segment.t_start, segment.t_stop, *segment_sample_times}) + ) + + def rhs(time_s, values): + nonlocal rhs_evaluations + rhs_evaluations += 1 + return closure.rhs_at(time_s, values) + + solution = integrate_ode( + rhs=rhs, + initial_state=state_vector, + config=segment, + t_eval=list(t_eval), + ) + segment_diagnostics.append( + TestMqlPnvoEventWindowSegmentDiagnostic( + t_start=segment.t_start, + t_stop=segment.t_stop, + method=segment.method, + rtol=segment.rtol, + atol=segment.atol, + max_step=segment.max_step, + rhs_evaluations=rhs_evaluations, + success=bool(solution.success), + message=str(solution.message), + ) + ) + for sample_time in segment_sample_times: + if sample_time in solution.t: + sample_index = list(solution.t).index(sample_time) + state_vector_by_sample_time[sample_time] = [ + row[sample_index] for row in solution.y + ] + state_vector = [row[-1] for row in solution.y] + if not solution.success: + break + amesim_results = load_test_mql_amesim_results( + config.paths.resolved_amesim_results_archive_path, + time_stop_s=final_time_s, + ) + sample_diagnostics = [] + for sample_time in sample_times: + if sample_time not in state_vector_by_sample_time: + continue + python_sample_values = closure.data_path_values( + time_s=sample_time, + state_vector=state_vector_by_sample_time[sample_time], + data_paths=data_paths, + ) + amesim_sample_values = { + data_path: interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + sample_time, + ) + for data_path in data_paths + } + pnl0001_rhs_diagnostics = ( + closure.pnl0001_line_rhs_diagnostic( + line_alias="pneumatic_69", + state_vector=state_vector_by_sample_time[sample_time], + time_s=sample_time, + ), + ) + pnl0001_pressure_loss_diagnostics = ( + _pnl0001_pressure_loss_calibration_diagnostic( + closure=closure, + amesim_results=amesim_results, + python_values_by_data_path=python_sample_values, + line_alias="pneumatic_69", + chamber_alias="pn_c1_8", + time_s=sample_time, + ), + ) + pnvo_flow_parameter_diagnostics = ( + _pnvo_flow_parameter_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + orifice_alias=orifice_alias, + time_s=sample_time, + ), + ) + pnl0001_energy_flow_diagnostics = ( + _pnl0001_energy_flow_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + rhs_diagnostic=pnl0001_rhs_diagnostics[0], + line_alias="pneumatic_69", + chamber_alias="pn_c1_8", + node_alias="pnnode4_16", + time_s=sample_time, + ), + ) + p4_node_enthalpy_breakdown_diagnostics = ( + _p4_node_enthalpy_breakdown_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + node_alias="pnnode4_16", + time_s=sample_time, + ), + ) + pnvo_upstream_enthalpy_diagnostics = ( + _pnvo_upstream_enthalpy_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + orifice_alias=orifice_alias, + upstream_line_alias="pneumatic_87", + downstream_node_alias="pnnode4_16", + time_s=sample_time, + ), + ) + pnl0003_energy_diagnostics = ( + _pnl0003_energy_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + line_alias="pneumatic_87", + time_s=sample_time, + ), + ) + pneumatic_96_inlet_line_diagnostics = ( + _pneumatic_96_inlet_line_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + line_alias="pneumatic_96", + node_alias="pn_node3_8", + orifice_alias="pn_orifice_18", + chamber_alias="pn_general_chamber", + time_s=sample_time, + ), + ) + pnch012_rhs_diagnostics = ( + closure.variable_chamber_rhs_diagnostic( + chamber_alias="pn_c1_8", + state_vector=state_vector_by_sample_time[sample_time], + time_s=sample_time, + ), + ) + pnch012_energy_equation_diagnostics = ( + _pnch012_energy_equation_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + rhs_diagnostic=pnch012_rhs_diagnostics[0], + chamber_alias="pn_c1_8", + piston_alias="pn_brp2_8", + line_alias="pneumatic_69", + time_s=sample_time, + ), + ) + ( + amesim_sample_left_time_s, + amesim_sample_right_time_s, + ) = _series_sample_bracket(times=amesim_results.times, time_s=sample_time) + sample_diagnostics.append( + TestMqlPnvoEventWindowSampleDiagnostic( + time_s=sample_time, + data_paths=data_paths, + python_values_by_data_path=python_sample_values, + amesim_values_by_data_path=amesim_sample_values, + amesim_sample_left_time_s=amesim_sample_left_time_s, + amesim_sample_right_time_s=amesim_sample_right_time_s, + pnl0001_rhs_diagnostics=pnl0001_rhs_diagnostics, + pnl0001_pressure_loss_diagnostics=pnl0001_pressure_loss_diagnostics, + pnvo_flow_parameter_diagnostics=pnvo_flow_parameter_diagnostics, + pnl0001_energy_flow_diagnostics=pnl0001_energy_flow_diagnostics, + p4_node_enthalpy_breakdown_diagnostics=( + p4_node_enthalpy_breakdown_diagnostics + ), + pnvo_upstream_enthalpy_diagnostics=( + pnvo_upstream_enthalpy_diagnostics + ), + pnl0003_energy_diagnostics=pnl0003_energy_diagnostics, + pneumatic_96_inlet_line_diagnostics=( + pneumatic_96_inlet_line_diagnostics + ), + pnch012_energy_equation_diagnostics=( + pnch012_energy_equation_diagnostics + ), + ) + ) + python_values = closure.data_path_values( + time_s=final_time_s, + state_vector=state_vector, + data_paths=data_paths, + ) + amesim_values = { + data_path: interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + final_time_s, + ) + for data_path in data_paths + } + return TestMqlPnvoEventWindowDiagnostic( + orifice_alias=orifice_alias, + event_time_s=event_time_s, + final_time_s=final_time_s, + data_paths=data_paths, + segment_diagnostics=tuple(segment_diagnostics), + sample_diagnostics=tuple(sample_diagnostics), + python_values_by_data_path=python_values, + amesim_values_by_data_path=amesim_values, + ) + + + +def _event_window_config_with_pneumatic_96_reference_rhs( + config: TestMqlFullStateComparisonScriptConfig | None, + *, + enabled: bool, +) -> TestMqlFullStateComparisonScriptConfig: + base = config or TestMqlFullStateComparisonScriptConfig() + return replace( + base, + execution=replace( + base.execution, + use_pneumatic_96_reference_rhs=enabled, + ), + ) + + +def _pnvo_event_window_sample_by_time( + diagnostic: TestMqlPnvoEventWindowDiagnostic, +) -> dict[float, TestMqlPnvoEventWindowSampleDiagnostic]: + return {sample.time_s: sample for sample in diagnostic.sample_diagnostics} + + +def _append_metric_comparison( + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], + *, + time_s: float, + metric_name: str, + default_abs_error: float, + candidate_abs_error: float, +) -> None: + comparisons.append( + TestMqlPnvoEventWindowCandidateMetricComparison( + time_s=time_s, + metric_name=metric_name, + default_abs_error=default_abs_error, + candidate_abs_error=candidate_abs_error, + ) + ) + + +def _append_pneumatic_96_candidate_metric_comparisons( + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], + *, + time_s: float, + default_sample: TestMqlPnvoEventWindowSampleDiagnostic, + candidate_sample: TestMqlPnvoEventWindowSampleDiagnostic, +) -> None: + if ( + not default_sample.pneumatic_96_inlet_line_diagnostics + or not candidate_sample.pneumatic_96_inlet_line_diagnostics + ): + return + default_line = default_sample.pneumatic_96_inlet_line_diagnostics[0] + candidate_line = candidate_sample.pneumatic_96_inlet_line_diagnostics[0] + for metric_name, default_value, candidate_value in ( + ( + "p96:p2_abs_error_pa", + default_line.python_line_pressure_error_pa, + candidate_line.python_line_pressure_error_pa, + ), + ( + "p96:t2_abs_error_k", + default_line.python_line_temperature_error_k, + candidate_line.python_line_temperature_error_k, + ), + ( + "p96:storage_dm_abs_error_g_s", + default_line.python_storage_mass_derivative_error_g_s, + candidate_line.python_storage_mass_derivative_error_g_s, + ), + ( + "p96:active_dT_abs_error_k_s", + default_line.python_active_temperature_derivative_error_k_s, + candidate_line.python_active_temperature_derivative_error_k_s, + ), + ( + "p96:active_dP_abs_error_pa_s", + default_line.python_active_pressure_derivative_eos_error_pa_s, + candidate_line.python_active_pressure_derivative_eos_error_pa_s, + ), + ): + _append_metric_comparison( + comparisons, + time_s=time_s, + metric_name=metric_name, + default_abs_error=abs(default_value), + candidate_abs_error=abs(candidate_value), + ) + + +def _append_pneumatic_87_candidate_metric_comparisons( + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], + *, + time_s: float, + default_sample: TestMqlPnvoEventWindowSampleDiagnostic, + candidate_sample: TestMqlPnvoEventWindowSampleDiagnostic, +) -> None: + if ( + not default_sample.pnl0003_energy_diagnostics + or not candidate_sample.pnl0003_energy_diagnostics + ): + return + default_line = default_sample.pnl0003_energy_diagnostics[0] + candidate_line = candidate_sample.pnl0003_energy_diagnostics[0] + for metric_name, default_value, candidate_value in ( + ( + "p87:node_to_line_dm_abs_error_g_s", + default_line.python_pn3_node_to_line_mass_flow_error_g_s, + candidate_line.python_pn3_node_to_line_mass_flow_error_g_s, + ), + ( + "p87:center_dm_abs_error_g_s", + default_line.amesim_center_flow_python_sign_error_g_s, + candidate_line.amesim_center_flow_python_sign_error_g_s, + ), + ( + "p87:sdm1_abs_error_g_s", + default_line.python_port_1_storage_mass_derivative_error_g_s, + candidate_line.python_port_1_storage_mass_derivative_error_g_s, + ), + ( + "p87:sdm2_abs_error_g_s", + default_line.python_port_2_storage_mass_derivative_error_g_s, + candidate_line.python_port_2_storage_mass_derivative_error_g_s, + ), + ( + "p87:t2_abs_error_k", + default_line.python_port_2_temperature_error_to_amesim_k, + candidate_line.python_port_2_temperature_error_to_amesim_k, + ), + ): + _append_metric_comparison( + comparisons, + time_s=time_s, + metric_name=metric_name, + default_abs_error=abs(default_value), + candidate_abs_error=abs(candidate_value), + ) + + +def _pnvo_event_window_candidate_metric_comparisons( + default_diagnostic: TestMqlPnvoEventWindowDiagnostic, + candidate_diagnostic: TestMqlPnvoEventWindowDiagnostic, +) -> tuple[TestMqlPnvoEventWindowCandidateMetricComparison, ...]: + candidate_samples = _pnvo_event_window_sample_by_time(candidate_diagnostic) + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison] = [] + for default_sample in default_diagnostic.sample_diagnostics: + candidate_sample = candidate_samples.get(default_sample.time_s) + if candidate_sample is None: + continue + for data_path in default_sample.data_paths: + _append_metric_comparison( + comparisons, + time_s=default_sample.time_s, + metric_name=f"path:{data_path}", + default_abs_error=default_sample.abs_error(data_path), + candidate_abs_error=candidate_sample.abs_error(data_path), + ) + _append_pneumatic_96_candidate_metric_comparisons( + comparisons, + time_s=default_sample.time_s, + default_sample=default_sample, + candidate_sample=candidate_sample, + ) + _append_pneumatic_87_candidate_metric_comparisons( + comparisons, + time_s=default_sample.time_s, + default_sample=default_sample, + candidate_sample=candidate_sample, + ) + return tuple(comparisons) + + +def run_test_mql_pnvo_event_window_candidate_comparison( + config: TestMqlFullStateComparisonScriptConfig | None = None, + *, + orifice_alias: str = "pn_morifice_1", + final_time_s: float = 0.041, +) -> TestMqlPnvoEventWindowCandidateComparisonDiagnostic: + default_diagnostic = run_test_mql_pnvo_event_window_diagnostic( + _event_window_config_with_pneumatic_96_reference_rhs( + config, + enabled=False, + ), + orifice_alias=orifice_alias, + final_time_s=final_time_s, + ) + candidate_diagnostic = run_test_mql_pnvo_event_window_diagnostic( + _event_window_config_with_pneumatic_96_reference_rhs( + config, + enabled=True, + ), + orifice_alias=orifice_alias, + final_time_s=final_time_s, + ) + return TestMqlPnvoEventWindowCandidateComparisonDiagnostic( + orifice_alias=orifice_alias, + event_time_s=default_diagnostic.event_time_s, + final_time_s=default_diagnostic.final_time_s, + default_diagnostic=default_diagnostic, + candidate_diagnostic=candidate_diagnostic, + metric_comparisons=_pnvo_event_window_candidate_metric_comparisons( + default_diagnostic, + candidate_diagnostic, + ), + ) + + +def format_test_mql_pnvo_event_window_summary( + diagnostic: TestMqlPnvoEventWindowDiagnostic, +) -> str: + lines = [ + "Model: test_mql", + f"Mode: PNVO event window diagnostic ({diagnostic.orifice_alias})", + f"Event time: {diagnostic.event_time_s}", + f"Final time: {diagnostic.final_time_s}", + "Segments:", + ] + for segment in diagnostic.segment_diagnostics: + lines.append( + f" - {segment.t_start} -> {segment.t_stop}: " + f"method={segment.method}, rtol={segment.rtol}, " + f"atol={segment.atol}, max_step={segment.max_step}, " + f"rhs={segment.rhs_evaluations}, success={segment.success}" + ) + if diagnostic.sample_diagnostics: + lines.append("Sample comparisons:") + for sample in diagnostic.sample_diagnostics: + if sample.amesim_values_are_interpolated: + amesim_sample_source = ( + "linear-interpolation@" + f"{sample.amesim_sample_left_time_s}" + f"..{sample.amesim_sample_right_time_s}" + ) + else: + amesim_sample_source = f"exact@{sample.amesim_sample_left_time_s}" + lines.append( + f" t={sample.time_s} (amesim={amesim_sample_source})" + ) + for data_path in sample.data_paths: + lines.append( + f" - {data_path}: " + f"python={sample.python_values_by_data_path[data_path]}, " + f"amesim={sample.amesim_values_by_data_path[data_path]}, " + f"abs_error={sample.abs_error(data_path)}" + ) + for rhs in sample.pnl0001_rhs_diagnostics: + lines.append( + f" - rhs@{rhs.line_alias}: " + f"chamber_to_line={rhs.chamber_to_line_flow_kg_s}, " + f"node_to_line={rhs.node_to_line_flow_kg_s}, " + f"dm_dt={rhs.mass_derivative_kg_s}, " + f"dU_dt={rhs.energy_derivative_w}" + ) + for pressure_loss in sample.pnl0001_pressure_loss_diagnostics: + lines.append( + f" - pressure_loss@{pressure_loss.line_alias}: " + f"cm={pressure_loss.amesim_cm}, " + f"amesim_dm1={pressure_loss.amesim_dm1_g_s}, " + f"amesim_dp={pressure_loss.amesim_pressure_drop_pa}, " + f"darcy_dp={pressure_loss.current_darcy_pressure_drop_pa}, " + f"dp_multiplier={pressure_loss.pressure_drop_multiplier}, " + f"candidate_pn2pipefr_dm1=" + f"{pressure_loss.candidate_pn2pipefr_dm1_g_s}, " + f"candidate_pn2pipefr_ratio=" + f"{pressure_loss.candidate_pn2pipefr_to_amesim_dm1_ratio}, " + f"amesim_linear_k=" + f"{pressure_loss.amesim_linear_conductance_kg_s_sqrt_k_per_pa}, " + f"python_linear_k=" + f"{pressure_loss.python_linear_conductance_kg_s_sqrt_k_per_pa}, " + f"linear_k_ratio=" + f"{pressure_loss.python_to_amesim_linear_conductance_ratio}" + ) + for flow_parameter in sample.pnvo_flow_parameter_diagnostics: + lines.append( + f" - flow_parameter@{flow_parameter.orifice_alias}: " + f"amesim_cm={flow_parameter.amesim_cm}, " + f"python_cm={flow_parameter.python_cm}, " + f"cm_ratio={flow_parameter.python_to_amesim_cm_ratio}, " + f"amesim_dm2={flow_parameter.amesim_dm2_g_s}, " + f"python_m={flow_parameter.python_mass_flow_kg_s}, " + f"opening={flow_parameter.python_opening}, " + f"effective_area={flow_parameter.python_effective_area_m2}, " + f"upstream_p={flow_parameter.python_upstream_pressure_pa}, " + f"upstream_t={flow_parameter.python_upstream_temperature_k}, " + f"amesim_gasvel={flow_parameter.amesim_gas_velocity_m_s}" + ) + for pnvo_enthalpy in sample.pnvo_upstream_enthalpy_diagnostics: + lines.append( + f" - pnvo_upstream_enthalpy@{pnvo_enthalpy.orifice_alias}: " + f"amesim_dh2=" + f"{pnvo_enthalpy.amesim_orifice_port_2_enthalpy_flow_w}, " + f"amesim_dm2=" + f"{pnvo_enthalpy.amesim_orifice_port_2_mass_flow_g_s}, " + f"amesim_dh3=" + f"{pnvo_enthalpy.amesim_orifice_port_3_enthalpy_flow_w}, " + f"amesim_dm3=" + f"{pnvo_enthalpy.amesim_orifice_port_3_mass_flow_g_s}, " + f"amesim_line_dhctr=" + f"{pnvo_enthalpy.amesim_upstream_line_center_enthalpy_flow_w}, " + f"amesim_line_dmctr=" + f"{pnvo_enthalpy.amesim_upstream_line_center_mass_flow_g_s}, " + f"amesim_line_t2=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_temperature_k}, " + f"amesim_line_p2_abs=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_pressure_pa}, " + f"amesim_implied_t2=" + f"{pnvo_enthalpy.amesim_orifice_port_2_implied_reference_temperature_k}, " + f"amesim_implied_t2_delta=" + f"{pnvo_enthalpy.amesim_orifice_port_2_implied_temperature_delta_to_line_k}, " + f"amesim_ref_dh2=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_reference_enthalpy_flow_w}, " + f"amesim_ref_dh2_error=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_reference_enthalpy_error_w}, " + f"amesim_real_ref_dh2=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w}, " + f"amesim_real_ref_dh2_error=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w}, " + f"python_flow=" + f"{pnvo_enthalpy.python_orifice_to_node_flow_kg_s}, " + f"python_line_t2=" + f"{pnvo_enthalpy.python_upstream_line_port_2_temperature_k}, " + f"python_line_p2_abs=" + f"{pnvo_enthalpy.python_upstream_line_port_2_pressure_pa}, " + f"python_current_dh2=" + f"{pnvo_enthalpy.python_current_port_2_enthalpy_flow_w}, " + f"python_ref_dh2=" + f"{pnvo_enthalpy.python_reference_port_2_enthalpy_flow_w}, " + f"python_real_ref_dh2=" + f"{pnvo_enthalpy.python_real_gas_reference_port_2_enthalpy_flow_w}, " + f"python_current_dh2_error=" + f"{pnvo_enthalpy.python_current_port_2_enthalpy_error_w}, " + f"python_ref_dh2_error=" + f"{pnvo_enthalpy.python_reference_port_2_enthalpy_error_w}, " + f"python_real_ref_dh2_error=" + f"{pnvo_enthalpy.python_real_gas_reference_port_2_enthalpy_error_w}" + ) + for inlet_line in sample.pneumatic_96_inlet_line_diagnostics: + lines.append( + f" - inlet_line@{inlet_line.line_alias}: " + f"node={inlet_line.node_alias}, " + f"orifice={inlet_line.orifice_alias}, " + f"chamber={inlet_line.chamber_alias}, " + f"amesim_p2_abs={inlet_line.amesim_line_pressure_pa}, " + f"amesim_t2={inlet_line.amesim_line_temperature_k}, " + f"amesim_mgas={inlet_line.amesim_line_mass_g}, " + f"amesim_fd_dmgas={inlet_line.amesim_line_mass_derivative_fd_g_s}, " + f"amesim_line_to_node_dm1={inlet_line.amesim_line_to_node_mass_flow_g_s}, " + f"amesim_orifice_to_line_dm2={inlet_line.amesim_orifice_to_line_mass_flow_g_s}, " + f"amesim_storage_dm_ports={inlet_line.amesim_storage_mass_derivative_from_ports_g_s}, " + f"amesim_storage_dm_residual={inlet_line.amesim_storage_mass_derivative_residual_g_s}, " + f"amesim_chamber_p_abs={inlet_line.amesim_chamber_pressure_pa}, " + f"amesim_chamber_t={inlet_line.amesim_chamber_temperature_k}, " + f"python_p2_abs={inlet_line.python_line_pressure_pa}, " + f"python_t2={inlet_line.python_line_temperature_k}, " + f"python_mgas={inlet_line.python_line_mass_g}, " + f"python_p2_error={inlet_line.python_line_pressure_error_pa}, " + f"python_t2_error={inlet_line.python_line_temperature_error_k}, " + f"python_mgas_error={inlet_line.python_line_mass_error_g}, " + f"python_line_to_node_dm={inlet_line.python_line_to_node_mass_flow_g_s}, " + f"python_line_to_node_dm_error={inlet_line.python_line_to_node_mass_flow_error_g_s}, " + f"python_orifice_to_line_dm={inlet_line.python_orifice_to_line_mass_flow_g_s}, " + f"python_orifice_to_line_dm_error={inlet_line.python_orifice_to_line_mass_flow_error_g_s}, " + f"python_storage_dm={inlet_line.python_storage_mass_derivative_g_s}, " + f"python_storage_dm_error={inlet_line.python_storage_mass_derivative_error_g_s}, " + f"amesim_node_p_abs={inlet_line.amesim_node_pressure_pa}, " + f"amesim_node_t={inlet_line.amesim_node_temperature_k}, " + f"python_line_to_node_amesim_node_dm={inlet_line.python_line_to_node_amesim_node_mass_flow_g_s}, " + f"python_line_to_node_amesim_node_dm_error={inlet_line.python_line_to_node_amesim_node_mass_flow_error_g_s}, " + f"python_line_to_node_amesim_line_dm={inlet_line.python_line_to_node_amesim_line_mass_flow_g_s}, " + f"python_line_to_node_amesim_line_dm_error={inlet_line.python_line_to_node_amesim_line_mass_flow_error_g_s}, " + f"python_line_to_node_amesim_node_line_dm={inlet_line.python_line_to_node_amesim_node_line_mass_flow_g_s}, " + f"python_line_to_node_amesim_node_line_dm_error={inlet_line.python_line_to_node_amesim_node_line_mass_flow_error_g_s}, " + f"python_orifice_to_line_amesim_line_dm={inlet_line.python_orifice_to_line_amesim_line_mass_flow_g_s}, " + f"python_orifice_to_line_amesim_line_dm_error={inlet_line.python_orifice_to_line_amesim_line_mass_flow_error_g_s}, " + f"python_orifice_to_line_amesim_chamber_dm={inlet_line.python_orifice_to_line_amesim_chamber_mass_flow_g_s}, " + f"python_orifice_to_line_amesim_chamber_dm_error={inlet_line.python_orifice_to_line_amesim_chamber_mass_flow_error_g_s}, " + f"python_orifice_to_line_amesim_line_chamber_dm={inlet_line.python_orifice_to_line_amesim_line_chamber_mass_flow_g_s}, " + f"python_orifice_to_line_amesim_line_chamber_dm_error={inlet_line.python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s}, " + f"python_storage_amesim_boundary_states_dm={inlet_line.python_storage_amesim_boundary_states_mass_derivative_g_s}, " + f"python_storage_amesim_boundary_states_dm_error={inlet_line.python_storage_amesim_boundary_states_mass_derivative_error_g_s}, " + f"python_chamber_p_abs={inlet_line.python_chamber_pressure_pa}, " + f"python_chamber_t={inlet_line.python_chamber_temperature_k}, " + f"amesim_fd_dT={inlet_line.amesim_line_temperature_derivative_fd_k_s}, " + f"amesim_dT_p1_h={inlet_line.amesim_port_1_enthalpy_dtemp_component_k_s}, " + f"amesim_dT_p2_h={inlet_line.amesim_port_2_enthalpy_dtemp_component_k_s}, " + f"amesim_dT_m_offset={inlet_line.amesim_mass_offset_dtemp_component_k_s}, " + f"amesim_ref_dT_sum={inlet_line.amesim_reference_temperature_derivative_sum_k_s}, " + f"amesim_ref_dT_residual={inlet_line.amesim_reference_temperature_derivative_residual_k_s}, " + f"python_current_dT={inlet_line.python_current_temperature_derivative_k_s}, " + f"python_dT_p1={inlet_line.python_port_1_current_dtemp_component_k_s}, " + f"python_dT_p2={inlet_line.python_port_2_current_dtemp_component_k_s}, " + f"python_dT_heat={inlet_line.python_heat_dtemp_component_k_s}, " + f"python_dT_error={inlet_line.python_temperature_derivative_error_k_s}, " + f"amesim_fd_dP={inlet_line.amesim_line_pressure_derivative_fd_pa_s}, " + f"amesim_dP_m={inlet_line.amesim_pressure_derivative_mass_component_pa_s}, " + f"amesim_dP_T={inlet_line.amesim_pressure_derivative_temperature_component_pa_s}, " + f"amesim_dP_sum={inlet_line.amesim_pressure_derivative_eos_sum_pa_s}, " + f"amesim_dP_residual={inlet_line.amesim_pressure_derivative_eos_residual_pa_s}, " + f"python_dP_m={inlet_line.python_pressure_derivative_mass_component_pa_s}, " + f"python_dP_T={inlet_line.python_pressure_derivative_temperature_component_pa_s}, " + f"python_dP_sum={inlet_line.python_pressure_derivative_eos_sum_pa_s}, " + f"python_dP_error={inlet_line.python_pressure_derivative_eos_error_pa_s}, " + f"python_ref_dh1={inlet_line.python_reference_port_1_enthalpy_flow_w}, " + f"python_ref_dh2={inlet_line.python_reference_port_2_enthalpy_flow_w}, " + f"python_ref_dT_p1={inlet_line.python_reference_port_1_dtemp_component_k_s}, " + f"python_ref_dT_p2={inlet_line.python_reference_port_2_dtemp_component_k_s}, " + f"python_ref_dT_m_offset={inlet_line.python_reference_mass_offset_dtemp_component_k_s}, " + f"python_ref_dT={inlet_line.python_reference_temperature_derivative_k_s}, " + f"python_ref_dT_error={inlet_line.python_reference_temperature_derivative_error_k_s}, " + f"python_ref_dP_m={inlet_line.python_reference_pressure_derivative_mass_component_pa_s}, " + f"python_ref_dP_T={inlet_line.python_reference_pressure_derivative_temperature_component_pa_s}, " + f"python_ref_dP_sum={inlet_line.python_reference_pressure_derivative_eos_sum_pa_s}, " + f"python_ref_dP_error={inlet_line.python_reference_pressure_derivative_eos_error_pa_s}, " + f"python_ref_amesim_storage_dT_m_offset={inlet_line.python_reference_amesim_storage_mass_offset_dtemp_component_k_s}, " + f"python_ref_amesim_storage_dT={inlet_line.python_reference_amesim_storage_temperature_derivative_k_s}, " + f"python_ref_amesim_storage_dT_error={inlet_line.python_reference_amesim_storage_temperature_derivative_error_k_s}, " + f"python_ref_amesim_storage_dP_m={inlet_line.python_reference_amesim_storage_pressure_derivative_mass_component_pa_s}, " + f"python_ref_amesim_storage_dP_T={inlet_line.python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s}, " + f"python_ref_amesim_storage_dP_sum={inlet_line.python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s}, " + f"python_ref_amesim_storage_dP_error={inlet_line.python_reference_amesim_storage_pressure_derivative_eos_error_pa_s}, " + f"python_active_dT={inlet_line.python_active_temperature_derivative_k_s}, " + f"python_active_dT_error={inlet_line.python_active_temperature_derivative_error_k_s}, " + f"python_active_dP_sum={inlet_line.python_active_pressure_derivative_eos_sum_pa_s}, " + f"python_active_dP_error={inlet_line.python_active_pressure_derivative_eos_error_pa_s}, " + f"python_chamber_p_error={inlet_line.python_chamber_pressure_error_pa}, " + f"python_chamber_t_error={inlet_line.python_chamber_temperature_error_k}" + ) + for pnl0003_energy in sample.pnl0003_energy_diagnostics: + lines.append( + f" - pnl0003_energy@{pnl0003_energy.line_alias}: " + f"amesim_t1={pnl0003_energy.amesim_port_1_temperature_k}, " + f"amesim_t2={pnl0003_energy.amesim_port_2_temperature_k}, " + f"amesim_p1_abs={pnl0003_energy.amesim_port_1_pressure_pa}, " + f"amesim_p2_abs={pnl0003_energy.amesim_port_2_pressure_pa}, " + f"amesim_dm1={pnl0003_energy.amesim_port_1_mass_flow_g_s}, " + f"amesim_dh1={pnl0003_energy.amesim_port_1_enthalpy_flow_w}, " + f"amesim_dmctr={pnl0003_energy.amesim_center_mass_flow_g_s}, " + f"amesim_dhctr={pnl0003_energy.amesim_center_enthalpy_flow_w}, " + f"amesim_dm2={pnl0003_energy.amesim_port_2_mass_flow_g_s}, " + f"amesim_dh2={pnl0003_energy.amesim_port_2_enthalpy_flow_w}, " + f"amesim_sdm1={pnl0003_energy.amesim_port_1_storage_mass_derivative_g_s}, " + f"amesim_sdh1={pnl0003_energy.amesim_port_1_storage_enthalpy_sum_w}, " + f"amesim_sdm2={pnl0003_energy.amesim_port_2_storage_mass_derivative_g_s}, " + f"amesim_sdh2={pnl0003_energy.amesim_port_2_storage_enthalpy_sum_w}, " + f"python_node_dm1={pnl0003_energy.python_pn3_node_port_1_mass_flow_g_s}, " + f"python_node_dm3={pnl0003_energy.python_pn3_node_port_3_mass_flow_g_s}, " + f"python_node_to_line_dm={pnl0003_energy.python_pn3_node_to_line_mass_flow_g_s}, " + f"python_node_to_line_dm_error={pnl0003_energy.python_pn3_node_to_line_mass_flow_error_g_s}, " + f"amesim_m1={pnl0003_energy.amesim_port_1_mass_estimate_g}, " + f"amesim_m2={pnl0003_energy.amesim_port_2_mass_estimate_g}, " + f"python_m1={pnl0003_energy.python_port_1_mass_g}, " + f"python_m2={pnl0003_energy.python_port_2_mass_g}, " + f"python_m1_error={pnl0003_energy.python_port_1_mass_error_to_amesim_g}, " + f"python_m2_error={pnl0003_energy.python_port_2_mass_error_to_amesim_g}, " + f"amesim_fd_dmgas={pnl0003_energy.amesim_total_mass_derivative_fd_g_s}, " + f"amesim_back_dmgas={pnl0003_energy.amesim_total_mass_derivative_backward_g_s}, " + f"amesim_fwd_dmgas={pnl0003_energy.amesim_total_mass_derivative_forward_g_s}, " + f"amesim_storage_dmgas={pnl0003_energy.amesim_total_storage_mass_derivative_g_s}, " + f"amesim_storage_dmgas_residual={pnl0003_energy.amesim_total_storage_mass_derivative_residual_g_s}, " + f"amesim_dmctr_py_sign={pnl0003_energy.amesim_center_flow_python_sign_equivalent_g_s}, " + f"python_center_dm={pnl0003_energy.python_center_mass_flow_g_s}, " + f"python_center_dm_error={pnl0003_energy.amesim_center_flow_python_sign_error_g_s}, " + f"amesim_sdm1_center={pnl0003_energy.amesim_port_1_center_mass_derivative_g_s}, " + f"amesim_sdm2_center={pnl0003_energy.amesim_port_2_center_mass_derivative_g_s}, " + f"python_sdm1_ext={pnl0003_energy.python_port_1_external_mass_flow_g_s}, " + f"python_sdm1_ext_error={pnl0003_energy.python_port_1_external_mass_flow_error_g_s}, " + f"python_sdm1_center={pnl0003_energy.python_port_1_center_mass_derivative_g_s}, " + f"python_sdm1_center_error={pnl0003_energy.python_port_1_center_mass_derivative_error_g_s}, " + f"python_sdm1={pnl0003_energy.python_port_1_storage_mass_derivative_g_s}, " + f"python_sdm1_error={pnl0003_energy.python_port_1_storage_mass_derivative_error_g_s}, " + f"python_sdm2_ext={pnl0003_energy.python_port_2_external_mass_flow_g_s}, " + f"python_sdm2_ext_error={pnl0003_energy.python_port_2_external_mass_flow_error_g_s}, " + f"python_sdm2_center={pnl0003_energy.python_port_2_center_mass_derivative_g_s}, " + f"python_sdm2_center_error={pnl0003_energy.python_port_2_center_mass_derivative_error_g_s}, " + f"python_sdm2={pnl0003_energy.python_port_2_storage_mass_derivative_g_s}, " + f"python_sdm2_error={pnl0003_energy.python_port_2_storage_mass_derivative_error_g_s}, " + f"python_amesim_state_darcy_center_dm=" + f"{pnl0003_energy.python_amesim_state_darcy_center_mass_flow_g_s}, " + f"python_amesim_state_darcy_center_dm_error=" + f"{pnl0003_energy.python_amesim_state_darcy_center_mass_flow_error_g_s}, " + f"python_amesim_state_pn2pipefr_center_dm=" + f"{pnl0003_energy.python_amesim_state_pn2pipefr_center_mass_flow_g_s}, " + f"python_amesim_state_pn2pipefr_center_dm_error=" + f"{pnl0003_energy.python_amesim_state_pn2pipefr_center_mass_flow_error_g_s}, " + f"amesim_center_real_p1=" + f"{pnl0003_energy.amesim_center_real_gas_reference_port_1_estimate_w}, " + f"amesim_center_real_p1_error=" + f"{pnl0003_energy.amesim_center_real_gas_reference_port_1_error_w}, " + f"python_center_ideal_p1=" + f"{pnl0003_energy.python_center_ideal_reference_port_1_estimate_w}, " + f"python_center_ideal_p1_error=" + f"{pnl0003_energy.python_center_ideal_reference_port_1_error_w}, " + f"python_center_ideal_p2=" + f"{pnl0003_energy.python_center_ideal_reference_port_2_estimate_w}, " + f"python_center_ideal_p2_error=" + f"{pnl0003_energy.python_center_ideal_reference_port_2_error_w}, " + f"python_center_real_p1=" + f"{pnl0003_energy.python_center_real_gas_reference_port_1_estimate_w}, " + f"python_center_real_p1_error=" + f"{pnl0003_energy.python_center_real_gas_reference_port_1_error_w}, " + f"python_center_real_p2=" + f"{pnl0003_energy.python_center_real_gas_reference_port_2_estimate_w}, " + f"python_center_real_p2_error=" + f"{pnl0003_energy.python_center_real_gas_reference_port_2_error_w}, " + f"python_dmgas_dt={pnl0003_energy.python_total_mass_derivative_kg_s}, " + f"python_ref_ext1={pnl0003_energy.python_port_1_reference_external_enthalpy_flow_w}, " + f"python_ref_ctr1={pnl0003_energy.python_port_1_reference_center_enthalpy_flow_w}, " + f"python_ref_sdh1={pnl0003_energy.python_port_1_reference_storage_enthalpy_sum_w}, " + f"python_ref_sdh1_error={pnl0003_energy.python_port_1_reference_storage_enthalpy_error_w}, " + f"python_ref_ext2={pnl0003_energy.python_port_2_reference_external_enthalpy_flow_w}, " + f"python_ref_ctr2={pnl0003_energy.python_port_2_reference_center_enthalpy_flow_w}, " + f"python_ref_sdh2={pnl0003_energy.python_port_2_reference_storage_enthalpy_sum_w}, " + f"python_ref_sdh2_error={pnl0003_energy.python_port_2_reference_storage_enthalpy_error_w}, " + f"amesim_dT1_ext_h={pnl0003_energy.amesim_port_1_external_enthalpy_dtemp_component_k_s}, " + f"amesim_dT1_ctr_h={pnl0003_energy.amesim_port_1_center_enthalpy_dtemp_component_k_s}, " + f"amesim_dT1_m_offset={pnl0003_energy.amesim_port_1_mass_offset_dtemp_component_k_s}, " + f"amesim_dT1_heat={pnl0003_energy.amesim_port_1_heat_dtemp_component_k_s}, " + f"python_dT1_ext_h={pnl0003_energy.python_port_1_external_enthalpy_dtemp_component_k_s}, " + f"python_dT1_ctr_h={pnl0003_energy.python_port_1_center_enthalpy_dtemp_component_k_s}, " + f"python_dT1_m_offset={pnl0003_energy.python_port_1_mass_offset_dtemp_component_k_s}, " + f"python_dT1_heat={pnl0003_energy.python_port_1_heat_dtemp_component_k_s}, " + f"amesim_dT2_ext_h={pnl0003_energy.amesim_port_2_external_enthalpy_dtemp_component_k_s}, " + f"amesim_dT2_ctr_h={pnl0003_energy.amesim_port_2_center_enthalpy_dtemp_component_k_s}, " + f"amesim_dT2_m_offset={pnl0003_energy.amesim_port_2_mass_offset_dtemp_component_k_s}, " + f"amesim_dT2_heat={pnl0003_energy.amesim_port_2_heat_dtemp_component_k_s}, " + f"python_dT2_ext_h={pnl0003_energy.python_port_2_external_enthalpy_dtemp_component_k_s}, " + f"python_dT2_ctr_h={pnl0003_energy.python_port_2_center_enthalpy_dtemp_component_k_s}, " + f"python_dT2_m_offset={pnl0003_energy.python_port_2_mass_offset_dtemp_component_k_s}, " + f"python_dT2_heat={pnl0003_energy.python_port_2_heat_dtemp_component_k_s}, " + f"amesim_fd_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_fd_k_s}, " + f"amesim_back_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_backward_k_s}, " + f"amesim_fwd_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_forward_k_s}, " + f"amesim_fd_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_fd_k_s}, " + f"amesim_back_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_backward_k_s}, " + f"amesim_fwd_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_forward_k_s}, " + f"amesim_pn2vol_dT1={pnl0003_energy.amesim_port_1_pn2vol_dtemp_k_s}, " + f"amesim_pn2vol_dT2={pnl0003_energy.amesim_port_2_pn2vol_dtemp_k_s}, " + f"amesim_pn2vol_dT1_residual={pnl0003_energy.amesim_port_1_pn2vol_dtemp_residual_k_s}, " + f"amesim_pn2vol_dT2_residual={pnl0003_energy.amesim_port_2_pn2vol_dtemp_residual_k_s}, " + f"amesim_fd_dP1={pnl0003_energy.amesim_port_1_pressure_derivative_fd_pa_s}, " + f"amesim_fd_dP2={pnl0003_energy.amesim_port_2_pressure_derivative_fd_pa_s}, " + f"amesim_dP1_m={pnl0003_energy.amesim_port_1_pressure_derivative_mass_component_pa_s}, " + f"amesim_dP1_T={pnl0003_energy.amesim_port_1_pressure_derivative_temperature_component_pa_s}, " + f"amesim_dP1_sum={pnl0003_energy.amesim_port_1_pressure_derivative_eos_sum_pa_s}, " + f"amesim_dP1_residual={pnl0003_energy.amesim_port_1_pressure_derivative_eos_residual_pa_s}, " + f"amesim_dP2_m={pnl0003_energy.amesim_port_2_pressure_derivative_mass_component_pa_s}, " + f"amesim_dP2_T={pnl0003_energy.amesim_port_2_pressure_derivative_temperature_component_pa_s}, " + f"amesim_dP2_sum={pnl0003_energy.amesim_port_2_pressure_derivative_eos_sum_pa_s}, " + f"amesim_dP2_residual={pnl0003_energy.amesim_port_2_pressure_derivative_eos_residual_pa_s}, " + f"python_dP1_m={pnl0003_energy.python_port_1_pressure_derivative_mass_component_pa_s}, " + f"python_dP1_T={pnl0003_energy.python_port_1_pressure_derivative_temperature_component_pa_s}, " + f"python_dP1_sum={pnl0003_energy.python_port_1_pressure_derivative_eos_sum_pa_s}, " + f"python_dP2_m={pnl0003_energy.python_port_2_pressure_derivative_mass_component_pa_s}, " + f"python_dP2_T={pnl0003_energy.python_port_2_pressure_derivative_temperature_component_pa_s}, " + f"python_dP2_sum={pnl0003_energy.python_port_2_pressure_derivative_eos_sum_pa_s}, " + f"python_t1={pnl0003_energy.python_port_1_temperature_k}, " + f"python_t2={pnl0003_energy.python_port_2_temperature_k}, " + f"python_p1_abs={pnl0003_energy.python_port_1_pressure_pa}, " + f"python_p2_abs={pnl0003_energy.python_port_2_pressure_pa}, " + f"python_dm1_dt={pnl0003_energy.python_port_1_mass_derivative_kg_s}, " + f"python_dm2_dt={pnl0003_energy.python_port_2_mass_derivative_kg_s}, " + f"python_current_dT1={pnl0003_energy.python_port_1_current_dtemp_k_s}, " + f"python_current_dT2={pnl0003_energy.python_port_2_current_dtemp_k_s}, " + f"python_real_ref_dT1={pnl0003_energy.python_port_1_real_gas_reference_dtemp_k_s}, " + f"python_real_ref_dT2={pnl0003_energy.python_port_2_real_gas_reference_dtemp_k_s}, " + f"python_amesim_center_dT2=" + f"{pnl0003_energy.python_port_2_amesim_center_counterfactual_dtemp_k_s}, " + f"python_amesim_ext_dT2=" + f"{pnl0003_energy.python_port_2_amesim_external_counterfactual_dtemp_k_s}, " + f"python_amesim_ext_ctr_dT2=" + f"{pnl0003_energy.python_port_2_amesim_external_center_counterfactual_dtemp_k_s}, " + f"python_t2_error={pnl0003_energy.python_port_2_temperature_error_to_amesim_k}" + ) + for energy_flow in sample.pnl0001_energy_flow_diagnostics: + lines.append( + f" - energy_flow@{energy_flow.line_alias}: " + f"amesim_dh1={energy_flow.amesim_port_1_enthalpy_flow_w}, " + f"amesim_dm1={energy_flow.amesim_port_1_mass_flow_g_s}, " + f"amesim_node_dh2={energy_flow.amesim_node_port_2_enthalpy_flow_w}, " + f"amesim_node_dm2={energy_flow.amesim_node_port_2_mass_flow_g_s}, " + f"amesim_observed_sdh=" + f"{energy_flow.amesim_observed_port_enthalpy_sum_w}, " + f"python_port1_u=" + f"{energy_flow.python_port_1_internal_energy_flow_w}, " + f"python_port2_u=" + f"{energy_flow.python_port_2_internal_energy_flow_w}, " + f"python_dU_dt={energy_flow.python_current_energy_derivative_w}, " + f"direct_h_dU_dt=" + f"{energy_flow.python_direct_enthalpy_energy_derivative_w}, " + f"direct_minus_current=" + f"{energy_flow.python_direct_minus_current_energy_derivative_w}, " + f"python_p4_node_dh2=" + f"{energy_flow.python_p4_node_port_2_enthalpy_flow_w}, " + f"python_p4_node_dm2=" + f"{energy_flow.python_p4_node_port_2_mass_flow_g_s}, " + f"python_p4_node_h2=" + f"{energy_flow.python_p4_node_port_2_connected_h_j_kg}, " + f"python_p4_node_t2=" + f"{energy_flow.python_p4_node_port_2_connected_temperature_k}, " + f"python_p4_node_h2_delta=" + f"{energy_flow.python_p4_node_port_2_connected_h_delta_to_line_h_j_kg}, " + f"python_p4_node_counterfactual_dT=" + f"{energy_flow.python_p4_node_port_2_counterfactual_dtemp_k_s}, " + f"href_t={energy_flow.reference_temperature_k}, " + f"ref_dh1_est=" + f"{energy_flow.amesim_port_1_reference_enthalpy_estimate_w}, " + f"ref_dh1_error=" + f"{energy_flow.amesim_port_1_reference_enthalpy_error_w}, " + f"candidate_dm2i=" + f"{energy_flow.amesim_candidate_pn2pipefr_dm2i_g_s}, " + f"candidate_dh2i=" + f"{energy_flow.amesim_candidate_pn2pipefr_dh2i_w}, " + f"candidate_storage_sdh=" + f"{energy_flow.amesim_candidate_storage_enthalpy_sum_w}, " + f"candidate_pn2pipefr_dT=" + f"{energy_flow.amesim_candidate_pn2pipefr_dtemp_k_s}, " + f"candidate_pn2pipefr_dT_residual=" + f"{energy_flow.amesim_candidate_pn2pipefr_dtemp_residual_k_s}, " + f"python_ref_dh1=" + f"{energy_flow.python_reference_port_1_enthalpy_flow_w}, " + f"python_ref_node_dh2=" + f"{energy_flow.python_reference_node_port_2_enthalpy_flow_w}, " + f"python_ref_sdh=" + f"{energy_flow.python_reference_observed_port_enthalpy_sum_w}, " + f"python_ref_dh1_error=" + f"{energy_flow.python_reference_port_1_to_amesim_error_w}, " + f"python_ref_node_dh2_error=" + f"{energy_flow.python_reference_node_port_2_to_amesim_error_w}, " + f"python_ref_sdh_error=" + f"{energy_flow.python_reference_sum_to_amesim_error_w}, " + f"python_current_dT=" + f"{energy_flow.python_current_line_temperature_derivative_k_s}, " + f"amesim_fd_dT=" + f"{energy_flow.amesim_line_temperature_derivative_fd_k_s}, " + f"amesim_pn2vol2_dT_node_plus_dh1=" + f"{energy_flow.amesim_pn2vol2_dtemp_node_plus_dh1_k_s}, " + f"amesim_pn2vol2_dT_node_minus_dh1=" + f"{energy_flow.amesim_pn2vol2_dtemp_node_minus_dh1_k_s}, " + f"python_ref_pn2vol2_dT_node_minus_dh1=" + f"{energy_flow.python_reference_pn2vol2_dtemp_node_minus_dh1_k_s}" + ) + for p4_node in sample.p4_node_enthalpy_breakdown_diagnostics: + lines.append( + f" - p4_node_enthalpy@{p4_node.node_alias}: " + f"amesim_p1_dh={p4_node.amesim_port_1_enthalpy_flow_w}, " + f"amesim_p1_dm={p4_node.amesim_port_1_mass_flow_g_s}, " + f"amesim_p3_dh={p4_node.amesim_port_3_enthalpy_flow_w}, " + f"amesim_p3_dm={p4_node.amesim_port_3_mass_flow_g_s}, " + f"amesim_p4_dh={p4_node.amesim_port_4_enthalpy_flow_w}, " + f"amesim_p4_dm={p4_node.amesim_port_4_mass_flow_g_s}, " + f"amesim_p2_dh={p4_node.amesim_port_2_enthalpy_flow_w}, " + f"amesim_p2_dm={p4_node.amesim_port_2_mass_flow_g_s}, " + f"python_p1_dh={p4_node.python_port_1_enthalpy_flow_w}, " + f"python_p1_dm={p4_node.python_port_1_mass_flow_g_s}, " + f"python_p3_dh={p4_node.python_port_3_enthalpy_flow_w}, " + f"python_p3_dm={p4_node.python_port_3_mass_flow_g_s}, " + f"python_p4_dh={p4_node.python_port_4_enthalpy_flow_w}, " + f"python_p4_dm={p4_node.python_port_4_mass_flow_g_s}, " + f"python_p2_dh={p4_node.python_port_2_enthalpy_flow_w}, " + f"python_p2_dm={p4_node.python_port_2_mass_flow_g_s}, " + f"python_ref_p1_dh=" + f"{p4_node.python_reference_port_1_enthalpy_flow_w}, " + f"python_ref_p3_dh=" + f"{p4_node.python_reference_port_3_enthalpy_flow_w}, " + f"python_ref_p4_dh=" + f"{p4_node.python_reference_port_4_enthalpy_flow_w}, " + f"python_ref_p2_dh=" + f"{p4_node.python_reference_port_2_enthalpy_flow_w}, " + f"python_real_ref_p1_dh=" + f"{p4_node.python_real_gas_reference_port_1_enthalpy_flow_w}, " + f"python_real_ref_p3_dh=" + f"{p4_node.python_real_gas_reference_port_3_enthalpy_flow_w}, " + f"python_real_ref_p4_dh=" + f"{p4_node.python_real_gas_reference_port_4_enthalpy_flow_w}, " + f"python_real_ref_p2_dh=" + f"{p4_node.python_real_gas_reference_port_2_enthalpy_flow_w}, " + f"python_p2_dh_error=" + f"{p4_node.python_port_2_enthalpy_error_w}, " + f"python_ref_p2_dh_error=" + f"{p4_node.python_reference_port_2_enthalpy_error_w}, " + f"python_real_ref_p2_dh_error=" + f"{p4_node.python_real_gas_reference_port_2_enthalpy_error_w}" + ) + for chamber_energy in sample.pnch012_energy_equation_diagnostics: + lines.append( + f" - pnch012_energy@{chamber_energy.chamber_alias}: " + f"amesim_dh1={chamber_energy.amesim_port_1_enthalpy_flow_w}, " + f"amesim_dm1={chamber_energy.amesim_port_1_mass_flow_g_s}, " + f"amesim_fd_dmgas=" + f"{chamber_energy.amesim_chamber_mass_derivative_fd_g_s}, " + f"amesim_dmgas_residual=" + f"{chamber_energy.amesim_chamber_mass_derivative_residual_g_s}, " + f"amesim_fd_dT=" + f"{chamber_energy.amesim_chamber_temperature_derivative_fd_k_s}, " + f"amesim_fd_dvol=" + f"{chamber_energy.amesim_chamber_volume_rate_fd_m3_s}, " + f"amesim_piston_dvol=" + f"{chamber_energy.amesim_piston_volume_rate_m3_s}, " + f"amesim_dq={chamber_energy.amesim_heat_flow_w}, " + f"amesim_boundary_work_fd=" + f"{chamber_energy.amesim_boundary_work_fd_volume_w}, " + f"amesim_pn2vol_ref_dT_fdvol=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_fd_volume_k_s}, " + f"amesim_pn2vol_ref_dT_fdvol_residual=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s}, " + f"amesim_pn2vol_ref_dT_pistonvol=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_piston_volume_k_s}, " + f"amesim_pn2vol_ref_dT_pistonvol_residual=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s}, " + f"python_port_a_dm=" + f"{chamber_energy.python_port_a_mass_flow_kg_s}, " + f"python_dU_dt=" + f"{chamber_energy.python_current_energy_derivative_w}, " + f"python_current_dT=" + f"{chamber_energy.python_current_chamber_temperature_derivative_k_s}, " + f"href_t={chamber_energy.reference_temperature_k}" + ) + lines.append("Final comparison:") + for data_path in diagnostic.data_paths: + lines.append( + f" - {data_path}: " + f"python={diagnostic.python_values_by_data_path[data_path]}, " + f"amesim={diagnostic.amesim_values_by_data_path[data_path]}, " + f"abs_error={diagnostic.abs_error(data_path)}" + ) + return "\n".join(lines) + "\n" + + + +def format_test_mql_pnvo_event_window_candidate_comparison_summary( + diagnostic: TestMqlPnvoEventWindowCandidateComparisonDiagnostic, +) -> str: + lines = [ + "Model: test_mql", + f"Mode: PNVO event window candidate comparison ({diagnostic.orifice_alias})", + f"Event time: {diagnostic.event_time_s}", + f"Final time: {diagnostic.final_time_s}", + "Default RHS: current pneumatic_96 internal-energy RHS", + "Candidate RHS: pneumatic_96 reference-enthalpy/mass-offset RHS", + "Metric comparisons: negative delta means the candidate reduced error", + ] + for comparison in diagnostic.metric_comparisons: + lines.append( + f" t={comparison.time_s} - {comparison.metric_name}: " + f"default_abs_error={comparison.default_abs_error}, " + f"candidate_abs_error={comparison.candidate_abs_error}, " + f"delta={comparison.abs_error_delta}" + ) + lines.append("Final comparison:") + for data_path in diagnostic.default_diagnostic.data_paths: + lines.append( + f" - {data_path}: " + f"default_abs_error={diagnostic.default_diagnostic.abs_error(data_path)}, " + f"candidate_abs_error={diagnostic.candidate_diagnostic.abs_error(data_path)}, " + f"delta=" + f"{diagnostic.candidate_diagnostic.abs_error(data_path) - diagnostic.default_diagnostic.abs_error(data_path)}" + ) + return "\n".join(lines) + "\n" + + +def format_test_mql_full_state_comparison_summary( + run: TestMqlFullStateComparisonRun, +) -> str: + lines = [ + "Model: test_mql", + "Mode: Python 132 full-state closure comparison", + f"Samples: {run.sample_count}", + f"Compared signals: {run.signal_count}", + f"Output schema signals: {run.output_schema.signal_count}", + f"AMESim first saved interval: " + f"{run.amesim_results.times[1] - run.amesim_results.times[0]}", + f"Final time aligns with AMESim sample: " + f"{_matches_amesim_sample(run, run.output.times[-1])}", + f"Max absolute error: {run.comparison.max_abs_error}", + f"Max relative error: {run.comparison.max_rel_error}", + ] + largest_metric = run.largest_abs_error_metric + if largest_metric is not None: + lines.append( + "Largest absolute error: " + f"{largest_metric.data_path}={largest_metric.max_abs_error}" + ) + largest_diagnostic = run.largest_final_abs_error_diagnostic + if largest_diagnostic is not None: + lines.append( + "Largest final endpoint error: " + f"{largest_diagnostic.data_path}={largest_diagnostic.final_abs_error}" + ) + lines.append("Metrics by max absolute error:") + for metric in run.metrics_by_max_abs_error(): + lines.append( + f" - {metric.data_path}: max_abs_error={metric.max_abs_error}, " + f"final_abs_error={metric.final_abs_error}" + ) + lines.append("Endpoint diagnostics by final absolute error:") + for diagnostic in run.diagnostics_by_final_abs_error(): + lines.append( + f" - {diagnostic.data_path}: " + f"initial_python={diagnostic.initial_python_value}, " + f"initial_amesim={diagnostic.initial_amesim_value}, " + f"final_python={diagnostic.final_python_value}, " + f"final_amesim={diagnostic.final_amesim_value}, " + f"final_abs_error={diagnostic.final_abs_error}" + ) + flow_diagnostic = _pneumatic_69_flow_diagnostic(run) + if flow_diagnostic is not None: + lines.append( + "PNL0001 canonical mass-flow diagnostic: " + f"{flow_diagnostic.data_path}" + ) + lines.extend( + [ + " - convention=Python chamber-to-line flow " + "equals -AMESim dm1 * 1e-3", + f" - initial_python_kg_s=" + f"{flow_diagnostic.initial_python_canonical_kg_s}", + f" - initial_amesim_kg_s=" + f"{flow_diagnostic.initial_amesim_canonical_kg_s}", + f" - final_python_kg_s=" + f"{flow_diagnostic.final_python_canonical_kg_s}", + f" - final_amesim_kg_s=" + f"{flow_diagnostic.final_amesim_canonical_kg_s}", + f" - final_abs_error_kg_s=" + f"{flow_diagnostic.final_canonical_abs_error_kg_s}", + ] + ) + pnvo_diagnostic = _pn_morifice_1_diagnostic(run) + if pnvo_diagnostic is not None: + lines.append(f"PNVO diagnostic: {pnvo_diagnostic.alias}") + lines.extend( + [ + f" - initial_python_opening=" + f"{pnvo_diagnostic.initial_python_opening}", + f" - initial_amesim_opening=" + f"{pnvo_diagnostic.initial_amesim_opening}", + f" - final_python_opening=" + f"{pnvo_diagnostic.final_python_opening}", + f" - final_amesim_opening=" + f"{pnvo_diagnostic.final_amesim_opening}", + f" - final_python_mass_flow_kg_s=" + f"{pnvo_diagnostic.final_python_mass_flow_kg_s}", + f" - final_amesim_mass_flow_kg_s=" + f"{pnvo_diagnostic.final_amesim_mass_flow_kg_s}", + f" - final_mass_flow_abs_error_kg_s=" + f"{pnvo_diagnostic.final_mass_flow_abs_error_kg_s}", + ] + ) + chamber_diagnostic = _largest_chamber_rhs_diagnostic(run) + if chamber_diagnostic is not None: + lines.append( + "Largest endpoint chamber RHS breakdown: " + f"{chamber_diagnostic.chamber_alias}" + ) + lines.extend( + [ + f" - piston_alias={chamber_diagnostic.piston_alias}", + f" - pressure_pa={chamber_diagnostic.chamber_pressure_pa}", + f" - volume_m3={chamber_diagnostic.chamber_volume_m3}", + f" - volume_rate_m3_s={chamber_diagnostic.chamber_volume_rate_m3_s}", + f" - mass_derivative_kg_s={chamber_diagnostic.mass_derivative_kg_s}", + f" - port_a_energy_flow_w={chamber_diagnostic.port_a_energy_flow_w}", + f" - boundary_work_w={chamber_diagnostic.boundary_work_w}", + f" - thermal_energy_flow_w=" + f"{chamber_diagnostic.thermal_energy_flow_w}", + f" - energy_derivative_w={chamber_diagnostic.energy_derivative_w}", + ] + ) + return "\n".join(lines) + "\n" + + +def _matches_amesim_sample( + run: TestMqlFullStateComparisonRun, + time_s: float, +) -> bool: + return any( + abs(float(sample_time) - float(time_s)) <= 1.0e-12 + for sample_time in run.amesim_results.times + ) + + +def _pneumatic_69_flow_diagnostic(run: TestMqlFullStateComparisonRun): + try: + return run.pnl0001_mass_flow_diagnostic() + except KeyError: + return None + + +def _pn_morifice_1_diagnostic(run: TestMqlFullStateComparisonRun): + try: + return run.pnvo_diagnostic() + except KeyError: + return None + + +def _largest_chamber_rhs_diagnostic(run: TestMqlFullStateComparisonRun): + diagnostic = run.largest_final_abs_error_diagnostic + if diagnostic is None or "@" not in diagnostic.data_path: + return None + _signal, alias = diagnostic.data_path.split("@", 1) + try: + return run.chamber_rhs_diagnostic(alias) + except KeyError: + return None + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--pnvo-event-boundary", + action="store_true", + help="compare the STEP0 left-state/right-opening boundary at t=0.04 s", + ) + parser.add_argument( + "--pnvo-event-window", + action="store_true", + help="run the segmented PNVO opening window through the t=0.05 s save point", + ) + parser.add_argument( + "--pnvo-event-window-candidate-comparison", + action="store_true", + help="compare the PNVO event window with and without the pneumatic_96 RHS candidate", + ) + parser.add_argument( + "--pnvo-event-window-final-time", + type=float, + help="override PNVO event window final time; candidate comparison defaults to 0.041 s", + ) + parser.add_argument( + "--amesim-results-archive", + type=Path, + help="read AMESim observations from a separate .ame results package", + ) + parser.add_argument( + "--pneumatic-96-reference-rhs", + action="store_true", + help="enable the local reference/mass-offset RHS candidate for pneumatic_96", + ) + parser.add_argument( + "--pneumatic-69-reference-rhs", + action="store_true", + help="enable the local reference/mass-offset RHS candidate for pneumatic_69", + ) + args = parser.parse_args() + config = None + if ( + args.amesim_results_archive is not None + or args.pneumatic_96_reference_rhs + or args.pneumatic_69_reference_rhs + ): + config = TestMqlFullStateComparisonScriptConfig( + paths=TestMqlFullStateComparisonPathConfig( + amesim_results_archive_path=args.amesim_results_archive, + ), + execution=TestMqlFullStateComparisonExecutionConfig( + use_pneumatic_96_reference_rhs=args.pneumatic_96_reference_rhs, + use_pneumatic_69_reference_rhs=args.pneumatic_69_reference_rhs, + ), + ) + if args.pnvo_event_boundary: + diagnostic = run_test_mql_pnvo_event_boundary_diagnostic(config) + print(format_test_mql_pnvo_event_boundary_summary(diagnostic), end="") + return + if args.pnvo_event_window_candidate_comparison: + diagnostic = run_test_mql_pnvo_event_window_candidate_comparison( + config, + final_time_s=args.pnvo_event_window_final_time or 0.041, + ) + print( + format_test_mql_pnvo_event_window_candidate_comparison_summary( + diagnostic + ), + end="", + ) + return + if args.pnvo_event_window: + if args.pnvo_event_window_final_time is None: + diagnostic = run_test_mql_pnvo_event_window_diagnostic(config) + else: + diagnostic = run_test_mql_pnvo_event_window_diagnostic( + config, + final_time_s=args.pnvo_event_window_final_time, + ) + print(format_test_mql_pnvo_event_window_summary(diagnostic), end="") + return + + run, output_dir = run_test_mql_full_state_comparison(config) + print(format_test_mql_full_state_comparison_summary(run), end="") + print(f"Output directory: {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/PythonModels/scripts/run_testmodel.py b/PythonModels/scripts/run_testmodel.py new file mode 100644 index 0000000..4834677 --- /dev/null +++ b/PythonModels/scripts/run_testmodel.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +from PythonModels.reporting import ( + COMPARISON_KEYS, + PRIMARY_KEYS, + TestModelArtifacts, + export_testmodel_artifacts, + format_testmodel_run_report, + load_modelica_series, + write_testmodel_run_report, +) +from PythonModels.core.solver import SolveIVPConfig +from PythonModels.systems.testmodel import ( + InitializationDiagnostics, + TestModelConfig, + TestModelSystem, +) +from PythonModels.systems.testmodel_closure import TestModelSolveDiagnostics + + +@dataclass(frozen=True) +class TestModelSamplingConfig: + step: float = 0.1 + + +@dataclass(frozen=True) +class TestModelPathConfig: + output_dir: Path | None = None + modelica_result_path: Path | None = None + + +@dataclass(frozen=True) +class TestModelExecutionConfig: + use_modelica_reference_if_available: bool = True + + +@dataclass(frozen=True) +class TestModelRunConfig: + model: TestModelConfig = field(default_factory=TestModelConfig) + solver: SolveIVPConfig = field(default_factory=SolveIVPConfig) + sampling: TestModelSamplingConfig = field(default_factory=TestModelSamplingConfig) + paths: TestModelPathConfig = field(default_factory=TestModelPathConfig) + execution: TestModelExecutionConfig = field(default_factory=TestModelExecutionConfig) + + @property + def sample_step(self) -> float: + return self.sampling.step + + def sample_times(self) -> list[float]: + return _sample_times( + self.solver.t_start, + self.solver.t_stop, + step=self.sampling.step, + ) + + +@dataclass(frozen=True) +class PreparedTestModelRun: + run_config: TestModelRunConfig + repo_root: Path + output_dir: Path + modelica_result_path: Path + t_eval: tuple[float, ...] + use_modelica_reference_if_available: bool + modelica_reference_exists: bool + + +@dataclass(frozen=True) +class TestModelRunResult: + run_config: TestModelRunConfig + prepared_run: PreparedTestModelRun + system: TestModelSystem + initialization: InitializationDiagnostics + raw_initial_state: tuple[float, ...] + consistent_initial_state: tuple[float, ...] + solution: object + series: dict[str, list[float]] + solve_diagnostics: TestModelSolveDiagnostics | None + artifacts: TestModelArtifacts + comparison_summary: dict[str, tuple[float, float]] | None + used_modelica_reference: bool + + +def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]: + point_count = int(round((t_stop - t_start) / step)) + return [t_start + index * step for index in range(point_count + 1)] + + +def _default_run_output_dir(pythonmodels_root: Path) -> Path: + timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f") + return pythonmodels_root / "runs" / timestamp + + +def prepare_testmodel_run( + *, + run_config: TestModelRunConfig | None = None, + output_dir: Path | None = None, + modelica_result_path: Path | None = None, +) -> PreparedTestModelRun: + run_config = run_config or TestModelRunConfig() + repo_root = Path(__file__).resolve().parents[2] + pythonmodels_root = Path(__file__).resolve().parents[1] + resolved_output_dir = ( + output_dir + or run_config.paths.output_dir + or _default_run_output_dir(pythonmodels_root) + ) + resolved_modelica_result_path = ( + modelica_result_path + or run_config.paths.modelica_result_path + or repo_root / "ModelicaModels" / "Simulation" / "Testmodel_res.csv" + ) + t_eval = tuple(run_config.sample_times()) + return PreparedTestModelRun( + run_config=run_config, + repo_root=repo_root, + output_dir=resolved_output_dir, + modelica_result_path=resolved_modelica_result_path, + t_eval=t_eval, + use_modelica_reference_if_available=run_config.execution.use_modelica_reference_if_available, + modelica_reference_exists=resolved_modelica_result_path.exists(), + ) + + +def run_prepared_testmodel(prepared_run: PreparedTestModelRun) -> TestModelRunResult: + run_config = prepared_run.run_config + system = TestModelSystem(config=run_config.model) + raw_initial_state = tuple(system.initial_state_vector()) + initialization = system.initialize_consistent_state() + consistent_initial_state = tuple(initialization.state_vector) + solution = system.simulate(config=run_config.solver, t_eval=list(prepared_run.t_eval)) + series = system.evaluate_solution(solution) + solve_diagnostics = system.last_solve_diagnostics + + modelica_series = None + used_modelica_reference = False + if ( + prepared_run.use_modelica_reference_if_available + and prepared_run.modelica_reference_exists + ): + modelica_series = load_modelica_series( + prepared_run.modelica_result_path, + COMPARISON_KEYS, + ) + used_modelica_reference = True + + artifacts, comparison_summary = export_testmodel_artifacts( + output_dir=prepared_run.output_dir, + series=series, + modelica_series=modelica_series, + ) + report_text = format_testmodel_run_report( + network_summary=system.network.summary(), + initialization=initialization, + raw_initial_state=raw_initial_state, + consistent_initial_state=consistent_initial_state, + solution=solution, + series=series, + solve_diagnostics=solve_diagnostics, + artifacts=artifacts, + comparison_summary=comparison_summary, + ) + write_testmodel_run_report(prepared_run.output_dir, report_text) + + return TestModelRunResult( + run_config=run_config, + prepared_run=prepared_run, + system=system, + initialization=initialization, + raw_initial_state=raw_initial_state, + consistent_initial_state=consistent_initial_state, + solution=solution, + series=series, + solve_diagnostics=solve_diagnostics, + artifacts=artifacts, + comparison_summary=comparison_summary, + used_modelica_reference=used_modelica_reference, + ) + + +def run_testmodel( + *, + run_config: TestModelRunConfig | None = None, + output_dir: Path | None = None, + modelica_result_path: Path | None = None, +) -> TestModelRunResult: + prepared_run = prepare_testmodel_run( + run_config=run_config, + output_dir=output_dir, + modelica_result_path=modelica_result_path, + ) + return run_prepared_testmodel(prepared_run) + + +def main() -> None: + run_config = TestModelRunConfig() + result = run_testmodel(run_config=run_config) + print( + format_testmodel_run_report( + network_summary=result.system.network.summary(), + initialization=result.initialization, + raw_initial_state=result.raw_initial_state, + consistent_initial_state=result.consistent_initial_state, + solution=result.solution, + series=result.series, + solve_diagnostics=result.solve_diagnostics, + artifacts=result.artifacts, + comparison_summary=result.comparison_summary, + ), + end="", + ) + + +if __name__ == "__main__": + main() diff --git a/PythonModels/systems/__init__.py b/PythonModels/systems/__init__.py new file mode 100644 index 0000000..187800c --- /dev/null +++ b/PythonModels/systems/__init__.py @@ -0,0 +1,2 @@ +"""System assembly modules.""" + diff --git a/PythonModels/systems/test_mql.py b/PythonModels/systems/test_mql.py new file mode 100644 index 0000000..1bd736b --- /dev/null +++ b/PythonModels/systems/test_mql.py @@ -0,0 +1,6334 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from types import SimpleNamespace + +from PythonModels.core.base import Component +from PythonModels.core.network import SimulationNetwork +from PythonModels.core.solver import SolveIVPConfig, integrate_ode + + +MODEL_NAME = "test_mql" +AMESIM_ARCHIVE_RELATIVE_PATH = Path("AmesimModels/test_mql.ame") + +# Extracted from AmesimModels/test_mql.ame on the model-development branch. +# This is a structural Python port scaffold: it preserves the AMESim component +# inventory, parameters, and connection topology without changing the existing +# Testmodel implementation. The detailed AMESim submodel equations should be +# replaced incrementally per submodel family. +MODEL_INFO = {'num_states': 132, 'num_discrete_states': 24, 'is_explicit': 1} +SIMULATION_SETTINGS = {'raw': '0 10 0.01 1e+30 1e-07 0.001 1 0.1\r\n0 0 0 0 8 0 0 0 0 0', + 't_start': 0.0, + 't_stop': 10.0, + 'print_interval': 0.01, + 'max_step': 0.001, + 'sample_step': 0.1} +GLOBAL_PARAMETERS = {'D2': '20', 'D1': '20', 'D3': '14', 'Pdq': '1', 'P0': '153', 'V': '15', 'cf': '0.45'} +SUBMODEL_COUNTS = {'PNRP17': 8, + 'F000': 16, + 'MECMAS21': 10, + 'LMECHN1': 2, + 'LSTP00A': 8, + 'PNCH012': 8, + 'PNPL01': 16, + 'FORC': 2, + 'PNCH023': 4, + 'PNOR001': 8, + 'PN3NODE2': 8, + 'P4NODE2': 8, + 'PNVO001': 8, + 'STEP0': 8, + 'UD00': 2, + 'PNGD00': 1} +LINE_SUBMODEL_COUNTS = {'DIRECT': 44, 'PNL0001': 20, 'PNL0003': 8, 'PNL0002': 8, 'PNL00R': 4} +COMPONENT_SPECS = [{'index': 0, + 'alias': 'pn_brp2_8', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 1, + 'alias': 'pn_brp2_9', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 2, + 'alias': 'pn_brp2_10', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 3, + 'alias': 'pn_brp2_11', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 4, + 'alias': 'pn_brp2_12', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 5, + 'alias': 'pn_brp2_13', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 6, + 'alias': 'pn_brp2_14', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 7, + 'alias': 'pn_brp2_15', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 8, + 'alias': 'zeroforcesource_17', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 9, + 'alias': 'mass_friction_endstops_10', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 10, + 'alias': 'zeroforcesource_18', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 11, + 'alias': 'mass_friction_endstops_11', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 12, + 'alias': 'zeroforcesource_19', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 13, + 'alias': 'mass_friction_endstops_12', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 14, + 'alias': 'zeroforcesource_20', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 15, + 'alias': 'mass_friction_endstops_13', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 16, + 'alias': 'zeroforcesource_21', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 17, + 'alias': 'mass_friction_endstops_14', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 18, + 'alias': 'zeroforcesource_22', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 19, + 'alias': 'mass_friction_endstops_15', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 20, + 'alias': 'zeroforcesource_23', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 21, + 'alias': 'mass_friction_endstops_16', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 22, + 'alias': 'zeroforcesource_24', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 23, + 'alias': 'mass_friction_endstops_17', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 24, + 'alias': 'dynamic_mechanical_node_alternative_2', + 'component_name': 'dynamic_mechanical_node_alternative', + 'library_id': 'meca.dynamic_mechanical_node_alternative', + 'submodel': 'LMECHN1', + 'label': 'dynamic linear mechanical node (velocity and displacement)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_6', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_7', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_8', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_9', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'v1', 'title': 'number of ports at right', 'value': '8', 'units': ''}, + {'name': 'sum', 'title': 'node sum', 'value': '1', 'units': ''}]}, + {'index': 25, + 'alias': 'mass_friction_endstops_18', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '1.70000000000000e+05', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '0.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '3.70000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 26, + 'alias': 'elasticendstop_8', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 27, + 'alias': 'elasticendstop_9', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 28, + 'alias': 'elasticendstop_10', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 29, + 'alias': 'elasticendstop_11', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 30, + 'alias': 'elasticendstop_12', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 31, + 'alias': 'elasticendstop_13', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 32, + 'alias': 'elasticendstop_14', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 33, + 'alias': 'elasticendstop_15', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 34, + 'alias': 'dynamic_mechanical_node_alternative_3', + 'component_name': 'dynamic_mechanical_node_alternative', + 'library_id': 'meca.dynamic_mechanical_node_alternative', + 'submodel': 'LMECHN1', + 'label': 'dynamic linear mechanical node (velocity and displacement)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_6', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_7', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_8', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_9', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'v1', 'title': 'number of ports at right', 'value': '8', 'units': ''}, + {'name': 'sum', 'title': 'node sum', 'value': '1', 'units': ''}]}, + {'index': 35, + 'alias': 'mass_friction_endstops_19', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '9.00000000000000e+04', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-7.20000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '0.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 36, + 'alias': 'pn_c1_8', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 37, + 'alias': 'pn_plug_16', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 38, + 'alias': 'pn_plug_17', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 39, + 'alias': 'pn_c1_9', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 40, + 'alias': 'pn_plug_18', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 41, + 'alias': 'pn_plug_19', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 42, + 'alias': 'pn_c1_10', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 43, + 'alias': 'pn_plug_20', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 44, + 'alias': 'pn_plug_21', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 45, + 'alias': 'pn_c1_11', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 46, + 'alias': 'pn_plug_22', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 47, + 'alias': 'pn_plug_23', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 48, + 'alias': 'pn_c1_12', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 49, + 'alias': 'pn_plug_24', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 50, + 'alias': 'pn_plug_25', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 51, + 'alias': 'pn_c1_13', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 52, + 'alias': 'pn_plug_26', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 53, + 'alias': 'pn_plug_27', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 54, + 'alias': 'pn_c1_14', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 55, + 'alias': 'pn_plug_28', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 56, + 'alias': 'pn_plug_29', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 57, + 'alias': 'pn_c1_15', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 58, + 'alias': 'pn_plug_30', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 59, + 'alias': 'pn_plug_31', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 60, + 'alias': 'zeroforcesource_26', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 61, + 'alias': 'zeroforcesource_27', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 62, + 'alias': 'zeroforcesource_28', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 63, + 'alias': 'zeroforcesource_29', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 64, + 'alias': 'zeroforcesource_30', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 65, + 'alias': 'zeroforcesource_31', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 66, + 'alias': 'zeroforcesource_32', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 67, + 'alias': 'zeroforcesource_33', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 68, + 'alias': 'forcecon_1', + 'component_name': 'forcecon', + 'library_id': 'meca.forcecon', + 'submodel': 'FORC', + 'label': 'conversion of signal input into a force in N', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 69, + 'alias': 'pn_general_chamber', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 70, + 'alias': 'pn_orifice_18', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 71, + 'alias': 'pn_orifice_19', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 72, + 'alias': 'pn_node3_8', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 73, + 'alias': 'pn_node3_9', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 74, + 'alias': 'pn_general_chamber_2', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 75, + 'alias': 'pn_orifice_20', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 76, + 'alias': 'pn_orifice_21', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 77, + 'alias': 'pn_node3_10', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 78, + 'alias': 'pn_node3_11', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 79, + 'alias': 'pn_general_chamber_4', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 80, + 'alias': 'pn_orifice_22', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 81, + 'alias': 'pn_orifice_23', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 82, + 'alias': 'pn_node3_12', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 83, + 'alias': 'pn_node3_13', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 84, + 'alias': 'pn_general_chamber_5', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 85, + 'alias': 'pn_orifice_24', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 86, + 'alias': 'pn_orifice_25', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 87, + 'alias': 'pn_node3_14', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 88, + 'alias': 'pn_node3_15', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 89, + 'alias': 'pnnode4_16', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 90, + 'alias': 'pnnode4_17', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 91, + 'alias': 'pnnode4_18', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 92, + 'alias': 'pnnode4_19', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 93, + 'alias': 'pnnode4_20', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 94, + 'alias': 'pnnode4_21', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 95, + 'alias': 'pnnode4_22', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 96, + 'alias': 'pnnode4_23', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 97, + 'alias': 'pn_morifice_11', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 98, + 'alias': 'step_8', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 99, + 'alias': 'pn_morifice_12', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 100, + 'alias': 'step_9', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 101, + 'alias': 'pn_morifice_13', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 102, + 'alias': 'step_10', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 103, + 'alias': 'pn_morifice_14', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 104, + 'alias': 'step_11', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 105, + 'alias': 'forcecon_2', + 'component_name': 'forcecon', + 'library_id': 'meca.forcecon', + 'submodel': 'FORC', + 'label': 'conversion of signal input into a force in N', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 106, + 'alias': 'piecewiselinear', + 'component_name': 'piecewiselinear', + 'library_id': 'sig.piecewiselinear', + 'submodel': 'UD00', + 'label': 'piecewise linear signal source', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'tstart', + 'title': 'time at which duty cycle starts', + 'value': '0.00000000000000e+00', + 'units': 's'}, + {'name': 'start1', + 'title': 'output at start of stage 1', + 'value': '1.00000000000000e+17', + 'units': 'null'}, + {'name': 'end1', + 'title': 'output at end of stage 1', + 'value': '1.00000000000000e+17', + 'units': 'null'}, + {'name': 't1', + 'title': 'duration of stage 1', + 'value': '8.00000000000000e-01', + 'units': 's'}, + {'name': 'start2', + 'title': 'output at start of stage 2', + 'value': '4.90000000000000e+04', + 'units': 'null'}, + {'name': 'end2', + 'title': 'output at end of stage 2', + 'value': '4.90000000000000e+04', + 'units': 'null'}, + {'name': 't2', + 'title': 'duration of stage 2', + 'value': '1.00000000000000e+01', + 'units': 's'}, + {'name': 'start3', + 'title': 'output at start of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end3', + 'title': 'output at end of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't3', + 'title': 'duration of stage 3', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start4', + 'title': 'output at start of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end4', + 'title': 'output at end of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't4', + 'title': 'duration of stage 4', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start5', + 'title': 'output at start of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end5', + 'title': 'output at end of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't5', + 'title': 'duration of stage 5', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start6', + 'title': 'output at start of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end6', + 'title': 'output at end of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't6', + 'title': 'duration of stage 6', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start7', + 'title': 'output at start of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end7', + 'title': 'output at end of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't7', + 'title': 'duration of stage 7', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start8', + 'title': 'output at start of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end8', + 'title': 'output at end of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't8', + 'title': 'duration of stage 8', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'nstages', 'title': 'number of stages', 'value': '2', 'units': ''}, + {'name': 'iscyclic', 'title': 'cyclic', 'value': '1', 'units': ''}]}, + {'index': 107, + 'alias': 'piecewiselinear_1', + 'component_name': 'piecewiselinear', + 'library_id': 'sig.piecewiselinear', + 'submodel': 'UD00', + 'label': 'piecewise linear signal source', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '2'}], + 'parameters': [{'name': 'tstart', + 'title': 'time at which duty cycle starts', + 'value': '0.00000000000000e+00', + 'units': 's'}, + {'name': 'start1', + 'title': 'output at start of stage 1', + 'value': '1.00000000000000e+12', + 'units': 'null'}, + {'name': 'end1', + 'title': 'output at end of stage 1', + 'value': '1.00000000000000e+12', + 'units': 'null'}, + {'name': 't1', + 'title': 'duration of stage 1', + 'value': '8.00000000000000e-01', + 'units': 's'}, + {'name': 'start2', + 'title': 'output at start of stage 2', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end2', + 'title': 'output at end of stage 2', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't2', + 'title': 'duration of stage 2', + 'value': '1.00000000000000e+01', + 'units': 's'}, + {'name': 'start3', + 'title': 'output at start of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end3', + 'title': 'output at end of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't3', + 'title': 'duration of stage 3', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start4', + 'title': 'output at start of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end4', + 'title': 'output at end of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't4', + 'title': 'duration of stage 4', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start5', + 'title': 'output at start of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end5', + 'title': 'output at end of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't5', + 'title': 'duration of stage 5', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start6', + 'title': 'output at start of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end6', + 'title': 'output at end of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't6', + 'title': 'duration of stage 6', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start7', + 'title': 'output at start of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end7', + 'title': 'output at end of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't7', + 'title': 'duration of stage 7', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start8', + 'title': 'output at start of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end8', + 'title': 'output at end of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't8', + 'title': 'duration of stage 8', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'nstages', 'title': 'number of stages', 'value': '2', 'units': ''}, + {'name': 'iscyclic', 'title': 'cyclic', 'value': '1', 'units': ''}]}, + {'index': 108, + 'alias': 'pn_morifice_1', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 109, + 'alias': 'step_12', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 110, + 'alias': 'pn_morifice_9', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 111, + 'alias': 'step_13', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 112, + 'alias': 'pn_morifice_10', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 113, + 'alias': 'step_14', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 114, + 'alias': 'pn_morifice_15', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 115, + 'alias': 'step_15', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 116, + 'alias': 'pn_gas_data', + 'component_name': 'pn_gas_data', + 'library_id': 'pn.pn_gas_data', + 'submodel': 'PNGD00', + 'label': 'gas definition for helium (He)', + 'ports': [], + 'parameters': [{'name': 'cp', + 'title': 'constant-pressure specific heat (Cp)', + 'value': '1.00520000000000e+03', + 'units': 'J/kg/K'}, + {'name': 'cv', + 'title': 'constant-volume specific heat (Cv)', + 'value': '7.18000000000000e+02', + 'units': 'J/kg/K'}, + {'name': 'gamma', + 'title': 'specific heat ratio (gamma)', + 'value': '1.40000000000000e+00', + 'units': 'null'}, + {'name': 'rGas', + 'title': 'specific gas constant (r)', + 'value': '2.87200000000000e+02', + 'units': 'J/kg/K'}, + {'name': 'mu', + 'title': 'absolute viscosity (mu)', + 'value': '1.82000000000000e-02', + 'units': 'cP'}, + {'name': 'lam', + 'title': 'thermal conductivity', + 'value': '2.64000000000000e-02', + 'units': 'W/m/K'}, + {'name': 'M', + 'title': 'molar mass', + 'value': '2.89651300000000e+01', + 'units': 'g/mol'}, + {'name': 'operatingTempK', + 'title': 'operating temperature', + 'value': '2.98150000000000e+02', + 'units': 'K'}, + {'name': 'enthForm', + 'title': 'enthalpy of formation', + 'value': '-1.25530000000000e+02', + 'units': 'J/mol'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'fluidType', 'title': 'fluid definition', 'value': '12', 'units': ''}, + {'name': 'eosType', 'title': 'properties definition', 'value': '6', 'units': ''}, + {'name': 'gasSetting', + 'title': 'setting properties through', + 'value': '1', + 'units': ''}]}] +CONNECTION_SPECS = [{'index': 0, + 'alias': 'linear_24', + 'source_component': 'pn_brp2_8', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_8', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 1, + 'alias': 'linear_25', + 'source_component': 'elasticendstop_8', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 2, + 'alias': 'linear_26', + 'source_component': 'pn_brp2_9', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_9', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 3, + 'alias': 'linear_27', + 'source_component': 'elasticendstop_9', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_2', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 4, + 'alias': 'linear_28', + 'source_component': 'pn_brp2_10', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_10', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 5, + 'alias': 'linear_29', + 'source_component': 'pn_brp2_11', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_11', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 6, + 'alias': 'linear_30', + 'source_component': 'pn_brp2_12', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_12', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 7, + 'alias': 'linear_31', + 'source_component': 'pn_brp2_13', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_13', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 8, + 'alias': 'linear_32', + 'source_component': 'pn_brp2_14', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_14', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 9, + 'alias': 'linear_33', + 'source_component': 'pn_brp2_15', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_15', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 10, + 'alias': 'linear_34', + 'source_component': 'elasticendstop_10', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 11, + 'alias': 'linear_35', + 'source_component': 'elasticendstop_11', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 12, + 'alias': 'linear_36', + 'source_component': 'elasticendstop_12', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_5', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 13, + 'alias': 'linear_37', + 'source_component': 'elasticendstop_13', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_6', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 14, + 'alias': 'linear_38', + 'source_component': 'elasticendstop_14', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_7', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 15, + 'alias': 'linear_39', + 'source_component': 'elasticendstop_15', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_8', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 16, + 'alias': 'linear_40', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_8', + 'target_component': 'pn_brp2_8', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 17, + 'alias': 'linear_41', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_7', + 'target_component': 'pn_brp2_9', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 18, + 'alias': 'linear_42', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_6', + 'target_component': 'pn_brp2_10', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 19, + 'alias': 'linear_43', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_5', + 'target_component': 'pn_brp2_11', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 20, + 'alias': 'linear_44', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_4', + 'target_component': 'pn_brp2_12', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 21, + 'alias': 'linear_45', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_13', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 22, + 'alias': 'linear_46', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_2', + 'target_component': 'pn_brp2_14', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 23, + 'alias': 'linear_47', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_1', + 'target_component': 'pn_brp2_15', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 24, + 'alias': 'pneumatic_56', + 'source_component': 'pn_c1_8', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_8', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 25, + 'alias': 'pneumatic_57', + 'source_component': 'pn_c1_9', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_9', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 26, + 'alias': 'pneumatic_58', + 'source_component': 'pn_c1_10', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_10', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 27, + 'alias': 'pneumatic_59', + 'source_component': 'pn_c1_11', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_11', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 28, + 'alias': 'pneumatic_60', + 'source_component': 'pn_c1_12', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_12', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 29, + 'alias': 'pneumatic_61', + 'source_component': 'pn_c1_13', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_13', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 30, + 'alias': 'pneumatic_62', + 'source_component': 'pn_c1_14', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_14', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 31, + 'alias': 'pneumatic_63', + 'source_component': 'pn_c1_15', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_15', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 32, + 'alias': 'pneumatic_64', + 'source_component': 'pn_morifice_15', + 'source_port': 'port_2', + 'target_component': 'pnnode4_19', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 33, + 'alias': 'pneumatic_65', + 'source_component': 'pnnode4_19', + 'source_port': 'port_2', + 'target_component': 'pn_c1_11', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 34, + 'alias': 'pneumatic_66', + 'source_component': 'pnnode4_18', + 'source_port': 'port_2', + 'target_component': 'pn_c1_10', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 35, + 'alias': 'pneumatic_67', + 'source_component': 'pn_morifice_10', + 'source_port': 'port_2', + 'target_component': 'pnnode4_18', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 36, + 'alias': 'pneumatic_68', + 'source_component': 'pnnode4_17', + 'source_port': 'port_2', + 'target_component': 'pn_c1_9', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 37, + 'alias': 'pneumatic_69', + 'source_component': 'pnnode4_16', + 'source_port': 'port_2', + 'target_component': 'pn_c1_8', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 38, + 'alias': 'pneumatic_70', + 'source_component': 'pn_morifice_12', + 'source_port': 'port_2', + 'target_component': 'pnnode4_20', + 'target_port': 'port_4', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 39, + 'alias': 'pneumatic_71', + 'source_component': 'pnnode4_23', + 'source_port': 'port_2', + 'target_component': 'pn_c1_12', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 40, + 'alias': 'pneumatic_72', + 'source_component': 'pnnode4_20', + 'source_port': 'port_2', + 'target_component': 'pn_c1_13', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 41, + 'alias': 'pneumatic_73', + 'source_component': 'pnnode4_21', + 'source_port': 'port_2', + 'target_component': 'pn_c1_14', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 42, + 'alias': 'pneumatic_74', + 'source_component': 'pnnode4_22', + 'source_port': 'port_2', + 'target_component': 'pn_c1_15', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 43, + 'alias': 'pneumatic_75', + 'source_component': 'pn_node3_10', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_10', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 44, + 'alias': 'pneumatic_76', + 'source_component': 'pn_node3_11', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_15', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 45, + 'alias': 'pneumatic_77', + 'source_component': 'pn_node3_12', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_11', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 46, + 'alias': 'pneumatic_78', + 'source_component': 'pn_node3_13', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_12', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 47, + 'alias': 'pneumatic_79', + 'source_component': 'pnnode4_23', + 'source_port': 'port_4', + 'target_component': 'pn_morifice_11', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 48, + 'alias': 'pneumatic_80', + 'source_component': 'pnnode4_18', + 'source_port': 'port_1', + 'target_component': 'pnnode4_19', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 49, + 'alias': 'pneumatic_81', + 'source_component': 'pnnode4_23', + 'source_port': 'port_1', + 'target_component': 'pnnode4_20', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 50, + 'alias': 'pneumatic_82', + 'source_component': 'pnnode4_21', + 'source_port': 'port_1', + 'target_component': 'pnnode4_22', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 51, + 'alias': 'pneumatic_83', + 'source_component': 'pnnode4_16', + 'source_port': 'port_1', + 'target_component': 'pnnode4_17', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 52, + 'alias': 'pneumatic_84', + 'source_component': 'pnnode4_20', + 'source_port': 'port_1', + 'target_component': 'pnnode4_21', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 53, + 'alias': 'pneumatic_85', + 'source_component': 'pnnode4_19', + 'source_port': 'port_1', + 'target_component': 'pnnode4_23', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 54, + 'alias': 'pneumatic_86', + 'source_component': 'pnnode4_17', + 'source_port': 'port_1', + 'target_component': 'pnnode4_18', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 55, + 'alias': 'pneumatic_87', + 'source_component': 'pn_node3_8', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_1', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 56, + 'alias': 'pneumatic_88', + 'source_component': 'pn_node3_9', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_9', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 57, + 'alias': 'pneumatic_89', + 'source_component': 'pn_morifice_1', + 'source_port': 'port_2', + 'target_component': 'pnnode4_16', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 58, + 'alias': 'pneumatic_90', + 'source_component': 'pn_morifice_9', + 'source_port': 'port_2', + 'target_component': 'pnnode4_17', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 59, + 'alias': 'pneumatic_91', + 'source_component': 'pn_node3_14', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_13', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 60, + 'alias': 'pneumatic_92', + 'source_component': 'pn_node3_15', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_14', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 61, + 'alias': 'pneumatic_93', + 'source_component': 'pn_morifice_13', + 'source_port': 'port_2', + 'target_component': 'pnnode4_21', + 'target_port': 'port_4', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 62, + 'alias': 'pneumatic_94', + 'source_component': 'pn_morifice_14', + 'source_port': 'port_2', + 'target_component': 'pnnode4_22', + 'target_port': 'port_4', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 63, + 'alias': 'pneumatic_95', + 'source_component': 'pnnode4_22', + 'source_port': 'port_1', + 'target_component': 'pnnode4_16', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 64, + 'alias': 'pneumatic_96', + 'source_component': 'pn_node3_8', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_18', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 65, + 'alias': 'pneumatic_97', + 'source_component': 'pn_orifice_19', + 'source_port': 'port_1', + 'target_component': 'pn_node3_9', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 66, + 'alias': 'pneumatic_98', + 'source_component': 'pn_node3_10', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_20', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 67, + 'alias': 'pneumatic_99', + 'source_component': 'pn_orifice_21', + 'source_port': 'port_1', + 'target_component': 'pn_node3_11', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 68, + 'alias': 'pneumatic_100', + 'source_component': 'pn_node3_9', + 'source_port': 'port_1', + 'target_component': 'pn_node3_10', + 'target_port': 'port_3', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 69, + 'alias': 'pneumatic_101', + 'source_component': 'pn_node3_12', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_22', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 70, + 'alias': 'pneumatic_102', + 'source_component': 'pn_orifice_23', + 'source_port': 'port_1', + 'target_component': 'pn_node3_13', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 71, + 'alias': 'pneumatic_103', + 'source_component': 'pn_node3_14', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_24', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 72, + 'alias': 'pneumatic_104', + 'source_component': 'pn_orifice_25', + 'source_port': 'port_1', + 'target_component': 'pn_node3_15', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 73, + 'alias': 'pneumatic_105', + 'source_component': 'pn_node3_8', + 'source_port': 'port_3', + 'target_component': 'pn_node3_15', + 'target_port': 'port_1', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 74, + 'alias': 'pneumatic_106', + 'source_component': 'pn_node3_13', + 'source_port': 'port_1', + 'target_component': 'pn_node3_14', + 'target_port': 'port_3', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 75, + 'alias': 'pneumatic_107', + 'source_component': 'pn_node3_11', + 'source_port': 'port_1', + 'target_component': 'pn_node3_12', + 'target_port': 'port_3', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 76, + 'alias': 'control_8', + 'source_component': 'step_8', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_11', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 77, + 'alias': 'control_9', + 'source_component': 'step_9', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_12', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 78, + 'alias': 'control_10', + 'source_component': 'step_10', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_13', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 79, + 'alias': 'control_11', + 'source_component': 'step_11', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_14', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 80, + 'alias': 'control_12', + 'source_component': 'step_12', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_1', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 81, + 'alias': 'control_13', + 'source_component': 'step_13', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_9', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 82, + 'alias': 'control_14', + 'source_component': 'step_14', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_10', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 83, + 'alias': 'control_15', + 'source_component': 'step_15', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_15', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}] + + +@dataclass(frozen=True) +class TestMqlRunConfig: + t_start: float = SIMULATION_SETTINGS["t_start"] + t_stop: float = SIMULATION_SETTINGS["t_stop"] + sample_step: float = SIMULATION_SETTINGS["sample_step"] + + def sample_times(self) -> list[float]: + if self.sample_step <= 0.0: + raise ValueError("sample_step must be positive") + point_count = int(round((self.t_stop - self.t_start) / self.sample_step)) + return [self.t_start + index * self.sample_step for index in range(point_count + 1)] + + +@dataclass(frozen=True) +class TestMqlSnapshot: + model_name: str + component_count: int + connection_count: int + continuous_state_count: int + discrete_state_count: int + global_parameters: dict[str, str] + submodel_counts: dict[str, int] + + +@dataclass(frozen=True) +class TestMqlSimulationResult: + t: list[float] + y: list[list[float]] + series: dict[str, list[float]] + + +@dataclass(frozen=True) +class TestMqlFullStateSnapshot: + pneumatic: object + mechanical: object + mechanical_node_total_force_by_alias: dict[str, float] + + @property + def state_count(self) -> int: + return self.pneumatic_state_count + self.mechanical_state_count + + @property + def pneumatic_state_count(self) -> int: + return 112 + + @property + def mechanical_state_count(self) -> int: + return self.mechanical.state_count + + +@dataclass(frozen=True) +class TestMqlVariableChamberRhsDiagnostic: + chamber_alias: str + piston_alias: str + chamber_pressure_pa: float + chamber_temperature_k: float + chamber_volume_m3: float + chamber_volume_rate_m3_s: float + port_a_mass_flow_kg_s: float + port_b_mass_flow_kg_s: float + mass_derivative_kg_s: float + port_a_energy_flow_w: float + port_b_energy_flow_w: float + boundary_work_w: float + thermal_energy_flow_w: float + energy_derivative_w: float + + +@dataclass(frozen=True) +class TestMqlPnl0001LineRhsDiagnostic: + line_alias: str + chamber_alias: str + line_pressure_pa: float + line_temperature_k: float + chamber_pressure_pa: float + chamber_temperature_k: float + chamber_to_line_flow_kg_s: float + node_to_line_flow_kg_s: float + mass_derivative_kg_s: float + port_1_energy_flow_w: float + port_2_energy_flow_w: float + thermal_energy_flow_w: float + energy_derivative_w: float + + +_PISTON_FORCE_BINDINGS = ( + ("mass_friction_endstops_10", "pn_brp2_8", "p4_port3_remote_primary_chamber"), + ("mass_friction_endstops_11", "pn_brp2_9", "p4_primary_chamber"), + ("mass_friction_endstops_12", "pn_brp2_10", "p4_port1_remote_primary_chamber"), + ("mass_friction_endstops_13", "pn_brp2_11", "p4_port1_far_primary_chamber"), + ("mass_friction_endstops_14", "pn_brp2_12", "p4_port1_next_primary_chamber"), + ("mass_friction_endstops_15", "pn_brp2_13", "p4_bridge_primary_chamber"), + ("mass_friction_endstops_16", "pn_brp2_14", "p4_port3_next_primary_chamber"), + ("mass_friction_endstops_17", "pn_brp2_15", "p4_port3_far_primary_chamber"), +) + +_ELASTIC_ENDSTOP_FORCE_BINDINGS = ( + ("mass_friction_endstops_10", "mass_friction_endstops_18", "elasticendstop_8"), + ("mass_friction_endstops_11", "mass_friction_endstops_18", "elasticendstop_9"), + ("mass_friction_endstops_12", "mass_friction_endstops_18", "elasticendstop_10"), + ("mass_friction_endstops_13", "mass_friction_endstops_18", "elasticendstop_11"), + ("mass_friction_endstops_14", "mass_friction_endstops_18", "elasticendstop_12"), + ("mass_friction_endstops_15", "mass_friction_endstops_18", "elasticendstop_13"), + ("mass_friction_endstops_16", "mass_friction_endstops_18", "elasticendstop_14"), + ("mass_friction_endstops_17", "mass_friction_endstops_18", "elasticendstop_15"), +) + + +def _default_full_state_data_paths() -> tuple[str, ...]: + chamber_paths: list[str] = [] + piston_paths: list[str] = [] + for _mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + chamber_alias = _PNCH012_ALIAS_BY_SNAPSHOT_FIELD[chamber_field] + chamber_paths.extend([f"press@{chamber_alias}", f"vol@{chamber_alias}"]) + piston_paths.extend([f"vol1@{piston_alias}", f"vvol1@{piston_alias}"]) + mass_paths = [ + f"{signal}@mass_friction_endstops_{index}" + for index in range(10, 20) + for signal in ("x1", "v1", "acc1") + ] + return tuple( + [ + *chamber_paths, + *piston_paths, + *mass_paths, + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", + ] + ) + + +_PNCH012_ALIAS_BY_SNAPSHOT_FIELD = { + "p4_port3_remote_primary_chamber": "pn_c1_8", + "p4_primary_chamber": "pn_c1_9", + "p4_port1_remote_primary_chamber": "pn_c1_10", + "p4_port1_far_primary_chamber": "pn_c1_11", + "p4_port1_next_primary_chamber": "pn_c1_12", + "p4_bridge_primary_chamber": "pn_c1_13", + "p4_port3_next_primary_chamber": "pn_c1_14", + "p4_port3_far_primary_chamber": "pn_c1_15", +} + + +_PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS = { + "pn_c1_8": ( + "p4_port3_remote_primary_chamber", + "p4_port3_remote_primary_line", + "p4_port3_remote_chamber_to_line_flow", + ), + "pn_c1_9": ( + "p4_primary_chamber", + "p4_primary_line", + "p4_primary_chamber_to_line_flow", + ), + "pn_c1_10": ( + "p4_port1_remote_primary_chamber", + "p4_port1_remote_primary_line", + "p4_port1_remote_chamber_to_line_flow", + ), + "pn_c1_11": ( + "p4_port1_far_primary_chamber", + "p4_port1_far_primary_line", + "p4_port1_far_chamber_to_line_flow", + ), + "pn_c1_12": ( + "p4_port1_next_primary_chamber", + "p4_port1_next_primary_line", + "p4_port1_next_chamber_to_line_flow", + ), + "pn_c1_13": ( + "p4_bridge_primary_chamber", + "p4_bridge_primary_line", + "p4_bridge_chamber_to_line_flow", + ), + "pn_c1_14": ( + "p4_port3_next_primary_chamber", + "p4_port3_next_primary_line", + "p4_port3_next_chamber_to_line_flow", + ), + "pn_c1_15": ( + "p4_port3_far_primary_chamber", + "p4_port3_far_primary_line", + "p4_port3_far_chamber_to_line_flow", + ), +} + + +_PNL0001_LINE_DIAGNOSTIC_FIELDS = { + "pneumatic_69": ( + "p4_port3_remote_primary_line", + "p4_port3_remote_primary_chamber", + "p4_port3_remote_chamber_to_line_flow", + "p4_port3_remote_node_to_primary_line_flow", + ), +} + +_VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS = { + "pn_morifice_1": "p4_port3_remote_orifice_to_node_flow", +} + + +class TestMqlFullStateClosure: + def __init__( + self, + *, + pneumatic_closure: object, + mechanical_closure: object, + pneumatic_assembly: object, + ) -> None: + self.pneumatic_closure = pneumatic_closure + self.mechanical_closure = mechanical_closure + self.pneumatic_assembly = pneumatic_assembly + self.pneumatic_state_count = len(pneumatic_closure.initial_state_vector()) + self.mechanical_state_count = len(mechanical_closure.initial_state_vector()) + + @property + def state_count(self) -> int: + return self.pneumatic_state_count + self.mechanical_state_count + + def initial_state_vector(self) -> list[float]: + return [ + *self.pneumatic_closure.initial_state_vector(), + *self.mechanical_closure.initial_state_vector(), + ] + + def _split_state(self, state_vector: list[float]) -> tuple[list[float], list[float]]: + if len(state_vector) != self.state_count: + raise ValueError("full test_mql state vector requires 132 values") + return ( + state_vector[: self.pneumatic_state_count], + state_vector[self.pneumatic_state_count :], + ) + + def snapshot(self, state_vector: list[float] | None = None) -> TestMqlFullStateSnapshot: + return self.snapshot_at(0.0, state_vector) + + def snapshot_at( + self, + time_s: float, + state_vector: list[float] | None = None, + ) -> TestMqlFullStateSnapshot: + values = self.initial_state_vector() if state_vector is None else list(state_vector) + pneumatic_state, mechanical_state = self._split_state(values) + mechanical_snapshot = self.mechanical_closure.snapshot(mechanical_state) + self._sync_variable_chamber_kinematics(mechanical_snapshot) + self.pneumatic_assembly.set_variable_orifice_openings(time_s) + pneumatic_snapshot = self.pneumatic_closure.snapshot(pneumatic_state) + return TestMqlFullStateSnapshot( + pneumatic=pneumatic_snapshot, + mechanical=mechanical_snapshot, + mechanical_node_total_force_by_alias=( + self._mechanical_node_total_force_by_alias( + pneumatic_snapshot, + mechanical_snapshot, + ) + ), + ) + + def rhs(self, state_vector: list[float]) -> list[float]: + return self.rhs_at(0.0, state_vector) + + def rhs_at(self, time_s: float, state_vector: list[float]) -> list[float]: + pneumatic_state, mechanical_state = self._split_state(list(state_vector)) + mechanical_snapshot = self.mechanical_closure.snapshot(mechanical_state) + self._sync_variable_chamber_kinematics(mechanical_snapshot) + self.pneumatic_assembly.set_variable_orifice_openings(time_s) + pneumatic_snapshot = self.pneumatic_closure.snapshot(pneumatic_state) + return [ + *self.pneumatic_closure.rhs(pneumatic_state), + *self.mechanical_closure.rhs( + mechanical_state, + force_by_mass_alias=self._force_by_mass_alias( + time_s, + pneumatic_snapshot, + mechanical_snapshot, + ), + constrained_mass_aliases={ + "mass_friction_endstops_18", + "mass_friction_endstops_19", + }, + ), + ] + + def _sync_variable_chamber_kinematics(self, mechanical_snapshot: object) -> None: + for _mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + piston = self.mechanical_closure.assembly.pistons[piston_alias] + kinematics = mechanical_snapshot.piston_kinematics_by_alias[piston_alias] + chamber = getattr(self.pneumatic_closure.components, chamber_field) + chamber.set_external_volume_m3( + piston.geometry().chamber_volume_m3( + kinematics.port_3_displacement_m, + kinematics.port_2_displacement_m, + ), + piston.geometry().chamber_volume_rate_m3_s( + kinematics.port_3_velocity_m_s, + kinematics.port_2_velocity_m_s, + ), + ) + + def variable_chamber_rhs_diagnostic( + self, + *, + chamber_alias: str, + state_vector: list[float], + time_s: float = 0.0, + ) -> TestMqlVariableChamberRhsDiagnostic: + if chamber_alias not in _PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS: + raise KeyError(chamber_alias) + chamber_field, line_field, flow_field = _PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS[ + chamber_alias + ] + piston_alias = next( + piston_alias + for _mass_alias, piston_alias, bound_chamber_field in _PISTON_FORCE_BINDINGS + if bound_chamber_field == chamber_field + ) + snapshot = self.snapshot_at(time_s, state_vector) + chamber = getattr(self.pneumatic_closure.components, chamber_field) + chamber_properties = getattr(snapshot.pneumatic, chamber_field) + line_properties = getattr(snapshot.pneumatic, line_field) + chamber_to_line_flow = getattr(snapshot.pneumatic, flow_field) + port_a_mass_flow = -chamber_to_line_flow + port_b_mass_flow = 0.0 + port_a_inlet_h = chamber.connection_inlet_enthalpy( + port_m_flow=port_a_mass_flow, + connected_h=line_properties.h, + internal_h=chamber_properties.h, + ) + port_b_inlet_h = chamber.connection_inlet_enthalpy( + port_m_flow=port_b_mass_flow, + connected_h=chamber_properties.h, + internal_h=chamber_properties.h, + ) + port_a_energy_flow = port_a_mass_flow * port_a_inlet_h + port_b_energy_flow = port_b_mass_flow * port_b_inlet_h + boundary_work = -chamber_properties.p * chamber.volume_rate_m3_s() + thermal_energy_flow = chamber.thermal_energy_flow_w( + chamber_properties.T + ) + return TestMqlVariableChamberRhsDiagnostic( + chamber_alias=chamber_alias, + piston_alias=piston_alias, + chamber_pressure_pa=chamber_properties.p, + chamber_temperature_k=chamber_properties.T, + chamber_volume_m3=chamber.volume, + chamber_volume_rate_m3_s=chamber.volume_rate_m3_s(), + port_a_mass_flow_kg_s=port_a_mass_flow, + port_b_mass_flow_kg_s=port_b_mass_flow, + mass_derivative_kg_s=port_a_mass_flow + port_b_mass_flow, + port_a_energy_flow_w=port_a_energy_flow, + port_b_energy_flow_w=port_b_energy_flow, + boundary_work_w=boundary_work, + thermal_energy_flow_w=thermal_energy_flow, + energy_derivative_w=( + port_a_energy_flow + + port_b_energy_flow + + boundary_work + + thermal_energy_flow + ), + ) + + def pnl0001_line_rhs_diagnostic( + self, + *, + line_alias: str, + state_vector: list[float], + time_s: float = 0.0, + ) -> TestMqlPnl0001LineRhsDiagnostic: + if line_alias not in _PNL0001_LINE_DIAGNOSTIC_FIELDS: + raise KeyError(line_alias) + ( + line_field, + chamber_field, + chamber_flow_field, + node_flow_field, + ) = _PNL0001_LINE_DIAGNOSTIC_FIELDS[line_alias] + chamber_alias = _PNCH012_ALIAS_BY_SNAPSHOT_FIELD[chamber_field] + snapshot = self.snapshot_at(time_s, state_vector) + line = getattr(self.pneumatic_closure.components, line_field) + line_properties = getattr(snapshot.pneumatic, line_field) + chamber_properties = getattr(snapshot.pneumatic, chamber_field) + chamber_to_line_flow = getattr(snapshot.pneumatic, chamber_flow_field) + node_to_line_flow = getattr(snapshot.pneumatic, node_flow_field) + p4_balance = snapshot.pneumatic.p4_port3_remote_balance + p4_port_2_mass_flow_kg_s = p4_balance.port_2_mass_flow_g_s * 1.0e-3 + node_connected_h = ( + line_properties.h + if abs(p4_port_2_mass_flow_kg_s) <= 1.0e-12 + else p4_balance.port_2_enthalpy_flow_w / p4_port_2_mass_flow_kg_s + ) + line_derivative = line.derivatives_from_transport_enthalpy_connections( + port_1_m_flow=chamber_to_line_flow, + connected_h_1=chamber_properties.h, + port_2_m_flow=node_to_line_flow, + connected_h_2=node_connected_h, + ) + port_1_inlet_h = ( + chamber_properties.h if chamber_to_line_flow > 0.0 else line_properties.h + ) + port_2_inlet_h = ( + node_connected_h if node_to_line_flow > 0.0 else line_properties.h + ) + thermal_energy_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - line_properties.T) + ) + return TestMqlPnl0001LineRhsDiagnostic( + line_alias=line_alias, + chamber_alias=chamber_alias, + line_pressure_pa=line_properties.p, + line_temperature_k=line_properties.T, + chamber_pressure_pa=chamber_properties.p, + chamber_temperature_k=chamber_properties.T, + chamber_to_line_flow_kg_s=chamber_to_line_flow, + node_to_line_flow_kg_s=node_to_line_flow, + mass_derivative_kg_s=line_derivative.m, + port_1_energy_flow_w=chamber_to_line_flow * port_1_inlet_h, + port_2_energy_flow_w=node_to_line_flow * port_2_inlet_h, + thermal_energy_flow_w=thermal_energy_flow, + energy_derivative_w=line_derivative.U, + ) + + def data_path_values( + self, + *, + time_s: float, + state_vector: list[float], + data_paths: tuple[str, ...] | list[str] | None = None, + ) -> dict[str, float]: + from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa + + selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths() + snapshot = self.snapshot_at(time_s, state_vector) + rhs = self.rhs_at(time_s, state_vector) + chamber_properties_by_alias = {} + chamber_by_alias = {} + for _mass_alias, _piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + chamber = getattr(self.pneumatic_closure.components, chamber_field) + chamber_by_alias[chamber.name] = chamber + chamber_properties_by_alias[chamber.name] = getattr(snapshot.pneumatic, chamber_field) + + state_by_alias = {state.alias: state for state in snapshot.mechanical.states} + acceleration_by_mass_alias = { + alias: rhs[self.pneumatic_state_count + 2 * index] + for index, alias in enumerate(self.mechanical_closure.mass_aliases) + } + values_by_data_path = {} + for data_path in selected_paths: + signal, alias = self._split_data_path(data_path) + if alias in _PNL0001_LINE_DIAGNOSTIC_FIELDS: + ( + line_field, + _chamber_field, + chamber_flow_field, + _node_flow_field, + ) = _PNL0001_LINE_DIAGNOSTIC_FIELDS[alias] + if signal == "dm1": + canonical_flow_kg_s = getattr(snapshot.pneumatic, chamber_flow_field) + values_by_data_path[data_path] = -canonical_flow_kg_s * 1.0e3 + elif signal == "p2": + line_properties = getattr(snapshot.pneumatic, line_field) + values_by_data_path[data_path] = pressure_to_amesim_gauge_pa( + line_properties.p + ) + elif signal == "t2": + line_properties = getattr(snapshot.pneumatic, line_field) + values_by_data_path[data_path] = line_properties.T + elif signal == "mgas": + line = getattr(self.pneumatic_closure.components, line_field) + values_by_data_path[data_path] = line.gas_mass_g() + elif signal in {"re", "v", "ff"}: + line = getattr(self.pneumatic_closure.components, line_field) + line_properties = getattr(snapshot.pneumatic, line_field) + canonical_flow_kg_s = getattr(snapshot.pneumatic, chamber_flow_field) + diagnostics = line.diagnostics( + mass_flow_kg_s=-canonical_flow_kg_s, + temperature_k=line_properties.T, + ) + if signal == "re": + values_by_data_path[data_path] = diagnostics.reynolds_number + elif signal == "v": + values_by_data_path[data_path] = diagnostics.gas_velocity_m_s + else: + values_by_data_path[data_path] = diagnostics.friction_factor + else: + raise KeyError(data_path) + elif alias in self.pneumatic_assembly.variable_orifices: + orifice = self.pneumatic_assembly.variable_orifices[alias] + if signal == "xv": + values_by_data_path[data_path] = orifice.opening + elif ( + signal == "dm2" + and alias in _VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS + ): + flow_kg_s = getattr( + snapshot.pneumatic, + _VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS[alias], + ) + values_by_data_path[data_path] = flow_kg_s * 1.0e3 + else: + raise KeyError(data_path) + elif alias in chamber_properties_by_alias: + chamber_properties = chamber_properties_by_alias[alias] + chamber = chamber_by_alias[alias] + if signal == "press": + values_by_data_path[data_path] = pressure_to_amesim_gauge_pa( + chamber_properties.p + ) + elif signal == "temp": + values_by_data_path[data_path] = chamber_properties.T + elif signal == "vol": + values_by_data_path[data_path] = chamber.volume_cm3() + elif signal in {"mgas", "mgas1"}: + values_by_data_path[data_path] = chamber.gas_mass_g() + else: + raise KeyError(data_path) + elif alias in self.mechanical_closure.assembly.pistons: + piston = self.mechanical_closure.assembly.pistons[alias] + kinematics = snapshot.mechanical.piston_kinematics_by_alias[alias] + geometry = piston.geometry() + if signal == "vol1": + values_by_data_path[data_path] = geometry.chamber_volume_cm3( + kinematics.port_3_displacement_m, + kinematics.port_2_displacement_m, + ) + elif signal == "vvol1": + values_by_data_path[data_path] = geometry.chamber_volume_rate_l_min( + kinematics.port_3_velocity_m_s, + kinematics.port_2_velocity_m_s, + ) + elif signal == "length": + values_by_data_path[data_path] = geometry.chamber_length_mm( + kinematics.port_3_displacement_m, + kinematics.port_2_displacement_m, + ) + elif signal == "x4": + values_by_data_path[data_path] = kinematics.port_3_displacement_m + elif signal == "x5": + values_by_data_path[data_path] = kinematics.port_2_displacement_m + elif signal == "v4": + values_by_data_path[data_path] = kinematics.port_3_velocity_m_s + elif signal == "v5": + values_by_data_path[data_path] = kinematics.port_2_velocity_m_s + else: + raise KeyError(data_path) + elif alias in state_by_alias: + mass_state = state_by_alias[alias] + if signal == "x1": + values_by_data_path[data_path] = mass_state.displacement_m + elif signal == "v1": + values_by_data_path[data_path] = mass_state.velocity_m_s + elif signal == "acc1": + values_by_data_path[data_path] = acceleration_by_mass_alias[alias] + else: + raise KeyError(data_path) + else: + raise KeyError(data_path) + return values_by_data_path + + def data_path_series( + self, + *, + times: tuple[float, ...] | list[float], + state_rows: list[list[float]], + data_paths: tuple[str, ...] | list[str] | None = None, + ) -> dict[str, list[float]]: + selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths() + series = {data_path: [] for data_path in selected_paths} + for sample_index, time_value in enumerate(times): + state_vector = [row[sample_index] for row in state_rows] + values = self.data_path_values( + time_s=float(time_value), + state_vector=state_vector, + data_paths=selected_paths, + ) + for data_path in selected_paths: + series[data_path].append(values[data_path]) + return series + + @staticmethod + def _split_data_path(data_path: str) -> tuple[str, str]: + if "@" not in data_path: + raise KeyError(data_path) + signal, alias = data_path.split("@", 1) + return signal, alias + + def _force_by_mass_alias( + self, + time_s: float, + pneumatic_snapshot: object, + mechanical_snapshot: object, + ) -> dict[str, float]: + force_by_mass_alias = self._piston_force_by_mass_alias(pneumatic_snapshot) + elastic_force_by_mass_alias = self._elastic_endstop_force_by_mass_alias( + mechanical_snapshot + ) + for mass_alias, contact_force in elastic_force_by_mass_alias.items(): + force_by_mass_alias[mass_alias] = ( + force_by_mass_alias.get(mass_alias, 0.0) - contact_force + ) + force_by_mass_alias.update( + self._large_mass_force_by_alias( + time_s, + pneumatic_snapshot, + mechanical_snapshot, + ) + ) + return force_by_mass_alias + + def _mechanical_node_total_force_by_alias( + self, + pneumatic_snapshot: object, + mechanical_snapshot: object, + ) -> dict[str, float]: + return { + "dynamic_mechanical_node_alternative_2": sum( + self._elastic_endstop_force_by_mass_alias(mechanical_snapshot).values() + ), + "dynamic_mechanical_node_alternative_3": sum( + self._piston_force_by_mass_alias(pneumatic_snapshot).values() + ), + } + + def _large_mass_force_by_alias( + self, + time_s: float, + pneumatic_snapshot: object, + mechanical_snapshot: object, + ) -> dict[str, float]: + node_force = self._mechanical_node_total_force_by_alias( + pneumatic_snapshot, + mechanical_snapshot, + ) + forcecon_1 = self.mechanical_closure.assembly.force_connectors["forcecon_1"] + forcecon_2 = self.mechanical_closure.assembly.force_connectors["forcecon_2"] + signals = self.mechanical_closure.assembly.piecewise_signals + return { + "mass_friction_endstops_18": ( + node_force["dynamic_mechanical_node_alternative_2"] + - forcecon_2.force_at(time_s, signals) + ), + "mass_friction_endstops_19": ( + forcecon_1.force_at(time_s, signals) + - node_force["dynamic_mechanical_node_alternative_3"] + ), + } + + def _piston_force_by_mass_alias(self, pneumatic_snapshot: object) -> dict[str, float]: + from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa + + force_by_mass_alias: dict[str, float] = {} + for mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + chamber = getattr(pneumatic_snapshot, chamber_field) + piston = self.mechanical_closure.assembly.pistons[piston_alias] + force_by_mass_alias[mass_alias] = ( + pressure_to_amesim_gauge_pa(chamber.p) * piston.annulus_area_m2 + ) + return force_by_mass_alias + + def _elastic_endstop_force_by_mass_alias( + self, + mechanical_snapshot: object, + ) -> dict[str, float]: + state_by_alias = {state.alias: state for state in mechanical_snapshot.states} + force_by_mass_alias: dict[str, float] = {} + for mass_alias, support_mass_alias, endstop_alias in _ELASTIC_ENDSTOP_FORCE_BINDINGS: + mass_state = state_by_alias[mass_alias] + support_state = state_by_alias[support_mass_alias] + endstop = self.mechanical_closure.assembly.elastic_endstops[ + endstop_alias + ].endstop() + gap_mm = (support_state.displacement_m - mass_state.displacement_m) * 1.0e3 + penetration_velocity_m_s = ( + mass_state.velocity_m_s - support_state.velocity_m_s + ) + force_by_mass_alias[mass_alias] = endstop.contact_force( + gap_mm, + penetration_velocity_m_s, + ) + return force_by_mass_alias + + +class AmesimStructuralComponent(Component): + def __init__(self, spec: dict[str, object]) -> None: + super().__init__(name=str(spec["alias"])) + self.spec = spec + self.submodel = str(spec["submodel"]) + self.component_name = str(spec["component_name"]) + self.parameters = { + str(parameter["name"]): parameter + for parameter in spec.get("parameters", []) + } + + @property + def port_names(self) -> tuple[str, ...]: + return tuple(str(port["name"]) for port in self.spec.get("ports", [])) + + +class TestMqlSystem: + """Structural Python port scaffold for AMESim model ``test_mql``.""" + + def __init__(self, archive_path: Path | None = None) -> None: + self.archive_path = archive_path or Path(__file__).resolve().parents[2] / AMESIM_ARCHIVE_RELATIVE_PATH + self.network = SimulationNetwork(name=MODEL_NAME) + self.pnl0001_assembly = self._build_pnl0001_assembly() + self.pnl0002_assembly = self._build_pnl0002_assembly() + self.pnl0003_assembly = self._build_pnl0003_assembly() + self.pnl00r_assembly = self._build_pnl00r_assembly() + self.node3_assembly = self._build_node3_assembly() + self.node4_assembly = self._build_node4_assembly() + self.mechanical_assembly = self._build_mechanical_assembly() + self.pneumatic_assembly = self._build_pneumatic_assembly() + pneumatic_components = self._pneumatic_components_by_alias() + for spec in COMPONENT_SPECS: + alias = str(spec["alias"]) + self.network.add_component( + pneumatic_components.get(alias, AmesimStructuralComponent(spec)) + ) + for spec in CONNECTION_SPECS: + self.network.connect( + str(spec["source_component"]), + str(spec["source_port"]), + str(spec["target_component"]), + str(spec["target_port"]), + ) + + @staticmethod + def _build_pneumatic_assembly(): + # Local import avoids a module cycle: test_mql_config reads constants from this module. + from PythonModels.systems.test_mql_pneumatic import build_test_mql_pneumatic_assembly + + return build_test_mql_pneumatic_assembly() + + @staticmethod + def _build_mechanical_assembly(): + # Local import avoids a module cycle: test_mql_config reads constants from this module. + from PythonModels.systems.test_mql_mechanical import build_test_mql_mechanical_assembly + + return build_test_mql_mechanical_assembly() + + def _build_pnl0001_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl0001_assembly, + ) + + return build_test_mql_pnl0001_assembly(self.archive_path) + + def _build_pnl0002_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl0002_assembly, + ) + + return build_test_mql_pnl0002_assembly(self.archive_path) + + def _build_pnl0003_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl0003_assembly, + ) + + return build_test_mql_pnl0003_assembly(self.archive_path) + + def _build_pnl00r_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl00r_assembly, + ) + + return build_test_mql_pnl00r_assembly(self.archive_path) + + @staticmethod + def _build_node3_assembly(): + from PythonModels.systems.test_mql_nodes import build_test_mql_node3_assembly + + return build_test_mql_node3_assembly() + + @staticmethod + def _build_node4_assembly(): + from PythonModels.systems.test_mql_nodes import build_test_mql_node4_assembly + + return build_test_mql_node4_assembly() + + def _pneumatic_components_by_alias(self) -> dict[str, Component]: + return { + **self.pneumatic_assembly.fixed_chambers, + **self.pneumatic_assembly.variable_chambers, + **self.pneumatic_assembly.fixed_orifices, + **self.pneumatic_assembly.variable_orifices, + } + + @property + def typed_pneumatic_component_count(self) -> int: + return len(self._pneumatic_components_by_alias()) + + @property + def typed_pnl0001_line_count(self) -> int: + return len(self.pnl0001_assembly.lines) + + @property + def typed_pnl0002_line_count(self) -> int: + return len(self.pnl0002_assembly.lines) + + @property + def typed_pnl0003_line_count(self) -> int: + return len(self.pnl0003_assembly.lines) + + @property + def typed_pnl00r_line_count(self) -> int: + return len(self.pnl00r_assembly.lines) + + @property + def typed_node3_count(self) -> int: + return len(self.node3_assembly) + + @property + def typed_node4_count(self) -> int: + return len(self.node4_assembly) + + def _pnl0001_spec_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl0001_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return spec + if spec.target_component == node_alias and spec.target_port == port_name: + return spec + raise KeyError(f"No PNL0001 line attached to {node_alias}.{port_name}") + + def _pnl0001_line_for_node_port(self, node_alias: str, port_name: str): + spec = self._pnl0001_spec_for_node_port(node_alias, port_name) + return self.pnl0001_assembly.lines[spec.alias] + + def _pnl0002_connection_for_node_port(self, node_alias: str, port_name: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodePortConnection + + spec = self._pnl0002_spec_for_node_port(node_alias, port_name) + if spec.source_component == node_alias and spec.source_port == port_name: + remote_node_alias = spec.target_component + remote_port = spec.target_port + elif spec.target_component == node_alias and spec.target_port == port_name: + remote_node_alias = spec.source_component + remote_port = spec.source_port + else: + raise KeyError(f"No PNL0002 line attached to {node_alias}.{port_name}") + if remote_node_alias not in self.node4_assembly: + raise KeyError( + f"PNL0002 line {spec.alias} remote endpoint is not a P4 node: " + f"{remote_node_alias}.{remote_port}" + ) + return TestMqlP4NodePortConnection( + line_alias=spec.alias, + local_node_alias=node_alias, + local_port=port_name, + remote_node_alias=remote_node_alias, + remote_port=remote_port, + ) + + def _pnl0002_spec_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl0002_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return spec + if spec.target_component == node_alias and spec.target_port == port_name: + return spec + raise KeyError(f"No PNL0002 line attached to {node_alias}.{port_name}") + + def _pnl0002_line_for_node_port(self, node_alias: str, port_name: str): + spec = self._pnl0002_spec_for_node_port(node_alias, port_name) + return self.pnl0002_assembly.lines[spec.alias] + + def _p4_primary_connection_for_node(self, node_alias: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodePrimaryConnection + + spec = self._pnl0001_spec_for_node_port(node_alias, "port_2") + if spec.source_component == node_alias and spec.source_port == "port_2": + chamber_alias = spec.target_component + chamber_port = spec.target_port + elif spec.target_component == node_alias and spec.target_port == "port_2": + chamber_alias = spec.source_component + chamber_port = spec.source_port + else: + raise KeyError(f"No PNL0001 primary line attached to {node_alias}.port_2") + return TestMqlP4NodePrimaryConnection( + line_alias=spec.alias, + node_alias=node_alias, + node_port="port_2", + chamber_alias=chamber_alias, + chamber_port=chamber_port, + ) + + def _p4_port4_orifice_connection_for_node(self, node_alias: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodeOrificeConnection + + for spec in CONNECTION_SPECS: + if spec["submodel"] != "DIRECT": + continue + if spec["target_component"] == node_alias and spec["target_port"] == "port_4": + orifice_alias = str(spec["source_component"]) + if orifice_alias in self.pneumatic_assembly.variable_orifices: + return TestMqlP4NodeOrificeConnection( + orifice_alias=orifice_alias, + node_alias=node_alias, + node_port="port_4", + direct_line_alias=str(spec["alias"]), + ) + if spec["source_component"] == node_alias and spec["source_port"] == "port_4": + orifice_alias = str(spec["target_component"]) + if orifice_alias in self.pneumatic_assembly.variable_orifices: + return TestMqlP4NodeOrificeConnection( + orifice_alias=orifice_alias, + node_alias=node_alias, + node_port="port_4", + direct_line_alias=str(spec["alias"]), + ) + raise KeyError(f"No PNVO orifice attached to {node_alias}.port_4") + + def p4_node_neighborhood(self, node_alias: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodeNeighborhood + + if node_alias not in self.node4_assembly: + raise KeyError(f"Unknown P4 node: {node_alias}") + return TestMqlP4NodeNeighborhood( + node_alias=node_alias, + primary=self._p4_primary_connection_for_node(node_alias), + port_1=self._pnl0002_connection_for_node_port(node_alias, "port_1"), + port_3=self._pnl0002_connection_for_node_port(node_alias, "port_3"), + port_4=self._p4_port4_orifice_connection_for_node(node_alias), + ) + + def _p4_node_alias_for_orifice_port_2(self, orifice_alias: str) -> str: + for spec in CONNECTION_SPECS: + if spec["submodel"] != "DIRECT": + continue + if ( + spec["source_component"] == orifice_alias + and spec["source_port"] == "port_2" + and str(spec["target_component"]) in self.node4_assembly + ): + return str(spec["target_component"]) + if ( + spec["target_component"] == orifice_alias + and spec["target_port"] == "port_2" + and str(spec["source_component"]) in self.node4_assembly + ): + return str(spec["source_component"]) + raise KeyError(f"No P4 node attached to {orifice_alias}.port_2") + + def _pnl0003_spec_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl0003_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return spec + if spec.target_component == node_alias and spec.target_port == port_name: + return spec + raise KeyError(f"No PNL0003 line attached to {node_alias}.{port_name}") + + def _pnl0003_line_for_node_port(self, node_alias: str, port_name: str): + spec = self._pnl0003_spec_for_node_port(node_alias, port_name) + return self.pnl0003_assembly.lines[spec.alias] + + def _pnl00r_line_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl00r_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return self.pnl00r_assembly.lines[spec.alias] + if spec.target_component == node_alias and spec.target_port == port_name: + return self.pnl00r_assembly.lines[spec.alias] + raise KeyError(f"No PNL00R line attached to {node_alias}.{port_name}") + + def pneumatic_state_vector(self) -> list[float]: + return self.network.initial_state_vector() + + def mechanical_mass_closure(self): + from PythonModels.systems.test_mql_mechanical import TestMqlMechanicalMassClosure + + return TestMqlMechanicalMassClosure(self.mechanical_assembly) + + def mechanical_state_vector(self) -> list[float]: + return self.mechanical_mass_closure().initial_state_vector() + + def simulate_mechanical_masses( + self, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.mechanical_mass_closure() + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=config or SolveIVPConfig(), + t_eval=t_eval, + ) + + def full_state_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + use_pneumatic_96_reference_rhs: bool = False, + use_pneumatic_69_reference_rhs: bool = False, + ) -> TestMqlFullStateClosure: + return TestMqlFullStateClosure( + pneumatic_closure=self.pn3_p4_node_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + use_inlet_line_reference_rhs=use_pneumatic_96_reference_rhs, + use_p4_port3_remote_primary_reference_rhs=( + use_pneumatic_69_reference_rhs + ), + ), + mechanical_closure=self.mechanical_mass_closure(), + pneumatic_assembly=self.pneumatic_assembly, + ) + + def simulate_full_state_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + use_pneumatic_96_reference_rhs: bool = False, + use_pneumatic_69_reference_rhs: bool = False, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.full_state_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + use_pneumatic_96_reference_rhs=use_pneumatic_96_reference_rhs, + use_pneumatic_69_reference_rhs=use_pneumatic_69_reference_rhs, + ) + return integrate_ode( + rhs=lambda t, state: closure.rhs_at(t, state), + initial_state=closure.initial_state_vector(), + config=config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5), + t_eval=t_eval, + ) + + def simulate_full_state_series_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + data_paths: tuple[str, ...] | list[str] | None = None, + use_pneumatic_96_reference_rhs: bool = False, + use_pneumatic_69_reference_rhs: bool = False, + ) -> TestMqlSimulationResult: + closure = self.full_state_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + use_pneumatic_96_reference_rhs=use_pneumatic_96_reference_rhs, + use_pneumatic_69_reference_rhs=use_pneumatic_69_reference_rhs, + ) + solution = integrate_ode( + rhs=lambda t, state: closure.rhs_at(t, state), + initial_state=closure.initial_state_vector(), + config=config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5), + t_eval=t_eval, + ) + times = [float(value) for value in solution.t] + state_rows = [list(row) for row in solution.y] + series = { + "time": times, + **closure.data_path_series( + times=times, + state_rows=state_rows, + data_paths=data_paths, + ), + } + return TestMqlSimulationResult(t=times, y=state_rows, series=series) + + @staticmethod + def pneumatic_branch_spec( + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + source: str = "manual", + ): + from PythonModels.systems.test_mql_closure import TestMqlPneumaticBranchSpec + + return TestMqlPneumaticBranchSpec( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + source=source, + ) + + def discover_pneumatic_branch_topology(self): + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticTopologyCandidate, + TestMqlPneumaticTopologyDiscovery, + ) + from PythonModels.systems.test_mql_pneumatic_topology import ( + discover_fixed_chamber_segments, + ) + from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology + + topology = load_test_mql_cir_topology(self.archive_path) + chamber_segment_specs = discover_fixed_chamber_segments( + topology, + COMPONENT_SPECS, + CONNECTION_SPECS, + ) + + typed_aliases = set(self._pneumatic_components_by_alias()) + component_submodels = { + str(component["alias"]): str(component["submodel"]) + for component in COMPONENT_SPECS + } + blocked_candidates = [] + for spec in CONNECTION_SPECS: + source_component = str(spec["source_component"]) + target_component = str(spec["target_component"]) + source_is_typed = source_component in typed_aliases + target_is_typed = target_component in typed_aliases + if not (source_is_typed or target_is_typed): + continue + + line_submodel = str(spec["submodel"]) + endpoint_submodels = { + component_submodels.get(source_component, ""), + component_submodels.get(target_component, ""), + } + if "PNRP17" in endpoint_submodels: + category = "piston-boundary" + reason = "requires pneumatic piston and mechanical coupling interpretation" + elif "STEP0" in endpoint_submodels: + category = "control-boundary" + reason = "requires modulated-orifice control signal evaluation" + elif endpoint_submodels & {"PN3NODE2", "P4NODE2"}: + category = "node-line-boundary" + reason = "requires AMESim node/line equations before full-network closure" + elif source_is_typed and target_is_typed: + category = "typed-direct" + reason = "direct typed connection is not a complete simulated subnetwork" + elif line_submodel != "DIRECT": + category = "line-boundary" + reason = "requires AMESim line equations before full-network closure" + else: + category = "component-boundary" + reason = "requires AMESim boundary component interpretation" + + blocked_candidates.append( + TestMqlPneumaticTopologyCandidate( + connection_alias=str(spec["alias"]), + line_submodel=line_submodel, + source_component=source_component, + source_port=str(spec["source_port"]), + target_component=target_component, + target_port=str(spec["target_port"]), + source_is_typed_pneumatic=source_is_typed, + target_is_typed_pneumatic=target_is_typed, + category=category, + reason=reason, + ) + ) + + return TestMqlPneumaticTopologyDiscovery( + branch_specs=(), + chamber_segment_specs=chamber_segment_specs, + blocked_candidates=tuple(blocked_candidates), + ) + + def pneumatic_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_pressure_pa: float, + outlet_pressure_pa: float, + inlet_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + ): + """Build a reduced segment using absolute-pressure line boundaries. + + Boundary values apply at the PNL0001/orifice interfaces, not at the nodes. + """ + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPneumaticChamberSegmentClosure, + TestMqlPneumaticChamberSegmentComponents, + ) + + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPneumaticChamberSegmentClosure( + components=TestMqlPneumaticChamberSegmentComponents( + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_pressure_pa, + temperature_k=inlet_temperature_k, + ), + outlet_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=outlet_pressure_pa, + temperature_k=outlet_temperature_k, + ), + ) + + def simulate_pneumatic_chamber_segment_from_spec( + self, + spec, + *, + inlet_pressure_pa: float, + outlet_pressure_pa: float, + inlet_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + """Integrate a reduced segment with absolute-pressure line boundaries.""" + closure = self.pneumatic_chamber_segment_closure_from_spec( + spec, + inlet_pressure_pa=inlet_pressure_pa, + outlet_pressure_pa=outlet_pressure_pa, + inlet_temperature_k=inlet_temperature_k, + outlet_temperature_k=outlet_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pnl0001_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + ): + """Insert the topology-derived inlet PNL0001 into a chamber segment.""" + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPnl0001ChamberSegmentClosure, + TestMqlPnl0001ChamberSegmentComponents, + ) + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPnl0001ChamberSegmentClosure( + components=TestMqlPnl0001ChamberSegmentComponents( + inlet_line=inlet_line, + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + outlet_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=outlet_pressure_pa, + temperature_k=outlet_temperature_k, + ), + ) + + def simulate_pnl0001_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pnl0001_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + outlet_pressure_pa=outlet_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + outlet_temperature_k=outlet_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pnl0001_pair_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_node_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_node_temperature_k: float = 293.15, + ): + """Insert both topology-derived PNL0001 states into the segment.""" + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPnl0001PairChamberSegmentClosure, + TestMqlPnl0001PairChamberSegmentComponents, + ) + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPnl0001PairChamberSegmentClosure( + components=TestMqlPnl0001PairChamberSegmentComponents( + inlet_line=inlet_line, + outlet_line=outlet_line, + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + outlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=outlet_node_pressure_pa, + temperature_k=outlet_node_temperature_k, + ), + ) + + def simulate_pnl0001_pair_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_node_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_node_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pnl0001_pair_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + outlet_node_pressure_pa=outlet_node_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + outlet_node_temperature_k=outlet_node_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pn3_node_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + p4_boundary_pressure_pa: float = 15.3e6, + p4_boundary_temperature_k: float = 293.15, + ): + """Close a PNL0001 chamber segment with the real outlet PN3 node. + + The PN3 primary pressure/temperature is taken from the attached PNL0003 + port-1 compliance. The PNL0003 port-2 side is connected through the + real PNVO001 orifice to a prescribed P4 pressure boundary. + """ + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPn3NodeChamberSegmentClosure, + TestMqlPn3NodeChamberSegmentComponents, + ) + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] + node_line_spec = self._pnl0003_spec_for_node_port(spec.outlet_node_alias, "port_2") + node_line = self.pnl0003_assembly.lines[node_line_spec.alias] + node_orifice_alias = ( + node_line_spec.target_component + if node_line_spec.source_component == spec.outlet_node_alias + else node_line_spec.source_component + ) + node_orifice = self.pneumatic_assembly.variable_orifices[node_orifice_alias] + node_resistance = self._pnl00r_line_for_node_port( + spec.outlet_node_alias, "port_1" + ) + node = self.node3_assembly[spec.outlet_node_alias] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPn3NodeChamberSegmentClosure( + components=TestMqlPn3NodeChamberSegmentComponents( + inlet_line=inlet_line, + outlet_line=outlet_line, + node_line=node_line, + node_resistance=node_resistance, + node_orifice=node_orifice, + node=node, + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + resistance_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=resistance_boundary_pressure_pa, + temperature_k=resistance_boundary_temperature_k, + ), + p4_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=p4_boundary_pressure_pa, + temperature_k=p4_boundary_temperature_k, + ), + ) + + def simulate_pn3_node_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + p4_boundary_pressure_pa: float = 15.3e6, + p4_boundary_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pn3_node_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + p4_boundary_pressure_pa=p4_boundary_pressure_pa, + p4_boundary_temperature_k=p4_boundary_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pn3_p4_node_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + use_inlet_line_reference_rhs: bool = False, + use_p4_port3_remote_primary_reference_rhs: bool = False, + ): + """Close a PN3 chamber segment through the adjacent real P4 node. + + The P4 primary pressure/temperature is supplied by the PNL0001 line on + P4 port 2. Ports 1 and 3 use the adjacent PNL0002 center compliances. + The next P4 nodes contribute their primary PNL0001/chamber states, and + PNVO upstream endpoints are closed through their adjacent PN3/PNL0003 + subnets. + """ + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPn3P4NodeChamberSegmentClosure, + TestMqlPn3P4NodeChamberSegmentComponents, + ) + + def pnl0003_line_and_node_for_orifice(orifice_alias: str): + for line_spec in self.pnl0003_assembly.specs: + if ( + line_spec.target_component == orifice_alias + and line_spec.target_port == "port_3" + and line_spec.source_component in self.node3_assembly + ): + return ( + self.pnl0003_assembly.lines[line_spec.alias], + self.node3_assembly[line_spec.source_component], + ) + if ( + line_spec.source_component == orifice_alias + and line_spec.source_port == "port_3" + and line_spec.target_component in self.node3_assembly + ): + return ( + self.pnl0003_assembly.lines[line_spec.alias], + self.node3_assembly[line_spec.target_component], + ) + raise KeyError(f"No PN3/PNL0003 line attached to {orifice_alias}.port_3") + + def other_node3_for_pnl00r(line_alias: str, local_node_alias: str): + for line_spec in self.pnl00r_assembly.specs: + if line_spec.alias != line_alias: + continue + if line_spec.source_component == local_node_alias: + return self.node3_assembly[line_spec.target_component] + if line_spec.target_component == local_node_alias: + return self.node3_assembly[line_spec.source_component] + raise KeyError(f"No remote PN3 node found for {line_alias}") + + def chamber_segment_for_inlet_node(node_alias: str): + chamber_specs = ( + self.discover_pneumatic_branch_topology().chamber_segment_specs + ) + for chamber_spec in chamber_specs: + if chamber_spec.inlet_node_alias == node_alias: + return chamber_spec + raise KeyError(f"No fixed chamber segment starts at {node_alias}") + + def chamber_segment_for_outlet_node(node_alias: str): + chamber_specs = ( + self.discover_pneumatic_branch_topology().chamber_segment_specs + ) + for chamber_spec in chamber_specs: + if chamber_spec.outlet_node_alias == node_alias: + return chamber_spec + raise KeyError(f"No fixed chamber segment ends at {node_alias}") + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] + node_line_spec = self._pnl0003_spec_for_node_port( + spec.outlet_node_alias, + "port_2", + ) + node_line = self.pnl0003_assembly.lines[node_line_spec.alias] + node_orifice_alias = ( + node_line_spec.target_component + if node_line_spec.source_component == spec.outlet_node_alias + else node_line_spec.source_component + ) + p4_node_alias = self._p4_node_alias_for_orifice_port_2(node_orifice_alias) + p4_neighborhood = self.p4_node_neighborhood(p4_node_alias) + if p4_neighborhood.port_4.orifice_alias != node_orifice_alias: + raise ValueError( + f"P4 neighborhood for {p4_node_alias} does not match " + f"{node_orifice_alias}" + ) + node_orifice = self.pneumatic_assembly.variable_orifices[ + p4_neighborhood.port_4.orifice_alias + ] + node_resistance = self._pnl00r_line_for_node_port( + spec.outlet_node_alias, + "port_1", + ) + node = self.node3_assembly[spec.outlet_node_alias] + p4_node = self.node4_assembly[p4_neighborhood.node_alias] + p4_primary_line = self.pnl0001_assembly.lines[ + p4_neighborhood.primary.line_alias + ] + p4_primary_chamber_alias = p4_neighborhood.primary.chamber_alias + p4_primary_chamber = self.network.components[p4_primary_chamber_alias] + p4_port1_line = self.pnl0002_assembly.lines[ + p4_neighborhood.port_1.line_alias + ] + p4_port3_line = self.pnl0002_assembly.lines[ + p4_neighborhood.port_3.line_alias + ] + p4_port1_remote_neighborhood = self.p4_node_neighborhood( + p4_neighborhood.port_1.remote_node_alias + ) + p4_port1_remote_node = self.node4_assembly[ + p4_port1_remote_neighborhood.node_alias + ] + p4_port1_remote_primary_line = self.pnl0001_assembly.lines[ + p4_port1_remote_neighborhood.primary.line_alias + ] + p4_port1_remote_primary_chamber_alias = ( + p4_port1_remote_neighborhood.primary.chamber_alias + ) + p4_port1_remote_primary_chamber = self.network.components[ + p4_port1_remote_primary_chamber_alias + ] + p4_port1_remote_port1_line = self.pnl0002_assembly.lines[ + p4_port1_remote_neighborhood.port_1.line_alias + ] + p4_port1_far_node_alias = p4_port1_remote_neighborhood.port_1.remote_node_alias + p4_port1_far_node = self.node4_assembly[p4_port1_far_node_alias] + p4_port1_far_primary = self._p4_primary_connection_for_node( + p4_port1_far_node_alias + ) + p4_port1_far_primary_line = self.pnl0001_assembly.lines[ + p4_port1_far_primary.line_alias + ] + p4_port1_far_primary_chamber_alias = p4_port1_far_primary.chamber_alias + p4_port1_far_primary_chamber = self.network.components[ + p4_port1_far_primary_chamber_alias + ] + p4_port1_far_port1 = self._pnl0002_connection_for_node_port( + p4_port1_far_node_alias, + "port_1", + ) + p4_port1_far_port1_line = self.pnl0002_assembly.lines[ + p4_port1_far_port1.line_alias + ] + p4_port1_next_node_alias = p4_port1_far_port1.remote_node_alias + p4_port1_next_node = self.node4_assembly[p4_port1_next_node_alias] + p4_port1_next_primary = self._p4_primary_connection_for_node( + p4_port1_next_node_alias + ) + p4_port1_next_primary_line = self.pnl0001_assembly.lines[ + p4_port1_next_primary.line_alias + ] + p4_port1_next_primary_chamber_alias = p4_port1_next_primary.chamber_alias + p4_port1_next_primary_chamber = self.network.components[ + p4_port1_next_primary_chamber_alias + ] + p4_port1_next_port1 = self._pnl0002_connection_for_node_port( + p4_port1_next_node_alias, + "port_1", + ) + p4_port1_next_port1_line = self.pnl0002_assembly.lines[ + p4_port1_next_port1.line_alias + ] + p4_port1_next_orifice_output_spec = self._pnl0001_spec_for_node_port( + p4_port1_next_node_alias, + "port_4", + ) + p4_port1_next_orifice_output_line = self.pnl0001_assembly.lines[ + p4_port1_next_orifice_output_spec.alias + ] + p4_bridge_node_alias = p4_port1_next_port1.remote_node_alias + p4_bridge_node = self.node4_assembly[p4_bridge_node_alias] + p4_bridge_primary = self._p4_primary_connection_for_node( + p4_bridge_node_alias + ) + p4_bridge_primary_line = self.pnl0001_assembly.lines[ + p4_bridge_primary.line_alias + ] + p4_bridge_primary_chamber_alias = p4_bridge_primary.chamber_alias + p4_bridge_primary_chamber = self.network.components[ + p4_bridge_primary_chamber_alias + ] + p4_port1_remote_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port1_remote_neighborhood.port_4.orifice_alias + ] + ( + p4_port1_remote_orifice_line, + p4_port1_remote_orifice_node, + ) = pnl0003_line_and_node_for_orifice( + p4_port1_remote_neighborhood.port_4.orifice_alias + ) + p4_port3_remote_neighborhood = self.p4_node_neighborhood( + p4_neighborhood.port_3.remote_node_alias + ) + p4_port3_remote_node = self.node4_assembly[ + p4_port3_remote_neighborhood.node_alias + ] + p4_port3_remote_primary_line = self.pnl0001_assembly.lines[ + p4_port3_remote_neighborhood.primary.line_alias + ] + p4_port3_remote_primary_chamber_alias = ( + p4_port3_remote_neighborhood.primary.chamber_alias + ) + p4_port3_remote_primary_chamber = self.network.components[ + p4_port3_remote_primary_chamber_alias + ] + p4_port3_remote_port3_line = self.pnl0002_assembly.lines[ + p4_port3_remote_neighborhood.port_3.line_alias + ] + p4_port3_far_node_alias = p4_port3_remote_neighborhood.port_3.remote_node_alias + p4_port3_far_node = self.node4_assembly[p4_port3_far_node_alias] + p4_port3_far_primary = self._p4_primary_connection_for_node( + p4_port3_far_node_alias + ) + p4_port3_far_primary_line = self.pnl0001_assembly.lines[ + p4_port3_far_primary.line_alias + ] + p4_port3_far_primary_chamber_alias = p4_port3_far_primary.chamber_alias + p4_port3_far_primary_chamber = self.network.components[ + p4_port3_far_primary_chamber_alias + ] + p4_port3_far_port3 = self._pnl0002_connection_for_node_port( + p4_port3_far_node_alias, + "port_3", + ) + p4_port3_far_port3_line = self.pnl0002_assembly.lines[ + p4_port3_far_port3.line_alias + ] + p4_port3_next_node_alias = p4_port3_far_port3.remote_node_alias + p4_port3_next_node = self.node4_assembly[p4_port3_next_node_alias] + p4_port3_next_primary = self._p4_primary_connection_for_node( + p4_port3_next_node_alias + ) + p4_port3_next_primary_line = self.pnl0001_assembly.lines[ + p4_port3_next_primary.line_alias + ] + p4_port3_next_primary_chamber_alias = p4_port3_next_primary.chamber_alias + p4_port3_next_primary_chamber = self.network.components[ + p4_port3_next_primary_chamber_alias + ] + p4_port3_next_port3 = self._pnl0002_connection_for_node_port( + p4_port3_next_node_alias, + "port_3", + ) + if p4_port3_next_port3.remote_node_alias != p4_bridge_node_alias: + raise ValueError( + f"{p4_port3_next_port3.line_alias} does not connect back to " + f"{p4_bridge_node_alias}" + ) + p4_port3_next_port3_line = self.pnl0002_assembly.lines[ + p4_port3_next_port3.line_alias + ] + p4_port3_next_orifice_output_spec = self._pnl0001_spec_for_node_port( + p4_port3_next_node_alias, + "port_4", + ) + p4_port3_next_orifice_alias = ( + p4_port3_next_orifice_output_spec.source_component + if p4_port3_next_orifice_output_spec.target_component + == p4_port3_next_node_alias + else p4_port3_next_orifice_output_spec.target_component + ) + p4_port3_next_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_next_orifice_alias + ] + ( + p4_port3_next_orifice_line, + p4_port3_next_orifice_node, + ) = pnl0003_line_and_node_for_orifice(p4_port3_next_orifice_alias) + p4_port3_next_orifice_output_line = self.pnl0001_assembly.lines[ + p4_port3_next_orifice_output_spec.alias + ] + p4_port3_next_chamber_spec = chamber_segment_for_inlet_node( + p4_port3_next_orifice_node.alias + ) + p4_port3_next_chamber_inlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_chamber_spec.inlet_line_alias + ] + p4_port3_next_chamber = self.network.components[ + p4_port3_next_chamber_spec.volume_alias + ] + p4_port3_next_chamber_outlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_chamber_spec.outlet_line_alias + ] + p4_port3_next_chamber_inlet_orifice = self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_chamber_spec.inlet_orifice_alias + ] + p4_port3_next_chamber_outlet_orifice = self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_chamber_spec.outlet_orifice_alias + ] + p4_port3_next_orifice_resistance = self._pnl00r_line_for_node_port( + p4_port3_next_orifice_node.alias, + "port_3", + ) + p4_port3_next_resistance_node = other_node3_for_pnl00r( + p4_port3_next_orifice_resistance.name, + p4_port3_next_orifice_node.alias, + ) + p4_port3_next_resistance_node_line_spec = self._pnl0003_spec_for_node_port( + p4_port3_next_resistance_node.alias, + "port_2", + ) + p4_port3_next_resistance_node_line = self.pnl0003_assembly.lines[ + p4_port3_next_resistance_node_line_spec.alias + ] + p4_port3_next_resistance_orifice_alias = ( + p4_port3_next_resistance_node_line_spec.target_component + if p4_port3_next_resistance_node_line_spec.source_component + == p4_port3_next_resistance_node.alias + else p4_port3_next_resistance_node_line_spec.source_component + ) + p4_port3_next_resistance_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_next_resistance_orifice_alias + ] + p4_port3_next_resistance_output_spec = self._pnl0001_spec_for_node_port( + p4_bridge_node_alias, + "port_4", + ) + if ( + p4_port3_next_resistance_output_spec.source_component + != p4_port3_next_resistance_orifice_alias + ): + raise ValueError( + f"{p4_port3_next_resistance_output_spec.alias} is not fed by " + f"{p4_port3_next_resistance_orifice_alias}" + ) + p4_port3_next_resistance_output_line = self.pnl0001_assembly.lines[ + p4_port3_next_resistance_output_spec.alias + ] + p4_port3_next_resistance_chamber_spec = chamber_segment_for_outlet_node( + p4_port3_next_resistance_node.alias + ) + p4_port3_next_resistance_chamber_inlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_resistance_chamber_spec.inlet_line_alias + ] + p4_port3_next_resistance_chamber = self.network.components[ + p4_port3_next_resistance_chamber_spec.volume_alias + ] + p4_port3_next_resistance_chamber_outlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_resistance_chamber_spec.outlet_line_alias + ] + p4_port3_next_resistance_chamber_inlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_resistance_chamber_spec.inlet_orifice_alias + ] + ) + p4_port3_next_resistance_chamber_outlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_resistance_chamber_spec.outlet_orifice_alias + ] + ) + p4_port1_next_orifice_node = self.node3_assembly[ + p4_port3_next_resistance_chamber_spec.inlet_node_alias + ] + p4_port1_next_orifice_line_spec = self._pnl0003_spec_for_node_port( + p4_port1_next_orifice_node.alias, + "port_2", + ) + p4_port1_next_orifice_line = self.pnl0003_assembly.lines[ + p4_port1_next_orifice_line_spec.alias + ] + p4_port1_next_orifice_alias = ( + p4_port1_next_orifice_line_spec.target_component + if p4_port1_next_orifice_line_spec.source_component + == p4_port1_next_orifice_node.alias + else p4_port1_next_orifice_line_spec.source_component + ) + p4_port1_next_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port1_next_orifice_alias + ] + if ( + p4_port1_next_orifice_output_spec.source_component != p4_port1_next_node_alias + or p4_port1_next_orifice_output_spec.target_component + != p4_port1_next_orifice_alias + ): + raise ValueError( + f"{p4_port1_next_orifice_output_spec.alias} does not connect " + f"{p4_port1_next_node_alias}.port_4 to {p4_port1_next_orifice_alias}" + ) + p4_port1_next_orifice_resistance = self._pnl00r_line_for_node_port( + p4_port1_next_orifice_node.alias, + "port_3", + ) + p4_port1_next_orifice_resistance_node = other_node3_for_pnl00r( + p4_port1_next_orifice_resistance.name, + p4_port1_next_orifice_node.alias, + ) + p4_port1_next_orifice_resistance_line_spec = self._pnl0003_spec_for_node_port( + p4_port1_next_orifice_resistance_node.alias, + "port_2", + ) + p4_port1_next_orifice_resistance_line = self.pnl0003_assembly.lines[ + p4_port1_next_orifice_resistance_line_spec.alias + ] + p4_port1_next_orifice_resistance_orifice_alias = ( + p4_port1_next_orifice_resistance_line_spec.target_component + if p4_port1_next_orifice_resistance_line_spec.source_component + == p4_port1_next_orifice_resistance_node.alias + else p4_port1_next_orifice_resistance_line_spec.source_component + ) + p4_port1_next_orifice_resistance_orifice = ( + self.pneumatic_assembly.variable_orifices[ + p4_port1_next_orifice_resistance_orifice_alias + ] + ) + p4_port1_far_orifice_direct = self.p4_node_neighborhood( + p4_port1_far_node_alias + ).port_4 + if ( + p4_port1_far_orifice_direct.orifice_alias + != p4_port1_next_orifice_resistance_orifice_alias + ): + raise ValueError( + f"{p4_port1_far_node_alias}.port_4 does not use " + f"{p4_port1_next_orifice_resistance_orifice_alias}" + ) + p4_port1_next_orifice_resistance_chamber_spec = ( + chamber_segment_for_outlet_node( + p4_port1_next_orifice_resistance_node.alias + ) + ) + p4_port1_next_orifice_resistance_chamber_inlet_line = ( + self.pnl0001_assembly.lines[ + p4_port1_next_orifice_resistance_chamber_spec.inlet_line_alias + ] + ) + p4_port1_next_orifice_resistance_chamber = self.network.components[ + p4_port1_next_orifice_resistance_chamber_spec.volume_alias + ] + p4_port1_next_orifice_resistance_chamber_outlet_line = ( + self.pnl0001_assembly.lines[ + p4_port1_next_orifice_resistance_chamber_spec.outlet_line_alias + ] + ) + p4_port1_next_orifice_resistance_chamber_inlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port1_next_orifice_resistance_chamber_spec.inlet_orifice_alias + ] + ) + p4_port1_next_orifice_resistance_chamber_outlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port1_next_orifice_resistance_chamber_spec.outlet_orifice_alias + ] + ) + p4_port3_remote_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_remote_neighborhood.port_4.orifice_alias + ] + ( + p4_port3_remote_orifice_line, + p4_port3_remote_orifice_node, + ) = pnl0003_line_and_node_for_orifice( + p4_port3_remote_neighborhood.port_4.orifice_alias + ) + p4_port3_remote_orifice_resistance = self._pnl00r_line_for_node_port( + p4_port3_remote_orifice_node.alias, + "port_3", + ) + p4_port3_remote_resistance_node = other_node3_for_pnl00r( + p4_port3_remote_orifice_resistance.name, + p4_port3_remote_orifice_node.alias, + ) + p4_port3_remote_resistance_node_line_spec = self._pnl0003_spec_for_node_port( + p4_port3_remote_resistance_node.alias, + "port_2", + ) + p4_port3_remote_resistance_node_line = self.pnl0003_assembly.lines[ + p4_port3_remote_resistance_node_line_spec.alias + ] + p4_port3_remote_resistance_orifice_alias = ( + p4_port3_remote_resistance_node_line_spec.target_component + if p4_port3_remote_resistance_node_line_spec.source_component + == p4_port3_remote_resistance_node.alias + else p4_port3_remote_resistance_node_line_spec.source_component + ) + p4_port3_remote_resistance_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_remote_resistance_orifice_alias + ] + p4_port3_remote_resistance_output_spec = self._pnl0001_spec_for_node_port( + p4_port3_far_node_alias, + "port_4", + ) + if ( + p4_port3_remote_resistance_output_spec.source_component + != p4_port3_remote_resistance_orifice_alias + ): + raise ValueError( + f"{p4_port3_remote_resistance_output_spec.alias} is not fed by " + f"{p4_port3_remote_resistance_orifice_alias}" + ) + p4_port3_remote_resistance_output_line = self.pnl0001_assembly.lines[ + p4_port3_remote_resistance_output_spec.alias + ] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(p4_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port1_remote_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port1_remote_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_remote_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_remote_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port1_far_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port1_far_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_far_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_far_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port1_next_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port1_next_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_next_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_next_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_next_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_next_chamber_spec.volume_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_next_chamber_inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_chamber_outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_resistance_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_resistance_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_resistance_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_next_resistance_chamber_spec.volume_alias} is not an AMESim pneumatic volume" + ) + if not isinstance( + p4_port3_next_resistance_chamber_inlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port3_next_resistance_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port3_next_resistance_chamber_outlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port3_next_resistance_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port1_next_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port1_next_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port1_next_orifice_resistance_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port1_next_orifice_resistance_chamber, + AmesimPneumaticVolume, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_chamber_spec.volume_alias} is not an AMESim pneumatic volume" + ) + if not isinstance( + p4_port1_next_orifice_resistance_chamber_inlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port1_next_orifice_resistance_chamber_outlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_bridge_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_bridge_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_remote_resistance_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_remote_resistance_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPn3P4NodeChamberSegmentClosure( + components=TestMqlPn3P4NodeChamberSegmentComponents( + inlet_line=inlet_line, + outlet_line=outlet_line, + node_line=node_line, + node_resistance=node_resistance, + node_orifice=node_orifice, + node=node, + p4_node=p4_node, + p4_primary_line=p4_primary_line, + p4_primary_chamber=p4_primary_chamber, + p4_port1_line=p4_port1_line, + p4_port1_remote_node=p4_port1_remote_node, + p4_port1_remote_primary_line=p4_port1_remote_primary_line, + p4_port1_remote_primary_chamber=p4_port1_remote_primary_chamber, + p4_port1_remote_port1_line=p4_port1_remote_port1_line, + p4_port1_far_node=p4_port1_far_node, + p4_port1_far_primary_line=p4_port1_far_primary_line, + p4_port1_far_primary_chamber=p4_port1_far_primary_chamber, + p4_port1_far_port1_line=p4_port1_far_port1_line, + p4_port1_next_node=p4_port1_next_node, + p4_port1_next_primary_line=p4_port1_next_primary_line, + p4_port1_next_primary_chamber=p4_port1_next_primary_chamber, + p4_port1_next_port1_line=p4_port1_next_port1_line, + p4_port1_next_orifice=p4_port1_next_orifice, + p4_port1_next_orifice_line=p4_port1_next_orifice_line, + p4_port1_next_orifice_node=p4_port1_next_orifice_node, + p4_port1_next_orifice_output_line=p4_port1_next_orifice_output_line, + p4_port1_next_orifice_resistance=p4_port1_next_orifice_resistance, + p4_port1_next_orifice_resistance_node=( + p4_port1_next_orifice_resistance_node + ), + p4_port1_next_orifice_resistance_line=( + p4_port1_next_orifice_resistance_line + ), + p4_port1_next_orifice_resistance_orifice=( + p4_port1_next_orifice_resistance_orifice + ), + p4_port1_next_orifice_resistance_chamber_inlet_line=( + p4_port1_next_orifice_resistance_chamber_inlet_line + ), + p4_port1_next_orifice_resistance_chamber=( + p4_port1_next_orifice_resistance_chamber + ), + p4_port1_next_orifice_resistance_chamber_outlet_line=( + p4_port1_next_orifice_resistance_chamber_outlet_line + ), + p4_port1_next_orifice_resistance_chamber_inlet_orifice=( + p4_port1_next_orifice_resistance_chamber_inlet_orifice + ), + p4_port1_next_orifice_resistance_chamber_outlet_orifice=( + p4_port1_next_orifice_resistance_chamber_outlet_orifice + ), + p4_port1_next_orifice_resistance_chamber_spec=( + p4_port1_next_orifice_resistance_chamber_spec + ), + p4_bridge_node=p4_bridge_node, + p4_bridge_primary_line=p4_bridge_primary_line, + p4_bridge_primary_chamber=p4_bridge_primary_chamber, + p4_port1_remote_orifice=p4_port1_remote_orifice, + p4_port1_remote_orifice_line=p4_port1_remote_orifice_line, + p4_port1_remote_orifice_node=p4_port1_remote_orifice_node, + p4_port3_line=p4_port3_line, + p4_port3_remote_node=p4_port3_remote_node, + p4_port3_remote_primary_line=p4_port3_remote_primary_line, + p4_port3_remote_primary_chamber=p4_port3_remote_primary_chamber, + p4_port3_remote_port3_line=p4_port3_remote_port3_line, + p4_port3_far_node=p4_port3_far_node, + p4_port3_far_primary_line=p4_port3_far_primary_line, + p4_port3_far_primary_chamber=p4_port3_far_primary_chamber, + p4_port3_far_port3_line=p4_port3_far_port3_line, + p4_port3_next_node=p4_port3_next_node, + p4_port3_next_primary_line=p4_port3_next_primary_line, + p4_port3_next_primary_chamber=p4_port3_next_primary_chamber, + p4_port3_next_port3_line=p4_port3_next_port3_line, + p4_port3_next_orifice=p4_port3_next_orifice, + p4_port3_next_orifice_line=p4_port3_next_orifice_line, + p4_port3_next_orifice_node=p4_port3_next_orifice_node, + p4_port3_next_orifice_output_line=p4_port3_next_orifice_output_line, + p4_port3_next_chamber_inlet_line=p4_port3_next_chamber_inlet_line, + p4_port3_next_chamber=p4_port3_next_chamber, + p4_port3_next_chamber_outlet_line=p4_port3_next_chamber_outlet_line, + p4_port3_next_chamber_inlet_orifice=p4_port3_next_chamber_inlet_orifice, + p4_port3_next_chamber_outlet_orifice=p4_port3_next_chamber_outlet_orifice, + p4_port3_next_chamber_spec=p4_port3_next_chamber_spec, + p4_port3_next_orifice_resistance=p4_port3_next_orifice_resistance, + p4_port3_next_resistance_node=p4_port3_next_resistance_node, + p4_port3_next_resistance_node_line=p4_port3_next_resistance_node_line, + p4_port3_next_resistance_orifice=p4_port3_next_resistance_orifice, + p4_port3_next_resistance_output_line=p4_port3_next_resistance_output_line, + p4_port3_next_resistance_chamber_inlet_line=( + p4_port3_next_resistance_chamber_inlet_line + ), + p4_port3_next_resistance_chamber=p4_port3_next_resistance_chamber, + p4_port3_next_resistance_chamber_outlet_line=( + p4_port3_next_resistance_chamber_outlet_line + ), + p4_port3_next_resistance_chamber_inlet_orifice=( + p4_port3_next_resistance_chamber_inlet_orifice + ), + p4_port3_next_resistance_chamber_outlet_orifice=( + p4_port3_next_resistance_chamber_outlet_orifice + ), + p4_port3_next_resistance_chamber_spec=( + p4_port3_next_resistance_chamber_spec + ), + p4_port3_remote_orifice=p4_port3_remote_orifice, + p4_port3_remote_orifice_line=p4_port3_remote_orifice_line, + p4_port3_remote_orifice_node=p4_port3_remote_orifice_node, + p4_port3_remote_orifice_resistance=p4_port3_remote_orifice_resistance, + p4_port3_remote_resistance_node=p4_port3_remote_resistance_node, + p4_port3_remote_resistance_node_line=( + p4_port3_remote_resistance_node_line + ), + p4_port3_remote_resistance_orifice=( + p4_port3_remote_resistance_orifice + ), + p4_port3_remote_resistance_output_line=( + p4_port3_remote_resistance_output_line + ), + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + resistance_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=resistance_boundary_pressure_pa, + temperature_k=resistance_boundary_temperature_k, + ), + use_inlet_line_reference_rhs=use_inlet_line_reference_rhs, + use_p4_port3_remote_primary_reference_rhs=( + use_p4_port3_remote_primary_reference_rhs + ), + ) + + def simulate_pn3_p4_node_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pn3_p4_node_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pneumatic_branch_closure_from_spec(self, spec): + return self.pneumatic_branch_closure( + name=spec.name, + upstream_volume_alias=spec.upstream_volume_alias, + orifice_alias=spec.orifice_alias, + downstream_volume_alias=spec.downstream_volume_alias, + spec=spec, + ) + + def pneumatic_branch_closure( + self, + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + spec=None, + ): + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBranchComponents, + TestMqlPneumaticBranchSpec, + TestMqlPneumaticClosure, + ) + + upstream_volume = self.network.components[upstream_volume_alias] + orifice = self.network.components[orifice_alias] + downstream_volume = self.network.components[downstream_volume_alias] + if not isinstance(upstream_volume, AmesimPneumaticVolume): + raise TypeError(f"{upstream_volume_alias} is not an AMESim pneumatic volume") + if not isinstance(downstream_volume, AmesimPneumaticVolume): + raise TypeError(f"{downstream_volume_alias} is not an AMESim pneumatic volume") + if not isinstance(orifice, AmesimPneumaticOrifice): + raise TypeError(f"{orifice_alias} is not an AMESim pneumatic orifice") + + def initial_state_vector() -> list[float]: + return [ + *upstream_volume.get_state_vector(), + *downstream_volume.get_state_vector(), + ] + + def apply_state_vector(values: list[float]) -> None: + if len(values) != 4: + raise ValueError("two-volume pneumatic branch state vector requires four values") + upstream_volume.set_state_vector(values[:2]) + downstream_volume.set_state_vector(values[2:]) + + branch_spec = spec or TestMqlPneumaticBranchSpec( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + ) + return TestMqlPneumaticClosure( + components=TestMqlPneumaticBranchComponents( + name=name, + upstream_volume=upstream_volume, + orifice=orifice, + downstream_volume=downstream_volume, + spec=branch_spec, + ), + initial_state_vector=initial_state_vector, + apply_state_vector=apply_state_vector, + ) + + def simulate_pneumatic_branch( + self, + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pneumatic_branch_closure( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def simulate_pneumatic_branch_from_spec( + self, + spec, + *, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + return self.simulate_pneumatic_branch( + name=spec.name, + upstream_volume_alias=spec.upstream_volume_alias, + orifice_alias=spec.orifice_alias, + downstream_volume_alias=spec.downstream_volume_alias, + config=config, + t_eval=t_eval, + ) + + def evaluate_pneumatic_branch_solution_from_spec( + self, + solution: object, + spec, + ) -> dict[str, list[float]]: + return self.evaluate_pneumatic_branch_solution( + solution, + name=spec.name, + upstream_volume_alias=spec.upstream_volume_alias, + orifice_alias=spec.orifice_alias, + downstream_volume_alias=spec.downstream_volume_alias, + ) + + def evaluate_pneumatic_branch_solution( + self, + solution: object, + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + ) -> dict[str, list[float]]: + closure = self.pneumatic_branch_closure( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + ) + series = { + "time": [], + f"{name}.flow": [], + f"{upstream_volume_alias}.p": [], + f"{upstream_volume_alias}.T": [], + f"{upstream_volume_alias}.m_flow": [], + f"{downstream_volume_alias}.p": [], + f"{downstream_volume_alias}.T": [], + f"{downstream_volume_alias}.m_flow": [], + } + for index, time_value in enumerate(solution.t): + state_vector = [row[index] for row in solution.y] + snapshot = closure.snapshot(state_vector) + series["time"].append(float(time_value)) + series[f"{name}.flow"].append(snapshot.flow) + series[f"{upstream_volume_alias}.p"].append(snapshot.upstream.p) + series[f"{upstream_volume_alias}.T"].append(snapshot.upstream.T) + series[f"{upstream_volume_alias}.m_flow"].append( + closure.components.upstream_volume.port_a.m_flow + ) + series[f"{downstream_volume_alias}.p"].append(snapshot.downstream.p) + series[f"{downstream_volume_alias}.T"].append(snapshot.downstream.T) + series[f"{downstream_volume_alias}.m_flow"].append( + closure.components.downstream_volume.port_a.m_flow + ) + return series + + def snapshot(self) -> TestMqlSnapshot: + return TestMqlSnapshot( + model_name=MODEL_NAME, + component_count=len(COMPONENT_SPECS), + connection_count=len(CONNECTION_SPECS), + continuous_state_count=int(MODEL_INFO.get("num_states", 0)), + discrete_state_count=int(MODEL_INFO.get("num_discrete_states", 0)), + global_parameters=dict(GLOBAL_PARAMETERS), + submodel_counts=dict(SUBMODEL_COUNTS), + ) + + def initial_state_vector(self) -> list[float]: + return [0.0] * int(MODEL_INFO.get("num_states", 0)) + + def simulate(self, config: TestMqlRunConfig | None = None) -> TestMqlSimulationResult: + run_config = config or TestMqlRunConfig() + times = run_config.sample_times() + state_count = int(MODEL_INFO.get("num_states", 0)) + y = [[0.0 for _ in times] for _ in range(state_count)] + series = self.evaluate_solution(SimpleNamespace(t=times, y=y)) + return TestMqlSimulationResult(t=times, y=y, series=series) + + def evaluate_solution(self, solution: object) -> dict[str, list[float]]: + times = [float(value) for value in solution.t] + return { + "time": times, + "component_count": [float(len(COMPONENT_SPECS)) for _ in times], + "connection_count": [float(len(CONNECTION_SPECS)) for _ in times], + "continuous_state_count": [float(MODEL_INFO.get("num_states", 0)) for _ in times], + "discrete_state_count": [float(MODEL_INFO.get("num_discrete_states", 0)) for _ in times], + } + + def component_specs_by_submodel(self) -> dict[str, list[dict[str, object]]]: + grouped: dict[str, list[dict[str, object]]] = {} + for spec in COMPONENT_SPECS: + grouped.setdefault(str(spec["submodel"]), []).append(spec) + return grouped + + def connection_specs_by_submodel(self) -> dict[str, list[dict[str, object]]]: + grouped: dict[str, list[dict[str, object]]] = {} + for spec in CONNECTION_SPECS: + grouped.setdefault(str(spec["submodel"]), []).append(spec) + return grouped + + +def build_test_mql() -> SimulationNetwork: + return TestMqlSystem().network diff --git a/PythonModels/systems/test_mql_baseline.py b/PythonModels/systems/test_mql_baseline.py new file mode 100644 index 0000000..fff6c38 --- /dev/null +++ b/PythonModels/systems/test_mql_baseline.py @@ -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, + ) diff --git a/PythonModels/systems/test_mql_closure.py b/PythonModels/systems/test_mql_closure.py new file mode 100644 index 0000000..984d9f7 --- /dev/null +++ b/PythonModels/systems/test_mql_closure.py @@ -0,0 +1,4213 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticGas, + AmesimPneumaticOrifice, + AmesimPneumaticVolume, +) +from PythonModels.components.amesim_pneumatic_line import ( + AmesimPnl0001Pipe, + AmesimPnl0002Pipe, + AmesimPnl0003Pipe, + AmesimPnl00rPipe, +) +from PythonModels.core.medium import ThermodynamicProperties +from PythonModels.core.ports import PortState +from PythonModels.core.state import VolumeState +from PythonModels.systems.test_mql_nodes import ( + TestMqlPneumaticNode3, + TestMqlPneumaticNode3Balance, + TestMqlPneumaticNode4, + TestMqlPneumaticNode4Balance, +) + + +@dataclass(frozen=True) +class TestMqlPneumaticBranchSpec: + name: str + upstream_volume_alias: str + orifice_alias: str + downstream_volume_alias: str + source: str = "manual" + + +@dataclass(frozen=True) +class TestMqlPneumaticTopologyCandidate: + connection_alias: str + line_submodel: str + source_component: str + source_port: str + target_component: str + target_port: str + source_is_typed_pneumatic: bool + target_is_typed_pneumatic: bool + category: str + reason: str + + +@dataclass(frozen=True) +class TestMqlPneumaticChamberSegmentSpec: + name: str + inlet_node_alias: str + inlet_line_alias: str + inlet_orifice_alias: str + inlet_orifice_boundary_port: str + inlet_orifice_volume_port: str + volume_alias: str + volume_inlet_port: str + volume_outlet_port: str + outlet_orifice_alias: str + outlet_orifice_volume_port: str + outlet_orifice_boundary_port: str + outlet_line_alias: str + outlet_node_alias: str + source: str = "amesim-cir-topology" + + +@dataclass(frozen=True) +class TestMqlPneumaticTopologyDiscovery: + branch_specs: tuple[TestMqlPneumaticBranchSpec, ...] + chamber_segment_specs: tuple[TestMqlPneumaticChamberSegmentSpec, ...] + blocked_candidates: tuple[TestMqlPneumaticTopologyCandidate, ...] + + +@dataclass(frozen=True) +class TestMqlPneumaticBoundaryCondition: + pressure_pa: float + temperature_k: float + + def properties(self, gas: AmesimPneumaticGas) -> ThermodynamicProperties: + if self.pressure_pa <= 0.0: + raise ValueError("boundary pressure must be positive") + if self.temperature_k <= 0.0: + raise ValueError("boundary temperature must be positive") + return ThermodynamicProperties( + p=self.pressure_pa, + T=self.temperature_k, + rho=gas.density(self.pressure_pa, self.temperature_k), + u=gas.specific_internal_energy(self.temperature_k), + h=gas.specific_enthalpy(self.temperature_k), + ) + + +@dataclass(frozen=True) +class TestMqlPneumaticChamberSegmentComponents: + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPneumaticChamberSegmentSnapshot: + chamber: ThermodynamicProperties + inlet_boundary: ThermodynamicProperties + outlet_boundary: ThermodynamicProperties + inlet_flow: float + outlet_flow: float + + +@dataclass(frozen=True) +class TestMqlPnl0001ChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPnl0001ChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + inlet_node: ThermodynamicProperties + outlet_boundary: ThermodynamicProperties + node_to_line_flow: float + line_to_chamber_flow: float + outlet_flow: float + + +@dataclass(frozen=True) +class TestMqlPnl0001PairChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + outlet_line: AmesimPnl0001Pipe + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPnl0001PairChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + outlet_line: ThermodynamicProperties + inlet_node: ThermodynamicProperties + outlet_node: ThermodynamicProperties + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + + +@dataclass(frozen=True) +class TestMqlPn3NodeChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + outlet_line: AmesimPnl0001Pipe + node_line: AmesimPnl0003Pipe + node_resistance: AmesimPnl00rPipe + node_orifice: AmesimPneumaticOrifice + node: TestMqlPneumaticNode3 + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPn3NodeChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + outlet_line: ThermodynamicProperties + node_line_port_1: ThermodynamicProperties + node_line_port_2: ThermodynamicProperties + inlet_node: ThermodynamicProperties + resistance_boundary: ThermodynamicProperties + p4_boundary: ThermodynamicProperties + node_balance: TestMqlPneumaticNode3Balance + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + node_to_resistance_flow: float + node_to_pnl0003_flow: float + pnl0003_center_flow: float + pnl0003_to_p4_flow: float + + +@dataclass(frozen=True) +class TestMqlPn3P4NodeChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + outlet_line: AmesimPnl0001Pipe + node_line: AmesimPnl0003Pipe + node_resistance: AmesimPnl00rPipe + node_orifice: AmesimPneumaticOrifice + node: TestMqlPneumaticNode3 + p4_node: TestMqlPneumaticNode4 + p4_primary_line: AmesimPnl0001Pipe + p4_primary_chamber: AmesimPneumaticVolume + p4_port1_line: AmesimPnl0002Pipe + p4_port1_remote_node: TestMqlPneumaticNode4 + p4_port1_remote_primary_line: AmesimPnl0001Pipe + p4_port1_remote_primary_chamber: AmesimPneumaticVolume + p4_port1_remote_port1_line: AmesimPnl0002Pipe + p4_port1_far_node: TestMqlPneumaticNode4 + p4_port1_far_primary_line: AmesimPnl0001Pipe + p4_port1_far_primary_chamber: AmesimPneumaticVolume + p4_port1_far_port1_line: AmesimPnl0002Pipe + p4_port1_next_node: TestMqlPneumaticNode4 + p4_port1_next_primary_line: AmesimPnl0001Pipe + p4_port1_next_primary_chamber: AmesimPneumaticVolume + p4_port1_next_port1_line: AmesimPnl0002Pipe + p4_port1_next_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_line: AmesimPnl0003Pipe + p4_port1_next_orifice_node: TestMqlPneumaticNode3 + p4_port1_next_orifice_output_line: AmesimPnl0001Pipe + p4_port1_next_orifice_resistance: AmesimPnl00rPipe + p4_port1_next_orifice_resistance_node: TestMqlPneumaticNode3 + p4_port1_next_orifice_resistance_line: AmesimPnl0003Pipe + p4_port1_next_orifice_resistance_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_resistance_chamber_inlet_line: AmesimPnl0001Pipe + p4_port1_next_orifice_resistance_chamber: AmesimPneumaticVolume + p4_port1_next_orifice_resistance_chamber_outlet_line: AmesimPnl0001Pipe + p4_port1_next_orifice_resistance_chamber_inlet_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_resistance_chamber_outlet_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_resistance_chamber_spec: TestMqlPneumaticChamberSegmentSpec + p4_bridge_node: TestMqlPneumaticNode4 + p4_bridge_primary_line: AmesimPnl0001Pipe + p4_bridge_primary_chamber: AmesimPneumaticVolume + p4_port1_remote_orifice: AmesimPneumaticOrifice + p4_port1_remote_orifice_line: AmesimPnl0003Pipe + p4_port1_remote_orifice_node: TestMqlPneumaticNode3 + p4_port3_line: AmesimPnl0002Pipe + p4_port3_remote_node: TestMqlPneumaticNode4 + p4_port3_remote_primary_line: AmesimPnl0001Pipe + p4_port3_remote_primary_chamber: AmesimPneumaticVolume + p4_port3_remote_port3_line: AmesimPnl0002Pipe + p4_port3_far_node: TestMqlPneumaticNode4 + p4_port3_far_primary_line: AmesimPnl0001Pipe + p4_port3_far_primary_chamber: AmesimPneumaticVolume + p4_port3_far_port3_line: AmesimPnl0002Pipe + p4_port3_next_node: TestMqlPneumaticNode4 + p4_port3_next_primary_line: AmesimPnl0001Pipe + p4_port3_next_primary_chamber: AmesimPneumaticVolume + p4_port3_next_port3_line: AmesimPnl0002Pipe + p4_port3_next_orifice: AmesimPneumaticOrifice + p4_port3_next_orifice_line: AmesimPnl0003Pipe + p4_port3_next_orifice_node: TestMqlPneumaticNode3 + p4_port3_next_orifice_output_line: AmesimPnl0001Pipe + p4_port3_next_chamber_inlet_line: AmesimPnl0001Pipe + p4_port3_next_chamber: AmesimPneumaticVolume + p4_port3_next_chamber_outlet_line: AmesimPnl0001Pipe + p4_port3_next_chamber_inlet_orifice: AmesimPneumaticOrifice + p4_port3_next_chamber_outlet_orifice: AmesimPneumaticOrifice + p4_port3_next_chamber_spec: TestMqlPneumaticChamberSegmentSpec + p4_port3_next_orifice_resistance: AmesimPnl00rPipe + p4_port3_next_resistance_node: TestMqlPneumaticNode3 + p4_port3_next_resistance_node_line: AmesimPnl0003Pipe + p4_port3_next_resistance_orifice: AmesimPneumaticOrifice + p4_port3_next_resistance_output_line: AmesimPnl0001Pipe + p4_port3_next_resistance_chamber_inlet_line: AmesimPnl0001Pipe + p4_port3_next_resistance_chamber: AmesimPneumaticVolume + p4_port3_next_resistance_chamber_outlet_line: AmesimPnl0001Pipe + p4_port3_next_resistance_chamber_inlet_orifice: AmesimPneumaticOrifice + p4_port3_next_resistance_chamber_outlet_orifice: AmesimPneumaticOrifice + p4_port3_next_resistance_chamber_spec: TestMqlPneumaticChamberSegmentSpec + p4_port3_remote_orifice: AmesimPneumaticOrifice + p4_port3_remote_orifice_line: AmesimPnl0003Pipe + p4_port3_remote_orifice_node: TestMqlPneumaticNode3 + p4_port3_remote_orifice_resistance: AmesimPnl00rPipe + p4_port3_remote_resistance_node: TestMqlPneumaticNode3 + p4_port3_remote_resistance_node_line: AmesimPnl0003Pipe + p4_port3_remote_resistance_orifice: AmesimPneumaticOrifice + p4_port3_remote_resistance_output_line: AmesimPnl0001Pipe + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlTwoStateOrificeLineFlows: + line_center_flow: float + line_to_boundary_flow: float + + +@dataclass(frozen=True) +class TestMqlP4NeighborhoodLineFlows: + primary_chamber_to_line_flow: float + port_1_line_flow: float + port_3_line_flow: float + + +@dataclass(frozen=True) +class TestMqlFixedChamberSegmentFlows: + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + + +@dataclass(frozen=True) +class TestMqlPn3NodeBalancePort: + flow_kg_s: float + node_h: float + connected_h: float + port_mass_flow_kg_s: float + + +@dataclass(frozen=True) +class TestMqlP4NodeBalancePort: + flow_kg_s: float + node_h: float + connected_h: float + port_mass_flow_kg_s: float + + +@dataclass(frozen=True) +class TestMqlPn3P4NodeChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + outlet_line: ThermodynamicProperties + node_line_port_1: ThermodynamicProperties + node_line_port_2: ThermodynamicProperties + p4_primary_line: ThermodynamicProperties + p4_boundary: ThermodynamicProperties + p4_primary_chamber: ThermodynamicProperties + p4_port1_line: ThermodynamicProperties + p4_port1_remote_primary_line: ThermodynamicProperties + p4_port1_remote_primary_chamber: ThermodynamicProperties + p4_port1_remote_port1_line: ThermodynamicProperties + p4_port1_far_primary_line: ThermodynamicProperties + p4_port1_far_primary_chamber: ThermodynamicProperties + p4_port1_far_port1_line: ThermodynamicProperties + p4_port1_next_primary_line: ThermodynamicProperties + p4_port1_next_primary_chamber: ThermodynamicProperties + p4_port1_next_port1_line: ThermodynamicProperties + p4_port1_next_orifice_line_port_1: ThermodynamicProperties + p4_port1_next_orifice_line_port_2: ThermodynamicProperties + p4_port1_next_orifice_output_line: ThermodynamicProperties + p4_port1_next_orifice_resistance_line_port_1: ThermodynamicProperties + p4_port1_next_orifice_resistance_line_port_2: ThermodynamicProperties + p4_port1_next_orifice_resistance_chamber_inlet_line: ThermodynamicProperties + p4_port1_next_orifice_resistance_chamber: ThermodynamicProperties + p4_port1_next_orifice_resistance_chamber_outlet_line: ThermodynamicProperties + p4_bridge_primary_line: ThermodynamicProperties + p4_bridge_primary_chamber: ThermodynamicProperties + p4_port1_remote_orifice_line_port_1: ThermodynamicProperties + p4_port1_remote_orifice_line_port_2: ThermodynamicProperties + p4_port3_line: ThermodynamicProperties + p4_port3_remote_primary_line: ThermodynamicProperties + p4_port3_remote_primary_chamber: ThermodynamicProperties + p4_port3_remote_port3_line: ThermodynamicProperties + p4_port3_far_primary_line: ThermodynamicProperties + p4_port3_far_primary_chamber: ThermodynamicProperties + p4_port3_far_port3_line: ThermodynamicProperties + p4_port3_next_primary_line: ThermodynamicProperties + p4_port3_next_primary_chamber: ThermodynamicProperties + p4_port3_next_port3_line: ThermodynamicProperties + p4_port3_next_orifice_line_port_1: ThermodynamicProperties + p4_port3_next_orifice_line_port_2: ThermodynamicProperties + p4_port3_next_orifice_output_line: ThermodynamicProperties + p4_port3_next_chamber_inlet_line: ThermodynamicProperties + p4_port3_next_chamber: ThermodynamicProperties + p4_port3_next_chamber_outlet_line: ThermodynamicProperties + p4_port3_next_resistance_node_line_port_1: ThermodynamicProperties + p4_port3_next_resistance_node_line_port_2: ThermodynamicProperties + p4_port3_next_resistance_output_line: ThermodynamicProperties + p4_port3_next_resistance_chamber_inlet_line: ThermodynamicProperties + p4_port3_next_resistance_chamber: ThermodynamicProperties + p4_port3_next_resistance_chamber_outlet_line: ThermodynamicProperties + p4_port3_remote_orifice_line_port_1: ThermodynamicProperties + p4_port3_remote_orifice_line_port_2: ThermodynamicProperties + p4_port3_remote_resistance_node_line_port_1: ThermodynamicProperties + p4_port3_remote_resistance_node_line_port_2: ThermodynamicProperties + p4_port3_remote_resistance_output_line: ThermodynamicProperties + inlet_node: ThermodynamicProperties + resistance_boundary: ThermodynamicProperties + node_balance: TestMqlPneumaticNode3Balance + p4_balance: TestMqlPneumaticNode4Balance + p4_port1_remote_balance: TestMqlPneumaticNode4Balance + p4_port1_far_balance: TestMqlPneumaticNode4Balance + p4_port1_next_balance: TestMqlPneumaticNode4Balance + p4_bridge_balance: TestMqlPneumaticNode4Balance + p4_port1_remote_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port1_next_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port1_next_orifice_resistance_node_balance: TestMqlPneumaticNode3Balance + p4_port3_remote_balance: TestMqlPneumaticNode4Balance + p4_port3_far_balance: TestMqlPneumaticNode4Balance + p4_port3_next_balance: TestMqlPneumaticNode4Balance + p4_port3_next_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port3_next_resistance_node_balance: TestMqlPneumaticNode3Balance + p4_port3_remote_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port3_remote_resistance_node_balance: TestMqlPneumaticNode3Balance + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + node_to_resistance_flow: float + node_to_pnl0003_flow: float + pnl0003_center_flow: float + pnl0003_to_p4_flow: float + p4_primary_chamber_to_line_flow: float + p4_node_to_primary_line_flow: float + p4_to_port1_line_flow: float + p4_port1_remote_node_to_line_flow: float + p4_port1_remote_chamber_to_line_flow: float + p4_port1_remote_node_to_primary_line_flow: float + p4_port1_remote_to_port1_line_flow: float + p4_port1_far_node_to_line_flow: float + p4_port1_far_chamber_to_line_flow: float + p4_port1_far_node_to_primary_line_flow: float + p4_port1_far_to_next_line_flow: float + p4_port1_next_node_to_line_flow: float + p4_port1_next_chamber_to_line_flow: float + p4_port1_next_node_to_primary_line_flow: float + p4_port1_next_to_bridge_line_flow: float + p4_port1_next_orifice_p4_to_output_line_flow: float + p4_port1_next_orifice_node_to_line_flow: float + p4_port1_next_orifice_line_center_flow: float + p4_port1_next_orifice_to_output_line_flow: float + p4_port1_next_orifice_resistance_node_to_next_flow: float + p4_port1_next_orifice_resistance_node_to_line_flow: float + p4_port1_next_orifice_resistance_line_center_flow: float + p4_port1_next_orifice_resistance_to_p4_flow: float + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow: float + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow: float + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow: float + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow: float + p4_bridge_to_port1_next_line_flow: float + p4_bridge_chamber_to_line_flow: float + p4_bridge_node_to_primary_line_flow: float + p4_port1_remote_node_to_orifice_line_flow: float + p4_port1_remote_orifice_line_center_flow: float + p4_port1_remote_orifice_to_node_flow: float + p4_to_port3_line_flow: float + p4_port3_remote_node_to_line_flow: float + p4_port3_remote_chamber_to_line_flow: float + p4_port3_remote_node_to_primary_line_flow: float + p4_port3_remote_to_port3_line_flow: float + p4_port3_far_node_to_line_flow: float + p4_port3_far_chamber_to_line_flow: float + p4_port3_far_node_to_primary_line_flow: float + p4_port3_far_to_next_line_flow: float + p4_port3_next_node_to_line_flow: float + p4_port3_next_chamber_to_line_flow: float + p4_port3_next_node_to_primary_line_flow: float + p4_bridge_to_port3_next_line_flow: float + p4_port3_next_to_bridge_line_flow: float + p4_port3_next_orifice_node_to_line_flow: float + p4_port3_next_orifice_line_center_flow: float + p4_port3_next_orifice_to_output_line_flow: float + p4_port3_next_orifice_output_to_p4_flow: float + p4_port3_next_chamber_inlet_node_to_line_flow: float + p4_port3_next_chamber_inlet_line_to_chamber_flow: float + p4_port3_next_chamber_outlet_node_to_line_flow: float + p4_port3_next_chamber_outlet_line_to_chamber_flow: float + p4_port3_next_resistance_node_to_next_flow: float + p4_port3_next_resistance_node_to_line_flow: float + p4_port3_next_resistance_line_center_flow: float + p4_port3_next_resistance_to_output_line_flow: float + p4_port3_next_resistance_output_to_p4_flow: float + p4_port3_next_resistance_chamber_inlet_node_to_line_flow: float + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow: float + p4_port3_next_resistance_chamber_outlet_node_to_line_flow: float + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow: float + p4_port3_remote_node_to_orifice_line_flow: float + p4_port3_remote_node_to_resistance_flow: float + p4_port3_remote_resistance_node_to_line_flow: float + p4_port3_remote_resistance_line_center_flow: float + p4_port3_remote_resistance_to_output_line_flow: float + p4_port3_remote_resistance_output_to_p4_flow: float + p4_port3_remote_orifice_line_center_flow: float + p4_port3_remote_orifice_to_node_flow: float + + +class TestMqlPn3P4NodeChamberSegmentClosure: + """PN3 chamber segment closed through the adjacent real P4 node. + + The P4 primary pressure/temperature comes from the PNL0001 storage on + ``port_2``. P4 ports 1 and 3 are represented by the adjacent PNL0002 center + compliances; their far nodes remain prescribed pressure boundaries. + """ + + def __init__( + self, + *, + components: TestMqlPn3P4NodeChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + resistance_boundary: TestMqlPneumaticBoundaryCondition, + use_inlet_line_reference_rhs: bool = False, + use_p4_port3_remote_primary_reference_rhs: bool = False, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.resistance_boundary = resistance_boundary + self.use_inlet_line_reference_rhs = use_inlet_line_reference_rhs + self.use_p4_port3_remote_primary_reference_rhs = ( + use_p4_port3_remote_primary_reference_rhs + ) + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + *self.components.outlet_line.get_state_vector(), + *self.components.node_line.get_state_vector(), + *self.components.p4_primary_line.get_state_vector(), + *self.components.p4_primary_chamber.get_state_vector(), + *self.components.p4_port1_line.get_state_vector(), + *self.components.p4_port1_remote_primary_line.get_state_vector(), + *self.components.p4_port1_remote_primary_chamber.get_state_vector(), + *self.components.p4_port1_remote_port1_line.get_state_vector(), + *self.components.p4_port1_far_primary_line.get_state_vector(), + *self.components.p4_port1_far_primary_chamber.get_state_vector(), + *self.components.p4_port1_far_port1_line.get_state_vector(), + *self.components.p4_port1_next_primary_line.get_state_vector(), + *self.components.p4_port1_next_primary_chamber.get_state_vector(), + *self.components.p4_port1_next_port1_line.get_state_vector(), + *self.components.p4_bridge_primary_line.get_state_vector(), + *self.components.p4_bridge_primary_chamber.get_state_vector(), + *self.components.p4_port1_remote_orifice_line.get_state_vector(), + *self.components.p4_port3_line.get_state_vector(), + *self.components.p4_port3_remote_primary_line.get_state_vector(), + *self.components.p4_port3_remote_primary_chamber.get_state_vector(), + *self.components.p4_port3_remote_port3_line.get_state_vector(), + *self.components.p4_port3_far_primary_line.get_state_vector(), + *self.components.p4_port3_far_primary_chamber.get_state_vector(), + *self.components.p4_port3_far_port3_line.get_state_vector(), + *self.components.p4_port3_next_primary_line.get_state_vector(), + *self.components.p4_port3_next_primary_chamber.get_state_vector(), + *self.components.p4_port3_next_port3_line.get_state_vector(), + *self.components.p4_port3_remote_resistance_node_line.get_state_vector(), + *self.components.p4_port3_remote_resistance_output_line.get_state_vector(), + *self.components.p4_port3_remote_orifice_line.get_state_vector(), + *self.components.p4_port3_next_orifice_line.get_state_vector(), + *self.components.p4_port3_next_orifice_output_line.get_state_vector(), + *self.components.p4_port3_next_chamber_inlet_line.get_state_vector(), + *self.components.p4_port3_next_chamber.get_state_vector(), + *self.components.p4_port3_next_chamber_outlet_line.get_state_vector(), + *self.components.p4_port3_next_resistance_node_line.get_state_vector(), + *self.components.p4_port3_next_resistance_output_line.get_state_vector(), + *self.components.p4_port3_next_resistance_chamber_inlet_line.get_state_vector(), + *self.components.p4_port3_next_resistance_chamber.get_state_vector(), + *self.components.p4_port3_next_resistance_chamber_outlet_line.get_state_vector(), + *self.components.p4_port1_next_orifice_line.get_state_vector(), + *self.components.p4_port1_next_orifice_output_line.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_line.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_chamber.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 112: + raise ValueError("PN3/P4 node chamber segment state vector requires one hundred twelve values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:4]) + self.components.outlet_line.set_state_vector(values[4:6]) + self.components.node_line.set_state_vector(values[6:10]) + self.components.p4_primary_line.set_state_vector(values[10:12]) + self.components.p4_primary_chamber.set_state_vector(values[12:14]) + self.components.p4_port1_line.set_state_vector(values[14:16]) + self.components.p4_port1_remote_primary_line.set_state_vector(values[16:18]) + self.components.p4_port1_remote_primary_chamber.set_state_vector(values[18:20]) + self.components.p4_port1_remote_port1_line.set_state_vector(values[20:22]) + self.components.p4_port1_far_primary_line.set_state_vector(values[22:24]) + self.components.p4_port1_far_primary_chamber.set_state_vector(values[24:26]) + self.components.p4_port1_far_port1_line.set_state_vector(values[26:28]) + self.components.p4_port1_next_primary_line.set_state_vector(values[28:30]) + self.components.p4_port1_next_primary_chamber.set_state_vector(values[30:32]) + self.components.p4_port1_next_port1_line.set_state_vector(values[32:34]) + self.components.p4_bridge_primary_line.set_state_vector(values[34:36]) + self.components.p4_bridge_primary_chamber.set_state_vector(values[36:38]) + self.components.p4_port1_remote_orifice_line.set_state_vector(values[38:42]) + self.components.p4_port3_line.set_state_vector(values[42:44]) + self.components.p4_port3_remote_primary_line.set_state_vector(values[44:46]) + self.components.p4_port3_remote_primary_chamber.set_state_vector(values[46:48]) + self.components.p4_port3_remote_port3_line.set_state_vector(values[48:50]) + self.components.p4_port3_far_primary_line.set_state_vector(values[50:52]) + self.components.p4_port3_far_primary_chamber.set_state_vector(values[52:54]) + self.components.p4_port3_far_port3_line.set_state_vector(values[54:56]) + self.components.p4_port3_next_primary_line.set_state_vector(values[56:58]) + self.components.p4_port3_next_primary_chamber.set_state_vector(values[58:60]) + self.components.p4_port3_next_port3_line.set_state_vector(values[60:62]) + self.components.p4_port3_remote_resistance_node_line.set_state_vector(values[62:66]) + self.components.p4_port3_remote_resistance_output_line.set_state_vector(values[66:68]) + self.components.p4_port3_remote_orifice_line.set_state_vector(values[68:72]) + self.components.p4_port3_next_orifice_line.set_state_vector(values[72:76]) + self.components.p4_port3_next_orifice_output_line.set_state_vector(values[76:78]) + self.components.p4_port3_next_chamber_inlet_line.set_state_vector(values[78:80]) + self.components.p4_port3_next_chamber.set_state_vector(values[80:82]) + self.components.p4_port3_next_chamber_outlet_line.set_state_vector(values[82:84]) + self.components.p4_port3_next_resistance_node_line.set_state_vector(values[84:88]) + self.components.p4_port3_next_resistance_output_line.set_state_vector(values[88:90]) + self.components.p4_port3_next_resistance_chamber_inlet_line.set_state_vector(values[90:92]) + self.components.p4_port3_next_resistance_chamber.set_state_vector(values[92:94]) + self.components.p4_port3_next_resistance_chamber_outlet_line.set_state_vector(values[94:96]) + self.components.p4_port1_next_orifice_line.set_state_vector(values[96:100]) + self.components.p4_port1_next_orifice_output_line.set_state_vector(values[100:102]) + self.components.p4_port1_next_orifice_resistance_line.set_state_vector(values[102:106]) + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.set_state_vector(values[106:108]) + self.components.p4_port1_next_orifice_resistance_chamber.set_state_vector(values[108:110]) + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.set_state_vector(values[110:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPn3P4NodeChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + inlet_line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + outlet_line = self.components.outlet_line.properties() + node_line_port_1 = self.components.node_line.properties_1() + node_line_port_2 = self.components.node_line.properties_2() + p4_primary_line = self.components.p4_primary_line.properties() + p4_primary_chamber = self.components.p4_primary_chamber.properties() + p4_port1_line = self.components.p4_port1_line.properties() + p4_port1_remote_primary_line = ( + self.components.p4_port1_remote_primary_line.properties() + ) + p4_port1_remote_primary_chamber = ( + self.components.p4_port1_remote_primary_chamber.properties() + ) + p4_port1_remote_port1_line = ( + self.components.p4_port1_remote_port1_line.properties() + ) + p4_port1_far_primary_line = self.components.p4_port1_far_primary_line.properties() + p4_port1_far_primary_chamber = ( + self.components.p4_port1_far_primary_chamber.properties() + ) + p4_port1_far_port1_line = self.components.p4_port1_far_port1_line.properties() + p4_port1_next_primary_line = self.components.p4_port1_next_primary_line.properties() + p4_port1_next_primary_chamber = ( + self.components.p4_port1_next_primary_chamber.properties() + ) + p4_port1_next_port1_line = self.components.p4_port1_next_port1_line.properties() + p4_port1_next_orifice_line_port_1 = ( + self.components.p4_port1_next_orifice_line.properties_1() + ) + p4_port1_next_orifice_line_port_2 = ( + self.components.p4_port1_next_orifice_line.properties_2() + ) + p4_port1_next_orifice_output_line = ( + self.components.p4_port1_next_orifice_output_line.properties() + ) + p4_port1_next_orifice_resistance_line_port_1 = ( + self.components.p4_port1_next_orifice_resistance_line.properties_1() + ) + p4_port1_next_orifice_resistance_line_port_2 = ( + self.components.p4_port1_next_orifice_resistance_line.properties_2() + ) + p4_port1_next_orifice_resistance_chamber_inlet_line = ( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.properties() + ) + p4_port1_next_orifice_resistance_chamber = ( + self.components.p4_port1_next_orifice_resistance_chamber.properties() + ) + p4_port1_next_orifice_resistance_chamber_outlet_line = ( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.properties() + ) + p4_bridge_primary_line = self.components.p4_bridge_primary_line.properties() + p4_bridge_primary_chamber = self.components.p4_bridge_primary_chamber.properties() + p4_port1_remote_orifice_line_port_1 = ( + self.components.p4_port1_remote_orifice_line.properties_1() + ) + p4_port1_remote_orifice_line_port_2 = ( + self.components.p4_port1_remote_orifice_line.properties_2() + ) + p4_port3_line = self.components.p4_port3_line.properties() + p4_port3_remote_primary_line = ( + self.components.p4_port3_remote_primary_line.properties() + ) + p4_port3_remote_primary_chamber = ( + self.components.p4_port3_remote_primary_chamber.properties() + ) + p4_port3_remote_port3_line = ( + self.components.p4_port3_remote_port3_line.properties() + ) + p4_port3_far_primary_line = self.components.p4_port3_far_primary_line.properties() + p4_port3_far_primary_chamber = ( + self.components.p4_port3_far_primary_chamber.properties() + ) + p4_port3_far_port3_line = self.components.p4_port3_far_port3_line.properties() + p4_port3_next_primary_line = self.components.p4_port3_next_primary_line.properties() + p4_port3_next_primary_chamber = ( + self.components.p4_port3_next_primary_chamber.properties() + ) + p4_port3_next_port3_line = self.components.p4_port3_next_port3_line.properties() + p4_port3_next_orifice_line_port_1 = ( + self.components.p4_port3_next_orifice_line.properties_1() + ) + p4_port3_next_orifice_line_port_2 = ( + self.components.p4_port3_next_orifice_line.properties_2() + ) + p4_port3_next_orifice_output_line = ( + self.components.p4_port3_next_orifice_output_line.properties() + ) + p4_port3_next_chamber_inlet_line = ( + self.components.p4_port3_next_chamber_inlet_line.properties() + ) + p4_port3_next_chamber = self.components.p4_port3_next_chamber.properties() + p4_port3_next_chamber_outlet_line = ( + self.components.p4_port3_next_chamber_outlet_line.properties() + ) + p4_port3_next_resistance_node_line_port_1 = ( + self.components.p4_port3_next_resistance_node_line.properties_1() + ) + p4_port3_next_resistance_node_line_port_2 = ( + self.components.p4_port3_next_resistance_node_line.properties_2() + ) + p4_port3_next_resistance_output_line = ( + self.components.p4_port3_next_resistance_output_line.properties() + ) + p4_port3_next_resistance_chamber_inlet_line = ( + self.components.p4_port3_next_resistance_chamber_inlet_line.properties() + ) + p4_port3_next_resistance_chamber = ( + self.components.p4_port3_next_resistance_chamber.properties() + ) + p4_port3_next_resistance_chamber_outlet_line = ( + self.components.p4_port3_next_resistance_chamber_outlet_line.properties() + ) + p4_port3_remote_orifice_line_port_1 = ( + self.components.p4_port3_remote_orifice_line.properties_1() + ) + p4_port3_remote_orifice_line_port_2 = ( + self.components.p4_port3_remote_orifice_line.properties_2() + ) + p4_port3_remote_resistance_node_line_port_1 = ( + self.components.p4_port3_remote_resistance_node_line.properties_1() + ) + p4_port3_remote_resistance_node_line_port_2 = ( + self.components.p4_port3_remote_resistance_node_line.properties_2() + ) + p4_port3_remote_resistance_output_line = ( + self.components.p4_port3_remote_resistance_output_line.properties() + ) + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + resistance_boundary = self.resistance_boundary.properties( + self.components.node_resistance.gas + ) + chamber_segment_flows = self._fixed_chamber_segment_flows( + chamber_properties=chamber, + inlet_line=self.components.inlet_line, + inlet_line_properties=inlet_line, + inlet_node_properties=p4_port3_remote_orifice_line_port_1, + inlet_orifice=self.components.inlet_orifice, + outlet_line=self.components.outlet_line, + outlet_line_properties=outlet_line, + outlet_node_properties=node_line_port_1, + outlet_orifice=self.components.outlet_orifice, + ) + inlet_node_to_line_flow = chamber_segment_flows.inlet_node_to_line_flow + inlet_line_to_chamber_flow = chamber_segment_flows.inlet_line_to_chamber_flow + outlet_node_to_line_flow = chamber_segment_flows.outlet_node_to_line_flow + outlet_line_to_chamber_flow = chamber_segment_flows.outlet_line_to_chamber_flow + node_to_resistance_flow = self._resistance_flow( + resistance=self.components.node_resistance, + port_1_properties=node_line_port_1, + port_2_properties=p4_port1_remote_orifice_line_port_1, + ) + node_balance = self._pn3_node_balance( + node=self.components.node, + primary_properties=node_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=node_to_resistance_flow, + node_h=node_line_port_1.h, + connected_h=p4_port1_remote_orifice_line_port_1.h, + port_mass_flow_kg_s=node_to_resistance_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=outlet_node_to_line_flow, + node_h=node_line_port_1.h, + connected_h=outlet_line.h, + port_mass_flow_kg_s=outlet_node_to_line_flow, + ), + ) + node_to_pnl0003_flow = -node_balance.port_2_mass_flow_g_s * 1.0e-3 + pnl0003_center_flow = self.components.node_line.resistance_mass_flow() + pnl0003_to_p4_flow = self._line_to_p4_flow( + line=node_line_port_2, + p4_boundary=p4_primary_line, + orifice=self.components.node_orifice, + ) + + p4_node_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_primary_line, + primary_chamber_properties=p4_primary_chamber, + port_1_line=self.components.p4_port1_line, + port_1_properties=p4_primary_line, + port_3_line=self.components.p4_port3_line, + port_3_properties=p4_primary_line, + ) + p4_primary_chamber_to_line_flow = ( + p4_node_line_flows.primary_chamber_to_line_flow + ) + p4_to_port1_line_flow = p4_node_line_flows.port_1_line_flow + p4_to_port3_line_flow = p4_node_line_flows.port_3_line_flow + p4_port1_remote_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port1_remote_primary_line, + primary_chamber_properties=p4_port1_remote_primary_chamber, + port_1_line=self.components.p4_port1_remote_port1_line, + port_1_properties=p4_port1_remote_primary_line, + port_3_line=self.components.p4_port1_line, + port_3_properties=p4_port1_remote_primary_line, + ) + p4_port1_remote_chamber_to_line_flow = ( + p4_port1_remote_line_flows.primary_chamber_to_line_flow + ) + p4_port1_remote_to_port1_line_flow = ( + p4_port1_remote_line_flows.port_1_line_flow + ) + p4_port1_remote_node_to_line_flow = ( + p4_port1_remote_line_flows.port_3_line_flow + ) + p4_port1_far_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port1_far_primary_line, + primary_chamber_properties=p4_port1_far_primary_chamber, + port_1_line=self.components.p4_port1_far_port1_line, + port_1_properties=p4_port1_far_primary_line, + port_3_line=self.components.p4_port1_remote_port1_line, + port_3_properties=p4_port1_far_primary_line, + ) + p4_port1_far_chamber_to_line_flow = ( + p4_port1_far_line_flows.primary_chamber_to_line_flow + ) + p4_port1_far_to_next_line_flow = p4_port1_far_line_flows.port_1_line_flow + p4_port1_far_node_to_line_flow = p4_port1_far_line_flows.port_3_line_flow + p4_port1_next_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port1_next_primary_line, + primary_chamber_properties=p4_port1_next_primary_chamber, + port_1_line=self.components.p4_port1_next_port1_line, + port_1_properties=p4_port1_next_primary_line, + port_3_line=self.components.p4_port1_far_port1_line, + port_3_properties=p4_port1_next_primary_line, + ) + p4_port1_next_chamber_to_line_flow = ( + p4_port1_next_line_flows.primary_chamber_to_line_flow + ) + p4_port1_next_to_bridge_line_flow = ( + p4_port1_next_line_flows.port_1_line_flow + ) + p4_port1_next_node_to_line_flow = p4_port1_next_line_flows.port_3_line_flow + p4_port1_next_orifice_p4_to_output_line_flow = ( + self.components.p4_port1_next_orifice_output_line.resistance_mass_flow( + port_1_pressure_pa=p4_port1_next_primary_line.p, + port_1_temperature_k=p4_port1_next_primary_line.T, + ) + ) + p4_bridge_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_bridge_primary_line, + primary_chamber_properties=p4_bridge_primary_chamber, + port_1_line=self.components.p4_port3_next_port3_line, + port_1_properties=p4_bridge_primary_line, + port_3_line=self.components.p4_port1_next_port1_line, + port_3_properties=p4_bridge_primary_line, + ) + p4_bridge_chamber_to_line_flow = ( + p4_bridge_line_flows.primary_chamber_to_line_flow + ) + p4_bridge_to_port3_next_line_flow = p4_bridge_line_flows.port_1_line_flow + p4_bridge_to_port1_next_line_flow = p4_bridge_line_flows.port_3_line_flow + p4_port1_next_orifice_resistance_chamber_flows = ( + self._fixed_chamber_segment_flows( + chamber_properties=p4_port1_next_orifice_resistance_chamber, + inlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_line_properties=( + p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_node_properties=p4_port1_remote_orifice_line_port_1, + inlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice + ), + outlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_line_properties=( + p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_node_properties=p4_port1_next_orifice_resistance_line_port_1, + outlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice + ), + ) + ) + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.inlet_node_to_line_flow + ) + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.inlet_line_to_chamber_flow + ) + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.outlet_node_to_line_flow + ) + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.outlet_line_to_chamber_flow + ) + p4_port1_remote_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port1_remote_orifice_node, + primary_properties=p4_port1_remote_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + node_h=p4_port1_remote_orifice_line_port_1.h, + connected_h=p4_port1_next_orifice_resistance_chamber_inlet_line.h, + port_mass_flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=-node_to_resistance_flow, + node_h=p4_port1_remote_orifice_line_port_1.h, + connected_h=node_line_port_1.h, + port_mass_flow_kg_s=-node_to_resistance_flow, + ), + ) + p4_port1_remote_node_to_orifice_line_flow = ( + -p4_port1_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_remote_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port1_remote_orifice_line, + line_port_2_properties=p4_port1_remote_orifice_line_port_2, + boundary_properties=p4_port1_remote_primary_line, + orifice=self.components.p4_port1_remote_orifice, + ) + p4_port1_remote_orifice_line_center_flow = ( + p4_port1_remote_orifice_line_flows.line_center_flow + ) + p4_port1_remote_orifice_to_node_flow = ( + p4_port1_remote_orifice_line_flows.line_to_boundary_flow + ) + p4_port3_remote_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port3_remote_primary_line, + primary_chamber_properties=p4_port3_remote_primary_chamber, + port_1_line=self.components.p4_port3_line, + port_1_properties=p4_port3_remote_primary_line, + port_3_line=self.components.p4_port3_remote_port3_line, + port_3_properties=p4_port3_remote_primary_line, + ) + p4_port3_remote_chamber_to_line_flow = ( + p4_port3_remote_line_flows.primary_chamber_to_line_flow + ) + p4_port3_remote_node_to_line_flow = ( + p4_port3_remote_line_flows.port_1_line_flow + ) + p4_port3_remote_to_port3_line_flow = ( + p4_port3_remote_line_flows.port_3_line_flow + ) + p4_port3_far_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port3_far_primary_line, + primary_chamber_properties=p4_port3_far_primary_chamber, + port_1_line=self.components.p4_port3_remote_port3_line, + port_1_properties=p4_port3_far_primary_line, + port_3_line=self.components.p4_port3_far_port3_line, + port_3_properties=p4_port3_far_primary_line, + ) + p4_port3_far_chamber_to_line_flow = ( + p4_port3_far_line_flows.primary_chamber_to_line_flow + ) + p4_port3_far_node_to_line_flow = p4_port3_far_line_flows.port_1_line_flow + p4_port3_far_to_next_line_flow = p4_port3_far_line_flows.port_3_line_flow + p4_port3_next_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port3_next_primary_line, + primary_chamber_properties=p4_port3_next_primary_chamber, + port_1_line=self.components.p4_port3_far_port3_line, + port_1_properties=p4_port3_next_primary_line, + port_3_line=self.components.p4_port3_next_port3_line, + port_3_properties=p4_port3_next_primary_line, + ) + p4_port3_next_chamber_to_line_flow = ( + p4_port3_next_line_flows.primary_chamber_to_line_flow + ) + p4_port3_next_node_to_line_flow = p4_port3_next_line_flows.port_1_line_flow + p4_port3_next_to_bridge_line_flow = ( + p4_port3_next_line_flows.port_3_line_flow + ) + p4_port3_next_chamber_flows = self._fixed_chamber_segment_flows( + chamber_properties=p4_port3_next_chamber, + inlet_line=self.components.p4_port3_next_chamber_inlet_line, + inlet_line_properties=p4_port3_next_chamber_inlet_line, + inlet_node_properties=p4_port3_next_orifice_line_port_1, + inlet_orifice=self.components.p4_port3_next_chamber_inlet_orifice, + outlet_line=self.components.p4_port3_next_chamber_outlet_line, + outlet_line_properties=p4_port3_next_chamber_outlet_line, + outlet_node_properties=p4_port3_remote_resistance_node_line_port_1, + outlet_orifice=self.components.p4_port3_next_chamber_outlet_orifice, + ) + p4_port3_next_chamber_inlet_node_to_line_flow = ( + p4_port3_next_chamber_flows.inlet_node_to_line_flow + ) + p4_port3_next_chamber_inlet_line_to_chamber_flow = ( + p4_port3_next_chamber_flows.inlet_line_to_chamber_flow + ) + p4_port3_next_chamber_outlet_node_to_line_flow = ( + p4_port3_next_chamber_flows.outlet_node_to_line_flow + ) + p4_port3_next_chamber_outlet_line_to_chamber_flow = ( + p4_port3_next_chamber_flows.outlet_line_to_chamber_flow + ) + p4_port3_next_resistance_node_to_next_flow = self._resistance_flow( + resistance=self.components.p4_port3_next_orifice_resistance, + port_1_properties=p4_port3_next_resistance_node_line_port_1, + port_2_properties=p4_port3_next_orifice_line_port_1, + ) + p4_port3_next_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_next_orifice_node, + primary_properties=p4_port3_next_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_chamber_inlet_node_to_line_flow, + node_h=p4_port3_next_orifice_line_port_1.h, + connected_h=p4_port3_next_chamber_inlet_line.h, + port_mass_flow_kg_s=p4_port3_next_chamber_inlet_node_to_line_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=-p4_port3_next_resistance_node_to_next_flow, + node_h=p4_port3_next_orifice_line_port_1.h, + connected_h=p4_port3_next_resistance_node_line_port_1.h, + port_mass_flow_kg_s=-p4_port3_next_resistance_node_to_next_flow, + ), + ) + p4_port3_next_orifice_node_to_line_flow = ( + -p4_port3_next_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_next_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_next_orifice_line, + line_port_2_properties=p4_port3_next_orifice_line_port_2, + boundary_properties=p4_port3_next_orifice_output_line, + orifice=self.components.p4_port3_next_orifice, + ) + p4_port3_next_orifice_line_center_flow = ( + p4_port3_next_orifice_line_flows.line_center_flow + ) + p4_port3_next_orifice_to_output_line_flow = ( + p4_port3_next_orifice_line_flows.line_to_boundary_flow + ) + p4_port3_next_orifice_output_to_p4_flow = ( + p4_port3_next_orifice_to_output_line_flow + ) + p4_port3_next_resistance_chamber_flows = ( + self._fixed_chamber_segment_flows( + chamber_properties=p4_port3_next_resistance_chamber, + inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, + inlet_line_properties=p4_port3_next_resistance_chamber_inlet_line, + inlet_node_properties=p4_port1_next_orifice_line_port_1, + inlet_orifice=( + self.components.p4_port3_next_resistance_chamber_inlet_orifice + ), + outlet_line=( + self.components.p4_port3_next_resistance_chamber_outlet_line + ), + outlet_line_properties=p4_port3_next_resistance_chamber_outlet_line, + outlet_node_properties=p4_port3_next_resistance_node_line_port_1, + outlet_orifice=( + self.components.p4_port3_next_resistance_chamber_outlet_orifice + ), + ) + ) + p4_port3_next_resistance_chamber_inlet_node_to_line_flow = ( + p4_port3_next_resistance_chamber_flows.inlet_node_to_line_flow + ) + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow = ( + p4_port3_next_resistance_chamber_flows.inlet_line_to_chamber_flow + ) + p4_port3_next_resistance_chamber_outlet_node_to_line_flow = ( + p4_port3_next_resistance_chamber_flows.outlet_node_to_line_flow + ) + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow = ( + p4_port3_next_resistance_chamber_flows.outlet_line_to_chamber_flow + ) + p4_port1_next_orifice_resistance_node_to_next_flow = self._resistance_flow( + resistance=self.components.p4_port1_next_orifice_resistance, + port_1_properties=p4_port1_next_orifice_resistance_line_port_1, + port_2_properties=p4_port1_next_orifice_line_port_1, + ) + p4_port1_next_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port1_next_orifice_node, + primary_properties=p4_port1_next_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_chamber_inlet_node_to_line_flow, + node_h=p4_port1_next_orifice_line_port_1.h, + connected_h=p4_port3_next_resistance_chamber_inlet_line.h, + port_mass_flow_kg_s=( + p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=-p4_port1_next_orifice_resistance_node_to_next_flow, + node_h=p4_port1_next_orifice_line_port_1.h, + connected_h=p4_port1_next_orifice_resistance_line_port_1.h, + port_mass_flow_kg_s=( + -p4_port1_next_orifice_resistance_node_to_next_flow + ), + ), + ) + p4_port1_next_orifice_node_to_line_flow = ( + -p4_port1_next_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_next_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port1_next_orifice_line, + line_port_2_properties=p4_port1_next_orifice_line_port_2, + boundary_properties=p4_port1_next_orifice_output_line, + orifice=self.components.p4_port1_next_orifice, + ) + p4_port1_next_orifice_line_center_flow = ( + p4_port1_next_orifice_line_flows.line_center_flow + ) + p4_port1_next_orifice_to_output_line_flow = ( + p4_port1_next_orifice_line_flows.line_to_boundary_flow + ) + p4_port1_next_orifice_resistance_node_balance = self._pn3_node_balance( + node=self.components.p4_port1_next_orifice_resistance_node, + primary_properties=p4_port1_next_orifice_resistance_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port1_next_orifice_resistance_node_to_next_flow, + node_h=p4_port1_next_orifice_resistance_line_port_1.h, + connected_h=p4_port1_next_orifice_line_port_1.h, + port_mass_flow_kg_s=p4_port1_next_orifice_resistance_node_to_next_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + node_h=p4_port1_next_orifice_resistance_line_port_1.h, + connected_h=p4_port1_next_orifice_resistance_chamber_outlet_line.h, + port_mass_flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + ), + ) + p4_port1_next_orifice_resistance_node_to_line_flow = ( + -p4_port1_next_orifice_resistance_node_balance.port_2_mass_flow_g_s + * 1.0e-3 + ) + p4_port1_next_orifice_resistance_line_flows = ( + self._two_state_orifice_line_flows( + line=self.components.p4_port1_next_orifice_resistance_line, + line_port_2_properties=( + p4_port1_next_orifice_resistance_line_port_2 + ), + boundary_properties=p4_port1_far_primary_line, + orifice=self.components.p4_port1_next_orifice_resistance_orifice, + ) + ) + p4_port1_next_orifice_resistance_line_center_flow = ( + p4_port1_next_orifice_resistance_line_flows.line_center_flow + ) + p4_port1_next_orifice_resistance_to_p4_flow = ( + p4_port1_next_orifice_resistance_line_flows.line_to_boundary_flow + ) + p4_port3_next_resistance_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_next_resistance_node, + primary_properties=p4_port3_next_resistance_node_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_node_to_next_flow, + node_h=p4_port3_next_resistance_node_line_port_1.h, + connected_h=p4_port3_next_orifice_line_port_1.h, + port_mass_flow_kg_s=p4_port3_next_resistance_node_to_next_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_chamber_outlet_node_to_line_flow, + node_h=p4_port3_next_resistance_node_line_port_1.h, + connected_h=p4_port3_next_resistance_chamber_outlet_line.h, + port_mass_flow_kg_s=( + p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + ), + ) + p4_port3_next_resistance_node_to_line_flow = ( + -p4_port3_next_resistance_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_next_resistance_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_next_resistance_node_line, + line_port_2_properties=p4_port3_next_resistance_node_line_port_2, + boundary_properties=p4_port3_next_resistance_output_line, + orifice=self.components.p4_port3_next_resistance_orifice, + ) + p4_port3_next_resistance_line_center_flow = ( + p4_port3_next_resistance_line_flows.line_center_flow + ) + p4_port3_next_resistance_to_output_line_flow = ( + p4_port3_next_resistance_line_flows.line_to_boundary_flow + ) + p4_port3_next_resistance_output_to_p4_flow = ( + p4_port3_next_resistance_to_output_line_flow + ) + p4_port3_remote_node_to_resistance_flow = self._resistance_flow( + resistance=self.components.p4_port3_remote_orifice_resistance, + port_1_properties=p4_port3_remote_orifice_line_port_1, + port_2_properties=p4_port3_remote_resistance_node_line_port_1, + ) + p4_port3_remote_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_remote_orifice_node, + primary_properties=p4_port3_remote_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=inlet_node_to_line_flow, + node_h=p4_port3_remote_orifice_line_port_1.h, + connected_h=inlet_line.h, + port_mass_flow_kg_s=inlet_node_to_line_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_remote_node_to_resistance_flow, + node_h=p4_port3_remote_orifice_line_port_1.h, + connected_h=p4_port3_remote_resistance_node_line_port_1.h, + port_mass_flow_kg_s=p4_port3_remote_node_to_resistance_flow, + ), + ) + p4_port3_remote_node_to_orifice_line_flow = ( + -p4_port3_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_remote_resistance_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_remote_resistance_node, + primary_properties=p4_port3_remote_resistance_node_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=-p4_port3_remote_node_to_resistance_flow, + node_h=p4_port3_remote_resistance_node_line_port_1.h, + connected_h=p4_port3_remote_orifice_line_port_1.h, + port_mass_flow_kg_s=-p4_port3_remote_node_to_resistance_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_chamber_outlet_node_to_line_flow, + node_h=p4_port3_remote_resistance_node_line_port_1.h, + connected_h=p4_port3_next_chamber_outlet_line.h, + port_mass_flow_kg_s=p4_port3_next_chamber_outlet_node_to_line_flow, + ), + ) + p4_port3_remote_resistance_node_to_line_flow = ( + -p4_port3_remote_resistance_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_remote_resistance_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_remote_resistance_node_line, + line_port_2_properties=p4_port3_remote_resistance_node_line_port_2, + boundary_properties=p4_port3_remote_resistance_output_line, + orifice=self.components.p4_port3_remote_resistance_orifice, + ) + p4_port3_remote_resistance_line_center_flow = ( + p4_port3_remote_resistance_line_flows.line_center_flow + ) + p4_port3_remote_resistance_to_output_line_flow = ( + p4_port3_remote_resistance_line_flows.line_to_boundary_flow + ) + p4_port3_remote_resistance_output_to_p4_flow = ( + p4_port3_remote_resistance_to_output_line_flow + ) + p4_port3_remote_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_remote_orifice_line, + line_port_2_properties=p4_port3_remote_orifice_line_port_2, + boundary_properties=p4_port3_remote_primary_line, + orifice=self.components.p4_port3_remote_orifice, + ) + p4_port3_remote_orifice_line_center_flow = ( + p4_port3_remote_orifice_line_flows.line_center_flow + ) + p4_port3_remote_orifice_to_node_flow = ( + p4_port3_remote_orifice_line_flows.line_to_boundary_flow + ) + p4_balance = self._p4_node_balance( + node=self.components.p4_node, + primary_properties=p4_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_to_port1_line_flow, + node_h=p4_primary_line.h, + connected_h=p4_port1_line.h, + port_mass_flow_kg_s=-p4_to_port1_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_to_port3_line_flow, + node_h=p4_primary_line.h, + connected_h=p4_port3_line.h, + port_mass_flow_kg_s=-p4_to_port3_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=pnl0003_to_p4_flow, + node_h=node_line_port_2.h, + connected_h=p4_primary_line.h, + port_mass_flow_kg_s=pnl0003_to_p4_flow, + ), + ) + p4_node_to_primary_line_flow = p4_balance.port_2_mass_flow_g_s * 1.0e-3 + p4_port1_remote_balance = self._p4_node_balance( + node=self.components.p4_port1_remote_node, + primary_properties=p4_port1_remote_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_remote_to_port1_line_flow, + node_h=p4_port1_remote_primary_line.h, + connected_h=p4_port1_remote_port1_line.h, + port_mass_flow_kg_s=-p4_port1_remote_to_port1_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_remote_node_to_line_flow, + node_h=p4_port1_remote_primary_line.h, + connected_h=p4_port1_line.h, + port_mass_flow_kg_s=-p4_port1_remote_node_to_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_remote_orifice_to_node_flow, + node_h=p4_port1_remote_orifice_line_port_2.h, + connected_h=p4_port1_remote_primary_line.h, + port_mass_flow_kg_s=p4_port1_remote_orifice_to_node_flow, + ), + ) + p4_port1_remote_node_to_primary_line_flow = ( + p4_port1_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_far_balance = self._p4_node_balance( + node=self.components.p4_port1_far_node, + primary_properties=p4_port1_far_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_far_to_next_line_flow, + node_h=p4_port1_far_primary_line.h, + connected_h=p4_port1_far_port1_line.h, + port_mass_flow_kg_s=-p4_port1_far_to_next_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_far_node_to_line_flow, + node_h=p4_port1_far_primary_line.h, + connected_h=p4_port1_remote_port1_line.h, + port_mass_flow_kg_s=-p4_port1_far_node_to_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_orifice_resistance_to_p4_flow, + node_h=p4_port1_next_orifice_resistance_line_port_2.h, + connected_h=p4_port1_far_primary_line.h, + port_mass_flow_kg_s=p4_port1_next_orifice_resistance_to_p4_flow, + ), + ) + p4_port1_far_node_to_primary_line_flow = ( + p4_port1_far_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_next_balance = self._p4_node_balance( + node=self.components.p4_port1_next_node, + primary_properties=p4_port1_next_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_to_bridge_line_flow, + node_h=p4_port1_next_primary_line.h, + connected_h=p4_port1_next_port1_line.h, + port_mass_flow_kg_s=-p4_port1_next_to_bridge_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_node_to_line_flow, + node_h=p4_port1_next_primary_line.h, + connected_h=p4_port1_far_port1_line.h, + port_mass_flow_kg_s=-p4_port1_next_node_to_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_orifice_p4_to_output_line_flow, + node_h=p4_port1_next_primary_line.h, + connected_h=p4_port1_next_orifice_output_line.h, + port_mass_flow_kg_s=-p4_port1_next_orifice_p4_to_output_line_flow, + ), + ) + p4_port1_next_node_to_primary_line_flow = ( + p4_port1_next_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_remote_balance = self._p4_node_balance( + node=self.components.p4_port3_remote_node, + primary_properties=p4_port3_remote_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_node_to_line_flow, + node_h=p4_port3_remote_primary_line.h, + connected_h=p4_port3_line.h, + port_mass_flow_kg_s=-p4_port3_remote_node_to_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_to_port3_line_flow, + node_h=p4_port3_remote_primary_line.h, + connected_h=p4_port3_remote_port3_line.h, + port_mass_flow_kg_s=-p4_port3_remote_to_port3_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_orifice_to_node_flow, + node_h=p4_port3_remote_orifice_line_port_2.h, + connected_h=p4_port3_remote_primary_line.h, + port_mass_flow_kg_s=p4_port3_remote_orifice_to_node_flow, + ), + ) + p4_port3_remote_node_to_primary_line_flow = ( + p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_far_balance = self._p4_node_balance( + node=self.components.p4_port3_far_node, + primary_properties=p4_port3_far_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_far_node_to_line_flow, + node_h=p4_port3_far_primary_line.h, + connected_h=p4_port3_remote_port3_line.h, + port_mass_flow_kg_s=-p4_port3_far_node_to_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_far_to_next_line_flow, + node_h=p4_port3_far_primary_line.h, + connected_h=p4_port3_far_port3_line.h, + port_mass_flow_kg_s=-p4_port3_far_to_next_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_resistance_output_to_p4_flow, + node_h=p4_port3_remote_resistance_output_line.h, + connected_h=p4_port3_far_primary_line.h, + port_mass_flow_kg_s=p4_port3_remote_resistance_output_to_p4_flow, + ), + ) + p4_port3_far_node_to_primary_line_flow = ( + p4_port3_far_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_next_balance = self._p4_node_balance( + node=self.components.p4_port3_next_node, + primary_properties=p4_port3_next_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_node_to_line_flow, + node_h=p4_port3_next_primary_line.h, + connected_h=p4_port3_far_port3_line.h, + port_mass_flow_kg_s=-p4_port3_next_node_to_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_to_bridge_line_flow, + node_h=p4_port3_next_primary_line.h, + connected_h=p4_port3_next_port3_line.h, + port_mass_flow_kg_s=-p4_port3_next_to_bridge_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_orifice_output_to_p4_flow, + node_h=p4_port3_next_orifice_output_line.h, + connected_h=p4_port3_next_primary_line.h, + port_mass_flow_kg_s=p4_port3_next_orifice_output_to_p4_flow, + ), + ) + p4_port3_next_node_to_primary_line_flow = ( + p4_port3_next_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_bridge_balance = self._p4_node_balance( + node=self.components.p4_bridge_node, + primary_properties=p4_bridge_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_bridge_to_port3_next_line_flow, + node_h=p4_bridge_primary_line.h, + connected_h=p4_port3_next_port3_line.h, + port_mass_flow_kg_s=-p4_bridge_to_port3_next_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_bridge_to_port1_next_line_flow, + node_h=p4_bridge_primary_line.h, + connected_h=p4_port1_next_port1_line.h, + port_mass_flow_kg_s=-p4_bridge_to_port1_next_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_output_to_p4_flow, + node_h=p4_port3_next_resistance_output_line.h, + connected_h=p4_bridge_primary_line.h, + port_mass_flow_kg_s=p4_port3_next_resistance_output_to_p4_flow, + ), + ) + p4_bridge_node_to_primary_line_flow = ( + p4_bridge_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + + snapshot = TestMqlPn3P4NodeChamberSegmentSnapshot( + inlet_line=inlet_line, + chamber=chamber, + outlet_line=outlet_line, + node_line_port_1=node_line_port_1, + node_line_port_2=node_line_port_2, + p4_primary_line=p4_primary_line, + p4_boundary=p4_primary_line, + p4_primary_chamber=p4_primary_chamber, + p4_port1_line=p4_port1_line, + p4_port1_remote_primary_line=p4_port1_remote_primary_line, + p4_port1_remote_primary_chamber=p4_port1_remote_primary_chamber, + p4_port1_remote_port1_line=p4_port1_remote_port1_line, + p4_port1_far_primary_line=p4_port1_far_primary_line, + p4_port1_far_primary_chamber=p4_port1_far_primary_chamber, + p4_port1_far_port1_line=p4_port1_far_port1_line, + p4_port1_next_primary_line=p4_port1_next_primary_line, + p4_port1_next_primary_chamber=p4_port1_next_primary_chamber, + p4_port1_next_port1_line=p4_port1_next_port1_line, + p4_port1_next_orifice_line_port_1=p4_port1_next_orifice_line_port_1, + p4_port1_next_orifice_line_port_2=p4_port1_next_orifice_line_port_2, + p4_port1_next_orifice_output_line=p4_port1_next_orifice_output_line, + p4_port1_next_orifice_resistance_line_port_1=( + p4_port1_next_orifice_resistance_line_port_1 + ), + p4_port1_next_orifice_resistance_line_port_2=( + p4_port1_next_orifice_resistance_line_port_2 + ), + p4_port1_next_orifice_resistance_chamber_inlet_line=( + p4_port1_next_orifice_resistance_chamber_inlet_line + ), + p4_port1_next_orifice_resistance_chamber=( + p4_port1_next_orifice_resistance_chamber + ), + p4_port1_next_orifice_resistance_chamber_outlet_line=( + p4_port1_next_orifice_resistance_chamber_outlet_line + ), + p4_bridge_primary_line=p4_bridge_primary_line, + p4_bridge_primary_chamber=p4_bridge_primary_chamber, + p4_port1_remote_orifice_line_port_1=p4_port1_remote_orifice_line_port_1, + p4_port1_remote_orifice_line_port_2=p4_port1_remote_orifice_line_port_2, + p4_port3_line=p4_port3_line, + p4_port3_remote_primary_line=p4_port3_remote_primary_line, + p4_port3_remote_primary_chamber=p4_port3_remote_primary_chamber, + p4_port3_remote_port3_line=p4_port3_remote_port3_line, + p4_port3_far_primary_line=p4_port3_far_primary_line, + p4_port3_far_primary_chamber=p4_port3_far_primary_chamber, + p4_port3_far_port3_line=p4_port3_far_port3_line, + p4_port3_next_primary_line=p4_port3_next_primary_line, + p4_port3_next_primary_chamber=p4_port3_next_primary_chamber, + p4_port3_next_port3_line=p4_port3_next_port3_line, + p4_port3_next_orifice_line_port_1=p4_port3_next_orifice_line_port_1, + p4_port3_next_orifice_line_port_2=p4_port3_next_orifice_line_port_2, + p4_port3_next_orifice_output_line=p4_port3_next_orifice_output_line, + p4_port3_next_chamber_inlet_line=p4_port3_next_chamber_inlet_line, + p4_port3_next_chamber=p4_port3_next_chamber, + p4_port3_next_chamber_outlet_line=p4_port3_next_chamber_outlet_line, + p4_port3_next_resistance_node_line_port_1=( + p4_port3_next_resistance_node_line_port_1 + ), + p4_port3_next_resistance_node_line_port_2=( + p4_port3_next_resistance_node_line_port_2 + ), + p4_port3_next_resistance_output_line=p4_port3_next_resistance_output_line, + p4_port3_next_resistance_chamber_inlet_line=( + p4_port3_next_resistance_chamber_inlet_line + ), + p4_port3_next_resistance_chamber=p4_port3_next_resistance_chamber, + p4_port3_next_resistance_chamber_outlet_line=( + p4_port3_next_resistance_chamber_outlet_line + ), + p4_port3_remote_orifice_line_port_1=p4_port3_remote_orifice_line_port_1, + p4_port3_remote_orifice_line_port_2=p4_port3_remote_orifice_line_port_2, + p4_port3_remote_resistance_node_line_port_1=( + p4_port3_remote_resistance_node_line_port_1 + ), + p4_port3_remote_resistance_node_line_port_2=( + p4_port3_remote_resistance_node_line_port_2 + ), + p4_port3_remote_resistance_output_line=( + p4_port3_remote_resistance_output_line + ), + inlet_node=inlet_node, + resistance_boundary=resistance_boundary, + node_balance=node_balance, + p4_balance=p4_balance, + p4_port1_remote_balance=p4_port1_remote_balance, + p4_port1_far_balance=p4_port1_far_balance, + p4_port1_next_balance=p4_port1_next_balance, + p4_bridge_balance=p4_bridge_balance, + p4_port1_remote_orifice_node_balance=p4_port1_remote_orifice_node_balance, + p4_port1_next_orifice_node_balance=p4_port1_next_orifice_node_balance, + p4_port1_next_orifice_resistance_node_balance=( + p4_port1_next_orifice_resistance_node_balance + ), + p4_port3_remote_balance=p4_port3_remote_balance, + p4_port3_far_balance=p4_port3_far_balance, + p4_port3_next_balance=p4_port3_next_balance, + p4_port3_next_orifice_node_balance=p4_port3_next_orifice_node_balance, + p4_port3_next_resistance_node_balance=p4_port3_next_resistance_node_balance, + p4_port3_remote_orifice_node_balance=p4_port3_remote_orifice_node_balance, + p4_port3_remote_resistance_node_balance=( + p4_port3_remote_resistance_node_balance + ), + inlet_node_to_line_flow=inlet_node_to_line_flow, + inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, + outlet_node_to_line_flow=outlet_node_to_line_flow, + outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, + node_to_resistance_flow=node_to_resistance_flow, + node_to_pnl0003_flow=node_to_pnl0003_flow, + pnl0003_center_flow=pnl0003_center_flow, + pnl0003_to_p4_flow=pnl0003_to_p4_flow, + p4_primary_chamber_to_line_flow=p4_primary_chamber_to_line_flow, + p4_node_to_primary_line_flow=p4_node_to_primary_line_flow, + p4_to_port1_line_flow=p4_to_port1_line_flow, + p4_port1_remote_node_to_line_flow=p4_port1_remote_node_to_line_flow, + p4_port1_remote_chamber_to_line_flow=p4_port1_remote_chamber_to_line_flow, + p4_port1_remote_node_to_primary_line_flow=( + p4_port1_remote_node_to_primary_line_flow + ), + p4_port1_remote_to_port1_line_flow=p4_port1_remote_to_port1_line_flow, + p4_port1_far_node_to_line_flow=p4_port1_far_node_to_line_flow, + p4_port1_far_chamber_to_line_flow=p4_port1_far_chamber_to_line_flow, + p4_port1_far_node_to_primary_line_flow=( + p4_port1_far_node_to_primary_line_flow + ), + p4_port1_far_to_next_line_flow=p4_port1_far_to_next_line_flow, + p4_port1_next_node_to_line_flow=p4_port1_next_node_to_line_flow, + p4_port1_next_chamber_to_line_flow=p4_port1_next_chamber_to_line_flow, + p4_port1_next_node_to_primary_line_flow=( + p4_port1_next_node_to_primary_line_flow + ), + p4_port1_next_to_bridge_line_flow=p4_port1_next_to_bridge_line_flow, + p4_port1_next_orifice_p4_to_output_line_flow=( + p4_port1_next_orifice_p4_to_output_line_flow + ), + p4_port1_next_orifice_node_to_line_flow=( + p4_port1_next_orifice_node_to_line_flow + ), + p4_port1_next_orifice_line_center_flow=( + p4_port1_next_orifice_line_center_flow + ), + p4_port1_next_orifice_to_output_line_flow=( + p4_port1_next_orifice_to_output_line_flow + ), + p4_port1_next_orifice_resistance_node_to_next_flow=( + p4_port1_next_orifice_resistance_node_to_next_flow + ), + p4_port1_next_orifice_resistance_node_to_line_flow=( + p4_port1_next_orifice_resistance_node_to_line_flow + ), + p4_port1_next_orifice_resistance_line_center_flow=( + p4_port1_next_orifice_resistance_line_center_flow + ), + p4_port1_next_orifice_resistance_to_p4_flow=( + p4_port1_next_orifice_resistance_to_p4_flow + ), + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow=( + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow=( + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow + ), + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow=( + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow=( + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow + ), + p4_bridge_to_port1_next_line_flow=p4_bridge_to_port1_next_line_flow, + p4_bridge_chamber_to_line_flow=p4_bridge_chamber_to_line_flow, + p4_bridge_node_to_primary_line_flow=p4_bridge_node_to_primary_line_flow, + p4_port1_remote_node_to_orifice_line_flow=( + p4_port1_remote_node_to_orifice_line_flow + ), + p4_port1_remote_orifice_line_center_flow=( + p4_port1_remote_orifice_line_center_flow + ), + p4_port1_remote_orifice_to_node_flow=p4_port1_remote_orifice_to_node_flow, + p4_to_port3_line_flow=p4_to_port3_line_flow, + p4_port3_remote_node_to_line_flow=p4_port3_remote_node_to_line_flow, + p4_port3_remote_chamber_to_line_flow=p4_port3_remote_chamber_to_line_flow, + p4_port3_remote_node_to_primary_line_flow=( + p4_port3_remote_node_to_primary_line_flow + ), + p4_port3_remote_to_port3_line_flow=p4_port3_remote_to_port3_line_flow, + p4_port3_far_node_to_line_flow=p4_port3_far_node_to_line_flow, + p4_port3_far_chamber_to_line_flow=p4_port3_far_chamber_to_line_flow, + p4_port3_far_node_to_primary_line_flow=( + p4_port3_far_node_to_primary_line_flow + ), + p4_port3_far_to_next_line_flow=p4_port3_far_to_next_line_flow, + p4_port3_next_node_to_line_flow=p4_port3_next_node_to_line_flow, + p4_port3_next_chamber_to_line_flow=p4_port3_next_chamber_to_line_flow, + p4_port3_next_node_to_primary_line_flow=( + p4_port3_next_node_to_primary_line_flow + ), + p4_bridge_to_port3_next_line_flow=p4_bridge_to_port3_next_line_flow, + p4_port3_next_to_bridge_line_flow=p4_port3_next_to_bridge_line_flow, + p4_port3_next_orifice_node_to_line_flow=( + p4_port3_next_orifice_node_to_line_flow + ), + p4_port3_next_orifice_line_center_flow=( + p4_port3_next_orifice_line_center_flow + ), + p4_port3_next_orifice_to_output_line_flow=( + p4_port3_next_orifice_to_output_line_flow + ), + p4_port3_next_orifice_output_to_p4_flow=( + p4_port3_next_orifice_output_to_p4_flow + ), + p4_port3_next_chamber_inlet_node_to_line_flow=( + p4_port3_next_chamber_inlet_node_to_line_flow + ), + p4_port3_next_chamber_inlet_line_to_chamber_flow=( + p4_port3_next_chamber_inlet_line_to_chamber_flow + ), + p4_port3_next_chamber_outlet_node_to_line_flow=( + p4_port3_next_chamber_outlet_node_to_line_flow + ), + p4_port3_next_chamber_outlet_line_to_chamber_flow=( + p4_port3_next_chamber_outlet_line_to_chamber_flow + ), + p4_port3_next_resistance_node_to_next_flow=( + p4_port3_next_resistance_node_to_next_flow + ), + p4_port3_next_resistance_node_to_line_flow=( + p4_port3_next_resistance_node_to_line_flow + ), + p4_port3_next_resistance_line_center_flow=( + p4_port3_next_resistance_line_center_flow + ), + p4_port3_next_resistance_to_output_line_flow=( + p4_port3_next_resistance_to_output_line_flow + ), + p4_port3_next_resistance_output_to_p4_flow=( + p4_port3_next_resistance_output_to_p4_flow + ), + p4_port3_next_resistance_chamber_inlet_node_to_line_flow=( + p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow=( + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow + ), + p4_port3_next_resistance_chamber_outlet_node_to_line_flow=( + p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow=( + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow + ), + p4_port3_remote_node_to_orifice_line_flow=( + p4_port3_remote_node_to_orifice_line_flow + ), + p4_port3_remote_node_to_resistance_flow=( + p4_port3_remote_node_to_resistance_flow + ), + p4_port3_remote_resistance_node_to_line_flow=( + p4_port3_remote_resistance_node_to_line_flow + ), + p4_port3_remote_resistance_line_center_flow=( + p4_port3_remote_resistance_line_center_flow + ), + p4_port3_remote_resistance_to_output_line_flow=( + p4_port3_remote_resistance_to_output_line_flow + ), + p4_port3_remote_resistance_output_to_p4_flow=( + p4_port3_remote_resistance_output_to_p4_flow + ), + p4_port3_remote_orifice_line_center_flow=( + p4_port3_remote_orifice_line_center_flow + ), + p4_port3_remote_orifice_to_node_flow=p4_port3_remote_orifice_to_node_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_chamber_flow( + *, + line: ThermodynamicProperties, + chamber: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + return TestMqlPn3NodeChamberSegmentClosure._line_to_chamber_flow( + line=line, + chamber=chamber, + orifice=orifice, + ) + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + return TestMqlPn3NodeChamberSegmentClosure._line_to_p4_flow( + line=line, + p4_boundary=p4_boundary, + orifice=orifice, + ) + + @staticmethod + def _boundary_to_p4_flow( + *, + boundary: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = boundary.T if boundary.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(boundary.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPn3NodeChamberSegmentClosure._port(component, port_name) + + @staticmethod + def _enthalpy_flow_from_node( + *, + flow_kg_s: float, + node_h: float, + connected_h: float, + ) -> float: + return TestMqlPn3NodeChamberSegmentClosure._enthalpy_flow_from_node( + flow_kg_s=flow_kg_s, + node_h=node_h, + connected_h=connected_h, + ) + + @staticmethod + def _enthalpy_flow_for_node_port( + *, + flow_kg_s: float, + node_h: float, + connected_h: float, + port_mass_flow_kg_s: float, + ) -> float: + upstream_h = node_h if flow_kg_s >= 0.0 else connected_h + return port_mass_flow_kg_s * upstream_h + + def _two_state_orifice_line_flows( + self, + *, + line: AmesimPnl0003Pipe, + line_port_2_properties: ThermodynamicProperties, + boundary_properties: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> TestMqlTwoStateOrificeLineFlows: + return TestMqlTwoStateOrificeLineFlows( + line_center_flow=line.resistance_mass_flow(), + line_to_boundary_flow=self._line_to_p4_flow( + line=line_port_2_properties, + p4_boundary=boundary_properties, + orifice=orifice, + ), + ) + + @staticmethod + def _resistance_flow( + *, + resistance: AmesimPnl00rPipe, + port_1_properties: ThermodynamicProperties, + port_2_properties: ThermodynamicProperties, + ) -> float: + return resistance.mass_flow( + port_1_pressure_pa=port_1_properties.p, + port_1_temperature_k=port_1_properties.T, + port_2_pressure_pa=port_2_properties.p, + port_2_temperature_k=port_2_properties.T, + ) + + @staticmethod + def _p4_neighborhood_line_flows( + *, + primary_line: AmesimPnl0001Pipe, + primary_chamber_properties: ThermodynamicProperties, + port_1_line: AmesimPnl0002Pipe, + port_1_properties: ThermodynamicProperties, + port_3_line: AmesimPnl0002Pipe, + port_3_properties: ThermodynamicProperties, + ) -> TestMqlP4NeighborhoodLineFlows: + return TestMqlP4NeighborhoodLineFlows( + primary_chamber_to_line_flow=primary_line.resistance_mass_flow( + port_1_pressure_pa=primary_chamber_properties.p, + port_1_temperature_k=primary_chamber_properties.T, + ), + port_1_line_flow=port_1_line.port_mass_flow( + port_pressure_pa=port_1_properties.p, + port_temperature_k=port_1_properties.T, + ), + port_3_line_flow=port_3_line.port_mass_flow( + port_pressure_pa=port_3_properties.p, + port_temperature_k=port_3_properties.T, + ), + ) + + def _fixed_chamber_segment_flows( + self, + *, + chamber_properties: ThermodynamicProperties, + inlet_line: AmesimPnl0001Pipe, + inlet_line_properties: ThermodynamicProperties, + inlet_node_properties: ThermodynamicProperties, + inlet_orifice: AmesimPneumaticOrifice, + outlet_line: AmesimPnl0001Pipe, + outlet_line_properties: ThermodynamicProperties, + outlet_node_properties: ThermodynamicProperties, + outlet_orifice: AmesimPneumaticOrifice, + ) -> TestMqlFixedChamberSegmentFlows: + return TestMqlFixedChamberSegmentFlows( + inlet_node_to_line_flow=inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node_properties.p, + port_1_temperature_k=inlet_node_properties.T, + ), + inlet_line_to_chamber_flow=self._line_to_chamber_flow( + line=inlet_line_properties, + chamber=chamber_properties, + orifice=inlet_orifice, + ), + outlet_node_to_line_flow=outlet_line.resistance_mass_flow( + port_1_pressure_pa=outlet_node_properties.p, + port_1_temperature_k=outlet_node_properties.T, + ), + outlet_line_to_chamber_flow=self._line_to_chamber_flow( + line=outlet_line_properties, + chamber=chamber_properties, + orifice=outlet_orifice, + ), + ) + + def _pn3_node_balance( + self, + *, + node: TestMqlPneumaticNode3, + primary_properties: ThermodynamicProperties, + port_1: TestMqlPn3NodeBalancePort, + port_3: TestMqlPn3NodeBalancePort, + ) -> TestMqlPneumaticNode3Balance: + return node.balance( + port_2_temperature_k=primary_properties.T, + port_2_pressure_pa=primary_properties.p, + port_1_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_1.flow_kg_s, + node_h=port_1.node_h, + connected_h=port_1.connected_h, + port_mass_flow_kg_s=port_1.port_mass_flow_kg_s, + ), + port_1_mass_flow_g_s=port_1.port_mass_flow_kg_s * 1.0e3, + port_3_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_3.flow_kg_s, + node_h=port_3.node_h, + connected_h=port_3.connected_h, + port_mass_flow_kg_s=port_3.port_mass_flow_kg_s, + ), + port_3_mass_flow_g_s=port_3.port_mass_flow_kg_s * 1.0e3, + ) + + @staticmethod + def _pn3_node_port_2_connected_h( + *, + balance: TestMqlPneumaticNode3Balance, + fallback_h: float, + ) -> float: + mass_flow_kg_s = balance.port_2_mass_flow_g_s * 1.0e-3 + if abs(mass_flow_kg_s) <= 1.0e-12: + return fallback_h + return balance.port_2_enthalpy_flow_w / mass_flow_kg_s + + @staticmethod + def _p4_node_port_2_connected_h( + *, + balance: TestMqlPneumaticNode4Balance, + fallback_h: float, + ) -> float: + mass_flow_kg_s = balance.port_2_mass_flow_g_s * 1.0e-3 + if abs(mass_flow_kg_s) <= 1.0e-12: + return fallback_h + return balance.port_2_enthalpy_flow_w / mass_flow_kg_s + + @staticmethod + def _reference_enthalpy_flow_for_node_port( + *, + gas, + node_properties: ThermodynamicProperties, + connected_properties: ThermodynamicProperties, + flow_kg_s: float, + port_mass_flow_kg_s: float, + reference_temperature_k: float = 298.15, + ) -> float: + stream_properties = ( + node_properties if flow_kg_s >= 0.0 else connected_properties + ) + return port_mass_flow_kg_s * gas.pressure_reference_enthalpy( + stream_properties.p, + stream_properties.T, + reference_temperature=reference_temperature_k, + ) + + def _p4_port3_remote_port_2_reference_h( + self, + *, + snapshot: TestMqlPn3P4NodeChamberSegmentSnapshot, + reference_temperature_k: float = 298.15, + ) -> float: + gas = self.components.p4_port3_remote_primary_line.gas + port_2_mass_flow = snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + if abs(port_2_mass_flow) <= 1.0e-12: + return gas.pressure_reference_enthalpy( + snapshot.p4_port3_remote_primary_line.p, + snapshot.p4_port3_remote_primary_line.T, + reference_temperature=reference_temperature_k, + ) + port_2_enthalpy_flow = sum( + ( + self._reference_enthalpy_flow_for_node_port( + gas=gas, + node_properties=snapshot.p4_port3_remote_primary_line, + connected_properties=snapshot.p4_port3_line, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + reference_temperature_k=reference_temperature_k, + ), + self._reference_enthalpy_flow_for_node_port( + gas=gas, + node_properties=snapshot.p4_port3_remote_primary_line, + connected_properties=snapshot.p4_port3_remote_port3_line, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + reference_temperature_k=reference_temperature_k, + ), + self._reference_enthalpy_flow_for_node_port( + gas=gas, + node_properties=snapshot.p4_port3_remote_orifice_line_port_2, + connected_properties=snapshot.p4_port3_remote_primary_line, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + reference_temperature_k=reference_temperature_k, + ), + ) + ) + return port_2_enthalpy_flow / port_2_mass_flow + + def _p4_node_balance( + self, + *, + node: TestMqlPneumaticNode4, + primary_properties: ThermodynamicProperties, + port_1: TestMqlP4NodeBalancePort, + port_3: TestMqlP4NodeBalancePort, + port_4: TestMqlP4NodeBalancePort, + ) -> TestMqlPneumaticNode4Balance: + return node.balance( + port_2_temperature_k=primary_properties.T, + port_2_pressure_pa=primary_properties.p, + port_1_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_1.flow_kg_s, + node_h=port_1.node_h, + connected_h=port_1.connected_h, + port_mass_flow_kg_s=port_1.port_mass_flow_kg_s, + ), + port_1_mass_flow_g_s=port_1.port_mass_flow_kg_s * 1.0e3, + port_3_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_3.flow_kg_s, + node_h=port_3.node_h, + connected_h=port_3.connected_h, + port_mass_flow_kg_s=port_3.port_mass_flow_kg_s, + ), + port_3_mass_flow_g_s=port_3.port_mass_flow_kg_s * 1.0e3, + port_4_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_4.flow_kg_s, + node_h=port_4.node_h, + connected_h=port_4.connected_h, + port_mass_flow_kg_s=port_4.port_mass_flow_kg_s, + ), + port_4_mass_flow_g_s=port_4.port_mass_flow_kg_s * 1.0e3, + ) + + def _write_fixed_chamber_segment( + self, + *, + spec: TestMqlPneumaticChamberSegmentSpec, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + inlet_line: AmesimPnl0001Pipe, + inlet_line_properties: ThermodynamicProperties, + inlet_node_properties: ThermodynamicProperties, + inlet_node_to_line_flow: float, + inlet_line_to_chamber_flow: float, + inlet_orifice: AmesimPneumaticOrifice, + outlet_line: AmesimPnl0001Pipe, + outlet_line_properties: ThermodynamicProperties, + outlet_node_properties: ThermodynamicProperties, + outlet_node_to_line_flow: float, + outlet_line_to_chamber_flow: float, + outlet_orifice: AmesimPneumaticOrifice, + ) -> None: + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=inlet_line, + line_properties=inlet_line_properties, + node_properties=inlet_node_properties, + node_to_line_flow=inlet_node_to_line_flow, + line_to_chamber_flow=inlet_line_to_chamber_flow, + orifice=inlet_orifice, + orifice_boundary_port=spec.inlet_orifice_boundary_port, + orifice_volume_port=spec.inlet_orifice_volume_port, + chamber_port=chamber_inlet_port, + chamber_properties=chamber_properties, + ) + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=outlet_line, + line_properties=outlet_line_properties, + node_properties=outlet_node_properties, + node_to_line_flow=outlet_node_to_line_flow, + line_to_chamber_flow=outlet_line_to_chamber_flow, + orifice=outlet_orifice, + orifice_boundary_port=spec.outlet_orifice_boundary_port, + orifice_volume_port=spec.outlet_orifice_volume_port, + chamber_port=chamber_outlet_port, + chamber_properties=chamber_properties, + ) + + def _fixed_chamber_segment_derivatives( + self, + *, + spec: TestMqlPneumaticChamberSegmentSpec, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + inlet_line: AmesimPnl0001Pipe, + inlet_line_properties: ThermodynamicProperties, + inlet_connected_properties: ThermodynamicProperties, + inlet_node_to_line_flow: float, + inlet_line_to_chamber_flow: float, + outlet_line: AmesimPnl0001Pipe, + outlet_line_properties: ThermodynamicProperties, + outlet_connected_properties: ThermodynamicProperties, + outlet_node_to_line_flow: float, + outlet_line_to_chamber_flow: float, + ) -> tuple[VolumeState, VolumeState, VolumeState]: + inlet_derivative = inlet_line.derivatives_from_connections( + port_1_m_flow=inlet_node_to_line_flow, + connected_h_1=inlet_connected_properties.h, + port_2_m_flow=-inlet_line_to_chamber_flow, + connected_h_2=chamber_properties.h, + ) + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + inlet_line_properties.h + if spec.volume_inlet_port == "port_1" + else outlet_line_properties.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + inlet_line_properties.h + if spec.volume_inlet_port == "port_2" + else outlet_line_properties.h + ), + internal_h=chamber_properties.h, + ) + outlet_derivative = outlet_line.derivatives_from_connections( + port_1_m_flow=outlet_node_to_line_flow, + connected_h_1=outlet_connected_properties.h, + port_2_m_flow=-outlet_line_to_chamber_flow, + connected_h_2=chamber_properties.h, + ) + return inlet_derivative, chamber_derivative, outlet_derivative + + @staticmethod + def _write_primary_chamber_line( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + chamber_to_line_flow: float, + node_to_line_flow: float, + ) -> None: + line.port_1.p = chamber_properties.p + line.port_1.m_flow = chamber_to_line_flow + line.port_1.h_outflow = line_properties.h + line.port_2.p = line_properties.p + line.port_2.m_flow = node_to_line_flow + line.port_2.h_outflow = line_properties.h + + chamber.port_a.p = chamber_properties.p + chamber.port_a.m_flow = -chamber_to_line_flow + chamber.port_a.h_outflow = chamber_properties.h + + @staticmethod + def _pnl0001_reference_enthalpy_derivative( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + port_1_connected_reference_h: float | None = None, + port_2_connected_reference_h: float | None = None, + reference_temperature_k: float = 298.15, + ) -> VolumeState: + def specific_reference_h( + *, + port_flow: float, + connected: ThermodynamicProperties, + connected_reference_h: float | None, + ) -> float: + if port_flow > 0.0 and connected_reference_h is not None: + return connected_reference_h + if port_flow > 0.0: + pressure = connected.p + temperature = connected.T + else: + pressure = line_properties.p + temperature = line_properties.T + return line.gas.pressure_reference_enthalpy( + pressure, + temperature, + reference_temperature=reference_temperature_k, + ) + + def reference_enthalpy_flow( + *, + port_flow: float, + connected: ThermodynamicProperties, + connected_reference_h: float | None, + ) -> float: + return port_flow * specific_reference_h( + port_flow=port_flow, + connected=connected, + connected_reference_h=connected_reference_h, + ) + + mass_derivative = port_1_flow + port_2_flow + heat_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - line_properties.T) + ) + enthalpy_flow = reference_enthalpy_flow( + port_flow=port_1_flow, + connected=port_1_properties, + connected_reference_h=port_1_connected_reference_h, + ) + reference_enthalpy_flow( + port_flow=port_2_flow, + connected=port_2_properties, + connected_reference_h=port_2_connected_reference_h, + ) + reference_offset_flow = ( + line.gas.cp * reference_temperature_k - line.gas.cv * line_properties.T + ) * mass_derivative + temperature_derivative = ( + enthalpy_flow + reference_offset_flow + heat_flow + ) / (line.state.m * line.gas.cv) + return VolumeState( + m=mass_derivative, + U=line.gas.cv + * (line.state.m * temperature_derivative + line_properties.T * mass_derivative), + ) + + @staticmethod + def _primary_chamber_line_derivatives( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + chamber_to_line_flow: float, + node_to_line_flow: float, + node_connected_h: float | None = None, + ) -> tuple[VolumeState, VolumeState]: + derivative_method = ( + line.derivatives_from_connections + if node_connected_h is None + else line.derivatives_from_transport_enthalpy_connections + ) + line_derivative = derivative_method( + port_1_m_flow=chamber_to_line_flow, + connected_h_1=chamber_properties.h, + port_2_m_flow=node_to_line_flow, + connected_h_2=( + line_properties.h if node_connected_h is None else node_connected_h + ), + ) + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=-chamber_to_line_flow, + connected_h_a=line_properties.h, + port_b_m_flow=0.0, + connected_h_b=chamber_properties.h, + internal_h=chamber_properties.h, + ) + return line_derivative, chamber_derivative + + @staticmethod + def _write_connection_line( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> None: + line.port_1.p = port_1_properties.p + line.port_1.m_flow = port_1_flow + line.port_1.h_outflow = line_properties.h + line.port_2.p = port_2_properties.p + line.port_2.m_flow = port_2_flow + line.port_2.h_outflow = line_properties.h + + @staticmethod + def _connection_line_derivative( + *, + line: AmesimPnl0001Pipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> VolumeState: + return line.derivatives_from_connections( + port_1_m_flow=port_1_flow, + connected_h_1=port_1_properties.h, + port_2_m_flow=port_2_flow, + connected_h_2=port_2_properties.h, + ) + + @staticmethod + def _write_two_state_connection_line( + *, + line: AmesimPnl0003Pipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> None: + line.port_1.p = port_1_properties.p + line.port_1.m_flow = port_1_flow + line.port_1.h_outflow = port_1_properties.h + line.port_2.p = port_2_properties.p + line.port_2.m_flow = port_2_flow + line.port_2.h_outflow = port_2_properties.h + + @staticmethod + def _write_orifice_ports( + *, + orifice: AmesimPneumaticOrifice, + port_a_properties: ThermodynamicProperties, + port_a_flow: float, + port_b_properties: ThermodynamicProperties, + port_b_flow: float, + ) -> None: + orifice.port_a.p = port_a_properties.p + orifice.port_a.m_flow = port_a_flow + orifice.port_a.h_outflow = port_a_properties.h + orifice.port_b.p = port_b_properties.p + orifice.port_b.m_flow = port_b_flow + orifice.port_b.h_outflow = port_b_properties.h + + @staticmethod + def _write_resistance_ports( + *, + resistance: AmesimPnl00rPipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> None: + resistance.port_1.p = port_1_properties.p + resistance.port_1.m_flow = port_1_flow + resistance.port_1.h_outflow = port_1_properties.h + resistance.port_2.p = port_2_properties.p + resistance.port_2.m_flow = port_2_flow + resistance.port_2.h_outflow = port_2_properties.h + + @staticmethod + def _two_state_connection_line_derivatives( + *, + line: AmesimPnl0003Pipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_connected_properties: ThermodynamicProperties, + port_2_flow: float, + port_1_connected_h: float | None = None, + ) -> tuple[VolumeState, VolumeState]: + return line.derivatives_from_connections( + port_1_m_flow=port_1_flow, + connected_h_1=( + port_1_properties.h + if port_1_connected_h is None + else port_1_connected_h + ), + port_2_m_flow=port_2_flow, + connected_h_2=port_2_connected_properties.h, + ) + + def _write_port_states( + self, + snapshot: TestMqlPn3P4NodeChamberSegmentSnapshot, + ) -> None: + self._write_fixed_chamber_segment( + spec=self.components.spec, + chamber=self.components.volume, + chamber_properties=snapshot.chamber, + inlet_line=self.components.inlet_line, + inlet_line_properties=snapshot.inlet_line, + inlet_node_properties=snapshot.p4_port3_remote_orifice_line_port_1, + inlet_node_to_line_flow=snapshot.inlet_node_to_line_flow, + inlet_line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + inlet_orifice=self.components.inlet_orifice, + outlet_line=self.components.outlet_line, + outlet_line_properties=snapshot.outlet_line, + outlet_node_properties=snapshot.node_line_port_1, + outlet_node_to_line_flow=snapshot.outlet_node_to_line_flow, + outlet_line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + outlet_orifice=self.components.outlet_orifice, + ) + + self._write_two_state_connection_line( + line=self.components.node_line, + port_1_properties=snapshot.node_line_port_1, + port_1_flow=snapshot.node_to_pnl0003_flow, + port_2_properties=snapshot.node_line_port_2, + port_2_flow=-snapshot.pnl0003_to_p4_flow, + ) + + self._write_orifice_ports( + orifice=self.components.node_orifice, + port_a_properties=snapshot.node_line_port_2, + port_a_flow=snapshot.pnl0003_to_p4_flow, + port_b_properties=snapshot.p4_primary_line, + port_b_flow=-snapshot.pnl0003_to_p4_flow, + ) + + self._write_resistance_ports( + resistance=self.components.node_resistance, + port_1_properties=snapshot.node_line_port_1, + port_1_flow=snapshot.node_to_resistance_flow, + port_2_properties=snapshot.p4_port1_remote_orifice_line_port_1, + port_2_flow=-snapshot.node_to_resistance_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_primary_line, + line_properties=snapshot.p4_primary_line, + chamber=self.components.p4_primary_chamber, + chamber_properties=snapshot.p4_primary_chamber, + chamber_to_line_flow=snapshot.p4_primary_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_line, + line_properties=snapshot.p4_port1_line, + port_1_properties=snapshot.p4_primary_line, + port_1_flow=snapshot.p4_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_remote_primary_line, + port_2_flow=snapshot.p4_port1_remote_node_to_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port1_remote_primary_line, + line_properties=snapshot.p4_port1_remote_primary_line, + chamber=self.components.p4_port1_remote_primary_chamber, + chamber_properties=snapshot.p4_port1_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_remote_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_remote_port1_line, + line_properties=snapshot.p4_port1_remote_port1_line, + port_1_properties=snapshot.p4_port1_remote_primary_line, + port_1_flow=snapshot.p4_port1_remote_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_far_primary_line, + port_2_flow=snapshot.p4_port1_far_node_to_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port1_far_primary_line, + line_properties=snapshot.p4_port1_far_primary_line, + chamber=self.components.p4_port1_far_primary_chamber, + chamber_properties=snapshot.p4_port1_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_far_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_far_port1_line, + line_properties=snapshot.p4_port1_far_port1_line, + port_1_properties=snapshot.p4_port1_far_primary_line, + port_1_flow=snapshot.p4_port1_far_to_next_line_flow, + port_2_properties=snapshot.p4_port1_next_primary_line, + port_2_flow=snapshot.p4_port1_next_node_to_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port1_next_primary_line, + line_properties=snapshot.p4_port1_next_primary_line, + chamber=self.components.p4_port1_next_primary_chamber, + chamber_properties=snapshot.p4_port1_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_next_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_next_port1_line, + line_properties=snapshot.p4_port1_next_port1_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_to_bridge_line_flow, + port_2_properties=snapshot.p4_bridge_primary_line, + port_2_flow=snapshot.p4_bridge_to_port1_next_line_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port1_next_orifice_line, + port_1_properties=snapshot.p4_port1_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_node_to_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_line_port_2, + port_2_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port1_next_orifice, + port_a_properties=snapshot.p4_port1_next_orifice_line_port_2, + port_a_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, + port_b_properties=snapshot.p4_port1_next_orifice_output_line, + port_b_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_next_orifice_output_line, + line_properties=snapshot.p4_port1_next_orifice_output_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_orifice_p4_to_output_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_output_line, + port_2_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + + self._write_resistance_ports( + resistance=self.components.p4_port1_next_orifice_resistance, + port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, + port_1_flow=( + snapshot.p4_port1_next_orifice_resistance_node_to_next_flow + ), + port_2_properties=snapshot.p4_port1_next_orifice_line_port_1, + port_2_flow=( + -snapshot.p4_port1_next_orifice_resistance_node_to_next_flow + ), + ) + + self._write_two_state_connection_line( + line=self.components.p4_port1_next_orifice_resistance_line, + port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_resistance_node_to_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_resistance_line_port_2, + port_2_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port1_next_orifice_resistance_orifice, + port_a_properties=snapshot.p4_port1_next_orifice_resistance_line_port_2, + port_a_flow=snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + port_b_properties=snapshot.p4_port1_far_primary_line, + port_b_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + ) + + self._write_fixed_chamber_segment( + spec=self.components.p4_port1_next_orifice_resistance_chamber_spec, + chamber=self.components.p4_port1_next_orifice_resistance_chamber, + chamber_properties=snapshot.p4_port1_next_orifice_resistance_chamber, + inlet_line=self.components.p4_port1_next_orifice_resistance_chamber_inlet_line, + inlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_node_properties=snapshot.p4_port1_remote_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow + ), + inlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice + ), + outlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_node_properties=( + snapshot.p4_port1_next_orifice_resistance_line_port_1 + ), + outlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow + ), + outlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice + ), + ) + + self._write_primary_chamber_line( + line=self.components.p4_bridge_primary_line, + line_properties=snapshot.p4_bridge_primary_line, + chamber=self.components.p4_bridge_primary_chamber, + chamber_properties=snapshot.p4_bridge_primary_chamber, + chamber_to_line_flow=snapshot.p4_bridge_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_bridge_node_to_primary_line_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port1_remote_orifice_line, + port_1_properties=snapshot.p4_port1_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_remote_node_to_orifice_line_flow, + port_2_properties=snapshot.p4_port1_remote_orifice_line_port_2, + port_2_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port1_remote_orifice, + port_a_properties=snapshot.p4_port1_remote_orifice_line_port_2, + port_a_flow=snapshot.p4_port1_remote_orifice_to_node_flow, + port_b_properties=snapshot.p4_port1_remote_primary_line, + port_b_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_line, + line_properties=snapshot.p4_port3_line, + port_1_properties=snapshot.p4_port3_remote_primary_line, + port_1_flow=snapshot.p4_port3_remote_node_to_line_flow, + port_2_properties=snapshot.p4_primary_line, + port_2_flow=snapshot.p4_to_port3_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port3_remote_primary_line, + line_properties=snapshot.p4_port3_remote_primary_line, + chamber=self.components.p4_port3_remote_primary_chamber, + chamber_properties=snapshot.p4_port3_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_remote_port3_line, + line_properties=snapshot.p4_port3_remote_port3_line, + port_1_properties=snapshot.p4_port3_far_primary_line, + port_1_flow=snapshot.p4_port3_far_node_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=snapshot.p4_port3_remote_to_port3_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port3_far_primary_line, + line_properties=snapshot.p4_port3_far_primary_line, + chamber=self.components.p4_port3_far_primary_chamber, + chamber_properties=snapshot.p4_port3_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_far_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_far_port3_line, + line_properties=snapshot.p4_port3_far_port3_line, + port_1_properties=snapshot.p4_port3_next_primary_line, + port_1_flow=snapshot.p4_port3_next_node_to_line_flow, + port_2_properties=snapshot.p4_port3_far_primary_line, + port_2_flow=snapshot.p4_port3_far_to_next_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port3_next_primary_line, + line_properties=snapshot.p4_port3_next_primary_line, + chamber=self.components.p4_port3_next_primary_chamber, + chamber_properties=snapshot.p4_port3_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_next_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_next_port3_line, + line_properties=snapshot.p4_port3_next_port3_line, + port_1_properties=snapshot.p4_bridge_primary_line, + port_1_flow=snapshot.p4_bridge_to_port3_next_line_flow, + port_2_properties=snapshot.p4_port3_next_primary_line, + port_2_flow=snapshot.p4_port3_next_to_bridge_line_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_next_orifice_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_next_orifice_node_to_line_flow, + port_2_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_2_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_next_orifice, + port_a_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_a_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, + port_b_properties=snapshot.p4_port3_next_orifice_output_line, + port_b_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_next_orifice_output_line, + line_properties=snapshot.p4_port3_next_orifice_output_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_1_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, + port_2_properties=snapshot.p4_port3_next_orifice_output_line, + port_2_flow=-snapshot.p4_port3_next_orifice_output_to_p4_flow, + ) + + self._write_fixed_chamber_segment( + spec=self.components.p4_port3_next_chamber_spec, + chamber=self.components.p4_port3_next_chamber, + chamber_properties=snapshot.p4_port3_next_chamber, + inlet_line=self.components.p4_port3_next_chamber_inlet_line, + inlet_line_properties=snapshot.p4_port3_next_chamber_inlet_line, + inlet_node_properties=snapshot.p4_port3_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_inlet_line_to_chamber_flow + ), + inlet_orifice=self.components.p4_port3_next_chamber_inlet_orifice, + outlet_line=self.components.p4_port3_next_chamber_outlet_line, + outlet_line_properties=snapshot.p4_port3_next_chamber_outlet_line, + outlet_node_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + outlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_outlet_line_to_chamber_flow + ), + outlet_orifice=self.components.p4_port3_next_chamber_outlet_orifice, + ) + + self._write_resistance_ports( + resistance=self.components.p4_port3_next_orifice_resistance, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_next_resistance_node_to_next_flow, + port_2_properties=snapshot.p4_port3_next_orifice_line_port_1, + port_2_flow=-snapshot.p4_port3_next_resistance_node_to_next_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_next_resistance_node_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_next_resistance_node_to_line_flow, + port_2_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_2_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_next_resistance_orifice, + port_a_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_a_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, + port_b_properties=snapshot.p4_port3_next_resistance_output_line, + port_b_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_next_resistance_output_line, + line_properties=snapshot.p4_port3_next_resistance_output_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_port3_next_resistance_output_line, + port_2_flow=-snapshot.p4_port3_next_resistance_output_to_p4_flow, + ) + + self._write_fixed_chamber_segment( + spec=self.components.p4_port3_next_resistance_chamber_spec, + chamber=self.components.p4_port3_next_resistance_chamber, + chamber_properties=snapshot.p4_port3_next_resistance_chamber, + inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, + inlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_inlet_line + ), + inlet_node_properties=snapshot.p4_port1_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow + ), + inlet_orifice=( + self.components.p4_port3_next_resistance_chamber_inlet_orifice + ), + outlet_line=self.components.p4_port3_next_resistance_chamber_outlet_line, + outlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_outlet_line + ), + outlet_node_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + outlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow + ), + outlet_orifice=( + self.components.p4_port3_next_resistance_chamber_outlet_orifice + ), + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_remote_orifice_line, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_remote_node_to_orifice_line_flow, + port_2_properties=snapshot.p4_port3_remote_orifice_line_port_2, + port_2_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, + ) + + self._write_resistance_ports( + resistance=self.components.p4_port3_remote_orifice_resistance, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_remote_node_to_resistance_flow, + port_2_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + port_2_flow=-snapshot.p4_port3_remote_node_to_resistance_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_remote_resistance_node_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_remote_resistance_node_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_2_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_remote_resistance_orifice, + port_a_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_a_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, + port_b_properties=snapshot.p4_port3_remote_resistance_output_line, + port_b_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_remote_resistance_output_line, + line_properties=snapshot.p4_port3_remote_resistance_output_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_port3_remote_resistance_output_line, + port_2_flow=-snapshot.p4_port3_remote_resistance_output_to_p4_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_remote_orifice, + port_a_properties=snapshot.p4_port3_remote_orifice_line_port_2, + port_a_flow=snapshot.p4_port3_remote_orifice_to_node_flow, + port_b_properties=snapshot.p4_port3_remote_primary_line, + port_b_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, + ) + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + ( + inlet_derivative, + chamber_derivative, + outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.spec, + chamber=self.components.volume, + chamber_properties=snapshot.chamber, + inlet_line=self.components.inlet_line, + inlet_line_properties=snapshot.inlet_line, + inlet_connected_properties=snapshot.p4_port3_remote_orifice_line_port_1, + inlet_node_to_line_flow=snapshot.inlet_node_to_line_flow, + inlet_line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + outlet_line=self.components.outlet_line, + outlet_line_properties=snapshot.outlet_line, + outlet_connected_properties=snapshot.node_line_port_1, + outlet_node_to_line_flow=snapshot.outlet_node_to_line_flow, + outlet_line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + ) + if self.use_inlet_line_reference_rhs: + inlet_derivative = self._pnl0001_reference_enthalpy_derivative( + line=self.components.inlet_line, + line_properties=snapshot.inlet_line, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.inlet_node_to_line_flow, + port_2_properties=snapshot.chamber, + port_2_flow=-snapshot.inlet_line_to_chamber_flow, + ) + ( + node_line_derivative_1, + node_line_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.node_line, + port_1_properties=snapshot.node_line_port_1, + port_1_flow=snapshot.node_to_pnl0003_flow, + port_2_connected_properties=snapshot.p4_primary_line, + port_2_flow=-snapshot.pnl0003_to_p4_flow, + ) + ( + p4_primary_derivative, + p4_primary_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_primary_line, + line_properties=snapshot.p4_primary_line, + chamber=self.components.p4_primary_chamber, + chamber_properties=snapshot.p4_primary_chamber, + chamber_to_line_flow=snapshot.p4_primary_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_node_to_primary_line_flow, + ) + p4_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_line, + port_1_properties=snapshot.p4_primary_line, + port_1_flow=snapshot.p4_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_remote_primary_line, + port_2_flow=snapshot.p4_port1_remote_node_to_line_flow, + ) + ( + p4_port1_remote_primary_derivative, + p4_port1_remote_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port1_remote_primary_line, + line_properties=snapshot.p4_port1_remote_primary_line, + chamber=self.components.p4_port1_remote_primary_chamber, + chamber_properties=snapshot.p4_port1_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_remote_node_to_primary_line_flow, + ) + p4_port1_remote_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_remote_port1_line, + port_1_properties=snapshot.p4_port1_remote_primary_line, + port_1_flow=snapshot.p4_port1_remote_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_far_primary_line, + port_2_flow=snapshot.p4_port1_far_node_to_line_flow, + ) + ( + p4_port1_far_primary_derivative, + p4_port1_far_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port1_far_primary_line, + line_properties=snapshot.p4_port1_far_primary_line, + chamber=self.components.p4_port1_far_primary_chamber, + chamber_properties=snapshot.p4_port1_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_far_node_to_primary_line_flow, + ) + p4_port1_far_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_far_port1_line, + port_1_properties=snapshot.p4_port1_far_primary_line, + port_1_flow=snapshot.p4_port1_far_to_next_line_flow, + port_2_properties=snapshot.p4_port1_next_primary_line, + port_2_flow=snapshot.p4_port1_next_node_to_line_flow, + ) + ( + p4_port1_next_primary_derivative, + p4_port1_next_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port1_next_primary_line, + line_properties=snapshot.p4_port1_next_primary_line, + chamber=self.components.p4_port1_next_primary_chamber, + chamber_properties=snapshot.p4_port1_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_next_node_to_primary_line_flow, + ) + p4_port1_next_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_next_port1_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_to_bridge_line_flow, + port_2_properties=snapshot.p4_bridge_primary_line, + port_2_flow=snapshot.p4_bridge_to_port1_next_line_flow, + ) + ( + p4_bridge_primary_derivative, + p4_bridge_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_bridge_primary_line, + line_properties=snapshot.p4_bridge_primary_line, + chamber=self.components.p4_bridge_primary_chamber, + chamber_properties=snapshot.p4_bridge_primary_chamber, + chamber_to_line_flow=snapshot.p4_bridge_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_bridge_node_to_primary_line_flow, + ) + ( + p4_port1_remote_orifice_derivative_1, + p4_port1_remote_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port1_remote_orifice_line, + port_1_properties=snapshot.p4_port1_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_remote_node_to_orifice_line_flow, + port_2_connected_properties=snapshot.p4_port1_remote_primary_line, + port_2_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, + ) + p4_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_line, + port_1_properties=snapshot.p4_port3_remote_primary_line, + port_1_flow=snapshot.p4_port3_remote_node_to_line_flow, + port_2_properties=snapshot.p4_primary_line, + port_2_flow=snapshot.p4_to_port3_line_flow, + ) + p4_port3_remote_node_connected_h = self._p4_node_port_2_connected_h( + balance=snapshot.p4_port3_remote_balance, + fallback_h=snapshot.p4_port3_remote_primary_line.h, + ) + ( + p4_port3_remote_primary_derivative, + p4_port3_remote_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port3_remote_primary_line, + line_properties=snapshot.p4_port3_remote_primary_line, + chamber=self.components.p4_port3_remote_primary_chamber, + chamber_properties=snapshot.p4_port3_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, + node_connected_h=p4_port3_remote_node_connected_h, + ) + if self.use_p4_port3_remote_primary_reference_rhs: + p4_port3_remote_primary_derivative = ( + self._pnl0001_reference_enthalpy_derivative( + line=self.components.p4_port3_remote_primary_line, + line_properties=snapshot.p4_port3_remote_primary_line, + port_1_properties=snapshot.p4_port3_remote_primary_chamber, + port_1_flow=snapshot.p4_port3_remote_chamber_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, + port_2_connected_reference_h=( + self._p4_port3_remote_port_2_reference_h( + snapshot=snapshot + ) + ), + ) + ) + p4_port3_remote_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_remote_port3_line, + port_1_properties=snapshot.p4_port3_far_primary_line, + port_1_flow=snapshot.p4_port3_far_node_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=snapshot.p4_port3_remote_to_port3_line_flow, + ) + ( + p4_port3_far_primary_derivative, + p4_port3_far_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port3_far_primary_line, + line_properties=snapshot.p4_port3_far_primary_line, + chamber=self.components.p4_port3_far_primary_chamber, + chamber_properties=snapshot.p4_port3_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_far_node_to_primary_line_flow, + ) + p4_port3_far_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_far_port3_line, + port_1_properties=snapshot.p4_port3_next_primary_line, + port_1_flow=snapshot.p4_port3_next_node_to_line_flow, + port_2_properties=snapshot.p4_port3_far_primary_line, + port_2_flow=snapshot.p4_port3_far_to_next_line_flow, + ) + ( + p4_port3_next_primary_derivative, + p4_port3_next_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port3_next_primary_line, + line_properties=snapshot.p4_port3_next_primary_line, + chamber=self.components.p4_port3_next_primary_chamber, + chamber_properties=snapshot.p4_port3_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_next_node_to_primary_line_flow, + ) + p4_port3_next_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_next_port3_line, + port_1_properties=snapshot.p4_bridge_primary_line, + port_1_flow=snapshot.p4_bridge_to_port3_next_line_flow, + port_2_properties=snapshot.p4_port3_next_primary_line, + port_2_flow=snapshot.p4_port3_next_to_bridge_line_flow, + ) + ( + p4_port3_remote_resistance_derivative_1, + p4_port3_remote_resistance_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_remote_resistance_node_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_remote_resistance_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port3_remote_resistance_output_line, + port_2_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, + ) + p4_port3_remote_resistance_output_derivative = self._connection_line_derivative( + line=self.components.p4_port3_remote_resistance_output_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_port3_far_primary_line, + port_2_flow=-snapshot.p4_port3_remote_resistance_output_to_p4_flow, + ) + ( + p4_port3_remote_orifice_derivative_1, + p4_port3_remote_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_remote_orifice_line, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_remote_node_to_orifice_line_flow, + port_2_connected_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, + port_1_connected_h=self._pn3_node_port_2_connected_h( + balance=snapshot.p4_port3_remote_orifice_node_balance, + fallback_h=snapshot.p4_port3_remote_orifice_line_port_1.h, + ), + ) + ( + p4_port3_next_orifice_derivative_1, + p4_port3_next_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_next_orifice_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_next_orifice_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port3_next_orifice_output_line, + port_2_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, + ) + p4_port3_next_orifice_output_derivative = self._connection_line_derivative( + line=self.components.p4_port3_next_orifice_output_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_1_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, + port_2_properties=snapshot.p4_port3_next_primary_line, + port_2_flow=-snapshot.p4_port3_next_orifice_output_to_p4_flow, + ) + ( + p4_port3_next_chamber_inlet_derivative, + p4_port3_next_fixed_chamber_derivative, + p4_port3_next_chamber_outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.p4_port3_next_chamber_spec, + chamber=self.components.p4_port3_next_chamber, + chamber_properties=snapshot.p4_port3_next_chamber, + inlet_line=self.components.p4_port3_next_chamber_inlet_line, + inlet_line_properties=snapshot.p4_port3_next_chamber_inlet_line, + inlet_connected_properties=snapshot.p4_port3_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_inlet_line_to_chamber_flow + ), + outlet_line=self.components.p4_port3_next_chamber_outlet_line, + outlet_line_properties=snapshot.p4_port3_next_chamber_outlet_line, + outlet_connected_properties=( + snapshot.p4_port3_remote_resistance_node_line_port_1 + ), + outlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_outlet_line_to_chamber_flow + ), + ) + ( + p4_port3_next_resistance_derivative_1, + p4_port3_next_resistance_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_next_resistance_node_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_next_resistance_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port3_next_resistance_output_line, + port_2_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, + ) + p4_port3_next_resistance_output_derivative = self._connection_line_derivative( + line=self.components.p4_port3_next_resistance_output_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_bridge_primary_line, + port_2_flow=-snapshot.p4_port3_next_resistance_output_to_p4_flow, + ) + ( + p4_port3_next_resistance_chamber_inlet_derivative, + p4_port3_next_resistance_fixed_chamber_derivative, + p4_port3_next_resistance_chamber_outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.p4_port3_next_resistance_chamber_spec, + chamber=self.components.p4_port3_next_resistance_chamber, + chamber_properties=snapshot.p4_port3_next_resistance_chamber, + inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, + inlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_inlet_line + ), + inlet_connected_properties=snapshot.p4_port1_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow + ), + outlet_line=self.components.p4_port3_next_resistance_chamber_outlet_line, + outlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_outlet_line + ), + outlet_connected_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + outlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow + ), + ) + ( + p4_port1_next_orifice_derivative_1, + p4_port1_next_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port1_next_orifice_line, + port_1_properties=snapshot.p4_port1_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port1_next_orifice_output_line, + port_2_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + p4_port1_next_orifice_output_derivative = self._connection_line_derivative( + line=self.components.p4_port1_next_orifice_output_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_orifice_p4_to_output_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_line_port_2, + port_2_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + ( + p4_port1_next_orifice_resistance_derivative_1, + p4_port1_next_orifice_resistance_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port1_next_orifice_resistance_line, + port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_resistance_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port1_far_primary_line, + port_2_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + ) + ( + p4_port1_next_orifice_resistance_chamber_inlet_derivative, + p4_port1_next_orifice_resistance_fixed_chamber_derivative, + p4_port1_next_orifice_resistance_chamber_outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.p4_port1_next_orifice_resistance_chamber_spec, + chamber=self.components.p4_port1_next_orifice_resistance_chamber, + chamber_properties=snapshot.p4_port1_next_orifice_resistance_chamber, + inlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_connected_properties=snapshot.p4_port1_remote_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow + ), + outlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_connected_properties=( + snapshot.p4_port1_next_orifice_resistance_line_port_1 + ), + outlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow + ), + ) + return [ + *inlet_derivative.as_vector(), + *chamber_derivative.as_vector(), + *outlet_derivative.as_vector(), + *node_line_derivative_1.as_vector(), + *node_line_derivative_2.as_vector(), + *p4_primary_derivative.as_vector(), + *p4_primary_chamber_derivative.as_vector(), + *p4_port1_derivative.as_vector(), + *p4_port1_remote_primary_derivative.as_vector(), + *p4_port1_remote_chamber_derivative.as_vector(), + *p4_port1_remote_port1_derivative.as_vector(), + *p4_port1_far_primary_derivative.as_vector(), + *p4_port1_far_chamber_derivative.as_vector(), + *p4_port1_far_port1_derivative.as_vector(), + *p4_port1_next_primary_derivative.as_vector(), + *p4_port1_next_chamber_derivative.as_vector(), + *p4_port1_next_port1_derivative.as_vector(), + *p4_bridge_primary_derivative.as_vector(), + *p4_bridge_chamber_derivative.as_vector(), + *p4_port1_remote_orifice_derivative_1.as_vector(), + *p4_port1_remote_orifice_derivative_2.as_vector(), + *p4_port3_derivative.as_vector(), + *p4_port3_remote_primary_derivative.as_vector(), + *p4_port3_remote_chamber_derivative.as_vector(), + *p4_port3_remote_port3_derivative.as_vector(), + *p4_port3_far_primary_derivative.as_vector(), + *p4_port3_far_chamber_derivative.as_vector(), + *p4_port3_far_port3_derivative.as_vector(), + *p4_port3_next_primary_derivative.as_vector(), + *p4_port3_next_chamber_derivative.as_vector(), + *p4_port3_next_port3_derivative.as_vector(), + *p4_port3_remote_resistance_derivative_1.as_vector(), + *p4_port3_remote_resistance_derivative_2.as_vector(), + *p4_port3_remote_resistance_output_derivative.as_vector(), + *p4_port3_remote_orifice_derivative_1.as_vector(), + *p4_port3_remote_orifice_derivative_2.as_vector(), + *p4_port3_next_orifice_derivative_1.as_vector(), + *p4_port3_next_orifice_derivative_2.as_vector(), + *p4_port3_next_orifice_output_derivative.as_vector(), + *p4_port3_next_chamber_inlet_derivative.as_vector(), + *p4_port3_next_fixed_chamber_derivative.as_vector(), + *p4_port3_next_chamber_outlet_derivative.as_vector(), + *p4_port3_next_resistance_derivative_1.as_vector(), + *p4_port3_next_resistance_derivative_2.as_vector(), + *p4_port3_next_resistance_output_derivative.as_vector(), + *p4_port3_next_resistance_chamber_inlet_derivative.as_vector(), + *p4_port3_next_resistance_fixed_chamber_derivative.as_vector(), + *p4_port3_next_resistance_chamber_outlet_derivative.as_vector(), + *p4_port1_next_orifice_derivative_1.as_vector(), + *p4_port1_next_orifice_derivative_2.as_vector(), + *p4_port1_next_orifice_output_derivative.as_vector(), + *p4_port1_next_orifice_resistance_derivative_1.as_vector(), + *p4_port1_next_orifice_resistance_derivative_2.as_vector(), + *p4_port1_next_orifice_resistance_chamber_inlet_derivative.as_vector(), + *p4_port1_next_orifice_resistance_fixed_chamber_derivative.as_vector(), + *p4_port1_next_orifice_resistance_chamber_outlet_derivative.as_vector(), + ] + + +@dataclass(frozen=True) +class TestMqlPneumaticBranchComponents: + name: str + upstream_volume: AmesimPneumaticVolume + orifice: AmesimPneumaticOrifice + downstream_volume: AmesimPneumaticVolume + spec: TestMqlPneumaticBranchSpec | None = None + + +@dataclass(frozen=True) +class TestMqlPneumaticBranchState: + name: str + upstream: ThermodynamicProperties + downstream: ThermodynamicProperties + flow: float + upstream_inlet_h: float + downstream_inlet_h: float + + +@dataclass(frozen=True) +class TestMqlPneumaticSnapshot: + branch: TestMqlPneumaticBranchState + + @property + def flow(self) -> float: + return self.branch.flow + + @property + def upstream(self) -> ThermodynamicProperties: + return self.branch.upstream + + @property + def downstream(self) -> ThermodynamicProperties: + return self.branch.downstream + + +class TestMqlPneumaticClosure: + """Minimal test_mql pneumatic closure following the existing Testmodel pattern. + + The canonical branch flow is positive from ``upstream_volume`` through the + orifice port_a/port_b into ``downstream_volume``. Port ``m_flow`` values are + written with Modelica-style signs: positive means flow into that component. + """ + + def __init__( + self, + *, + components: TestMqlPneumaticBranchComponents, + initial_state_vector: Callable[[], list[float]], + apply_state_vector: Callable[[list[float]], None], + ) -> None: + self.components = components + self._initial_state_vector = initial_state_vector + self._apply_state_vector = apply_state_vector + + def initial_state_vector(self) -> list[float]: + return self._initial_state_vector() + + def apply_state_vector(self, values: list[float]) -> None: + self._apply_state_vector(values) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPneumaticSnapshot: + if state_vector is not None: + self._apply_state_vector(state_vector) + upstream = self.components.upstream_volume.properties() + downstream = self.components.downstream_volume.properties() + flow = self._solve_branch_flow(upstream, downstream) + upstream_inlet_h = self.components.upstream_volume.connection_inlet_enthalpy( + port_m_flow=-flow, + connected_h=downstream.h, + internal_h=upstream.h, + ) + downstream_inlet_h = self.components.downstream_volume.connection_inlet_enthalpy( + port_m_flow=flow, + connected_h=upstream.h, + internal_h=downstream.h, + ) + branch = TestMqlPneumaticBranchState( + name=self.components.name, + upstream=upstream, + downstream=downstream, + flow=flow, + upstream_inlet_h=upstream_inlet_h, + downstream_inlet_h=downstream_inlet_h, + ) + self._write_port_states(branch) + return TestMqlPneumaticSnapshot(branch=branch) + + def _solve_branch_flow( + self, + upstream: ThermodynamicProperties, + downstream: ThermodynamicProperties, + ) -> float: + upstream_temperature = upstream.T if upstream.p >= downstream.p else downstream.T + return self.components.orifice.mass_flow( + upstream.p, + downstream.p, + upstream_temperature, + ) + + def _write_port_states(self, branch: TestMqlPneumaticBranchState) -> None: + upstream_volume = self.components.upstream_volume + downstream_volume = self.components.downstream_volume + orifice = self.components.orifice + + upstream_volume.port_a.p = branch.upstream.p + upstream_volume.port_a.m_flow = -branch.flow + upstream_volume.port_a.h_outflow = branch.upstream.h + + orifice.port_a.p = branch.upstream.p + orifice.port_a.m_flow = branch.flow + orifice.port_a.h_outflow = branch.upstream.h + orifice.port_b.p = branch.downstream.p + orifice.port_b.m_flow = -branch.flow + orifice.port_b.h_outflow = branch.downstream.h + + downstream_volume.port_a.p = branch.downstream.p + downstream_volume.port_a.m_flow = branch.flow + downstream_volume.port_a.h_outflow = branch.downstream.h + + def branch_derivatives( + self, + snapshot: TestMqlPneumaticSnapshot, + ) -> tuple[VolumeState, VolumeState]: + flow = snapshot.flow + upstream_derivative = self.components.upstream_volume.derivatives( + inlet_h=snapshot.branch.upstream_inlet_h, + m_flow=-flow, + ) + downstream_derivative = self.components.downstream_volume.derivatives( + inlet_h=snapshot.branch.downstream_inlet_h, + m_flow=flow, + ) + return upstream_derivative, downstream_derivative + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + upstream_derivative, downstream_derivative = self.branch_derivatives(snapshot) + return [ + *upstream_derivative.as_vector(), + *downstream_derivative.as_vector(), + ] + + +class TestMqlPneumaticChamberSegmentClosure: + """Boundary-reduced fixed chamber segment discovered from AMESim topology. + + The two PNL0001 lines remain prescribed boundary interfaces in this first + closure. Their aliases are retained in the spec so line dynamics can be + inserted later without rediscovering the component path. + """ + + def __init__( + self, + *, + components: TestMqlPneumaticChamberSegmentComponents, + inlet_boundary: TestMqlPneumaticBoundaryCondition, + outlet_boundary: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_boundary = inlet_boundary + self.outlet_boundary = outlet_boundary + + def initial_state_vector(self) -> list[float]: + return self.components.volume.get_state_vector() + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 2: + raise ValueError("single-chamber segment state vector requires two values") + self.components.volume.set_state_vector(values) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPneumaticChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + chamber = self.components.volume.properties() + inlet_boundary = self.inlet_boundary.properties(self.components.volume.gas) + outlet_boundary = self.outlet_boundary.properties(self.components.volume.gas) + inlet_temperature = ( + inlet_boundary.T if inlet_boundary.p >= chamber.p else chamber.T + ) + outlet_temperature = ( + chamber.T if chamber.p >= outlet_boundary.p else outlet_boundary.T + ) + inlet_flow = self.components.inlet_orifice.mass_flow( + inlet_boundary.p, + chamber.p, + inlet_temperature, + ) + outlet_flow = self.components.outlet_orifice.mass_flow( + chamber.p, + outlet_boundary.p, + outlet_temperature, + ) + snapshot = TestMqlPneumaticChamberSegmentSnapshot( + chamber=chamber, + inlet_boundary=inlet_boundary, + outlet_boundary=outlet_boundary, + inlet_flow=inlet_flow, + outlet_flow=outlet_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + if port_name == "port_1": + return component.port_a + if port_name == "port_2": + return component.port_b + raise ValueError(f"unsupported pneumatic port: {port_name}") + + def _write_port_states( + self, + snapshot: TestMqlPneumaticChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + chamber = self.components.volume + inlet_orifice = self.components.inlet_orifice + outlet_orifice = self.components.outlet_orifice + + inlet_boundary_port = self._port( + inlet_orifice, + spec.inlet_orifice_boundary_port, + ) + inlet_volume_port = self._port(inlet_orifice, spec.inlet_orifice_volume_port) + inlet_boundary_port.p = snapshot.inlet_boundary.p + inlet_boundary_port.m_flow = snapshot.inlet_flow + inlet_boundary_port.h_outflow = snapshot.inlet_boundary.h + inlet_volume_port.p = snapshot.chamber.p + inlet_volume_port.m_flow = -snapshot.inlet_flow + inlet_volume_port.h_outflow = snapshot.chamber.h + + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + chamber_inlet_port.m_flow = snapshot.inlet_flow + chamber_outlet_port.m_flow = -snapshot.outlet_flow + + outlet_volume_port = self._port( + outlet_orifice, + spec.outlet_orifice_volume_port, + ) + outlet_boundary_port = self._port( + outlet_orifice, + spec.outlet_orifice_boundary_port, + ) + outlet_volume_port.p = snapshot.chamber.p + outlet_volume_port.m_flow = snapshot.outlet_flow + outlet_volume_port.h_outflow = snapshot.chamber.h + outlet_boundary_port.p = snapshot.outlet_boundary.p + outlet_boundary_port.m_flow = -snapshot.outlet_flow + outlet_boundary_port.h_outflow = snapshot.outlet_boundary.h + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + spec = self.components.spec + chamber = self.components.volume + derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_boundary.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_boundary.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_boundary.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_boundary.h + ), + internal_h=snapshot.chamber.h, + ) + return derivative.as_vector() + + +class TestMqlPnl0001ChamberSegmentClosure: + """Fixed chamber segment with the topology-derived inlet PNL0001 state. + + The inlet line has a closed causal boundary here: port-1 pressure and + temperature come from the PN3 node boundary, while port-2 flow comes from + the fixed orifice. The outlet line remains a boundary until its three-line + PN3 node balance is assembled. + """ + + def __init__( + self, + *, + components: TestMqlPnl0001ChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + outlet_boundary: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.outlet_boundary = outlet_boundary + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 4: + raise ValueError("PNL0001/chamber segment state vector requires four values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPnl0001ChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + outlet_boundary = self.outlet_boundary.properties(self.components.volume.gas) + node_to_line_flow = self.components.inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node.p, + port_1_temperature_k=inlet_node.T, + ) + inlet_temperature = line.T if line.p >= chamber.p else chamber.T + line_to_chamber_flow = self.components.inlet_orifice.mass_flow( + line.p, + chamber.p, + inlet_temperature, + ) + outlet_temperature = ( + chamber.T if chamber.p >= outlet_boundary.p else outlet_boundary.T + ) + outlet_flow = self.components.outlet_orifice.mass_flow( + chamber.p, + outlet_boundary.p, + outlet_temperature, + ) + snapshot = TestMqlPnl0001ChamberSegmentSnapshot( + inlet_line=line, + chamber=chamber, + inlet_node=inlet_node, + outlet_boundary=outlet_boundary, + node_to_line_flow=node_to_line_flow, + line_to_chamber_flow=line_to_chamber_flow, + outlet_flow=outlet_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) + + def _write_port_states( + self, + snapshot: TestMqlPnl0001ChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + line = self.components.inlet_line + chamber = self.components.volume + inlet_orifice = self.components.inlet_orifice + outlet_orifice = self.components.outlet_orifice + + line.port_1.p = snapshot.inlet_node.p + line.port_1.m_flow = snapshot.node_to_line_flow + line.port_1.h_outflow = snapshot.inlet_line.h + line.port_2.p = snapshot.inlet_line.p + line.port_2.m_flow = -snapshot.line_to_chamber_flow + + inlet_boundary_port = self._port( + inlet_orifice, + spec.inlet_orifice_boundary_port, + ) + inlet_volume_port = self._port(inlet_orifice, spec.inlet_orifice_volume_port) + inlet_boundary_port.p = snapshot.inlet_line.p + inlet_boundary_port.m_flow = snapshot.line_to_chamber_flow + inlet_boundary_port.h_outflow = snapshot.inlet_line.h + inlet_volume_port.p = snapshot.chamber.p + inlet_volume_port.m_flow = -snapshot.line_to_chamber_flow + inlet_volume_port.h_outflow = snapshot.chamber.h + + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + chamber_inlet_port.m_flow = snapshot.line_to_chamber_flow + chamber_outlet_port.m_flow = -snapshot.outlet_flow + + outlet_volume_port = self._port( + outlet_orifice, + spec.outlet_orifice_volume_port, + ) + outlet_boundary_port = self._port( + outlet_orifice, + spec.outlet_orifice_boundary_port, + ) + outlet_volume_port.p = snapshot.chamber.p + outlet_volume_port.m_flow = snapshot.outlet_flow + outlet_volume_port.h_outflow = snapshot.chamber.h + outlet_boundary_port.p = snapshot.outlet_boundary.p + outlet_boundary_port.m_flow = -snapshot.outlet_flow + outlet_boundary_port.h_outflow = snapshot.outlet_boundary.h + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + line_derivative = self.components.inlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.node_to_line_flow, + connected_h_1=snapshot.inlet_node.h, + port_2_m_flow=-snapshot.line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + chamber = self.components.volume + spec = self.components.spec + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_boundary.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_boundary.h + ), + internal_h=snapshot.chamber.h, + ) + return [*line_derivative.as_vector(), *chamber_derivative.as_vector()] + + +class TestMqlPnl0001PairChamberSegmentClosure: + """Six-state fixed chamber segment with both PNL0001 compliance states. + + Generated C shows that both line instances receive ``t1/p1`` from their + PN3 nodes and send their explicit ``t2/p2`` states to the fixed orifices. + The reduced closure therefore prescribes node pressure/temperature at both + resistive port-1 boundaries while keeping both line storage states native. + """ + + def __init__( + self, + *, + components: TestMqlPnl0001PairChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + outlet_node: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.outlet_node = outlet_node + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + *self.components.outlet_line.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 6: + raise ValueError("PNL0001 pair/chamber state vector requires six values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:4]) + self.components.outlet_line.set_state_vector(values[4:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPnl0001PairChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + inlet_line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + outlet_line = self.components.outlet_line.properties() + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + outlet_node = self.outlet_node.properties(self.components.outlet_line.gas) + inlet_node_to_line_flow = self.components.inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node.p, + port_1_temperature_k=inlet_node.T, + ) + outlet_node_to_line_flow = self.components.outlet_line.resistance_mass_flow( + port_1_pressure_pa=outlet_node.p, + port_1_temperature_k=outlet_node.T, + ) + inlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=inlet_line, + chamber=chamber, + orifice=self.components.inlet_orifice, + ) + outlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=outlet_line, + chamber=chamber, + orifice=self.components.outlet_orifice, + ) + snapshot = TestMqlPnl0001PairChamberSegmentSnapshot( + inlet_line=inlet_line, + chamber=chamber, + outlet_line=outlet_line, + inlet_node=inlet_node, + outlet_node=outlet_node, + inlet_node_to_line_flow=inlet_node_to_line_flow, + inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, + outlet_node_to_line_flow=outlet_node_to_line_flow, + outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_chamber_flow( + *, + line: ThermodynamicProperties, + chamber: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= chamber.p else chamber.T + return orifice.mass_flow(line.p, chamber.p, upstream_temperature) + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) + + def _write_port_states( + self, + snapshot: TestMqlPnl0001PairChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + chamber = self.components.volume + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + + self._write_line_side( + line=self.components.inlet_line, + line_properties=snapshot.inlet_line, + node_properties=snapshot.inlet_node, + node_to_line_flow=snapshot.inlet_node_to_line_flow, + line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + orifice=self.components.inlet_orifice, + orifice_boundary_port=spec.inlet_orifice_boundary_port, + orifice_volume_port=spec.inlet_orifice_volume_port, + chamber_port=chamber_inlet_port, + chamber_properties=snapshot.chamber, + ) + self._write_line_side( + line=self.components.outlet_line, + line_properties=snapshot.outlet_line, + node_properties=snapshot.outlet_node, + node_to_line_flow=snapshot.outlet_node_to_line_flow, + line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + orifice=self.components.outlet_orifice, + orifice_boundary_port=spec.outlet_orifice_boundary_port, + orifice_volume_port=spec.outlet_orifice_volume_port, + chamber_port=chamber_outlet_port, + chamber_properties=snapshot.chamber, + ) + + def _write_line_side( + self, + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + node_properties: ThermodynamicProperties, + node_to_line_flow: float, + line_to_chamber_flow: float, + orifice: AmesimPneumaticOrifice, + orifice_boundary_port: str, + orifice_volume_port: str, + chamber_port: PortState, + chamber_properties: ThermodynamicProperties, + ) -> None: + line.port_1.p = node_properties.p + line.port_1.m_flow = node_to_line_flow + line.port_1.h_outflow = line_properties.h + line.port_2.p = line_properties.p + line.port_2.m_flow = -line_to_chamber_flow + + boundary_port = self._port(orifice, orifice_boundary_port) + volume_port = self._port(orifice, orifice_volume_port) + boundary_port.p = line_properties.p + boundary_port.m_flow = line_to_chamber_flow + boundary_port.h_outflow = line_properties.h + volume_port.p = chamber_properties.p + volume_port.m_flow = -line_to_chamber_flow + volume_port.h_outflow = chamber_properties.h + chamber_port.m_flow = line_to_chamber_flow + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + inlet_derivative = self.components.inlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.inlet_node_to_line_flow, + connected_h_1=snapshot.inlet_node.h, + port_2_m_flow=-snapshot.inlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + outlet_derivative = self.components.outlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.outlet_node_to_line_flow, + connected_h_1=snapshot.outlet_node.h, + port_2_m_flow=-snapshot.outlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + chamber = self.components.volume + spec = self.components.spec + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_line.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_line.h + ), + internal_h=snapshot.chamber.h, + ) + return [ + *inlet_derivative.as_vector(), + *chamber_derivative.as_vector(), + *outlet_derivative.as_vector(), + ] + + +class TestMqlPn3NodeChamberSegmentClosure: + """Boundary-reduced chamber segment closed by one real PN3 node. + + ``pneumatic_88`` supplies the PN3 primary pressure/temperature from its + port-1 compliance. The node has no storage, so the sum of the port-1 + PNL00R flow and port-3 PNL0001 flow is written as the opposite port-1 + flow into the PNL0003 state. + """ + + def __init__( + self, + *, + components: TestMqlPn3NodeChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + resistance_boundary: TestMqlPneumaticBoundaryCondition, + p4_boundary: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.resistance_boundary = resistance_boundary + self.p4_boundary = p4_boundary + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + *self.components.outlet_line.get_state_vector(), + *self.components.node_line.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 10: + raise ValueError("PN3 node/chamber segment state vector requires ten values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:4]) + self.components.outlet_line.set_state_vector(values[4:6]) + self.components.node_line.set_state_vector(values[6:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPn3NodeChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + inlet_line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + outlet_line = self.components.outlet_line.properties() + node_line_port_1 = self.components.node_line.properties_1() + node_line_port_2 = self.components.node_line.properties_2() + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + resistance_boundary = self.resistance_boundary.properties( + self.components.node_resistance.gas + ) + p4_boundary = self.p4_boundary.properties(self.components.node_line.gas) + inlet_node_to_line_flow = self.components.inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node.p, + port_1_temperature_k=inlet_node.T, + ) + outlet_node_to_line_flow = self.components.outlet_line.resistance_mass_flow( + port_1_pressure_pa=node_line_port_1.p, + port_1_temperature_k=node_line_port_1.T, + ) + inlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=inlet_line, + chamber=chamber, + orifice=self.components.inlet_orifice, + ) + outlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=outlet_line, + chamber=chamber, + orifice=self.components.outlet_orifice, + ) + node_to_resistance_flow = self.components.node_resistance.mass_flow( + port_1_pressure_pa=node_line_port_1.p, + port_1_temperature_k=node_line_port_1.T, + port_2_pressure_pa=resistance_boundary.p, + port_2_temperature_k=resistance_boundary.T, + ) + node_balance = self.components.node.balance( + port_2_temperature_k=node_line_port_1.T, + port_2_pressure_pa=node_line_port_1.p, + port_1_enthalpy_flow_w=self._enthalpy_flow_from_node( + flow_kg_s=node_to_resistance_flow, + node_h=node_line_port_1.h, + connected_h=resistance_boundary.h, + ), + port_1_mass_flow_g_s=node_to_resistance_flow * 1.0e3, + port_3_enthalpy_flow_w=self._enthalpy_flow_from_node( + flow_kg_s=outlet_node_to_line_flow, + node_h=node_line_port_1.h, + connected_h=outlet_line.h, + ), + port_3_mass_flow_g_s=outlet_node_to_line_flow * 1.0e3, + ) + node_to_pnl0003_flow = -node_balance.port_2_mass_flow_g_s * 1.0e-3 + pnl0003_center_flow = self.components.node_line.resistance_mass_flow() + pnl0003_to_p4_flow = self._line_to_p4_flow( + line=node_line_port_2, + p4_boundary=p4_boundary, + orifice=self.components.node_orifice, + ) + snapshot = TestMqlPn3NodeChamberSegmentSnapshot( + inlet_line=inlet_line, + chamber=chamber, + outlet_line=outlet_line, + node_line_port_1=node_line_port_1, + node_line_port_2=node_line_port_2, + inlet_node=inlet_node, + resistance_boundary=resistance_boundary, + p4_boundary=p4_boundary, + node_balance=node_balance, + inlet_node_to_line_flow=inlet_node_to_line_flow, + inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, + outlet_node_to_line_flow=outlet_node_to_line_flow, + outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, + node_to_resistance_flow=node_to_resistance_flow, + node_to_pnl0003_flow=node_to_pnl0003_flow, + pnl0003_center_flow=pnl0003_center_flow, + pnl0003_to_p4_flow=pnl0003_to_p4_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_chamber_flow( + *, + line: ThermodynamicProperties, + chamber: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + return TestMqlPnl0001PairChamberSegmentClosure._line_to_chamber_flow( + line=line, + chamber=chamber, + orifice=orifice, + ) + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) + + @staticmethod + def _enthalpy_flow_from_node( + *, + flow_kg_s: float, + node_h: float, + connected_h: float, + ) -> float: + return flow_kg_s * (node_h if flow_kg_s >= 0.0 else connected_h) + + def _write_port_states( + self, + snapshot: TestMqlPn3NodeChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + chamber = self.components.volume + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=self.components.inlet_line, + line_properties=snapshot.inlet_line, + node_properties=snapshot.inlet_node, + node_to_line_flow=snapshot.inlet_node_to_line_flow, + line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + orifice=self.components.inlet_orifice, + orifice_boundary_port=spec.inlet_orifice_boundary_port, + orifice_volume_port=spec.inlet_orifice_volume_port, + chamber_port=chamber_inlet_port, + chamber_properties=snapshot.chamber, + ) + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=self.components.outlet_line, + line_properties=snapshot.outlet_line, + node_properties=snapshot.node_line_port_1, + node_to_line_flow=snapshot.outlet_node_to_line_flow, + line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + orifice=self.components.outlet_orifice, + orifice_boundary_port=spec.outlet_orifice_boundary_port, + orifice_volume_port=spec.outlet_orifice_volume_port, + chamber_port=chamber_outlet_port, + chamber_properties=snapshot.chamber, + ) + node_line = self.components.node_line + node_line.port_1.p = snapshot.node_line_port_1.p + node_line.port_1.m_flow = snapshot.node_to_pnl0003_flow + node_line.port_1.h_outflow = snapshot.node_line_port_1.h + node_line.port_2.p = snapshot.node_line_port_2.p + node_line.port_2.m_flow = -snapshot.pnl0003_to_p4_flow + node_line.port_2.h_outflow = snapshot.node_line_port_2.h + + node_orifice = self.components.node_orifice + node_orifice.port_a.p = snapshot.node_line_port_2.p + node_orifice.port_a.m_flow = snapshot.pnl0003_to_p4_flow + node_orifice.port_a.h_outflow = snapshot.node_line_port_2.h + node_orifice.port_b.p = snapshot.p4_boundary.p + node_orifice.port_b.m_flow = -snapshot.pnl0003_to_p4_flow + node_orifice.port_b.h_outflow = snapshot.p4_boundary.h + + resistance = self.components.node_resistance + resistance.port_1.p = snapshot.node_line_port_1.p + resistance.port_1.m_flow = snapshot.node_to_resistance_flow + resistance.port_1.h_outflow = snapshot.node_line_port_1.h + resistance.port_2.p = snapshot.resistance_boundary.p + resistance.port_2.m_flow = -snapshot.node_to_resistance_flow + resistance.port_2.h_outflow = snapshot.resistance_boundary.h + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + inlet_derivative = self.components.inlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.inlet_node_to_line_flow, + connected_h_1=snapshot.inlet_node.h, + port_2_m_flow=-snapshot.inlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + outlet_derivative = self.components.outlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.outlet_node_to_line_flow, + connected_h_1=snapshot.node_line_port_1.h, + port_2_m_flow=-snapshot.outlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + chamber = self.components.volume + spec = self.components.spec + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_line.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_line.h + ), + internal_h=snapshot.chamber.h, + ) + node_line_derivative_1, node_line_derivative_2 = ( + self.components.node_line.derivatives_from_connections( + port_1_m_flow=snapshot.node_to_pnl0003_flow, + connected_h_1=snapshot.node_line_port_1.h, + port_2_m_flow=-snapshot.pnl0003_to_p4_flow, + connected_h_2=snapshot.p4_boundary.h, + ) + ) + return [ + *inlet_derivative.as_vector(), + *chamber_derivative.as_vector(), + *outlet_derivative.as_vector(), + *node_line_derivative_1.as_vector(), + *node_line_derivative_2.as_vector(), + ] diff --git a/PythonModels/systems/test_mql_computed.py b/PythonModels/systems/test_mql_computed.py new file mode 100644 index 0000000..e13bfb9 --- /dev/null +++ b/PythonModels/systems/test_mql_computed.py @@ -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 + diff --git a/PythonModels/systems/test_mql_config.py b/PythonModels/systems/test_mql_config.py new file mode 100644 index 0000000..487936c --- /dev/null +++ b/PythonModels/systems/test_mql_config.py @@ -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)}") diff --git a/PythonModels/systems/test_mql_line_parameters.py b/PythonModels/systems/test_mql_line_parameters.py new file mode 100644 index 0000000..e79298f --- /dev/null +++ b/PythonModels/systems/test_mql_line_parameters.py @@ -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".*?", 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".*?", 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".*?", 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".*?", 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}>.*?", + 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".*?", 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".*?", 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}>(.*?)", block, flags=re.DOTALL) + return match.group(1).strip() if match is not None else None diff --git a/PythonModels/systems/test_mql_lines.py b/PythonModels/systems/test_mql_lines.py new file mode 100644 index 0000000..d437bc5 --- /dev/null +++ b/PythonModels/systems/test_mql_lines.py @@ -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 diff --git a/PythonModels/systems/test_mql_mechanical.py b/PythonModels/systems/test_mql_mechanical.py new file mode 100644 index 0000000..1b8481f --- /dev/null +++ b/PythonModels/systems/test_mql_mechanical.py @@ -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) diff --git a/PythonModels/systems/test_mql_nodes.py b/PythonModels/systems/test_mql_nodes.py new file mode 100644 index 0000000..1227abc --- /dev/null +++ b/PythonModels/systems/test_mql_nodes.py @@ -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" + } diff --git a/PythonModels/systems/test_mql_pneumatic.py b/PythonModels/systems/test_mql_pneumatic.py new file mode 100644 index 0000000..208507c --- /dev/null +++ b/PythonModels/systems/test_mql_pneumatic.py @@ -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 diff --git a/PythonModels/systems/test_mql_pneumatic_lines.py b/PythonModels/systems/test_mql_pneumatic_lines.py new file mode 100644 index 0000000..850991b --- /dev/null +++ b/PythonModels/systems/test_mql_pneumatic_lines.py @@ -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) diff --git a/PythonModels/systems/test_mql_pneumatic_topology.py b/PythonModels/systems/test_mql_pneumatic_topology.py new file mode 100644 index 0000000..70babcc --- /dev/null +++ b/PythonModels/systems/test_mql_pneumatic_topology.py @@ -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, + } diff --git a/PythonModels/systems/test_mql_topology.py b/PythonModels/systems/test_mql_topology.py new file mode 100644 index 0000000..4fac424 --- /dev/null +++ b/PythonModels/systems/test_mql_topology.py @@ -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".*?", + "", + 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 diff --git a/PythonModels/systems/testmodel.py b/PythonModels/systems/testmodel.py new file mode 100644 index 0000000..2652292 --- /dev/null +++ b/PythonModels/systems/testmodel.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from PythonModels.components.cylinder import Cylinder +from PythonModels.components.orifice import Orifice +from PythonModels.components.pipe import Pipe +from PythonModels.components.tank import Tank +from PythonModels.components.tee import Tee +from PythonModels.core.medium import IdealGasMedium +from PythonModels.core.network import SimulationNetwork +from PythonModels.core.solver import SolveIVPConfig, integrate_ode +from PythonModels.systems.testmodel_closure import ( + BranchClosureComponents, + InitializationDiagnostics, + TestModelClosure, + TestModelClosureComponents, + TestModelSnapshot, +) + + +@dataclass(frozen=True) +class CylinderConfig: + volume: float = 0.01 + p0: float = 35e6 + T0: float = 300.0 + + +@dataclass(frozen=True) +class OrificeConfig: + K: float = 1e-5 + + +@dataclass(frozen=True) +class TankConfig: + volume: float = 0.1 + p0: float = 1e5 + T0: float = 300.0 + + +@dataclass(frozen=True) +class PipeConfig: + length: float = 5.0 + diameter: float = 0.02 + lambda_darcy: float = 0.02 + p0: float = 1e5 + T0: float = 300.0 + + +@dataclass(frozen=True) +class BranchConfig: + orifice: OrificeConfig = field(default_factory=OrificeConfig) + pipe: PipeConfig = field(default_factory=PipeConfig) + + +@dataclass(frozen=True) +class TestModelConfig: + cylinder: CylinderConfig = field(default_factory=CylinderConfig) + upper_branch: BranchConfig = field(default_factory=BranchConfig) + lower_branch: BranchConfig = field(default_factory=BranchConfig) + tank: TankConfig = field(default_factory=TankConfig) + + +class TestModelSystem: + """Runnable first-pass Python system for the current Testmodel topology. + + This version keeps the component split from the Modelica model while keeping + the downstream tee-tank pressure coupling in the ODE framework. The original + Modelica system is a tighter DAE because both pipe outlets discharge into an + ideal lossless junction directly connected to the tank. Here the branch + outlet flows are solved from a pressure-consistent energy balance so the + outlet is no longer driven by an arbitrary conductance parameter. + """ + + def __init__( + self, + medium: IdealGasMedium | None = None, + config: TestModelConfig | None = None, + ) -> None: + self.medium = medium or IdealGasMedium() + self.config = config or TestModelConfig() + + self.mycylinder = Cylinder( + name="mycylinder", + medium=self.medium, + V=self.config.cylinder.volume, + p0=self.config.cylinder.p0, + T0=self.config.cylinder.T0, + ) + self.mytee = Tee(name="mytee") + self.myorifice = Orifice(name="myorifice", K=self.config.upper_branch.orifice.K) + self.mypipe = Pipe( + name="mypipe", + medium=self.medium, + L=self.config.upper_branch.pipe.length, + D=self.config.upper_branch.pipe.diameter, + lambda_darcy=self.config.upper_branch.pipe.lambda_darcy, + p0=self.config.upper_branch.pipe.p0, + T0=self.config.upper_branch.pipe.T0, + ) + self.myorifice1 = Orifice(name="myorifice1", K=self.config.lower_branch.orifice.K) + self.mypipe1 = Pipe( + name="mypipe1", + medium=self.medium, + L=self.config.lower_branch.pipe.length, + D=self.config.lower_branch.pipe.diameter, + lambda_darcy=self.config.lower_branch.pipe.lambda_darcy, + p0=self.config.lower_branch.pipe.p0, + T0=self.config.lower_branch.pipe.T0, + ) + self.mytee1 = Tee(name="mytee1") + self.mytank = Tank( + name="mytank", + medium=self.medium, + V=self.config.tank.volume, + p0=self.config.tank.p0, + T0=self.config.tank.T0, + ) + + self.network = SimulationNetwork(name="Testmodel") + for component in ( + self.mycylinder, + self.mytee, + self.myorifice, + self.mypipe, + self.myorifice1, + self.mypipe1, + self.mytee1, + self.mytank, + ): + self.network.add_component(component) + + self.network.connect("mycylinder", "port_b", "mytee", "port_in") + self.network.connect("mytee", "port_out1", "myorifice", "port_a") + self.network.connect("myorifice", "port_b", "mypipe", "port_a") + self.network.connect("mypipe", "port_b", "mytee1", "port_out2") + self.network.connect("mytee", "port_out2", "myorifice1", "port_a") + self.network.connect("myorifice1", "port_b", "mypipe1", "port_a") + self.network.connect("mypipe1", "port_b", "mytee1", "port_out1") + self.network.connect("mytee1", "port_in", "mytank", "port_a") + + self.closure = TestModelClosure( + medium=self.medium, + components=TestModelClosureComponents( + cylinder=self.mycylinder, + upstream_tee=self.mytee, + upper_branch=BranchClosureComponents( + name="upper_branch", + orifice=self.myorifice, + pipe=self.mypipe, + ), + lower_branch=BranchClosureComponents( + name="lower_branch", + orifice=self.myorifice1, + pipe=self.mypipe1, + ), + downstream_tee=self.mytee1, + tank=self.mytank, + ), + initial_state_vector=self.initial_state_vector, + apply_state_vector=self.apply_state_vector, + ) + + def initial_state_vector(self) -> list[float]: + return self.network.initial_state_vector() + + def apply_state_vector(self, values: list[float]) -> None: + self.network.apply_state_vector(values) + + def consistent_initial_state_vector(self) -> list[float]: + return self.closure.consistent_initial_state_vector() + + @property + def last_solve_diagnostics(self): + return self.closure.last_solve_diagnostics + + def initialize_consistent_state( + self, + max_iterations: int = 12, + state_tolerance: float = 1e-9, + flow_tolerance: float = 1e-9, + enthalpy_tolerance: float = 1e-6, + pressure_tolerance: float = 1e-6, + strict_internal_solvers: bool = False, + ) -> InitializationDiagnostics: + return self.closure.initialize_consistent_state( + max_iterations=max_iterations, + state_tolerance=state_tolerance, + flow_tolerance=flow_tolerance, + enthalpy_tolerance=enthalpy_tolerance, + pressure_tolerance=pressure_tolerance, + strict_internal_solvers=strict_internal_solvers, + ) + + def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None: + self.closure.project_downstream_pressure_constraints(strict=strict) + + def snapshot( + self, + state_vector: list[float] | None = None, + *, + strict: bool = False, + ) -> TestModelSnapshot: + return self.closure.snapshot(state_vector, strict=strict) + + def rhs(self, _t: float, state_vector: list[float]) -> list[float]: + return self.closure.rhs(state_vector) + + @staticmethod + def _legacy_branch_series_key_map() -> tuple[tuple[str, str, str], tuple[str, str, str]]: + return ( + ("upper_branch", "branch_upper.in", "branch_upper.out"), + ("lower_branch", "branch_lower.in", "branch_lower.out"), + ) + + @classmethod + def _legacy_branch_series_keys_by_name(cls) -> dict[str, tuple[str, str]]: + return { + branch_name: (inlet_key, outlet_key) + for branch_name, inlet_key, outlet_key in cls._legacy_branch_series_key_map() + } + + @staticmethod + def _generic_branch_series_keys(branch_name: str) -> tuple[str, str, str]: + return ( + f"branch.{branch_name}.p", + f"branch.{branch_name}.in", + f"branch.{branch_name}.out", + ) + + @staticmethod + def _legacy_branch_pressure_keys_by_name() -> dict[str, str]: + return { + "upper_branch": "mypipe.p", + "lower_branch": "mypipe1.p", + } + + @classmethod + def _append_legacy_branch_series_aliases( + cls, + series: dict[str, list[float]], + ) -> dict[str, list[float]]: + legacy_branch_series_keys = cls._legacy_branch_series_keys_by_name() + legacy_branch_pressure_keys = cls._legacy_branch_pressure_keys_by_name() + for branch_name, (legacy_inlet_key, legacy_outlet_key) in legacy_branch_series_keys.items(): + pressure_key, generic_inlet_key, generic_outlet_key = cls._generic_branch_series_keys( + branch_name + ) + series[legacy_branch_pressure_keys[branch_name]] = list(series[pressure_key]) + series[legacy_inlet_key] = list(series[generic_inlet_key]) + series[legacy_outlet_key] = list(series[generic_outlet_key]) + return series + + def simulate( + self, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ) -> Any: + return integrate_ode( + rhs=self.rhs, + initial_state=self.consistent_initial_state_vector(), + config=config or SolveIVPConfig(), + t_eval=t_eval, + ) + + def evaluate_solution(self, solution: Any) -> dict[str, list[float]]: + series = { + "time": [], + "mycylinder.p": [], + "mycylinder.T": [], + "mytank.p": [], + "mytank.T": [], + } + for branch_name, _, _ in self._legacy_branch_series_key_map(): + pressure_key, inlet_key, outlet_key = self._generic_branch_series_keys(branch_name) + series[pressure_key] = [] + series[inlet_key] = [] + series[outlet_key] = [] + + for index, time_value in enumerate(solution.t): + state_vector = [row[index] for row in solution.y] + snapshot = self.snapshot(state_vector) + series["time"].append(float(time_value)) + series["mycylinder.p"].append(snapshot.cylinder.p) + series["mycylinder.T"].append(snapshot.cylinder.T) + series["mytank.p"].append(snapshot.tank.p) + series["mytank.T"].append(snapshot.tank.T) + for branch in snapshot.branches: + pressure_key, generic_inlet_key, generic_outlet_key = self._generic_branch_series_keys( + branch.name + ) + series[pressure_key].append(branch.pipe.p) + series[generic_inlet_key].append(branch.inlet_flow) + series[generic_outlet_key].append(branch.outlet_flow) + + return self._append_legacy_branch_series_aliases(series) + + +def build_testmodel() -> SimulationNetwork: + """Compatibility helper for callers that only need the topology.""" + + return TestModelSystem().network diff --git a/PythonModels/systems/testmodel_closure.py b/PythonModels/systems/testmodel_closure.py new file mode 100644 index 0000000..cbcaff8 --- /dev/null +++ b/PythonModels/systems/testmodel_closure.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from PythonModels.components.cylinder import Cylinder +from PythonModels.components.orifice import Orifice +from PythonModels.components.pipe import Pipe +from PythonModels.components.tank import Tank +from PythonModels.components.tee import Tee +from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties +from PythonModels.core.state import VolumeState + + +@dataclass(frozen=True) +class BranchInletFlowDiagnostics: + converged: bool + iterations: int + residual: float + m_flow: float + inlet_pressure: float + + +@dataclass(frozen=True) +class DownstreamPressureDiagnostics: + converged: bool + iterations: int + residual: float + pressure: float + target_total_internal_energy: float + + +@dataclass(frozen=True) +class TestModelSolveDiagnostics: + upper_branch_inlet: BranchInletFlowDiagnostics + lower_branch_inlet: BranchInletFlowDiagnostics + downstream_pressure_projection: DownstreamPressureDiagnostics | None + + +@dataclass(frozen=True) +class BranchClosureComponents: + name: str + orifice: Orifice + pipe: Pipe + + +@dataclass(frozen=True) +class BranchClosureState: + name: str + pipe: ThermodynamicProperties + inlet_flow: float + outlet_flow: float + inlet_h: float + inlet_flow_diagnostics: BranchInletFlowDiagnostics + + +@dataclass(frozen=True) +class BranchSnapshot: + name: str + pipe: ThermodynamicProperties + inlet_flow: float + outlet_flow: float + inlet_h: float + inlet_flow_diagnostics: BranchInletFlowDiagnostics + + +@dataclass(frozen=True) +class TestModelSnapshot: + cylinder: ThermodynamicProperties + tank: ThermodynamicProperties + tee_upstream_h: float + tee_downstream_h: float + branches: tuple[BranchSnapshot, ...] = field(default_factory=tuple) + solve_diagnostics: TestModelSolveDiagnostics | None = None + + @property + def pipe_upper(self) -> ThermodynamicProperties: + return self.branches[0].pipe + + @property + def pipe_lower(self) -> ThermodynamicProperties: + return self.branches[1].pipe + + @property + def branch_inlet_flows(self) -> tuple[float, ...]: + return tuple(branch.inlet_flow for branch in self.branches) + + @property + def branch_outlet_flows(self) -> tuple[float, ...]: + return tuple(branch.outlet_flow for branch in self.branches) + + +@dataclass(frozen=True) +class InitializationDiagnostics: + converged: bool + iterations: int + max_state_delta: float + max_flow_delta: float + max_enthalpy_delta: float + downstream_pressure_spread: float + state_vector: tuple[float, ...] + + +@dataclass(frozen=True) +class TestModelClosureComponents: + cylinder: Cylinder + upstream_tee: Tee + upper_branch: BranchClosureComponents + lower_branch: BranchClosureComponents + downstream_tee: Tee + tank: Tank + + def branches(self) -> tuple[BranchClosureComponents, BranchClosureComponents]: + return (self.upper_branch, self.lower_branch) + + +class TestModelClosure: + """Owns Testmodel-specific closure, projection and port-writeback logic.""" + + def __init__( + self, + *, + medium: IdealGasMedium, + components: TestModelClosureComponents, + initial_state_vector: Callable[[], list[float]], + apply_state_vector: Callable[[list[float]], None], + ) -> None: + self.medium = medium + self.components = components + self._initial_state_vector = initial_state_vector + self._apply_state_vector = apply_state_vector + self.last_solve_diagnostics: TestModelSolveDiagnostics | None = None + self.last_downstream_pressure_diagnostics: DownstreamPressureDiagnostics | None = None + + @staticmethod + def _downstream_pressure_spread(snapshot: TestModelSnapshot) -> float: + downstream_pressures = tuple(branch.pipe.p for branch in snapshot.branches) + ( + snapshot.tank.p, + ) + return max(downstream_pressures) - min(downstream_pressures) + + @staticmethod + def _initialization_flow_delta( + previous_snapshot: TestModelSnapshot | None, + current_snapshot: TestModelSnapshot, + ) -> float: + if previous_snapshot is None: + return max(abs(branch.outlet_flow) for branch in current_snapshot.branches) + return max( + abs(curr - prev) + for curr, prev in zip( + current_snapshot.branch_outlet_flows, + previous_snapshot.branch_outlet_flows, + ) + ) + + @staticmethod + def _initialization_enthalpy_delta( + previous_snapshot: TestModelSnapshot | None, + current_snapshot: TestModelSnapshot, + ) -> float: + if previous_snapshot is None: + return abs(current_snapshot.tee_downstream_h - current_snapshot.tank.h) + return max( + abs(current_snapshot.tee_upstream_h - previous_snapshot.tee_upstream_h), + abs(current_snapshot.tee_downstream_h - previous_snapshot.tee_downstream_h), + ) + + def consistent_initial_state_vector(self) -> list[float]: + return list(self.initialize_consistent_state().state_vector) + + def initialize_consistent_state( + self, + max_iterations: int = 12, + state_tolerance: float = 1e-9, + flow_tolerance: float = 1e-9, + enthalpy_tolerance: float = 1e-6, + pressure_tolerance: float = 1e-6, + strict_internal_solvers: bool = False, + ) -> InitializationDiagnostics: + raw_state = self._initial_state_vector() + previous_snapshot: TestModelSnapshot | None = None + diagnostics: InitializationDiagnostics | None = None + + for iteration in range(1, max_iterations + 1): + state_before_projection = self._initial_state_vector() + self.snapshot(state_before_projection, strict=strict_internal_solvers) + + self.project_downstream_pressure_constraints(strict=strict_internal_solvers) + + state_after_projection = self._initial_state_vector() + snapshot_after_projection = self.snapshot( + state_after_projection, + strict=strict_internal_solvers, + ) + + max_state_delta = max( + abs(after - before) + for before, after in zip(state_before_projection, state_after_projection) + ) + max_flow_delta = self._initialization_flow_delta( + previous_snapshot, + snapshot_after_projection, + ) + max_enthalpy_delta = self._initialization_enthalpy_delta( + previous_snapshot, + snapshot_after_projection, + ) + downstream_pressure_spread = self._downstream_pressure_spread( + snapshot_after_projection, + ) + + diagnostics = InitializationDiagnostics( + converged=( + max_state_delta <= state_tolerance + and max_flow_delta <= flow_tolerance + and max_enthalpy_delta <= enthalpy_tolerance + and downstream_pressure_spread <= pressure_tolerance + ), + iterations=iteration, + max_state_delta=max_state_delta, + max_flow_delta=max_flow_delta, + max_enthalpy_delta=max_enthalpy_delta, + downstream_pressure_spread=downstream_pressure_spread, + state_vector=tuple(state_after_projection), + ) + previous_snapshot = snapshot_after_projection + + if diagnostics.converged: + self._apply_state_vector(raw_state) + return diagnostics + + assert diagnostics is not None + self._apply_state_vector(raw_state) + return diagnostics + + def _solve_branch_inlet_flow( + self, + orifice: Orifice, + pipe: Pipe, + p_upstream: float, + pipe_props: ThermodynamicProperties, + *, + strict: bool = False, + ) -> tuple[float, BranchInletFlowDiagnostics]: + m_flow = orifice.mass_flow(p_upstream, pipe_props.p) + rho = max(pipe_props.rho, 1e-9) + p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p) + residual = abs(orifice.mass_flow(p_upstream, p_inlet) - m_flow) + converged = False + iterations = 0 + for iteration in range(1, 9): + p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p) + next_m_flow = orifice.mass_flow(p_upstream, p_inlet) + residual = abs(next_m_flow - m_flow) + iterations = iteration + if residual <= 1e-9 * max(1.0, abs(next_m_flow)): + m_flow = next_m_flow + converged = True + break + m_flow = next_m_flow + diagnostics = BranchInletFlowDiagnostics( + converged=converged, + iterations=iterations, + residual=residual, + m_flow=m_flow, + inlet_pressure=p_inlet, + ) + if strict and not diagnostics.converged: + raise RuntimeError( + f"Branch inlet flow solve did not converge for {pipe.name}: residual={residual:.6e}" + ) + return m_flow, diagnostics + + def _solve_downstream_branch_flows( + self, + cylinder: ThermodynamicProperties, + tank: ThermodynamicProperties, + branch_states: tuple[BranchClosureState, BranchClosureState], + ) -> tuple[float, float]: + return self._solve_downstream_branch_flows_from_state( + inlet_h_upper=branch_states[0].inlet_h, + inlet_h_lower=branch_states[1].inlet_h, + pipe_upper_h=max(branch_states[0].pipe.h, 1e-9), + pipe_lower_h=max(branch_states[1].pipe.h, 1e-9), + tank_h=max(tank.h, 1e-9), + q_in_upper=branch_states[0].inlet_flow, + q_in_lower=branch_states[1].inlet_flow, + ) + + def _project_volume_energy_to_pressure( + self, + component: Pipe | Tank, + target_pressure: float, + ) -> None: + target_temperature = target_pressure * component.V / ( + max(component.state.m, 1e-12) * self.medium.R_gas + ) + target_internal_energy = ( + component.state.m * self.medium.specific_internal_energy(target_temperature) + ) + component.state = VolumeState(m=component.state.m, U=target_internal_energy) + + def _downstream_total_internal_energy_for_pressure( + self, + target_pressure: float, + downstream_components: tuple[Pipe | Tank, ...], + ) -> float: + total_internal_energy = 0.0 + for component in downstream_components: + target_temperature = target_pressure * component.V / ( + max(component.state.m, 1e-12) * self.medium.R_gas + ) + total_internal_energy += ( + component.state.m * self.medium.specific_internal_energy(target_temperature) + ) + return total_internal_energy + + def _solve_downstream_common_pressure( + self, + downstream_components: tuple[Pipe | Tank, ...], + target_total_internal_energy: float, + *, + strict: bool = False, + ) -> tuple[float, DownstreamPressureDiagnostics]: + lower_pressure = 1.0 + upper_pressure = max(component.properties().p for component in downstream_components) + upper_pressure = max(upper_pressure, 1e5) + + def residual(pressure: float) -> float: + return ( + self._downstream_total_internal_energy_for_pressure( + pressure, + downstream_components, + ) + - target_total_internal_energy + ) + + upper_residual = residual(upper_pressure) + iteration_count = 0 + + while upper_residual < 0.0: + upper_pressure *= 2.0 + upper_residual = residual(upper_pressure) + + final_pressure = 0.5 * (lower_pressure + upper_pressure) + final_residual = residual(final_pressure) + converged = False + for iteration in range(1, 81): + middle_pressure = 0.5 * (lower_pressure + upper_pressure) + middle_residual = residual(middle_pressure) + iteration_count = iteration + final_pressure = middle_pressure + final_residual = middle_residual + if abs(middle_residual) <= 1e-12 * max(1.0, target_total_internal_energy): + converged = True + break + if middle_residual > 0.0: + upper_pressure = middle_pressure + else: + lower_pressure = middle_pressure + + diagnostics = DownstreamPressureDiagnostics( + converged=converged, + iterations=iteration_count, + residual=final_residual, + pressure=final_pressure, + target_total_internal_energy=target_total_internal_energy, + ) + if strict and not diagnostics.converged: + raise RuntimeError( + "Downstream common-pressure solve did not converge: " + f"residual={final_residual:.6e}" + ) + return final_pressure, diagnostics + + def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None: + downstream_components = ( + self.components.upper_branch.pipe, + self.components.lower_branch.pipe, + self.components.tank, + ) + total_internal_energy = sum(component.state.U for component in downstream_components) + common_pressure, diagnostics = self._solve_downstream_common_pressure( + downstream_components, + total_internal_energy, + strict=strict, + ) + self.last_downstream_pressure_diagnostics = diagnostics + + for component in downstream_components: + self._project_volume_energy_to_pressure(component, common_pressure) + + def _downstream_connection_enthalpy( + self, + q_out_upper: float, + q_out_lower: float, + pipe_upper_h: float, + pipe_lower_h: float, + tank_h: float, + ) -> float: + return self.components.downstream_tee.inlet_stream_enthalpy( + q_out_lower, + pipe_lower_h, + q_out_upper, + pipe_upper_h, + fallback_h=tank_h, + ) + + def _solve_downstream_branch_flows_from_state( + self, + *, + inlet_h_upper: float, + inlet_h_lower: float, + pipe_upper_h: float, + pipe_lower_h: float, + tank_h: float, + q_in_upper: float, + q_in_lower: float, + ) -> tuple[float, float]: + return self.components.downstream_tee.solve_branch_outlet_flows_from_energy_balance( + ratio_branch1=self.components.upper_branch.pipe.V / self.components.tank.V, + ratio_branch2=self.components.lower_branch.pipe.V / self.components.tank.V, + inlet_h_branch1=inlet_h_upper, + inlet_h_branch2=inlet_h_lower, + branch1_h=pipe_upper_h, + branch2_h=pipe_lower_h, + inlet_h=tank_h, + q_in_branch1=q_in_upper, + q_in_branch2=q_in_lower, + ) + + def _evaluate_branch_states( + self, + cylinder: ThermodynamicProperties, + ) -> tuple[BranchClosureState, BranchClosureState]: + states: list[BranchClosureState] = [] + for branch in self.components.branches(): + pipe_properties = branch.pipe.properties() + inlet_flow, inlet_flow_diagnostics = self._solve_branch_inlet_flow( + branch.orifice, + branch.pipe, + cylinder.p, + pipe_properties, + ) + inlet_h = branch.pipe.port_a_inlet_enthalpy( + port_a_m_flow=inlet_flow, + connected_h=cylinder.h, + internal_h=pipe_properties.h, + ) + states.append( + BranchClosureState( + name=branch.name, + pipe=pipe_properties, + inlet_flow=inlet_flow, + outlet_flow=0.0, + inlet_h=inlet_h, + inlet_flow_diagnostics=inlet_flow_diagnostics, + ) + ) + return (states[0], states[1]) + + @staticmethod + def _with_branch_outlet_flows( + branch_states: tuple[BranchClosureState, BranchClosureState], + outlet_flows: tuple[float, float], + ) -> tuple[BranchClosureState, BranchClosureState]: + return ( + BranchClosureState( + name=branch_states[0].name, + pipe=branch_states[0].pipe, + inlet_flow=branch_states[0].inlet_flow, + outlet_flow=outlet_flows[0], + inlet_h=branch_states[0].inlet_h, + inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics, + ), + BranchClosureState( + name=branch_states[1].name, + pipe=branch_states[1].pipe, + inlet_flow=branch_states[1].inlet_flow, + outlet_flow=outlet_flows[1], + inlet_h=branch_states[1].inlet_h, + inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics, + ), + ) + + @staticmethod + def _branch_snapshots( + branch_states: tuple[BranchClosureState, BranchClosureState], + ) -> tuple[BranchSnapshot, BranchSnapshot]: + return ( + BranchSnapshot( + name=branch_states[0].name, + pipe=branch_states[0].pipe, + inlet_flow=branch_states[0].inlet_flow, + outlet_flow=branch_states[0].outlet_flow, + inlet_h=branch_states[0].inlet_h, + inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics, + ), + BranchSnapshot( + name=branch_states[1].name, + pipe=branch_states[1].pipe, + inlet_flow=branch_states[1].inlet_flow, + outlet_flow=branch_states[1].outlet_flow, + inlet_h=branch_states[1].inlet_h, + inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics, + ), + ) + + def snapshot( + self, + state_vector: list[float] | None = None, + *, + strict: bool = False, + ) -> TestModelSnapshot: + if state_vector is not None: + self._apply_state_vector(list(state_vector)) + + cylinder = self.components.cylinder.properties() + tank = self.components.tank.properties() + branch_states = self._evaluate_branch_states(cylinder) + if strict: + for branch_state in branch_states: + if not branch_state.inlet_flow_diagnostics.converged: + raise RuntimeError( + "Branch inlet flow solve did not converge for " + f"{branch_state.name}: residual=" + f"{branch_state.inlet_flow_diagnostics.residual:.6e}" + ) + outlet_flows = self._solve_downstream_branch_flows(cylinder, tank, branch_states) + branch_states = self._with_branch_outlet_flows(branch_states, outlet_flows) + + tee_upstream_h = self.components.upstream_tee.inlet_stream_enthalpy( + -branch_states[0].inlet_flow, + branch_states[0].pipe.h, + -branch_states[1].inlet_flow, + branch_states[1].pipe.h, + fallback_h=cylinder.h, + ) + tee_downstream_h = self._downstream_connection_enthalpy( + branch_states[0].outlet_flow, + branch_states[1].outlet_flow, + branch_states[0].pipe.h, + branch_states[1].pipe.h, + tank.h, + ) + + self._write_port_states( + cylinder, + tank, + branch_states, + tee_upstream_h, + tee_downstream_h, + ) + + solve_diagnostics = TestModelSolveDiagnostics( + upper_branch_inlet=branch_states[0].inlet_flow_diagnostics, + lower_branch_inlet=branch_states[1].inlet_flow_diagnostics, + downstream_pressure_projection=self.last_downstream_pressure_diagnostics, + ) + self.last_solve_diagnostics = solve_diagnostics + branch_snapshots = self._branch_snapshots(branch_states) + + return TestModelSnapshot( + cylinder=cylinder, + tank=tank, + tee_upstream_h=tee_upstream_h, + tee_downstream_h=tee_downstream_h, + branches=branch_snapshots, + solve_diagnostics=solve_diagnostics, + ) + + def _write_port_states( + self, + cylinder: ThermodynamicProperties, + tank: ThermodynamicProperties, + branch_states: tuple[BranchClosureState, BranchClosureState], + tee_upstream_h: float, + tee_downstream_h: float, + ) -> None: + cylinder_m_flow = -sum(branch_state.inlet_flow for branch_state in branch_states) + tank_m_flow = sum(branch_state.outlet_flow for branch_state in branch_states) + + self.components.cylinder.port_b.m_flow = cylinder_m_flow + + self.components.upstream_tee.port_in.p = cylinder.p + self.components.upstream_tee.port_out1.p = cylinder.p + self.components.upstream_tee.port_out2.p = cylinder.p + self.components.upstream_tee.port_in.m_flow = cylinder_m_flow + self.components.upstream_tee.port_in.h_outflow = tee_upstream_h + self.components.upstream_tee.port_out1.h_outflow = cylinder.h + self.components.upstream_tee.port_out2.h_outflow = cylinder.h + self.components.upstream_tee.port_out1.m_flow = -branch_states[0].inlet_flow + self.components.upstream_tee.port_out2.m_flow = -branch_states[1].inlet_flow + + for branch_components, branch_state in zip(self.components.branches(), branch_states): + branch_components.orifice.port_a.p = cylinder.p + branch_components.orifice.port_b.p = branch_components.pipe.inlet_pressure( + branch_state.inlet_flow, + max(branch_state.pipe.rho, 1e-9), + branch_state.pipe.p, + ) + branch_components.orifice.port_a.m_flow = branch_state.inlet_flow + branch_components.orifice.port_b.m_flow = -branch_state.inlet_flow + branch_components.orifice.port_a.h_outflow = cylinder.h + branch_components.orifice.port_b.h_outflow = branch_state.pipe.h + + branch_components.pipe.port_a.p = branch_components.orifice.port_b.p + branch_components.pipe.port_a.m_flow = branch_state.inlet_flow + branch_components.pipe.port_b.m_flow = -branch_state.outlet_flow + branch_components.pipe.port_b.p = branch_state.pipe.p + + self.components.downstream_tee.port_in.p = tank.p + self.components.downstream_tee.port_out1.p = tank.p + self.components.downstream_tee.port_out2.p = tank.p + self.components.downstream_tee.port_in.m_flow = -tank_m_flow + self.components.downstream_tee.port_out1.m_flow = branch_states[1].outlet_flow + self.components.downstream_tee.port_out2.m_flow = branch_states[0].outlet_flow + self.components.downstream_tee.port_in.h_outflow = tee_downstream_h + self.components.downstream_tee.port_out1.h_outflow = tank.h + self.components.downstream_tee.port_out2.h_outflow = tank.h + + self.components.tank.port_a.m_flow = tank_m_flow + + def _branch_derivative_states( + self, + snapshot: TestModelSnapshot, + ) -> tuple[VolumeState, VolumeState]: + derivative_states: list[VolumeState] = [] + for branch_components, branch_snapshot in zip(self.components.branches(), snapshot.branches): + derivative_states.append( + branch_components.pipe.derivatives_from_connections( + port_a_m_flow=branch_snapshot.inlet_flow, + connected_h_a=snapshot.cylinder.h, + port_b_m_flow=-branch_snapshot.outlet_flow, + connected_h_b=snapshot.tank.h, + internal_h=branch_snapshot.pipe.h, + ) + ) + return (derivative_states[0], derivative_states[1]) + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + + cylinder_m_flow = -sum(branch.inlet_flow for branch in snapshot.branches) + tank_m_flow = sum(branch.outlet_flow for branch in snapshot.branches) + d_cylinder = self.components.cylinder.derivatives_from_connection( + connected_h=snapshot.tee_upstream_h, + port_m_flow=cylinder_m_flow, + internal_h=snapshot.cylinder.h, + ) + branch_derivatives = self._branch_derivative_states(snapshot) + d_tank = self.components.tank.derivatives_from_connection( + connected_h=snapshot.tee_downstream_h, + port_m_flow=tank_m_flow, + internal_h=snapshot.tank.h, + ) + + return [ + d_cylinder.m, + d_cylinder.U, + branch_derivatives[0].m, + branch_derivatives[0].U, + branch_derivatives[1].m, + branch_derivatives[1].U, + d_tank.m, + d_tank.U, + ] diff --git a/README.md b/README.md index 6b4cc30..45799de 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。 - `POST /api/reactflow/system-xml`:导出 System XML v2。 - `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。 - `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。 +- `POST /api/reactflow/simulate-test-mql`:运行现有固定拓扑 AMESim `test_mql` 迁移模型;该接口不把 AMESim 子模型注册为公开拖拽组件。 - `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。 - `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。 - `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。 diff --git a/app/main.py b/app/main.py index 89de93f..c722a86 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/simulation/README.md b/app/simulation/README.md index 193a99f..512bf6e 100644 --- a/app/simulation/README.md +++ b/app/simulation/README.md @@ -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 件事: diff --git a/app/simulation/components/amesim/__init__.py b/app/simulation/components/amesim/__init__.py new file mode 100644 index 0000000..bdec2fc --- /dev/null +++ b/app/simulation/components/amesim/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__: list[str] = [] diff --git a/app/simulation/components/amesim/boundary/__init__.py b/app/simulation/components/amesim/boundary/__init__.py new file mode 100644 index 0000000..92b0e5f --- /dev/null +++ b/app/simulation/components/amesim/boundary/__init__.py @@ -0,0 +1 @@ +"""AMESim pneumatic boundary components.""" diff --git a/app/simulation/components/amesim/boundary/sources.py b/app/simulation/components/amesim/boundary/sources.py new file mode 100644 index 0000000..87aca87 --- /dev/null +++ b/app/simulation/components/amesim/boundary/sources.py @@ -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"] diff --git a/app/simulation/components/amesim/flow/__init__.py b/app/simulation/components/amesim/flow/__init__.py new file mode 100644 index 0000000..c1f9910 --- /dev/null +++ b/app/simulation/components/amesim/flow/__init__.py @@ -0,0 +1 @@ +"""AMESim pneumatic flow components.""" diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py new file mode 100644 index 0000000..eef5d17 --- /dev/null +++ b/app/simulation/components/amesim/flow/orifices.py @@ -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)) diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py new file mode 100644 index 0000000..4f8fb31 --- /dev/null +++ b/app/simulation/components/amesim/flow/pipes.py @@ -0,0 +1,1196 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import isclose, log10, pi, sqrt + +from app.simulation.core.base import AlgebraicComponent, DynamicComponent, 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 AmesimPnl00r(AlgebraicComponent): + """AMESim PNL00R pneumatic pipe friction resistance. + + The public model exposes the AMESim PNL00R catalog/XML contract and uses + an auditable Darcy-Weisbach resistance with Reynolds/roughness-dependent + friction. Exact `pn2pipefr_` parity is left for the later model tuning pass. + """ + + MODEL_TYPE = "amesim_pnl00r" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "diam", + 0.01, + label="管径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "le", + 1.0, + label="管长", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "rr", + 1.0e-5, + label="相对粗糙度", + quantity="dimensionless", + unit="", + minimum=0.0, + maximum=0.1, + ), + ParameterDefinition( + "gi", + 1.0, + label="气体类型索引", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=99.0, + ), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition( + "re", + label="Reynolds 数", + quantity="dimensionless", + unit="", + category="derived", + order=10, + ), + ResultVariableDefinition( + "cm", + label="质量流量参数", + quantity="dimensionless", + unit="", + category="derived", + order=20, + ), + ResultVariableDefinition( + "v", + label="平均气体速度", + quantity="velocity", + unit="m/s", + category="derived", + order=30, + ), + ResultVariableDefinition( + "ff", + label="摩擦因子", + quantity="dimensionless", + unit="", + category="derived", + order=40, + ), + ) + DISPLAY = ComponentDisplaySpec( + label="PNL00R 气动管路阻力", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=20, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + diam: float = 0.01, + le: float = 1.0, + rr: float = 1.0e-5, + gi: float = 1.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values({"diam": diam, "le": le, "rr": rr, "gi": gi}) + self.medium = medium + self.diam = float(diam) + self.le = float(le) + self.rr = float(rr) + self.gi = self._integer_parameter("gi", gi) + self.area = pi * self.diam * self.diam / 4.0 + + 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"PNL00R parameter {name} must be an integer value.") + return int(rounded) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimPnl00r: + return cls( + name=name, + medium=medium, + diam=parameters["diam"], + le=parameters["le"], + rr=parameters["rr"], + gi=parameters["gi"], + ) + + def _port_temperature(self, port_name: str) -> float: + port = self.get_port(port_name) + if port.h_outflow > 0.0: + return max(port.h_outflow / self.medium.cp_ref, 1.0) + return self.medium.T_ref + + @staticmethod + def _dynamic_viscosity(temperature_k: float) -> float: + if temperature_k <= 0.0: + raise ValueError("temperature_k must be positive") + reference_temperature = 293.15 + reference_viscosity = 1.82e-5 + sutherland_constant = 110.4 + return ( + reference_viscosity + * (temperature_k / reference_temperature) ** 1.5 + * (reference_temperature + sutherland_constant) + / (temperature_k + sutherland_constant) + ) + + def reynolds_number(self, mass_flow: float, temperature: float) -> float: + viscosity = self._dynamic_viscosity(temperature) + return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) + + def friction_factor(self, reynolds_number: float) -> float: + if reynolds_number <= 0.0: + return 64_000_000.0 + laminar = 64.0 / reynolds_number + if reynolds_number <= 2300.0: + return laminar + turbulent = 1.0 / ( + -1.8 * log10((self.rr / 3.7) ** 1.11 + 6.9 / reynolds_number) + ) ** 2 + if reynolds_number >= 4000.0: + return turbulent + fraction = (reynolds_number - 2300.0) / 1700.0 + return laminar + fraction * (turbulent - laminar) + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = ( + friction + * (self.le / self.diam) + * density + * velocity + * velocity + / 2.0 + ) + return magnitude if mass_flow > 0.0 else -magnitude + + def _mass_flow_for_pressure_drop( + self, + pressure_drop: float, + *, + density: float, + temperature: float, + ) -> float: + if pressure_drop <= 0.0: + return 0.0 + upper = 1.0e-9 + while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: + upper *= 10.0 + if upper > 1.0e3: + raise ValueError("unable to bracket PNL00R resistance flow") + lower = 0.0 + for _ in range(48): + middle = 0.5 * (lower + upper) + if self.darcy_pressure_drop(middle, density=density, temperature=temperature) < pressure_drop: + lower = middle + else: + upper = middle + return 0.5 * (lower + upper) + + def mass_flow(self, p_1: float, p_2: float) -> float: + if p_1 == p_2: + return 0.0 + pressure_difference = p_1 - p_2 + upstream_pressure = max(p_1, p_2, 1.0) + upstream_temperature = self._port_temperature("port_1" if pressure_difference > 0.0 else "port_2") + density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def component_result_values(self) -> Mapping[str, float]: + m_flow = self.mass_flow(self.port_1.p, self.port_2.p) + upstream_pressure = max(self.port_1.p, self.port_2.p, 1.0) + upstream_temperature = self._port_temperature( + "port_1" if self.port_1.p >= self.port_2.p else "port_2" + ) + density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) + reynolds = self.reynolds_number(m_flow, upstream_temperature) + velocity = m_flow / (density * self.area) + cm = abs(m_flow) / max(self.area * upstream_pressure, 1.0e-18) + return { + "re": reynolds, + "cm": cm, + "v": velocity, + "ff": self.friction_factor(reynolds), + } + + 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 AmesimPnl0001(ThermodynamicVolumeComponent): + """AMESim PNL0001 C-R pneumatic pipe with compressibility and friction.""" + + MODEL_TYPE = "amesim_pnl0001" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "diam", + 0.01, + label="管径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "le", + 1.0, + label="管长", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "rr", + 1.0e-5, + label="相对粗糙度", + quantity="dimensionless", + unit="", + minimum=0.0, + maximum=0.1, + ), + ParameterDefinition( + "k", + 1.35, + label="多方指数", + quantity="dimensionless", + unit="", + minimum=0.0, + minimum_exclusive=True, + maximum=2.0, + ), + ParameterDefinition( + "kth", + 0.0, + label="换热系数", + quantity="heat_transfer_coefficient", + unit="W/(m2*K)", + 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( + "mode", + 2.0, + label="热模型", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=2.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 + ( + ResultVariableDefinition( + "re", + label="Reynolds 数", + quantity="dimensionless", + unit="", + category="derived", + order=100, + ), + ResultVariableDefinition( + "cm", + label="质量流量参数", + quantity="dimensionless", + unit="", + category="derived", + order=110, + ), + ResultVariableDefinition( + "v", + label="平均气体速度", + quantity="velocity", + unit="m/s", + category="derived", + order=120, + ), + ResultVariableDefinition( + "ff", + label="摩擦因子", + quantity="dimensionless", + unit="", + category="derived", + order=130, + ), + ) + DISPLAY = ComponentDisplaySpec( + label="PNL0001 C-R 动态管路", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=30, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + diam: float = 0.01, + le: float = 1.0, + rr: float = 1.0e-5, + k: float = 1.35, + kth: float = 0.0, + extemp: float = 293.15, + gi: float = 1.0, + mode: float = 2.0, + p0: float = 100000.0, + T0: float = 293.15, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "diam": diam, + "le": le, + "rr": rr, + "k": k, + "kth": kth, + "extemp": extemp, + "gi": gi, + "mode": mode, + "p0": p0, + "T0": T0, + } + ) + self.medium = medium + self.diam = float(diam) + self.le = float(le) + self.rr = float(rr) + self.k = float(k) + self.kth = float(kth) + self.extemp = float(extemp) + self.gi = self._integer_parameter("gi", gi) + self.mode = self._integer_parameter("mode", mode) + self.p0 = float(p0) + self.T0 = float(T0) + self.area = pi * self.diam * self.diam / 4.0 + self.volume = self.area * self.le + self.exchange_area = pi * self.diam * self.le + m0 = self.p0 * self.volume / (medium.R_gas * self.T0) + 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"PNL0001 parameter {name} must be an integer value.") + return int(rounded) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnl0001": + return cls( + name=name, + medium=medium, + diam=parameters["diam"], + le=parameters["le"], + rr=parameters["rr"], + k=parameters["k"], + kth=parameters["kth"], + extemp=parameters["extemp"], + gi=parameters["gi"], + mode=parameters["mode"], + 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.volume) + 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: + if self.mode == 1: + return 0.0 + return self.kth * self.exchange_area * (self.extemp - temperature) + + @staticmethod + def _dynamic_viscosity(temperature_k: float) -> float: + return AmesimPnl00r._dynamic_viscosity(temperature_k) + + def reynolds_number(self, mass_flow: float, temperature: float) -> float: + viscosity = self._dynamic_viscosity(temperature) + return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) + + def friction_factor(self, reynolds_number: float) -> float: + return AmesimPnl00r.friction_factor(self, reynolds_number) + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = ( + friction + * (self.le / self.diam) + * density + * velocity + * velocity + / 2.0 + ) + return magnitude if mass_flow > 0.0 else -magnitude + + def _mass_flow_for_pressure_drop( + self, + pressure_drop: float, + *, + density: float, + temperature: float, + ) -> float: + if pressure_drop <= 0.0: + return 0.0 + upper = 1.0e-9 + while self.darcy_pressure_drop( + upper, + density=density, + temperature=temperature, + ) < pressure_drop: + upper *= 10.0 + if upper > 1.0e3: + raise ValueError("unable to bracket PNL0001 resistance flow") + lower = 0.0 + for _ in range(48): + middle = 0.5 * (lower + upper) + if self.darcy_pressure_drop( + middle, + density=density, + temperature=temperature, + ) < pressure_drop: + lower = middle + else: + upper = middle + return 0.5 * (lower + upper) + + def mass_flow(self, p_1: float, p_2: float, temperature: float) -> float: + if p_1 == p_2: + return 0.0 + pressure_difference = p_1 - p_2 + upstream_pressure = max(p_1, p_2, 1.0) + density = max(self.medium.density(upstream_pressure, temperature), 1.0e-12) + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def component_result_values(self) -> Mapping[str, float]: + props = self.properties() + flow = self.mass_flow(self.port_1.p, props.p, props.T) + upstream_pressure = max(self.port_1.p, props.p, 1.0) + density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12) + reynolds = self.reynolds_number(flow, props.T) + return { + "m": self.state.m, + "U": self.state.U, + "p": props.p, + "T": props.T, + "rho": props.rho, + "u": props.u, + "h": props.h, + "re": reynolds, + "cm": abs(flow) / max(self.area * upstream_pressure, 1.0e-18), + "v": flow / (density * self.area), + "ff": self.friction_factor(reynolds), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) + return ( + 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 - props.p, + ), + EquationResidual( + id=f"{self.name}:port_1_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, props.p, props.T), + ), + ) + + 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() + + +class AmesimPnl0002(AmesimPnl0001): + """AMESim PNL0002 R-C-R pneumatic pipe with one center compliance.""" + + MODEL_TYPE = "amesim_pnl0002" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = AmesimPnl0001.PARAMETERS + RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES + DISPLAY = ComponentDisplaySpec( + label="PNL0002 R-C-R 动态管路", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=40, + ) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnl0002": + return cls( + name=name, + medium=medium, + diam=parameters["diam"], + le=parameters["le"], + rr=parameters["rr"], + k=parameters["k"], + kth=parameters["kth"], + extemp=parameters["extemp"], + gi=parameters["gi"], + mode=parameters["mode"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + @property + def resistance_length(self) -> float: + return self.le / 2.0 + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) + self.port_1.h_outflow = props.h + self.port_2.h_outflow = props.h + return props + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = ( + friction + * (self.resistance_length / self.diam) + * density + * velocity + * velocity + / 2.0 + ) + return magnitude if mass_flow > 0.0 else -magnitude + + def port_mass_flow( + self, + port_pressure: float, + center_pressure: float, + center_temperature: float, + ) -> float: + return self.mass_flow(port_pressure, center_pressure, center_temperature) + + def component_result_values(self) -> Mapping[str, float]: + props = self.properties() + flow_1 = self.port_mass_flow(self.port_1.p, props.p, props.T) + flow_2 = self.port_mass_flow(self.port_2.p, props.p, props.T) + diagnostic_flow = flow_1 if abs(flow_1) >= abs(flow_2) else flow_2 + upstream_pressure = max(self.port_1.p, self.port_2.p, props.p, 1.0) + density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12) + reynolds = self.reynolds_number(diagnostic_flow, props.T) + return { + "m": self.state.m, + "U": self.state.U, + "p": props.p, + "T": props.T, + "rho": props.rho, + "u": props.u, + "h": props.h, + "re": reynolds, + "cm": abs(diagnostic_flow) / max(self.area * upstream_pressure, 1.0e-18), + "v": diagnostic_flow / (density * self.area), + "ff": self.friction_factor(reynolds), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) + return ( + EquationResidual( + id=f"{self.name}:port_1_pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_1.p", + f"{self.name}.state", + f"{self.name}.port_1.m_flow", + ), + role="flow", + value=self.port_1.m_flow + - self.port_mass_flow(self.port_1.p, props.p, props.T), + ), + EquationResidual( + id=f"{self.name}:port_2_pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_2.p", + f"{self.name}.state", + f"{self.name}.port_2.m_flow", + ), + role="flow", + value=self.port_2.m_flow + - self.port_mass_flow(self.port_2.p, props.p, props.T), + ), + ) + + +class AmesimPnl0003(DynamicComponent): + """AMESim PNL0003 C-R-C pneumatic pipe with two end compliances.""" + + state_size = 4 + MODEL_TYPE = "amesim_pnl0003" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + ( + ParameterDefinition( + "p1_0", + 100000.0, + label="端口 1 初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T1_0", + 293.15, + label="端口 1 初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "p2_0", + 100000.0, + label="端口 2 初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T2_0", + 293.15, + label="端口 2 初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition("m1", "端口 1 侧质量", "mass", "kg", "state", 10), + ResultVariableDefinition("U1", "端口 1 侧内能", "internal_energy", "J", "state", 20), + ResultVariableDefinition("p1", "端口 1 侧压力", "pressure", "Pa", "thermodynamic", 30), + ResultVariableDefinition("T1", "端口 1 侧温度", "temperature", "K", "thermodynamic", 40), + ResultVariableDefinition("rho1", "端口 1 侧密度", "density", "kg/m³", "thermodynamic", 50), + ResultVariableDefinition("u1", "端口 1 侧比内能", "specific_internal_energy", "J/kg", "thermodynamic", 60), + ResultVariableDefinition("h1", "端口 1 侧比焓", "specific_enthalpy", "J/kg", "thermodynamic", 70), + ResultVariableDefinition("m2", "端口 2 侧质量", "mass", "kg", "state", 80), + ResultVariableDefinition("U2", "端口 2 侧内能", "internal_energy", "J", "state", 90), + ResultVariableDefinition("p2", "端口 2 侧压力", "pressure", "Pa", "thermodynamic", 100), + ResultVariableDefinition("T2", "端口 2 侧温度", "temperature", "K", "thermodynamic", 110), + ResultVariableDefinition("rho2", "端口 2 侧密度", "density", "kg/m³", "thermodynamic", 120), + ResultVariableDefinition("u2", "端口 2 侧比内能", "specific_internal_energy", "J/kg", "thermodynamic", 130), + ResultVariableDefinition("h2", "端口 2 侧比焓", "specific_enthalpy", "J/kg", "thermodynamic", 140), + ResultVariableDefinition("dmctr", "中心质量流量", "mass_flow", "kg/s", "derived", 150), + ResultVariableDefinition("re", "Reynolds 数", "dimensionless", "", "derived", 160), + ResultVariableDefinition("cm", "质量流量参数", "dimensionless", "", "derived", 170), + ResultVariableDefinition("v", "平均气体速度", "velocity", "m/s", "derived", 180), + ResultVariableDefinition("ff", "摩擦因子", "dimensionless", "", "derived", 190), + ) + DISPLAY = ComponentDisplaySpec( + label="PNL0003 C-R-C 动态管路", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=50, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + diam: float = 0.01, + le: float = 1.0, + rr: float = 1.0e-5, + k: float = 1.35, + kth: float = 0.0, + extemp: float = 293.15, + gi: float = 1.0, + mode: float = 2.0, + p1_0: float = 100000.0, + T1_0: float = 293.15, + p2_0: float = 100000.0, + T2_0: float = 293.15, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "diam": diam, + "le": le, + "rr": rr, + "k": k, + "kth": kth, + "extemp": extemp, + "gi": gi, + "mode": mode, + "p1_0": p1_0, + "T1_0": T1_0, + "p2_0": p2_0, + "T2_0": T2_0, + } + ) + self.medium = medium + self.diam = float(diam) + self.le = float(le) + self.rr = float(rr) + self.k = float(k) + self.kth = float(kth) + self.extemp = float(extemp) + self.gi = AmesimPnl0001._integer_parameter("gi", gi) + self.mode = AmesimPnl0001._integer_parameter("mode", mode) + self.area = pi * self.diam * self.diam / 4.0 + self.volume = self.area * self.le + self.compliance_volume = self.volume / 2.0 + self.exchange_area = pi * self.diam * self.le + self.state_1 = self._initial_state(float(p1_0), float(T1_0)) + self.state_2 = self._initial_state(float(p2_0), float(T2_0)) + h1 = medium.specific_enthalpy(float(T1_0)) + h2 = medium.specific_enthalpy(float(T2_0)) + self.port_1 = self.register_declared_port("port_1") + self.port_1.p = float(p1_0) + self.port_1.h_outflow = h1 + self.port_2 = self.register_declared_port("port_2") + self.port_2.p = float(p2_0) + self.port_2.h_outflow = h2 + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnl0003": + return cls(name=name, medium=medium, **dict(parameters)) + + def _initial_state(self, pressure: float, temperature: float) -> VolumeState: + mass = pressure * self.compliance_volume / (self.medium.R_gas * temperature) + return VolumeState(m=mass, U=mass * self.medium.specific_internal_energy(temperature)) + + def get_state_vector(self) -> list[float]: + return [*self.state_1.as_vector(), *self.state_2.as_vector()] + + def set_state_vector(self, values: list[float]) -> None: + if len(values) != 4: + raise ValueError("PNL0003 state vector requires four values") + self.state_1 = VolumeState.from_vector(values[:2]) + self.state_2 = VolumeState.from_vector(values[2:]) + + def _properties(self, state: VolumeState) -> ThermodynamicProperties: + return self.medium.properties_from_mU(state.m, state.U, self.compliance_volume) + + def properties_1(self) -> ThermodynamicProperties: + props = self._properties(self.state_1) + self.port_1.p = props.p + self.port_1.h_outflow = props.h + return props + + def properties_2(self) -> ThermodynamicProperties: + props = self._properties(self.state_2) + self.port_2.p = props.p + self.port_2.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> tuple[ThermodynamicProperties, ThermodynamicProperties]: + return self.properties_1(), self.properties_2() + + @staticmethod + def _dynamic_viscosity(temperature_k: float) -> float: + return AmesimPnl00r._dynamic_viscosity(temperature_k) + + def reynolds_number(self, mass_flow: float, temperature: float) -> float: + viscosity = self._dynamic_viscosity(temperature) + return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) + + def friction_factor(self, reynolds_number: float) -> float: + return AmesimPnl00r.friction_factor(self, reynolds_number) + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = friction * (self.le / self.diam) * density * velocity * velocity / 2.0 + return magnitude if mass_flow > 0.0 else -magnitude + + def _mass_flow_for_pressure_drop( + self, + pressure_drop: float, + *, + density: float, + temperature: float, + ) -> float: + if pressure_drop <= 0.0: + return 0.0 + upper = 1.0e-9 + while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: + upper *= 10.0 + if upper > 1.0e3: + raise ValueError("unable to bracket PNL0003 resistance flow") + lower = 0.0 + for _ in range(48): + middle = 0.5 * (lower + upper) + if self.darcy_pressure_drop(middle, density=density, temperature=temperature) < pressure_drop: + lower = middle + else: + upper = middle + return 0.5 * (lower + upper) + + def resistance_mass_flow(self) -> float: + port_1 = self._properties(self.state_1) + port_2 = self._properties(self.state_2) + pressure_difference = port_1.p - port_2.p + if pressure_difference == 0.0: + return 0.0 + upstream = port_1 if pressure_difference > 0.0 else port_2 + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=upstream.rho, + temperature=upstream.T, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def _heat_flow_each(self, temperature_1: float, temperature_2: float) -> float: + if self.mode == 1: + return 0.0 + return self.kth * self.exchange_area * (self.extemp - 0.5 * (temperature_1 + temperature_2)) / 2.0 + + def component_result_values(self) -> Mapping[str, float]: + port_1 = self.properties_1() + port_2 = self.properties_2() + center_flow = self.resistance_mass_flow() + upstream = port_1 if center_flow >= 0.0 else port_2 + reynolds = self.reynolds_number(center_flow, upstream.T) + return { + "m1": self.state_1.m, + "U1": self.state_1.U, + "p1": port_1.p, + "T1": port_1.T, + "rho1": port_1.rho, + "u1": port_1.u, + "h1": port_1.h, + "m2": self.state_2.m, + "U2": self.state_2.U, + "p2": port_2.p, + "T2": port_2.T, + "rho2": port_2.rho, + "u2": port_2.u, + "h2": port_2.h, + "dmctr": center_flow, + "re": reynolds, + "cm": abs(center_flow) / max(self.area * max(port_1.p, port_2.p, 1.0), 1.0e-18), + "v": center_flow / (max(upstream.rho, 1.0e-12) * self.area), + "ff": self.friction_factor(reynolds), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + port_1 = self._properties(self.state_1) + port_2 = self._properties(self.state_2) + 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 - port_1.p, + ), + 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 - port_2.p, + ), + ) + + def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: + port_1 = self.properties_1() + port_2 = self.properties_2() + center_flow = self.resistance_mass_flow() + heat_flow_each = self._heat_flow_each(port_1.T, port_2.T) + port_1_external_h = self.connection_inlet_enthalpy( + port_m_flow=self.port_1.m_flow, + connected_h=connected_h["port_1"], + internal_h=port_1.h, + ) + port_2_external_h = self.connection_inlet_enthalpy( + port_m_flow=self.port_2.m_flow, + connected_h=connected_h["port_2"], + internal_h=port_2.h, + ) + port_1_center_h = self.connection_inlet_enthalpy( + port_m_flow=-center_flow, + connected_h=port_2.h, + internal_h=port_1.h, + ) + port_2_center_h = self.connection_inlet_enthalpy( + port_m_flow=center_flow, + connected_h=port_1.h, + internal_h=port_2.h, + ) + d1 = VolumeState( + m=self.port_1.m_flow - center_flow, + U=self.port_1.m_flow * port_1_external_h - center_flow * port_1_center_h + heat_flow_each, + ) + d2 = VolumeState( + m=self.port_2.m_flow + center_flow, + U=self.port_2.m_flow * port_2_external_h + center_flow * port_2_center_h + heat_flow_each, + ) + return [*d1.as_vector(), *d2.as_vector()] diff --git a/app/simulation/components/amesim/junctions/__init__.py b/app/simulation/components/amesim/junctions/__init__.py new file mode 100644 index 0000000..830b29f --- /dev/null +++ b/app/simulation/components/amesim/junctions/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from app.simulation.components.amesim.junctions.nodes import AmesimP4Node2, AmesimPn3Node2 + +__all__ = ["AmesimP4Node2", "AmesimPn3Node2"] diff --git a/app/simulation/components/amesim/junctions/nodes.py b/app/simulation/components/amesim/junctions/nodes.py new file mode 100644 index 0000000..a7cd64c --- /dev/null +++ b/app/simulation/components/amesim/junctions/nodes.py @@ -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) diff --git a/app/simulation/components/amesim/library.py b/app/simulation/components/amesim/library.py new file mode 100644 index 0000000..3c3d63f --- /dev/null +++ b/app/simulation/components/amesim/library.py @@ -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", + ), +) diff --git a/app/simulation/components/amesim/mechanical/__init__.py b/app/simulation/components/amesim/mechanical/__init__.py new file mode 100644 index 0000000..a733997 --- /dev/null +++ b/app/simulation/components/amesim/mechanical/__init__.py @@ -0,0 +1 @@ +"""AMESim mechanical components.""" diff --git a/app/simulation/components/amesim/mechanical/translational.py b/app/simulation/components/amesim/mechanical/translational.py new file mode 100644 index 0000000..e6437ca --- /dev/null +++ b/app/simulation/components/amesim/mechanical/translational.py @@ -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} diff --git a/app/simulation/components/amesim/signals/__init__.py b/app/simulation/components/amesim/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/simulation/components/amesim/signals/sources.py b/app/simulation/components/amesim/signals/sources.py new file mode 100644 index 0000000..e6f9442 --- /dev/null +++ b/app/simulation/components/amesim/signals/sources.py @@ -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} diff --git a/app/simulation/components/amesim/storage/__init__.py b/app/simulation/components/amesim/storage/__init__.py new file mode 100644 index 0000000..68843c4 --- /dev/null +++ b/app/simulation/components/amesim/storage/__init__.py @@ -0,0 +1 @@ +"""AMESim pneumatic storage components.""" diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py new file mode 100644 index 0000000..0754d2a --- /dev/null +++ b/app/simulation/components/amesim/storage/chambers.py @@ -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") + ) diff --git a/app/simulation/core/metadata.py b/app/simulation/core/metadata.py index b0c597d..75aa23d 100644 --- a/app/simulation/core/metadata.py +++ b/app/simulation/core/metadata.py @@ -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", } diff --git a/app/simulation/core/peng_robinson.py b/app/simulation/core/peng_robinson.py new file mode 100644 index 0000000..f822189 --- /dev/null +++ b/app/simulation/core/peng_robinson.py @@ -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) diff --git a/app/simulation/core/ports.py b/app/simulation/core/ports.py index 401fe52..1ac3bac 100644 --- a/app/simulation/core/ports.py +++ b/app/simulation/core/ports.py @@ -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 diff --git a/app/simulation/examples/test_mql/__init__.py b/app/simulation/examples/test_mql/__init__.py new file mode 100644 index 0000000..bf8d45d --- /dev/null +++ b/app/simulation/examples/test_mql/__init__.py @@ -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", +] diff --git a/app/simulation/examples/test_mql/run.py b/app/simulation/examples/test_mql/run.py new file mode 100644 index 0000000..9fb3342 --- /dev/null +++ b/app/simulation/examples/test_mql/run.py @@ -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() diff --git a/app/simulation/examples/testmodel/run_test_mql.py b/app/simulation/examples/testmodel/run_test_mql.py new file mode 100644 index 0000000..0fd7977 --- /dev/null +++ b/app/simulation/examples/testmodel/run_test_mql.py @@ -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() diff --git a/app/simulation/examples/testmodel/run_test_mql_baseline.py b/app/simulation/examples/testmodel/run_test_mql_baseline.py new file mode 100644 index 0000000..eb37505 --- /dev/null +++ b/app/simulation/examples/testmodel/run_test_mql_baseline.py @@ -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() diff --git a/app/simulation/examples/testmodel/run_test_mql_full_state_comparison.py b/app/simulation/examples/testmodel/run_test_mql_full_state_comparison.py new file mode 100644 index 0000000..1b3c8d4 --- /dev/null +++ b/app/simulation/examples/testmodel/run_test_mql_full_state_comparison.py @@ -0,0 +1,4620 @@ +from __future__ import annotations + +import argparse +from bisect import bisect_left +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime +from math import nextafter, sqrt +from pathlib import Path + +from PythonModels.components.amesim_pneumatic import AmesimPneumaticGas +from PythonModels.core.solver import SolveIVPConfig, integrate_ode +from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results +from PythonModels.reporting.test_mql_comparison import ( + TestMqlComparisonResult, + interpolate_series_value, + write_test_mql_comparison_csv, +) +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.systems.test_mql import ( + TestMqlPnl0001LineRhsDiagnostic, + TestMqlSimulationResult, + TestMqlSystem, + TestMqlVariableChamberRhsDiagnostic, +) +from PythonModels.systems.test_mql_pneumatic import AMESIM_REFERENCE_PRESSURE_PA + + +DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS = ( + "press@pn_c1_8", + "vol@pn_c1_8", + "vol1@pn_brp2_8", + "vvol1@pn_brp2_8", + "x1@mass_friction_endstops_10", + "v1@mass_friction_endstops_10", + "acc1@mass_friction_endstops_10", + "x1@mass_friction_endstops_18", + "v1@mass_friction_endstops_18", + "acc1@mass_friction_endstops_18", + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", +) + + +@dataclass(frozen=True) +class TestMqlFullStateSignalDiagnostic: + data_path: str + initial_time_s: float + final_time_s: float + initial_python_value: float + initial_amesim_value: float + initial_abs_error: float + final_python_value: float + final_amesim_value: float + final_abs_error: float + + +@dataclass(frozen=True) +class TestMqlFullStateFlowDiagnostic: + data_path: str + initial_time_s: float + final_time_s: float + initial_python_dm1_g_s: float + initial_amesim_dm1_g_s: float + initial_python_canonical_kg_s: float + initial_amesim_canonical_kg_s: float + initial_canonical_abs_error_kg_s: float + final_python_dm1_g_s: float + final_amesim_dm1_g_s: float + final_python_canonical_kg_s: float + final_amesim_canonical_kg_s: float + final_canonical_abs_error_kg_s: float + + +@dataclass(frozen=True) +class TestMqlFullStatePnvoDiagnostic: + alias: str + initial_time_s: float + final_time_s: float + initial_python_opening: float + initial_amesim_opening: float + final_python_opening: float + final_amesim_opening: float + initial_python_mass_flow_kg_s: float + initial_amesim_mass_flow_kg_s: float + final_python_mass_flow_kg_s: float + final_amesim_mass_flow_kg_s: float + final_mass_flow_abs_error_kg_s: float + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonRun: + system: TestMqlSystem + closure: object + amesim_results: AmesimResults + output_schema: TestMqlOutputSchema + result: TestMqlSimulationResult + 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 metrics_by_max_abs_error(self): + return tuple( + sorted( + self.comparison.metrics, + key=lambda metric: metric.max_abs_error, + reverse=True, + ) + ) + + @property + def largest_abs_error_metric(self): + metrics = self.metrics_by_max_abs_error() + return metrics[0] if metrics else None + + def signal_diagnostic(self, data_path: str) -> TestMqlFullStateSignalDiagnostic: + times = self.output.times + python_values = self.output.series_by_data_path[data_path] + amesim_values = self.amesim_results.series(data_path) + initial_time = float(times[0]) + final_time = float(times[-1]) + initial_python = float(python_values[0]) + final_python = float(python_values[-1]) + initial_amesim = interpolate_series_value( + self.amesim_results.times, + amesim_values, + initial_time, + ) + final_amesim = interpolate_series_value( + self.amesim_results.times, + amesim_values, + final_time, + ) + return TestMqlFullStateSignalDiagnostic( + data_path=data_path, + initial_time_s=initial_time, + final_time_s=final_time, + initial_python_value=initial_python, + initial_amesim_value=initial_amesim, + initial_abs_error=abs(initial_python - initial_amesim), + final_python_value=final_python, + final_amesim_value=final_amesim, + final_abs_error=abs(final_python - final_amesim), + ) + + def pnl0001_mass_flow_diagnostic( + self, + data_path: str = "dm1@pneumatic_69", + ) -> TestMqlFullStateFlowDiagnostic: + diagnostic = self.signal_diagnostic(data_path) + initial_python_canonical = -diagnostic.initial_python_value * 1.0e-3 + initial_amesim_canonical = -diagnostic.initial_amesim_value * 1.0e-3 + final_python_canonical = -diagnostic.final_python_value * 1.0e-3 + final_amesim_canonical = -diagnostic.final_amesim_value * 1.0e-3 + return TestMqlFullStateFlowDiagnostic( + data_path=data_path, + initial_time_s=diagnostic.initial_time_s, + final_time_s=diagnostic.final_time_s, + initial_python_dm1_g_s=diagnostic.initial_python_value, + initial_amesim_dm1_g_s=diagnostic.initial_amesim_value, + initial_python_canonical_kg_s=initial_python_canonical, + initial_amesim_canonical_kg_s=initial_amesim_canonical, + initial_canonical_abs_error_kg_s=abs( + initial_python_canonical - initial_amesim_canonical + ), + final_python_dm1_g_s=diagnostic.final_python_value, + final_amesim_dm1_g_s=diagnostic.final_amesim_value, + final_python_canonical_kg_s=final_python_canonical, + final_amesim_canonical_kg_s=final_amesim_canonical, + final_canonical_abs_error_kg_s=abs( + final_python_canonical - final_amesim_canonical + ), + ) + + def pnvo_diagnostic( + self, + alias: str = "pn_morifice_1", + ) -> TestMqlFullStatePnvoDiagnostic: + opening = self.signal_diagnostic(f"xv@{alias}") + mass_flow = self.signal_diagnostic(f"dm2@{alias}") + initial_python_mass_flow = mass_flow.initial_python_value * 1.0e-3 + initial_amesim_mass_flow = mass_flow.initial_amesim_value * 1.0e-3 + final_python_mass_flow = mass_flow.final_python_value * 1.0e-3 + final_amesim_mass_flow = mass_flow.final_amesim_value * 1.0e-3 + return TestMqlFullStatePnvoDiagnostic( + alias=alias, + initial_time_s=opening.initial_time_s, + final_time_s=opening.final_time_s, + initial_python_opening=opening.initial_python_value, + initial_amesim_opening=opening.initial_amesim_value, + final_python_opening=opening.final_python_value, + final_amesim_opening=opening.final_amesim_value, + initial_python_mass_flow_kg_s=initial_python_mass_flow, + initial_amesim_mass_flow_kg_s=initial_amesim_mass_flow, + final_python_mass_flow_kg_s=final_python_mass_flow, + final_amesim_mass_flow_kg_s=final_amesim_mass_flow, + final_mass_flow_abs_error_kg_s=abs( + final_python_mass_flow - final_amesim_mass_flow + ), + ) + + def diagnostics_by_final_abs_error(self): + return tuple( + sorted( + ( + self.signal_diagnostic(data_path) + for data_path in self.output.data_paths + ), + key=lambda diagnostic: diagnostic.final_abs_error, + reverse=True, + ) + ) + + @property + def largest_final_abs_error_diagnostic(self): + diagnostics = self.diagnostics_by_final_abs_error() + return diagnostics[0] if diagnostics else None + + def chamber_rhs_diagnostic(self, chamber_alias: str, sample_index: int = -1): + state_vector = [row[sample_index] for row in self.result.y] + return self.closure.variable_chamber_rhs_diagnostic( + chamber_alias=chamber_alias, + state_vector=state_vector, + time_s=float(self.result.t[sample_index]), + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventBoundaryDiagnostic: + orifice_alias: str + event_time_s: float + integration_stop_time_s: float + data_paths: tuple[str, ...] + python_values_by_data_path: dict[str, float] + amesim_values_by_data_path: dict[str, float] + + def abs_error(self, data_path: str) -> float: + return abs( + self.python_values_by_data_path[data_path] + - self.amesim_values_by_data_path[data_path] + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowSegmentDiagnostic: + t_start: float + t_stop: float + method: str + rtol: float + atol: float + max_step: float + rhs_evaluations: int + success: bool + message: str + + +@dataclass(frozen=True) +class TestMqlPnl0001PressureLossCalibrationDiagnostic: + line_alias: str + chamber_alias: str + time_s: float + amesim_cm: float + amesim_dm1_g_s: float + amesim_mass_flow_magnitude_kg_s: float + amesim_line_gauge_pressure_pa: float + amesim_chamber_gauge_pressure_pa: float + amesim_line_temperature_k: float + amesim_pressure_drop_pa: float + current_darcy_pressure_drop_pa: float + pressure_drop_multiplier: float + candidate_pn2pipefr_dm1_g_s: float + candidate_pn2pipefr_to_amesim_dm1_ratio: float + amesim_linear_conductance_kg_s_sqrt_k_per_pa: float + python_linear_conductance_kg_s_sqrt_k_per_pa: float + python_to_amesim_linear_conductance_ratio: float + + +@dataclass(frozen=True) +class TestMqlPnvoFlowParameterDiagnostic: + orifice_alias: str + time_s: float + amesim_cm: float + amesim_dm2_g_s: float + amesim_opening: float + amesim_gas_velocity_m_s: float + python_opening: float + python_flow_coefficient: float + python_effective_area_m2: float + python_line_pressure_pa: float + python_boundary_pressure_pa: float + python_upstream_pressure_pa: float + python_upstream_temperature_k: float + python_mass_flow_kg_s: float + python_cm: float + python_to_amesim_cm_ratio: float + + +@dataclass(frozen=True) +class TestMqlPnl0001EnergyFlowDiagnostic: + line_alias: str + chamber_alias: str + node_alias: str + time_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_port_1_mass_flow_g_s: float + amesim_node_port_2_enthalpy_flow_w: float + amesim_node_port_2_mass_flow_g_s: float + amesim_observed_port_enthalpy_sum_w: float + python_port_1_internal_energy_flow_w: float + python_port_2_internal_energy_flow_w: float + python_current_energy_derivative_w: float + python_direct_enthalpy_port_1_flow_w: float + python_direct_enthalpy_port_2_flow_w: float + python_direct_enthalpy_energy_derivative_w: float + python_direct_minus_current_energy_derivative_w: float + python_p4_node_port_2_enthalpy_flow_w: float + python_p4_node_port_2_mass_flow_g_s: float + python_p4_node_port_2_connected_h_j_kg: float + python_p4_node_port_2_connected_temperature_k: float + python_p4_node_port_2_connected_h_delta_to_line_h_j_kg: float + python_p4_node_port_2_counterfactual_dtemp_k_s: float + reference_temperature_k: float + amesim_port_1_reference_enthalpy_estimate_w: float + amesim_port_1_reference_enthalpy_error_w: float + amesim_candidate_pn2pipefr_dm2i_g_s: float + amesim_candidate_pn2pipefr_dh2i_w: float + amesim_candidate_storage_enthalpy_sum_w: float + amesim_candidate_pn2pipefr_dtemp_k_s: float + amesim_candidate_pn2pipefr_dtemp_residual_k_s: float + python_reference_port_1_enthalpy_flow_w: float + python_reference_node_port_2_enthalpy_flow_w: float + python_reference_observed_port_enthalpy_sum_w: float + python_reference_port_1_to_amesim_error_w: float + python_reference_node_port_2_to_amesim_error_w: float + python_reference_sum_to_amesim_error_w: float + python_current_line_temperature_derivative_k_s: float + amesim_line_temperature_derivative_fd_k_s: float + amesim_pn2vol2_dtemp_node_plus_dh1_k_s: float + amesim_pn2vol2_dtemp_node_minus_dh1_k_s: float + python_reference_pn2vol2_dtemp_node_minus_dh1_k_s: float + + +@dataclass(frozen=True) +class TestMqlP4NodeEnthalpyBreakdownDiagnostic: + node_alias: str + time_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_port_1_mass_flow_g_s: float + amesim_port_3_enthalpy_flow_w: float + amesim_port_3_mass_flow_g_s: float + amesim_port_4_enthalpy_flow_w: float + amesim_port_4_mass_flow_g_s: float + amesim_port_2_enthalpy_flow_w: float + amesim_port_2_mass_flow_g_s: float + python_port_1_enthalpy_flow_w: float + python_port_1_mass_flow_g_s: float + python_port_3_enthalpy_flow_w: float + python_port_3_mass_flow_g_s: float + python_port_4_enthalpy_flow_w: float + python_port_4_mass_flow_g_s: float + python_port_2_enthalpy_flow_w: float + python_port_2_mass_flow_g_s: float + python_reference_port_1_enthalpy_flow_w: float + python_reference_port_3_enthalpy_flow_w: float + python_reference_port_4_enthalpy_flow_w: float + python_reference_port_2_enthalpy_flow_w: float + python_real_gas_reference_port_1_enthalpy_flow_w: float + python_real_gas_reference_port_3_enthalpy_flow_w: float + python_real_gas_reference_port_4_enthalpy_flow_w: float + python_real_gas_reference_port_2_enthalpy_flow_w: float + python_port_2_enthalpy_error_w: float + python_reference_port_2_enthalpy_error_w: float + python_real_gas_reference_port_2_enthalpy_error_w: float + + +@dataclass(frozen=True) +class TestMqlPnvoUpstreamEnthalpyDiagnostic: + orifice_alias: str + upstream_line_alias: str + time_s: float + amesim_orifice_port_2_enthalpy_flow_w: float + amesim_orifice_port_2_mass_flow_g_s: float + amesim_orifice_port_3_enthalpy_flow_w: float + amesim_orifice_port_3_mass_flow_g_s: float + amesim_upstream_line_center_enthalpy_flow_w: float + amesim_upstream_line_center_mass_flow_g_s: float + amesim_upstream_line_port_2_temperature_k: float + amesim_upstream_line_port_2_pressure_pa: float + amesim_orifice_port_2_implied_reference_temperature_k: float + amesim_orifice_port_2_implied_temperature_delta_to_line_k: float + amesim_upstream_line_port_2_reference_enthalpy_flow_w: float + amesim_upstream_line_port_2_reference_enthalpy_error_w: float + amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w: float + amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w: float + python_orifice_to_node_flow_kg_s: float + python_upstream_line_port_2_temperature_k: float + python_upstream_line_port_2_pressure_pa: float + python_current_port_2_enthalpy_flow_w: float + python_reference_port_2_enthalpy_flow_w: float + python_real_gas_reference_port_2_enthalpy_flow_w: float + python_current_port_2_enthalpy_error_w: float + python_reference_port_2_enthalpy_error_w: float + python_real_gas_reference_port_2_enthalpy_error_w: float + +@dataclass(frozen=True) +class TestMqlPnl0003EnergyDiagnostic: + line_alias: str + time_s: float + amesim_port_1_temperature_k: float + amesim_port_2_temperature_k: float + amesim_port_1_pressure_pa: float + amesim_port_2_pressure_pa: float + amesim_port_1_mass_flow_g_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_center_mass_flow_g_s: float + amesim_center_enthalpy_flow_w: float + amesim_port_2_mass_flow_g_s: float + amesim_port_2_enthalpy_flow_w: float + amesim_port_1_storage_mass_derivative_g_s: float + amesim_port_1_storage_enthalpy_sum_w: float + amesim_port_2_storage_mass_derivative_g_s: float + amesim_port_2_storage_enthalpy_sum_w: float + python_pn3_node_port_1_mass_flow_g_s: float + python_pn3_node_port_3_mass_flow_g_s: float + python_pn3_node_to_line_mass_flow_g_s: float + python_pn3_node_to_line_mass_flow_error_g_s: float + amesim_port_1_mass_estimate_g: float + amesim_port_2_mass_estimate_g: float + python_port_1_mass_g: float + python_port_2_mass_g: float + python_port_1_mass_error_to_amesim_g: float + python_port_2_mass_error_to_amesim_g: float + amesim_total_mass_derivative_fd_g_s: float + amesim_total_mass_derivative_backward_g_s: float + amesim_total_mass_derivative_forward_g_s: float + amesim_total_storage_mass_derivative_g_s: float + amesim_total_storage_mass_derivative_residual_g_s: float + amesim_center_flow_python_sign_equivalent_g_s: float + amesim_center_flow_python_sign_error_g_s: float + python_center_mass_flow_g_s: float + amesim_port_1_center_mass_derivative_g_s: float + amesim_port_2_center_mass_derivative_g_s: float + python_port_1_external_mass_flow_g_s: float + python_port_1_external_mass_flow_error_g_s: float + python_port_1_center_mass_derivative_g_s: float + python_port_1_center_mass_derivative_error_g_s: float + python_port_1_storage_mass_derivative_g_s: float + python_port_1_storage_mass_derivative_error_g_s: float + python_port_2_external_mass_flow_g_s: float + python_port_2_external_mass_flow_error_g_s: float + python_port_2_center_mass_derivative_g_s: float + python_port_2_center_mass_derivative_error_g_s: float + python_port_2_storage_mass_derivative_g_s: float + python_port_2_storage_mass_derivative_error_g_s: float + python_amesim_state_darcy_center_mass_flow_g_s: float + python_amesim_state_darcy_center_mass_flow_error_g_s: float + python_amesim_state_pn2pipefr_center_mass_flow_g_s: float + python_amesim_state_pn2pipefr_center_mass_flow_error_g_s: float + amesim_center_real_gas_reference_port_1_estimate_w: float + amesim_center_real_gas_reference_port_1_error_w: float + python_center_ideal_reference_port_1_estimate_w: float + python_center_ideal_reference_port_1_error_w: float + python_center_ideal_reference_port_2_estimate_w: float + python_center_ideal_reference_port_2_error_w: float + python_center_real_gas_reference_port_1_estimate_w: float + python_center_real_gas_reference_port_1_error_w: float + python_center_real_gas_reference_port_2_estimate_w: float + python_center_real_gas_reference_port_2_error_w: float + python_total_mass_derivative_kg_s: float + python_port_1_reference_external_enthalpy_flow_w: float + python_port_1_reference_center_enthalpy_flow_w: float + python_port_1_reference_storage_enthalpy_sum_w: float + python_port_1_reference_storage_enthalpy_error_w: float + python_port_2_reference_external_enthalpy_flow_w: float + python_port_2_reference_center_enthalpy_flow_w: float + python_port_2_reference_storage_enthalpy_sum_w: float + python_port_2_reference_storage_enthalpy_error_w: float + amesim_port_1_external_enthalpy_dtemp_component_k_s: float + amesim_port_1_center_enthalpy_dtemp_component_k_s: float + amesim_port_1_mass_offset_dtemp_component_k_s: float + amesim_port_1_heat_dtemp_component_k_s: float + python_port_1_external_enthalpy_dtemp_component_k_s: float + python_port_1_center_enthalpy_dtemp_component_k_s: float + python_port_1_mass_offset_dtemp_component_k_s: float + python_port_1_heat_dtemp_component_k_s: float + amesim_port_2_external_enthalpy_dtemp_component_k_s: float + amesim_port_2_center_enthalpy_dtemp_component_k_s: float + amesim_port_2_mass_offset_dtemp_component_k_s: float + amesim_port_2_heat_dtemp_component_k_s: float + python_port_2_external_enthalpy_dtemp_component_k_s: float + python_port_2_center_enthalpy_dtemp_component_k_s: float + python_port_2_mass_offset_dtemp_component_k_s: float + python_port_2_heat_dtemp_component_k_s: float + amesim_port_1_temperature_derivative_fd_k_s: float + amesim_port_1_temperature_derivative_backward_k_s: float + amesim_port_1_temperature_derivative_forward_k_s: float + amesim_port_2_temperature_derivative_fd_k_s: float + amesim_port_2_temperature_derivative_backward_k_s: float + amesim_port_2_temperature_derivative_forward_k_s: float + amesim_port_1_pn2vol_dtemp_k_s: float + amesim_port_2_pn2vol_dtemp_k_s: float + amesim_port_1_pn2vol_dtemp_residual_k_s: float + amesim_port_2_pn2vol_dtemp_residual_k_s: float + amesim_port_1_pressure_derivative_fd_pa_s: float + amesim_port_2_pressure_derivative_fd_pa_s: float + amesim_port_1_pressure_derivative_mass_component_pa_s: float + amesim_port_1_pressure_derivative_temperature_component_pa_s: float + amesim_port_1_pressure_derivative_eos_sum_pa_s: float + amesim_port_1_pressure_derivative_eos_residual_pa_s: float + amesim_port_2_pressure_derivative_mass_component_pa_s: float + amesim_port_2_pressure_derivative_temperature_component_pa_s: float + amesim_port_2_pressure_derivative_eos_sum_pa_s: float + amesim_port_2_pressure_derivative_eos_residual_pa_s: float + python_port_1_pressure_derivative_mass_component_pa_s: float + python_port_1_pressure_derivative_temperature_component_pa_s: float + python_port_1_pressure_derivative_eos_sum_pa_s: float + python_port_2_pressure_derivative_mass_component_pa_s: float + python_port_2_pressure_derivative_temperature_component_pa_s: float + python_port_2_pressure_derivative_eos_sum_pa_s: float + python_port_1_temperature_k: float + python_port_2_temperature_k: float + python_port_1_pressure_pa: float + python_port_2_pressure_pa: float + python_port_1_mass_derivative_kg_s: float + python_port_2_mass_derivative_kg_s: float + python_port_1_current_dtemp_k_s: float + python_port_2_current_dtemp_k_s: float + python_port_1_real_gas_reference_dtemp_k_s: float + python_port_2_real_gas_reference_dtemp_k_s: float + python_port_2_amesim_center_counterfactual_dtemp_k_s: float + python_port_2_amesim_external_counterfactual_dtemp_k_s: float + python_port_2_amesim_external_center_counterfactual_dtemp_k_s: float + python_port_2_temperature_error_to_amesim_k: float + + +@dataclass(frozen=True) +class TestMqlPnch012EnergyEquationDiagnostic: + chamber_alias: str + piston_alias: str + line_alias: str + time_s: float + amesim_port_1_enthalpy_flow_w: float + amesim_port_1_mass_flow_g_s: float + amesim_chamber_mass_derivative_fd_g_s: float + amesim_chamber_mass_derivative_residual_g_s: float + amesim_chamber_temperature_derivative_fd_k_s: float + amesim_chamber_volume_rate_fd_m3_s: float + amesim_piston_volume_rate_m3_s: float + amesim_heat_flow_w: float + amesim_boundary_work_fd_volume_w: float + amesim_pn2vol_reference_dtemp_fd_volume_k_s: float + amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s: float + amesim_pn2vol_reference_dtemp_piston_volume_k_s: float + amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s: float + python_port_a_mass_flow_kg_s: float + python_current_energy_derivative_w: float + python_current_chamber_temperature_derivative_k_s: float + reference_temperature_k: float + + +@dataclass(frozen=True) +class TestMqlPneumatic96InletLineDiagnostic: + line_alias: str + node_alias: str + orifice_alias: str + chamber_alias: str + time_s: float + amesim_line_pressure_pa: float + amesim_line_temperature_k: float + amesim_line_mass_g: float + amesim_line_mass_derivative_fd_g_s: float + amesim_line_to_node_mass_flow_g_s: float + amesim_orifice_to_line_mass_flow_g_s: float + amesim_storage_mass_derivative_from_ports_g_s: float + amesim_storage_mass_derivative_residual_g_s: float + amesim_chamber_pressure_pa: float + amesim_chamber_temperature_k: float + amesim_node_pressure_pa: float + amesim_node_temperature_k: float + amesim_line_temperature_derivative_fd_k_s: float + amesim_line_pressure_derivative_fd_pa_s: float + amesim_port_1_enthalpy_dtemp_component_k_s: float + amesim_port_2_enthalpy_dtemp_component_k_s: float + amesim_mass_offset_dtemp_component_k_s: float + amesim_reference_temperature_derivative_sum_k_s: float + amesim_reference_temperature_derivative_residual_k_s: float + amesim_pressure_derivative_mass_component_pa_s: float + amesim_pressure_derivative_temperature_component_pa_s: float + amesim_pressure_derivative_eos_sum_pa_s: float + amesim_pressure_derivative_eos_residual_pa_s: float + python_line_pressure_pa: float + python_line_temperature_k: float + python_line_mass_g: float + python_line_pressure_error_pa: float + python_line_temperature_error_k: float + python_line_mass_error_g: float + python_line_to_node_mass_flow_g_s: float + python_line_to_node_mass_flow_error_g_s: float + python_orifice_to_line_mass_flow_g_s: float + python_orifice_to_line_mass_flow_error_g_s: float + python_storage_mass_derivative_g_s: float + python_storage_mass_derivative_error_g_s: float + python_line_to_node_amesim_node_mass_flow_g_s: float + python_line_to_node_amesim_node_mass_flow_error_g_s: float + python_line_to_node_amesim_line_mass_flow_g_s: float + python_line_to_node_amesim_line_mass_flow_error_g_s: float + python_line_to_node_amesim_node_line_mass_flow_g_s: float + python_line_to_node_amesim_node_line_mass_flow_error_g_s: float + python_orifice_to_line_amesim_line_mass_flow_g_s: float + python_orifice_to_line_amesim_line_mass_flow_error_g_s: float + python_orifice_to_line_amesim_chamber_mass_flow_g_s: float + python_orifice_to_line_amesim_chamber_mass_flow_error_g_s: float + python_orifice_to_line_amesim_line_chamber_mass_flow_g_s: float + python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s: float + python_storage_amesim_boundary_states_mass_derivative_g_s: float + python_storage_amesim_boundary_states_mass_derivative_error_g_s: float + python_chamber_pressure_pa: float + python_chamber_temperature_k: float + python_current_temperature_derivative_k_s: float + python_port_1_current_dtemp_component_k_s: float + python_port_2_current_dtemp_component_k_s: float + python_heat_dtemp_component_k_s: float + python_temperature_derivative_error_k_s: float + python_pressure_derivative_mass_component_pa_s: float + python_pressure_derivative_temperature_component_pa_s: float + python_pressure_derivative_eos_sum_pa_s: float + python_pressure_derivative_eos_error_pa_s: float + python_reference_port_1_enthalpy_flow_w: float + python_reference_port_2_enthalpy_flow_w: float + python_reference_port_1_dtemp_component_k_s: float + python_reference_port_2_dtemp_component_k_s: float + python_reference_mass_offset_dtemp_component_k_s: float + python_reference_temperature_derivative_k_s: float + python_reference_temperature_derivative_error_k_s: float + python_reference_pressure_derivative_mass_component_pa_s: float + python_reference_pressure_derivative_temperature_component_pa_s: float + python_reference_pressure_derivative_eos_sum_pa_s: float + python_reference_pressure_derivative_eos_error_pa_s: float + python_reference_amesim_storage_mass_offset_dtemp_component_k_s: float + python_reference_amesim_storage_temperature_derivative_k_s: float + python_reference_amesim_storage_temperature_derivative_error_k_s: float + python_reference_amesim_storage_pressure_derivative_mass_component_pa_s: float + python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s: float + python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s: float + python_reference_amesim_storage_pressure_derivative_eos_error_pa_s: float + python_active_temperature_derivative_k_s: float + python_active_temperature_derivative_error_k_s: float + python_active_pressure_derivative_eos_sum_pa_s: float + python_active_pressure_derivative_eos_error_pa_s: float + python_chamber_pressure_error_pa: float + python_chamber_temperature_error_k: float + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowSampleDiagnostic: + time_s: float + data_paths: tuple[str, ...] + python_values_by_data_path: dict[str, float] + amesim_values_by_data_path: dict[str, float] + amesim_sample_left_time_s: float + amesim_sample_right_time_s: float + pnl0001_rhs_diagnostics: tuple[TestMqlPnl0001LineRhsDiagnostic, ...] = field( + default_factory=tuple + ) + pnl0001_pressure_loss_diagnostics: tuple[ + TestMqlPnl0001PressureLossCalibrationDiagnostic, ... + ] = field(default_factory=tuple) + pnvo_flow_parameter_diagnostics: tuple[ + TestMqlPnvoFlowParameterDiagnostic, ... + ] = field(default_factory=tuple) + pnl0001_energy_flow_diagnostics: tuple[ + TestMqlPnl0001EnergyFlowDiagnostic, ... + ] = field(default_factory=tuple) + p4_node_enthalpy_breakdown_diagnostics: tuple[ + TestMqlP4NodeEnthalpyBreakdownDiagnostic, ... + ] = field(default_factory=tuple) + pnvo_upstream_enthalpy_diagnostics: tuple[ + TestMqlPnvoUpstreamEnthalpyDiagnostic, ... + ] = field(default_factory=tuple) + pnl0003_energy_diagnostics: tuple[ + TestMqlPnl0003EnergyDiagnostic, ... + ] = field(default_factory=tuple) + pneumatic_96_inlet_line_diagnostics: tuple[ + TestMqlPneumatic96InletLineDiagnostic, ... + ] = field(default_factory=tuple) + pnch012_energy_equation_diagnostics: tuple[ + TestMqlPnch012EnergyEquationDiagnostic, ... + ] = field(default_factory=tuple) + + @property + def amesim_values_are_interpolated(self) -> bool: + return self.amesim_sample_left_time_s != self.amesim_sample_right_time_s + + def abs_error(self, data_path: str) -> float: + return abs( + self.python_values_by_data_path[data_path] + - self.amesim_values_by_data_path[data_path] + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowDiagnostic: + orifice_alias: str + event_time_s: float + final_time_s: float + data_paths: tuple[str, ...] + segment_diagnostics: tuple[TestMqlPnvoEventWindowSegmentDiagnostic, ...] + sample_diagnostics: tuple[TestMqlPnvoEventWindowSampleDiagnostic, ...] + python_values_by_data_path: dict[str, float] + amesim_values_by_data_path: dict[str, float] + + def abs_error(self, data_path: str) -> float: + return abs( + self.python_values_by_data_path[data_path] + - self.amesim_values_by_data_path[data_path] + ) + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowCandidateMetricComparison: + time_s: float + metric_name: str + default_abs_error: float + candidate_abs_error: float + + @property + def abs_error_delta(self) -> float: + return self.candidate_abs_error - self.default_abs_error + + +@dataclass(frozen=True) +class TestMqlPnvoEventWindowCandidateComparisonDiagnostic: + orifice_alias: str + event_time_s: float + final_time_s: float + default_diagnostic: TestMqlPnvoEventWindowDiagnostic + candidate_diagnostic: TestMqlPnvoEventWindowDiagnostic + metric_comparisons: tuple[ + TestMqlPnvoEventWindowCandidateMetricComparison, ... + ] + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonPathConfig: + archive_path: Path = field( + default_factory=lambda: Path(__file__).resolve().parents[2] + / "AmesimModels" + / "test_mql.ame" + ) + amesim_results_archive_path: Path | None = None + output_dir: Path | None = None + + @property + def resolved_amesim_results_archive_path(self) -> Path: + return self.amesim_results_archive_path or self.archive_path + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonExecutionConfig: + write_summary: bool = True + write_comparison_csv: bool = True + data_paths: tuple[str, ...] | None = DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS + solver: SolveIVPConfig = field( + default_factory=lambda: SolveIVPConfig(t_stop=1.0e-2, max_step=1.0e-3) + ) + t_eval: tuple[float, ...] | None = (0.0, 1.0e-2) + inlet_node_pressure_pa: float = 15.31e6 + resistance_boundary_pressure_pa: float = 15.29e6 + inlet_node_temperature_k: float = 293.15 + resistance_boundary_temperature_k: float = 293.15 + use_pneumatic_96_reference_rhs: bool = False + use_pneumatic_69_reference_rhs: bool = False + + +@dataclass(frozen=True) +class TestMqlFullStateComparisonScriptConfig: + paths: TestMqlFullStateComparisonPathConfig = field( + default_factory=TestMqlFullStateComparisonPathConfig + ) + execution: TestMqlFullStateComparisonExecutionConfig = field( + default_factory=TestMqlFullStateComparisonExecutionConfig + ) + + +def _default_output_dir() -> Path: + pythonmodels_root = Path(__file__).resolve().parents[1] + timestamp = datetime.now(UTC).strftime("test_mql_full_state_%Y%m%d_%H%M%S_%f") + return pythonmodels_root / "runs" / timestamp + + +def run_test_mql_full_state_comparison( + config: TestMqlFullStateComparisonScriptConfig | None = None, +) -> tuple[TestMqlFullStateComparisonRun, Path]: + config = config or TestMqlFullStateComparisonScriptConfig() + system = TestMqlSystem(archive_path=config.paths.archive_path) + amesim_results = load_test_mql_amesim_results( + config.paths.resolved_amesim_results_archive_path, + time_stop_s=config.execution.solver.t_stop, + ) + output_schema = build_test_mql_output_schema(amesim_results) + selected_paths = config.execution.data_paths + spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + result = system.simulate_full_state_series_from_spec( + spec, + inlet_node_pressure_pa=config.execution.inlet_node_pressure_pa, + resistance_boundary_pressure_pa=( + config.execution.resistance_boundary_pressure_pa + ), + inlet_node_temperature_k=config.execution.inlet_node_temperature_k, + resistance_boundary_temperature_k=( + config.execution.resistance_boundary_temperature_k + ), + use_pneumatic_96_reference_rhs=( + config.execution.use_pneumatic_96_reference_rhs + ), + use_pneumatic_69_reference_rhs=( + config.execution.use_pneumatic_69_reference_rhs + ), + config=config.execution.solver, + t_eval=list(config.execution.t_eval) if config.execution.t_eval is not None else None, + data_paths=selected_paths, + ) + closure = system.full_state_closure_from_spec( + spec, + inlet_node_pressure_pa=config.execution.inlet_node_pressure_pa, + resistance_boundary_pressure_pa=( + config.execution.resistance_boundary_pressure_pa + ), + inlet_node_temperature_k=config.execution.inlet_node_temperature_k, + resistance_boundary_temperature_k=( + config.execution.resistance_boundary_temperature_k + ), + use_pneumatic_96_reference_rhs=( + config.execution.use_pneumatic_96_reference_rhs + ), + use_pneumatic_69_reference_rhs=( + config.execution.use_pneumatic_69_reference_rhs + ), + ) + series_by_data_path = { + data_path: result.series[data_path] + for data_path in result.series + if data_path != "time" + } + output = validate_test_mql_output( + times=result.t, + series_by_data_path=series_by_data_path, + schema=output_schema, + data_paths=selected_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, + ) + run = TestMqlFullStateComparisonRun( + system=system, + closure=closure, + amesim_results=amesim_results, + output_schema=output_schema, + result=result, + output=output, + comparison=comparison, + ) + output_dir = config.paths.output_dir or _default_output_dir() + if config.execution.write_summary or config.execution.write_comparison_csv: + output_dir.mkdir(parents=True, exist_ok=True) + if config.execution.write_summary: + (output_dir / "test_mql_full_state_comparison_summary.txt").write_text( + format_test_mql_full_state_comparison_summary(run), + encoding="utf-8", + ) + if config.execution.write_comparison_csv: + write_test_mql_comparison_csv( + output_dir=output_dir, + python_times=output.times, + python_series_by_data_path=output.series_by_data_path, + amesim_results=amesim_results, + data_paths=output.data_paths, + ) + return run, output_dir + + +def run_test_mql_pnvo_event_boundary_diagnostic( + config: TestMqlFullStateComparisonScriptConfig | None = None, + *, + orifice_alias: str = "pn_morifice_1", +) -> TestMqlPnvoEventBoundaryDiagnostic: + config = config or TestMqlFullStateComparisonScriptConfig() + system = TestMqlSystem(archive_path=config.paths.archive_path) + control = system.pneumatic_assembly.variable_orifice_controls[orifice_alias] + event_time_s = control.step.step_time_s + integration_stop_time_s = nextafter(event_time_s, 0.0) + solver_template = config.execution.solver + solver = SolveIVPConfig( + t_start=solver_template.t_start, + t_stop=integration_stop_time_s, + method=solver_template.method, + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=solver_template.max_step, + ) + spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + closure_kwargs = { + "inlet_node_pressure_pa": config.execution.inlet_node_pressure_pa, + "resistance_boundary_pressure_pa": ( + config.execution.resistance_boundary_pressure_pa + ), + "inlet_node_temperature_k": config.execution.inlet_node_temperature_k, + "resistance_boundary_temperature_k": ( + config.execution.resistance_boundary_temperature_k + ), + "use_pneumatic_96_reference_rhs": ( + config.execution.use_pneumatic_96_reference_rhs + ), + "use_pneumatic_69_reference_rhs": ( + config.execution.use_pneumatic_69_reference_rhs + ), + } + solution = system.simulate_full_state_from_spec( + spec, + config=solver, + t_eval=[solver.t_start, integration_stop_time_s], + **closure_kwargs, + ) + state_vector = [row[-1] for row in solution.y] + closure = system.full_state_closure_from_spec(spec, **closure_kwargs) + data_paths = ( + "press@pn_c1_8", + "dm1@pneumatic_69", + f"xv@{orifice_alias}", + f"dm2@{orifice_alias}", + ) + python_values = closure.data_path_values( + time_s=event_time_s, + state_vector=state_vector, + data_paths=data_paths, + ) + amesim_results = load_test_mql_amesim_results( + config.paths.resolved_amesim_results_archive_path, + time_stop_s=event_time_s, + ) + amesim_values = { + data_path: interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + event_time_s, + ) + for data_path in data_paths + } + return TestMqlPnvoEventBoundaryDiagnostic( + orifice_alias=orifice_alias, + event_time_s=event_time_s, + integration_stop_time_s=integration_stop_time_s, + data_paths=data_paths, + python_values_by_data_path=python_values, + amesim_values_by_data_path=amesim_values, + ) + + +def format_test_mql_pnvo_event_boundary_summary( + diagnostic: TestMqlPnvoEventBoundaryDiagnostic, +) -> str: + lines = [ + "Model: test_mql", + f"Mode: PNVO event boundary diagnostic ({diagnostic.orifice_alias})", + f"Event time: {diagnostic.event_time_s}", + f"Integrated left limit: {diagnostic.integration_stop_time_s}", + "Observation side: right-continuous STEP0 opening", + ] + for data_path in diagnostic.data_paths: + lines.append( + f" - {data_path}: " + f"python={diagnostic.python_values_by_data_path[data_path]}, " + f"amesim={diagnostic.amesim_values_by_data_path[data_path]}, " + f"abs_error={diagnostic.abs_error(data_path)}" + ) + return "\n".join(lines) + "\n" + + +def _pnl0001_pressure_loss_calibration_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + python_values_by_data_path: dict[str, float], + line_alias: str, + chamber_alias: str, + time_s: float, +) -> TestMqlPnl0001PressureLossCalibrationDiagnostic: + if line_alias != "pneumatic_69" or chamber_alias != "pn_c1_8": + raise KeyError(f"Unsupported PNL0001 pressure-loss diagnostic: {line_alias}") + line = closure.pneumatic_closure.components.p4_port3_remote_primary_line + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_cm = amesim_value(f"cm@{line_alias}") + amesim_dm1_g_s = amesim_value(f"dm1@{line_alias}") + amesim_line_gauge_pressure_pa = amesim_value(f"p2@{line_alias}") + amesim_chamber_gauge_pressure_pa = amesim_value(f"press@{chamber_alias}") + amesim_line_temperature_k = amesim_value(f"t2@{line_alias}") + amesim_chamber_temperature_k = amesim_value(f"temp@{chamber_alias}") + mass_flow_magnitude_kg_s = abs(amesim_dm1_g_s) * 1.0e-3 + amesim_pressure_drop_pa = abs( + amesim_line_gauge_pressure_pa - amesim_chamber_gauge_pressure_pa + ) + current_darcy_pressure_drop_pa = abs( + line.darcy_pressure_drop_for_state( + mass_flow_kg_s=mass_flow_magnitude_kg_s, + pressure_pa=( + amesim_line_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA + ), + temperature_k=amesim_line_temperature_k, + ) + ) + if current_darcy_pressure_drop_pa > 0.0: + pressure_drop_multiplier = ( + amesim_pressure_drop_pa / current_darcy_pressure_drop_pa + ) + else: + pressure_drop_multiplier = ( + float("inf") if amesim_pressure_drop_pa > 0.0 else 1.0 + ) + candidate_port1_to_port2_kg_s = line.pn2pipefr_mass_flow( + port_1_pressure_pa=( + amesim_chamber_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA + ), + port_1_temperature_k=amesim_chamber_temperature_k, + port_2_pressure_pa=( + amesim_line_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA + ), + port_2_temperature_k=amesim_line_temperature_k, + ) + candidate_pn2pipefr_dm1_g_s = -candidate_port1_to_port2_kg_s * 1.0e3 + if amesim_dm1_g_s != 0.0: + candidate_pn2pipefr_to_amesim_dm1_ratio = ( + candidate_pn2pipefr_dm1_g_s / amesim_dm1_g_s + ) + else: + candidate_pn2pipefr_to_amesim_dm1_ratio = ( + float("inf") if candidate_pn2pipefr_dm1_g_s != 0.0 else 1.0 + ) + amesim_linear_conductance = _pnl0001_linear_conductance( + dm1_g_s=amesim_dm1_g_s, + temperature_k=amesim_line_temperature_k, + pressure_drop_pa=amesim_pressure_drop_pa, + ) + python_pressure_drop_pa = abs( + python_values_by_data_path[f"p2@{line_alias}"] + - python_values_by_data_path[f"press@{chamber_alias}"] + ) + python_linear_conductance = _pnl0001_linear_conductance( + dm1_g_s=python_values_by_data_path[f"dm1@{line_alias}"], + temperature_k=python_values_by_data_path[f"t2@{line_alias}"], + pressure_drop_pa=python_pressure_drop_pa, + ) + if amesim_linear_conductance > 0.0: + conductance_ratio = python_linear_conductance / amesim_linear_conductance + else: + conductance_ratio = ( + float("inf") if python_linear_conductance > 0.0 else 1.0 + ) + return TestMqlPnl0001PressureLossCalibrationDiagnostic( + line_alias=line_alias, + chamber_alias=chamber_alias, + time_s=time_s, + amesim_cm=amesim_cm, + amesim_dm1_g_s=amesim_dm1_g_s, + amesim_mass_flow_magnitude_kg_s=mass_flow_magnitude_kg_s, + amesim_line_gauge_pressure_pa=amesim_line_gauge_pressure_pa, + amesim_chamber_gauge_pressure_pa=amesim_chamber_gauge_pressure_pa, + amesim_line_temperature_k=amesim_line_temperature_k, + amesim_pressure_drop_pa=amesim_pressure_drop_pa, + current_darcy_pressure_drop_pa=current_darcy_pressure_drop_pa, + pressure_drop_multiplier=pressure_drop_multiplier, + candidate_pn2pipefr_dm1_g_s=candidate_pn2pipefr_dm1_g_s, + candidate_pn2pipefr_to_amesim_dm1_ratio=( + candidate_pn2pipefr_to_amesim_dm1_ratio + ), + amesim_linear_conductance_kg_s_sqrt_k_per_pa=( + amesim_linear_conductance + ), + python_linear_conductance_kg_s_sqrt_k_per_pa=( + python_linear_conductance + ), + python_to_amesim_linear_conductance_ratio=conductance_ratio, + ) + + +def _pnl0001_linear_conductance( + *, + dm1_g_s: float, + temperature_k: float, + pressure_drop_pa: float, +) -> float: + if temperature_k <= 0.0: + raise ValueError("temperature_k must be positive") + if pressure_drop_pa <= 0.0: + return 0.0 + return abs(dm1_g_s) * 1.0e-3 * sqrt(temperature_k) / pressure_drop_pa + + +def _ideal_pn2vol_reference_dtemp( + *, + gas: AmesimPneumaticGas, + mass_kg: float, + temperature_k: float, + mass_derivative_kg_s: float, + enthalpy_flow_w: float, + heat_flow_w: float, + reference_temperature_k: float = 298.15, +) -> float: + reference_offset_flow_w = ( + gas.cp * reference_temperature_k - gas.cv * temperature_k + ) * mass_derivative_kg_s + return ( + enthalpy_flow_w + reference_offset_flow_w + heat_flow_w + ) / (mass_kg * gas.cv) + + +def _series_finite_difference_at( + *, + times: tuple[float, ...] | list[float], + values: tuple[float, ...] | list[float], + time_s: float, +) -> float: + if len(times) != len(values): + raise ValueError("times and values must have equal length") + if len(times) < 2: + raise ValueError("at least two samples are required") + index = min(range(len(times)), key=lambda idx: abs(times[idx] - time_s)) + if index == 0: + left = 0 + right = 1 + elif index == len(times) - 1: + left = len(times) - 2 + right = len(times) - 1 + else: + left = index - 1 + right = index + 1 + dt = times[right] - times[left] + if dt == 0.0: + raise ValueError("finite difference time interval must be non-zero") + return (values[right] - values[left]) / dt + + +def _series_one_sided_differences_at( + *, + times: tuple[float, ...] | list[float], + values: tuple[float, ...] | list[float], + time_s: float, +) -> tuple[float, float]: + if len(times) != len(values): + raise ValueError("times and values must have equal length") + if len(times) < 2: + raise ValueError("at least two samples are required") + index = min(range(len(times)), key=lambda idx: abs(times[idx] - time_s)) + left = max(index - 1, 0) + right = min(index + 1, len(times) - 1) + backward_dt = times[index] - times[left] + forward_dt = times[right] - times[index] + if backward_dt == 0.0: + backward = (values[right] - values[index]) / forward_dt + else: + backward = (values[index] - values[left]) / backward_dt + if forward_dt == 0.0: + forward = (values[index] - values[left]) / backward_dt + else: + forward = (values[right] - values[index]) / forward_dt + return backward, forward + + +def _series_sample_bracket( + *, + times: tuple[float, ...] | list[float], + time_s: float, +) -> tuple[float, float]: + if not times: + raise ValueError("at least one sample time is required") + index = bisect_left(times, time_s) + tolerance = 1.0e-12 * max(1.0, abs(time_s)) + for candidate_index in (index - 1, index): + if 0 <= candidate_index < len(times): + candidate_time = times[candidate_index] + if abs(candidate_time - time_s) <= tolerance: + return candidate_time, candidate_time + if index <= 0: + return times[0], times[0] + if index >= len(times): + return times[-1], times[-1] + return times[index - 1], times[index] + + +def _reference_enthalpy_flow_for_node_port( + *, + gas: AmesimPneumaticGas, + reference_temperature_k: float, + flow_kg_s: float, + node_temperature_k: float, + connected_temperature_k: float, + port_mass_flow_kg_s: float, +) -> float: + stream_temperature_k = ( + node_temperature_k if flow_kg_s >= 0.0 else connected_temperature_k + ) + reference_h = gas.specific_reference_enthalpy( + stream_temperature_k, + reference_temperature_k, + ) + return port_mass_flow_kg_s * reference_h + + +def _real_gas_reference_enthalpy_flow_for_node_port( + *, + gas: AmesimPneumaticGas, + reference_temperature_k: float, + flow_kg_s: float, + node_pressure_pa: float, + node_temperature_k: float, + connected_pressure_pa: float, + connected_temperature_k: float, + port_mass_flow_kg_s: float, +) -> float: + stream_pressure_pa = node_pressure_pa if flow_kg_s >= 0.0 else connected_pressure_pa + stream_temperature_k = ( + node_temperature_k if flow_kg_s >= 0.0 else connected_temperature_k + ) + reference_h = gas.pressure_reference_enthalpy( + stream_pressure_pa, + stream_temperature_k, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + return port_mass_flow_kg_s * reference_h + + +def _pnl0001_energy_flow_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + rhs_diagnostic: TestMqlPnl0001LineRhsDiagnostic, + line_alias: str, + chamber_alias: str, + node_alias: str, + time_s: float, +) -> TestMqlPnl0001EnergyFlowDiagnostic: + if line_alias != "pneumatic_69" or chamber_alias != "pn_c1_8": + raise KeyError(f"Unsupported PNL0001 energy diagnostic: {line_alias}") + if node_alias != "pnnode4_16": + raise KeyError(f"Unsupported PNL0001 node diagnostic: {node_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_primary_line + line_properties = snapshot.p4_port3_remote_primary_line + chamber_properties = snapshot.p4_port3_remote_primary_chamber + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_dh1 = amesim_value(f"dh1@{line_alias}") + amesim_dm1 = amesim_value(f"dm1@{line_alias}") + amesim_node_dh2 = amesim_value(f"dh2@{node_alias}") + amesim_node_dm2 = amesim_value(f"dm2@{node_alias}") + direct_port_1 = rhs_diagnostic.chamber_to_line_flow_kg_s * ( + chamber_properties.h + if rhs_diagnostic.chamber_to_line_flow_kg_s > 0.0 + else line_properties.h + ) + direct_port_2 = rhs_diagnostic.node_to_line_flow_kg_s * line_properties.h + direct_energy_derivative = ( + direct_port_1 + direct_port_2 + rhs_diagnostic.thermal_energy_flow_w + ) + p4_node_port_2_mass_flow_kg_s = ( + snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + if abs(p4_node_port_2_mass_flow_kg_s) <= 1.0e-12: + p4_node_port_2_connected_h = line_properties.h + else: + p4_node_port_2_connected_h = ( + snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w + / p4_node_port_2_mass_flow_kg_s + ) + p4_node_port_2_inlet_h = ( + p4_node_port_2_connected_h + if rhs_diagnostic.node_to_line_flow_kg_s > 0.0 + else line_properties.h + ) + p4_node_port_2_counterfactual_energy_derivative = ( + rhs_diagnostic.port_1_energy_flow_w + + rhs_diagnostic.node_to_line_flow_kg_s * p4_node_port_2_inlet_h + + rhs_diagnostic.thermal_energy_flow_w + ) + p4_node_port_2_counterfactual_dtemp = ( + p4_node_port_2_counterfactual_energy_derivative + - line_properties.u * rhs_diagnostic.mass_derivative_kg_s + ) / (line.state.m * line.gas.cv) + reference_temperature_k = 298.15 + + def reference_h(temperature_k: float) -> float: + return line.gas.specific_reference_enthalpy( + temperature_k, + reference_temperature_k, + ) + + amesim_stream_temperature = ( + amesim_value(f"t2@{line_alias}") + if amesim_dm1 >= 0.0 + else amesim_value(f"temp@{chamber_alias}") + ) + reference_enthalpy_estimate = ( + amesim_dm1 * 1.0e-3 * reference_h(amesim_stream_temperature) + ) + python_dm1_kg_s = -rhs_diagnostic.chamber_to_line_flow_kg_s + python_port_1_stream_temperature_k = ( + line_properties.T if python_dm1_kg_s >= 0.0 else chamber_properties.T + ) + python_reference_port_1 = python_dm1_kg_s * reference_h( + python_port_1_stream_temperature_k + ) + python_reference_node_port_2 = sum( + ( + _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + ), + _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + ), + _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, + connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + ), + ) + ) + python_reference_sum = python_reference_port_1 + python_reference_node_port_2 + + amesim_mgas_kg = amesim_value(f"mgas@{line_alias}") * 1.0e-3 + amesim_line_temperature = amesim_value(f"t2@{line_alias}") + amesim_mass_derivative = (amesim_node_dm2 - amesim_dm1) * 1.0e-3 + amesim_heat_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - amesim_line_temperature) + ) + current_line_dtemp = ( + rhs_diagnostic.energy_derivative_w + - line_properties.u * rhs_diagnostic.mass_derivative_kg_s + ) / (line.state.m * line.gas.cv) + amesim_line_dtemp_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"t2@{line_alias}"), + time_s=time_s, + ) + candidate_pn2pipefr_dm2i_g_s = -amesim_dm1 + candidate_pn2pipefr_dh2i = -reference_enthalpy_estimate + candidate_storage_sdh = amesim_node_dh2 + candidate_pn2pipefr_dh2i + candidate_pn2pipefr_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=amesim_mgas_kg, + temperature_k=amesim_line_temperature, + mass_derivative_kg_s=amesim_mass_derivative, + enthalpy_flow_w=candidate_storage_sdh, + heat_flow_w=amesim_heat_flow, + ) + amesim_node_plus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=amesim_mgas_kg, + temperature_k=amesim_line_temperature, + mass_derivative_kg_s=amesim_mass_derivative, + enthalpy_flow_w=amesim_node_dh2 + amesim_dh1, + heat_flow_w=amesim_heat_flow, + ) + amesim_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=amesim_mgas_kg, + temperature_k=amesim_line_temperature, + mass_derivative_kg_s=amesim_mass_derivative, + enthalpy_flow_w=amesim_node_dh2 - amesim_dh1, + heat_flow_w=amesim_heat_flow, + ) + python_reference_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( + gas=line.gas, + mass_kg=line.state.m, + temperature_k=line_properties.T, + mass_derivative_kg_s=rhs_diagnostic.mass_derivative_kg_s, + enthalpy_flow_w=python_reference_node_port_2 - python_reference_port_1, + heat_flow_w=rhs_diagnostic.thermal_energy_flow_w, + ) + return TestMqlPnl0001EnergyFlowDiagnostic( + line_alias=line_alias, + chamber_alias=chamber_alias, + node_alias=node_alias, + time_s=time_s, + amesim_port_1_enthalpy_flow_w=amesim_dh1, + amesim_port_1_mass_flow_g_s=amesim_dm1, + amesim_node_port_2_enthalpy_flow_w=amesim_node_dh2, + amesim_node_port_2_mass_flow_g_s=amesim_node_dm2, + amesim_observed_port_enthalpy_sum_w=amesim_dh1 + amesim_node_dh2, + python_port_1_internal_energy_flow_w=rhs_diagnostic.port_1_energy_flow_w, + python_port_2_internal_energy_flow_w=rhs_diagnostic.port_2_energy_flow_w, + python_current_energy_derivative_w=rhs_diagnostic.energy_derivative_w, + python_direct_enthalpy_port_1_flow_w=direct_port_1, + python_direct_enthalpy_port_2_flow_w=direct_port_2, + python_direct_enthalpy_energy_derivative_w=direct_energy_derivative, + python_direct_minus_current_energy_derivative_w=( + direct_energy_derivative - rhs_diagnostic.energy_derivative_w + ), + python_p4_node_port_2_enthalpy_flow_w=( + snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w + ), + python_p4_node_port_2_mass_flow_g_s=( + snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s + ), + python_p4_node_port_2_connected_h_j_kg=p4_node_port_2_connected_h, + python_p4_node_port_2_connected_temperature_k=( + p4_node_port_2_connected_h / line.gas.cp + ), + python_p4_node_port_2_connected_h_delta_to_line_h_j_kg=( + p4_node_port_2_connected_h - line_properties.h + ), + python_p4_node_port_2_counterfactual_dtemp_k_s=( + p4_node_port_2_counterfactual_dtemp + ), + reference_temperature_k=reference_temperature_k, + amesim_port_1_reference_enthalpy_estimate_w=reference_enthalpy_estimate, + amesim_port_1_reference_enthalpy_error_w=( + reference_enthalpy_estimate - amesim_dh1 + ), + amesim_candidate_pn2pipefr_dm2i_g_s=candidate_pn2pipefr_dm2i_g_s, + amesim_candidate_pn2pipefr_dh2i_w=candidate_pn2pipefr_dh2i, + amesim_candidate_storage_enthalpy_sum_w=candidate_storage_sdh, + amesim_candidate_pn2pipefr_dtemp_k_s=candidate_pn2pipefr_dtemp, + amesim_candidate_pn2pipefr_dtemp_residual_k_s=( + candidate_pn2pipefr_dtemp - amesim_line_dtemp_fd + ), + python_reference_port_1_enthalpy_flow_w=python_reference_port_1, + python_reference_node_port_2_enthalpy_flow_w=python_reference_node_port_2, + python_reference_observed_port_enthalpy_sum_w=python_reference_sum, + python_reference_port_1_to_amesim_error_w=( + python_reference_port_1 - amesim_dh1 + ), + python_reference_node_port_2_to_amesim_error_w=( + python_reference_node_port_2 - amesim_node_dh2 + ), + python_reference_sum_to_amesim_error_w=( + python_reference_sum - (amesim_dh1 + amesim_node_dh2) + ), + python_current_line_temperature_derivative_k_s=current_line_dtemp, + amesim_line_temperature_derivative_fd_k_s=amesim_line_dtemp_fd, + amesim_pn2vol2_dtemp_node_plus_dh1_k_s=amesim_node_plus_dh1_dtemp, + amesim_pn2vol2_dtemp_node_minus_dh1_k_s=amesim_node_minus_dh1_dtemp, + python_reference_pn2vol2_dtemp_node_minus_dh1_k_s=( + python_reference_node_minus_dh1_dtemp + ), + ) + +def _p4_node_enthalpy_breakdown_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + node_alias: str, + time_s: float, +) -> TestMqlP4NodeEnthalpyBreakdownDiagnostic: + if node_alias != "pnnode4_16": + raise KeyError(f"Unsupported P4 node enthalpy diagnostic: {node_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_primary_line + balance = snapshot.p4_port3_remote_balance + reference_temperature_k = 298.15 + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_port_1_enthalpy = amesim_value("dh1@pneumatic_83") + amesim_port_1_mass = amesim_value("dm1@pneumatic_83") + amesim_port_3_enthalpy = amesim_value("dh2@pneumatic_95") + amesim_port_3_mass = amesim_value("dm2@pneumatic_95") + amesim_port_4_enthalpy = amesim_value("dh2@pn_morifice_1") + amesim_port_4_mass = amesim_value("dm2@pn_morifice_1") + amesim_port_2_enthalpy = amesim_value(f"dh2@{node_alias}") + amesim_port_2_mass = amesim_value(f"dm2@{node_alias}") + + reference_port_1 = _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + ) + reference_port_3 = _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + ) + reference_port_4 = _reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, + connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + ) + reference_port_2 = reference_port_1 + reference_port_3 + reference_port_4 + real_gas_reference_port_1 = _real_gas_reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + node_pressure_pa=snapshot.p4_port3_remote_primary_line.p, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_pressure_pa=snapshot.p4_port3_line.p, + connected_temperature_k=snapshot.p4_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + ) + real_gas_reference_port_3 = _real_gas_reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + node_pressure_pa=snapshot.p4_port3_remote_primary_line.p, + node_temperature_k=snapshot.p4_port3_remote_primary_line.T, + connected_pressure_pa=snapshot.p4_port3_remote_port3_line.p, + connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + ) + real_gas_reference_port_4 = _real_gas_reference_enthalpy_flow_for_node_port( + gas=line.gas, + reference_temperature_k=reference_temperature_k, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + node_pressure_pa=snapshot.p4_port3_remote_orifice_line_port_2.p, + node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, + connected_pressure_pa=snapshot.p4_port3_remote_primary_line.p, + connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + ) + real_gas_reference_port_2 = ( + real_gas_reference_port_1 + + real_gas_reference_port_3 + + real_gas_reference_port_4 + ) + + return TestMqlP4NodeEnthalpyBreakdownDiagnostic( + node_alias=node_alias, + time_s=time_s, + amesim_port_1_enthalpy_flow_w=amesim_port_1_enthalpy, + amesim_port_1_mass_flow_g_s=amesim_port_1_mass, + amesim_port_3_enthalpy_flow_w=amesim_port_3_enthalpy, + amesim_port_3_mass_flow_g_s=amesim_port_3_mass, + amesim_port_4_enthalpy_flow_w=amesim_port_4_enthalpy, + amesim_port_4_mass_flow_g_s=amesim_port_4_mass, + amesim_port_2_enthalpy_flow_w=amesim_port_2_enthalpy, + amesim_port_2_mass_flow_g_s=amesim_port_2_mass, + python_port_1_enthalpy_flow_w=balance.port_1_enthalpy_flow_w, + python_port_1_mass_flow_g_s=balance.port_1_mass_flow_g_s, + python_port_3_enthalpy_flow_w=balance.port_3_enthalpy_flow_w, + python_port_3_mass_flow_g_s=balance.port_3_mass_flow_g_s, + python_port_4_enthalpy_flow_w=balance.port_4_enthalpy_flow_w, + python_port_4_mass_flow_g_s=balance.port_4_mass_flow_g_s, + python_port_2_enthalpy_flow_w=balance.port_2_enthalpy_flow_w, + python_port_2_mass_flow_g_s=balance.port_2_mass_flow_g_s, + python_reference_port_1_enthalpy_flow_w=reference_port_1, + python_reference_port_3_enthalpy_flow_w=reference_port_3, + python_reference_port_4_enthalpy_flow_w=reference_port_4, + python_reference_port_2_enthalpy_flow_w=reference_port_2, + python_real_gas_reference_port_1_enthalpy_flow_w=real_gas_reference_port_1, + python_real_gas_reference_port_3_enthalpy_flow_w=real_gas_reference_port_3, + python_real_gas_reference_port_4_enthalpy_flow_w=real_gas_reference_port_4, + python_real_gas_reference_port_2_enthalpy_flow_w=real_gas_reference_port_2, + python_port_2_enthalpy_error_w=( + balance.port_2_enthalpy_flow_w - amesim_port_2_enthalpy + ), + python_reference_port_2_enthalpy_error_w=( + reference_port_2 - amesim_port_2_enthalpy + ), + python_real_gas_reference_port_2_enthalpy_error_w=( + real_gas_reference_port_2 - amesim_port_2_enthalpy + ), + ) + + +def _pnvo_upstream_enthalpy_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + orifice_alias: str, + upstream_line_alias: str, + downstream_node_alias: str, + time_s: float, +) -> TestMqlPnvoUpstreamEnthalpyDiagnostic: + if orifice_alias != "pn_morifice_1": + raise KeyError(f"Unsupported PNVO upstream diagnostic: {orifice_alias}") + if upstream_line_alias != "pneumatic_87": + raise KeyError(f"Unsupported PNVO upstream line diagnostic: {upstream_line_alias}") + if downstream_node_alias != "pnnode4_16": + raise KeyError(f"Unsupported PNVO downstream node diagnostic: {downstream_node_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_orifice_line + flow_kg_s = snapshot.p4_port3_remote_orifice_to_node_flow + reference_temperature_k = 298.15 + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_orifice_dh2 = amesim_value(f"dh2@{orifice_alias}") + amesim_orifice_dm2 = amesim_value(f"dm2@{orifice_alias}") + amesim_line_t2 = amesim_value(f"t2@{upstream_line_alias}") + if amesim_orifice_dm2 >= 0.0: + amesim_stream_temperature = amesim_line_t2 + amesim_stream_pressure = ( + amesim_value(f"p2@{upstream_line_alias}") + + AMESIM_REFERENCE_PRESSURE_PA + ) + else: + amesim_stream_temperature = amesim_value(f"temp1@{downstream_node_alias}") + amesim_stream_pressure = ( + amesim_value(f"press1@{downstream_node_alias}") + + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_dm2_kg_s = amesim_orifice_dm2 * 1.0e-3 + amesim_reference_dh2 = amesim_dm2_kg_s * line.gas.specific_reference_enthalpy( + amesim_stream_temperature, + reference_temperature_k, + ) + amesim_real_gas_reference_dh2 = ( + amesim_dm2_kg_s + * line.gas.pressure_reference_enthalpy( + amesim_stream_pressure, + amesim_stream_temperature, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + ) + if abs(amesim_orifice_dm2) <= 1.0e-12: + amesim_implied_temperature = amesim_stream_temperature + else: + amesim_implied_temperature = line.gas.reference_temperature_from_specific_enthalpy( + amesim_orifice_dh2 / (amesim_orifice_dm2 * 1.0e-3), + reference_temperature_k, + ) + + python_stream_properties = ( + snapshot.p4_port3_remote_orifice_line_port_2 + if flow_kg_s >= 0.0 + else snapshot.p4_port3_remote_primary_line + ) + python_current_dh2 = flow_kg_s * python_stream_properties.h + python_reference_dh2 = flow_kg_s * line.gas.specific_reference_enthalpy( + python_stream_properties.T, + reference_temperature_k, + ) + python_real_gas_reference_dh2 = flow_kg_s * line.gas.pressure_reference_enthalpy( + python_stream_properties.p, + python_stream_properties.T, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + + return TestMqlPnvoUpstreamEnthalpyDiagnostic( + orifice_alias=orifice_alias, + upstream_line_alias=upstream_line_alias, + time_s=time_s, + amesim_orifice_port_2_enthalpy_flow_w=amesim_orifice_dh2, + amesim_orifice_port_2_mass_flow_g_s=amesim_orifice_dm2, + amesim_orifice_port_3_enthalpy_flow_w=amesim_value( + f"dh3@{orifice_alias}" + ), + amesim_orifice_port_3_mass_flow_g_s=amesim_value( + f"dm3@{orifice_alias}" + ), + amesim_upstream_line_center_enthalpy_flow_w=amesim_value( + f"dhctr@{upstream_line_alias}" + ), + amesim_upstream_line_center_mass_flow_g_s=amesim_value( + f"dmctr@{upstream_line_alias}" + ), + amesim_upstream_line_port_2_temperature_k=amesim_line_t2, + amesim_upstream_line_port_2_pressure_pa=amesim_stream_pressure, + amesim_orifice_port_2_implied_reference_temperature_k=( + amesim_implied_temperature + ), + amesim_orifice_port_2_implied_temperature_delta_to_line_k=( + amesim_implied_temperature - amesim_line_t2 + ), + amesim_upstream_line_port_2_reference_enthalpy_flow_w=amesim_reference_dh2, + amesim_upstream_line_port_2_reference_enthalpy_error_w=( + amesim_reference_dh2 - amesim_orifice_dh2 + ), + amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w=( + amesim_real_gas_reference_dh2 + ), + amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w=( + amesim_real_gas_reference_dh2 - amesim_orifice_dh2 + ), + python_orifice_to_node_flow_kg_s=flow_kg_s, + python_upstream_line_port_2_temperature_k=( + snapshot.p4_port3_remote_orifice_line_port_2.T + ), + python_upstream_line_port_2_pressure_pa=python_stream_properties.p, + python_current_port_2_enthalpy_flow_w=python_current_dh2, + python_reference_port_2_enthalpy_flow_w=python_reference_dh2, + python_real_gas_reference_port_2_enthalpy_flow_w=( + python_real_gas_reference_dh2 + ), + python_current_port_2_enthalpy_error_w=( + python_current_dh2 - amesim_orifice_dh2 + ), + python_reference_port_2_enthalpy_error_w=( + python_reference_dh2 - amesim_orifice_dh2 + ), + python_real_gas_reference_port_2_enthalpy_error_w=( + python_real_gas_reference_dh2 - amesim_orifice_dh2 + ), + ) + + +def _pn2vol_reference_dtemp( + *, + gas: AmesimPneumaticGas, + temperature_k: float, + mass_kg: float, + mass_derivative_kg_s: float, + enthalpy_flow_w: float, + heat_flow_w: float, + reference_temperature_k: float = 298.15, +) -> float: + reference_offset_flow_w = ( + gas.cp * reference_temperature_k - gas.cv * temperature_k + ) * mass_derivative_kg_s + return (enthalpy_flow_w + reference_offset_flow_w + heat_flow_w) / ( + mass_kg * gas.cv + ) + + +def _actual_reference_enthalpy_flow( + *, + gas: AmesimPneumaticGas, + port_m_flow: float, + connected_pressure_pa: float, + connected_temperature_k: float, + internal_pressure_pa: float, + internal_temperature_k: float, + reference_temperature_k: float = 298.15, +) -> float: + if port_m_flow > 0.0: + pressure = connected_pressure_pa + temperature = connected_temperature_k + else: + pressure = internal_pressure_pa + temperature = internal_temperature_k + return port_m_flow * gas.pressure_reference_enthalpy( + pressure, + temperature, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + + +def _pressure_derivative_components_from_density_temperature( + *, + gas: AmesimPneumaticGas, + density_kg_m3: float, + temperature_k: float, + density_derivative_kg_m3_s: float, + temperature_derivative_k_s: float, +) -> tuple[float, float, float]: + density_step = max(abs(density_kg_m3) * 1.0e-6, 1.0e-9) + if density_kg_m3 <= density_step: + density_step = density_kg_m3 * 0.5 + temperature_step = max(abs(temperature_k) * 1.0e-6, 1.0e-4) + dp_drho = ( + gas.pressure(density_kg_m3 + density_step, temperature_k) + - gas.pressure(density_kg_m3 - density_step, temperature_k) + ) / (2.0 * density_step) + dp_dtemp = ( + gas.pressure(density_kg_m3, temperature_k + temperature_step) + - gas.pressure(density_kg_m3, temperature_k - temperature_step) + ) / (2.0 * temperature_step) + mass_component = dp_drho * density_derivative_kg_m3_s + temperature_component = dp_dtemp * temperature_derivative_k_s + return mass_component, temperature_component, mass_component + temperature_component + + +def _pnl0003_energy_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + line_alias: str, + time_s: float, +) -> TestMqlPnl0003EnergyDiagnostic: + if line_alias != "pneumatic_87": + raise KeyError(f"Unsupported PNL0003 energy diagnostic: {line_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.p4_port3_remote_orifice_line + port_1 = snapshot.p4_port3_remote_orifice_line_port_1 + port_2 = snapshot.p4_port3_remote_orifice_line_port_2 + connected_port_2 = snapshot.p4_port3_remote_primary_line + center_flow = snapshot.p4_port3_remote_orifice_line_center_flow + port_1_flow = snapshot.p4_port3_remote_node_to_orifice_line_flow + port_2_flow = -snapshot.p4_port3_remote_orifice_to_node_flow + reference_temperature_k = 298.15 + pn3_port_2_mass_flow_kg_s = ( + snapshot.p4_port3_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + if abs(pn3_port_2_mass_flow_kg_s) <= 1.0e-12: + port_1_connected_h = port_1.h + else: + port_1_connected_h = ( + snapshot.p4_port3_remote_orifice_node_balance.port_2_enthalpy_flow_w + / pn3_port_2_mass_flow_kg_s + ) + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_t1 = amesim_value(f"t1@{line_alias}") + amesim_t2 = amesim_value(f"t2@{line_alias}") + amesim_p1 = amesim_value(f"p1@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA + amesim_p2 = amesim_value(f"p2@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA + amesim_dm1 = amesim_value("dm2@pn_node3_8") + amesim_dh1 = amesim_value("dh2@pn_node3_8") + python_node_dm1 = snapshot.p4_port3_remote_orifice_node_balance.port_1_mass_flow_g_s + python_node_dm3 = snapshot.p4_port3_remote_orifice_node_balance.port_3_mass_flow_g_s + python_node_dm2_sum = python_node_dm1 + python_node_dm3 + python_node_to_line_dm = -python_node_dm2_sum + amesim_dm2 = amesim_value("dm3@pn_morifice_1") + amesim_dh2 = amesim_value("dh3@pn_morifice_1") + amesim_dmctr = amesim_value(f"dmctr@{line_alias}") + amesim_dhctr = amesim_value(f"dhctr@{line_alias}") + amesim_sdm1_kg_s = (amesim_dm1 + amesim_dmctr) * 1.0e-3 + amesim_sdm2_kg_s = (amesim_dm2 - amesim_dmctr) * 1.0e-3 + amesim_sdh1 = amesim_dh1 + amesim_dhctr + amesim_sdh2 = amesim_dh2 - amesim_dhctr + amesim_mgas_series = amesim_results.series(f"mgas@{line_alias}") + amesim_total_mass_derivative_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_mgas_series, + time_s=time_s, + ) + ( + amesim_total_mass_derivative_backward, + amesim_total_mass_derivative_forward, + ) = _series_one_sided_differences_at( + times=amesim_results.times, + values=amesim_mgas_series, + time_s=time_s, + ) + amesim_total_storage_mass_derivative = ( + amesim_sdm1_kg_s + amesim_sdm2_kg_s + ) * 1.0e3 + python_center_mass_flow_g_s = center_flow * 1.0e3 + amesim_center_python_sign_equivalent_g_s = -amesim_dmctr + amesim_center_mass_flow_python_sign_kg_s = ( + amesim_center_python_sign_equivalent_g_s * 1.0e-3 + ) + amesim_port_1_center_mass_derivative_g_s = amesim_dmctr + amesim_port_2_center_mass_derivative_g_s = -amesim_dmctr + python_port_1_external_mass_flow_g_s = port_1_flow * 1.0e3 + python_port_1_center_mass_derivative_g_s = -center_flow * 1.0e3 + python_port_1_storage_mass_derivative_g_s = ( + python_port_1_external_mass_flow_g_s + + python_port_1_center_mass_derivative_g_s + ) + python_port_2_external_mass_flow_g_s = port_2_flow * 1.0e3 + python_port_2_center_mass_derivative_g_s = center_flow * 1.0e3 + python_port_2_storage_mass_derivative_g_s = ( + python_port_2_external_mass_flow_g_s + + python_port_2_center_mass_derivative_g_s + ) + + def current_darcy_center_flow_for_state( + port_1_pressure_pa: float, + port_1_temperature_k: float, + port_2_pressure_pa: float, + port_2_temperature_k: float, + ) -> float: + pressure_difference = port_1_pressure_pa - port_2_pressure_pa + if pressure_difference == 0.0: + return 0.0 + upstream_pressure = ( + port_1_pressure_pa if pressure_difference > 0.0 else port_2_pressure_pa + ) + upstream_temperature = ( + port_1_temperature_k if pressure_difference > 0.0 else port_2_temperature_k + ) + upstream_density = line.gas.density(upstream_pressure, upstream_temperature) + magnitude = line._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=upstream_density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + amesim_state_darcy_center_flow = current_darcy_center_flow_for_state( + amesim_p1, + amesim_t1, + amesim_p2, + amesim_t2, + ) + amesim_state_pn2pipefr_center_flow = line.pn2pipefr_mass_flow( + port_1_pressure_pa=amesim_p1, + port_1_temperature_k=amesim_t1, + port_2_pressure_pa=amesim_p2, + port_2_temperature_k=amesim_t2, + ) + amesim_center_real_port_1 = ( + -amesim_center_mass_flow_python_sign_kg_s + * line.gas.pressure_reference_enthalpy( + amesim_p1, + amesim_t1, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + ) + amesim_mass_1 = line.gas.density(amesim_p1, amesim_t1) * line.compliance_volume + amesim_mass_2 = line.gas.density(amesim_p2, amesim_t2) * line.compliance_volume + heat_flow_1 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - amesim_t1) + ) + heat_flow_2 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - amesim_t2) + ) + amesim_pn2vol_dtemp_1 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=amesim_t1, + mass_kg=amesim_mass_1, + mass_derivative_kg_s=amesim_sdm1_kg_s, + enthalpy_flow_w=amesim_sdh1, + heat_flow_w=heat_flow_1, + reference_temperature_k=reference_temperature_k, + ) + amesim_pn2vol_dtemp_2 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=amesim_t2, + mass_kg=amesim_mass_2, + mass_derivative_kg_s=amesim_sdm2_kg_s, + enthalpy_flow_w=amesim_sdh2, + heat_flow_w=heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + amesim_t1_series = amesim_results.series(f"t1@{line_alias}") + amesim_t2_series = amesim_results.series(f"t2@{line_alias}") + amesim_fd_dtemp_1 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_t1_series, + time_s=time_s, + ) + amesim_fd_dtemp_2 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_t2_series, + time_s=time_s, + ) + amesim_backward_dtemp_1, amesim_forward_dtemp_1 = ( + _series_one_sided_differences_at( + times=amesim_results.times, + values=amesim_t1_series, + time_s=time_s, + ) + ) + amesim_backward_dtemp_2, amesim_forward_dtemp_2 = ( + _series_one_sided_differences_at( + times=amesim_results.times, + values=amesim_t2_series, + time_s=time_s, + ) + ) + amesim_p1_series = amesim_results.series(f"p1@{line_alias}") + amesim_p2_series = amesim_results.series(f"p2@{line_alias}") + amesim_fd_dpressure_1 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_p1_series, + time_s=time_s, + ) + amesim_fd_dpressure_2 = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_p2_series, + time_s=time_s, + ) + ( + amesim_dpressure_1_mass, + amesim_dpressure_1_temperature, + amesim_dpressure_1_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=amesim_mass_1 / line.compliance_volume, + temperature_k=amesim_t1, + density_derivative_kg_m3_s=amesim_sdm1_kg_s / line.compliance_volume, + temperature_derivative_k_s=amesim_fd_dtemp_1, + ) + ( + amesim_dpressure_2_mass, + amesim_dpressure_2_temperature, + amesim_dpressure_2_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=amesim_mass_2 / line.compliance_volume, + temperature_k=amesim_t2, + density_derivative_kg_m3_s=amesim_sdm2_kg_s / line.compliance_volume, + temperature_derivative_k_s=amesim_fd_dtemp_2, + ) + + current_derivative_1, current_derivative_2 = line.derivatives_from_connections( + port_1_m_flow=port_1_flow, + connected_h_1=port_1_connected_h, + port_2_m_flow=port_2_flow, + connected_h_2=connected_port_2.h, + ) + python_current_dtemp_1 = ( + current_derivative_1.U - port_1.u * current_derivative_1.m + ) / (line.state_1.m * line.gas.cv) + python_current_dtemp_2 = ( + current_derivative_2.U - port_2.u * current_derivative_2.m + ) / (line.state_2.m * line.gas.cv) + ( + python_dpressure_1_mass, + python_dpressure_1_temperature, + python_dpressure_1_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state_1.m / line.compliance_volume, + temperature_k=port_1.T, + density_derivative_kg_m3_s=current_derivative_1.m / line.compliance_volume, + temperature_derivative_k_s=python_current_dtemp_1, + ) + ( + python_dpressure_2_mass, + python_dpressure_2_temperature, + python_dpressure_2_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state_2.m / line.compliance_volume, + temperature_k=port_2.T, + density_derivative_kg_m3_s=current_derivative_2.m / line.compliance_volume, + temperature_derivative_k_s=python_current_dtemp_2, + ) + + python_center_dh_1 = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=-center_flow, + connected_pressure_pa=port_2.p, + connected_temperature_k=port_2.T, + internal_pressure_pa=port_1.p, + internal_temperature_k=port_1.T, + reference_temperature_k=reference_temperature_k, + ) + python_center_dh_2 = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=center_flow, + connected_pressure_pa=port_1.p, + connected_temperature_k=port_1.T, + internal_pressure_pa=port_2.p, + internal_temperature_k=port_2.T, + reference_temperature_k=reference_temperature_k, + ) + if port_1_flow > 0.0: + python_port_1_dh = port_1_flow * ( + port_1_connected_h - line.gas.cp * reference_temperature_k + ) + else: + python_port_1_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=port_1_flow, + connected_pressure_pa=port_1.p, + connected_temperature_k=port_1.T, + internal_pressure_pa=port_1.p, + internal_temperature_k=port_1.T, + reference_temperature_k=reference_temperature_k, + ) + python_port_2_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=port_2_flow, + connected_pressure_pa=connected_port_2.p, + connected_temperature_k=connected_port_2.T, + internal_pressure_pa=port_2.p, + internal_temperature_k=port_2.T, + reference_temperature_k=reference_temperature_k, + ) + python_center_ideal_port_1 = -center_flow * line.gas.specific_reference_enthalpy( + port_1.T, + reference_temperature_k, + ) + python_center_ideal_port_2 = -center_flow * line.gas.specific_reference_enthalpy( + port_2.T, + reference_temperature_k, + ) + python_center_real_port_1 = -center_flow * line.gas.pressure_reference_enthalpy( + port_1.p, + port_1.T, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + python_center_real_port_2 = -center_flow * line.gas.pressure_reference_enthalpy( + port_2.p, + port_2.T, + reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, + reference_temperature=reference_temperature_k, + ) + + python_heat_flow_1 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - port_1.T) + ) + python_heat_flow_2 = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * 0.5 + * (line.external_temperature - port_2.T) + ) + python_ref_dtemp_1 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_1.T, + mass_kg=line.state_1.m, + mass_derivative_kg_s=port_1_flow - center_flow, + enthalpy_flow_w=python_port_1_dh + python_center_dh_1, + heat_flow_w=python_heat_flow_1, + reference_temperature_k=reference_temperature_k, + ) + python_ref_dtemp_2 = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=port_2_flow + center_flow, + enthalpy_flow_w=python_port_2_dh + python_center_dh_2, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + + def dtemp_component(flow_w: float, mass_kg: float) -> float: + return flow_w / (mass_kg * line.gas.cv) + + amesim_port_1_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * amesim_t1 + ) * amesim_sdm1_kg_s + amesim_port_2_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * amesim_t2 + ) * amesim_sdm2_kg_s + python_port_1_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * port_1.T + ) * (port_1_flow - center_flow) + python_port_2_mass_offset_flow_w = ( + line.gas.cp * reference_temperature_k - line.gas.cv * port_2.T + ) * (port_2_flow + center_flow) + amesim_port_1_external_dtemp_component = dtemp_component( + amesim_dh1, + amesim_mass_1, + ) + amesim_port_1_center_dtemp_component = dtemp_component( + amesim_dhctr, + amesim_mass_1, + ) + amesim_port_1_mass_offset_dtemp_component = dtemp_component( + amesim_port_1_mass_offset_flow_w, + amesim_mass_1, + ) + amesim_port_1_heat_dtemp_component = dtemp_component( + heat_flow_1, + amesim_mass_1, + ) + amesim_port_2_external_dtemp_component = dtemp_component( + amesim_dh2, + amesim_mass_2, + ) + amesim_port_2_center_dtemp_component = dtemp_component( + -amesim_dhctr, + amesim_mass_2, + ) + amesim_port_2_mass_offset_dtemp_component = dtemp_component( + amesim_port_2_mass_offset_flow_w, + amesim_mass_2, + ) + amesim_port_2_heat_dtemp_component = dtemp_component( + heat_flow_2, + amesim_mass_2, + ) + python_port_1_external_dtemp_component = dtemp_component( + python_port_1_dh, + line.state_1.m, + ) + python_port_1_center_dtemp_component = dtemp_component( + python_center_dh_1, + line.state_1.m, + ) + python_port_1_mass_offset_dtemp_component = dtemp_component( + python_port_1_mass_offset_flow_w, + line.state_1.m, + ) + python_port_1_heat_dtemp_component = dtemp_component( + python_heat_flow_1, + line.state_1.m, + ) + python_port_2_external_dtemp_component = dtemp_component( + python_port_2_dh, + line.state_2.m, + ) + python_port_2_center_dtemp_component = dtemp_component( + python_center_dh_2, + line.state_2.m, + ) + python_port_2_mass_offset_dtemp_component = dtemp_component( + python_port_2_mass_offset_flow_w, + line.state_2.m, + ) + python_port_2_heat_dtemp_component = dtemp_component( + python_heat_flow_2, + line.state_2.m, + ) + + amesim_center_flow_python_sign_kg_s = ( + amesim_center_python_sign_equivalent_g_s * 1.0e-3 + ) + amesim_external_flow_kg_s = amesim_dm2 * 1.0e-3 + python_dtemp_2_amesim_center = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=port_2_flow + amesim_center_flow_python_sign_kg_s, + enthalpy_flow_w=python_port_2_dh - amesim_dhctr, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + python_dtemp_2_amesim_external = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=amesim_external_flow_kg_s + center_flow, + enthalpy_flow_w=amesim_dh2 + python_center_dh_2, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + python_dtemp_2_amesim_external_center = _pn2vol_reference_dtemp( + gas=line.gas, + temperature_k=port_2.T, + mass_kg=line.state_2.m, + mass_derivative_kg_s=( + amesim_external_flow_kg_s + amesim_center_flow_python_sign_kg_s + ), + enthalpy_flow_w=amesim_dh2 - amesim_dhctr, + heat_flow_w=python_heat_flow_2, + reference_temperature_k=reference_temperature_k, + ) + + return TestMqlPnl0003EnergyDiagnostic( + line_alias=line_alias, + time_s=time_s, + amesim_port_1_temperature_k=amesim_t1, + amesim_port_2_temperature_k=amesim_t2, + amesim_port_1_pressure_pa=amesim_p1, + amesim_port_2_pressure_pa=amesim_p2, + amesim_port_1_mass_flow_g_s=amesim_dm1, + amesim_port_1_enthalpy_flow_w=amesim_dh1, + amesim_center_mass_flow_g_s=amesim_dmctr, + amesim_center_enthalpy_flow_w=amesim_dhctr, + amesim_port_2_mass_flow_g_s=amesim_dm2, + amesim_port_2_enthalpy_flow_w=amesim_dh2, + amesim_port_1_storage_mass_derivative_g_s=amesim_sdm1_kg_s * 1.0e3, + amesim_port_1_storage_enthalpy_sum_w=amesim_sdh1, + amesim_port_2_storage_mass_derivative_g_s=amesim_sdm2_kg_s * 1.0e3, + amesim_port_2_storage_enthalpy_sum_w=amesim_sdh2, + python_pn3_node_port_1_mass_flow_g_s=python_node_dm1, + python_pn3_node_port_3_mass_flow_g_s=python_node_dm3, + python_pn3_node_to_line_mass_flow_g_s=python_node_to_line_dm, + python_pn3_node_to_line_mass_flow_error_g_s=( + python_node_to_line_dm - amesim_dm1 + ), + amesim_port_1_mass_estimate_g=amesim_mass_1 * 1.0e3, + amesim_port_2_mass_estimate_g=amesim_mass_2 * 1.0e3, + python_port_1_mass_g=line.state_1.m * 1.0e3, + python_port_2_mass_g=line.state_2.m * 1.0e3, + python_port_1_mass_error_to_amesim_g=( + line.state_1.m * 1.0e3 - amesim_mass_1 * 1.0e3 + ), + python_port_2_mass_error_to_amesim_g=( + line.state_2.m * 1.0e3 - amesim_mass_2 * 1.0e3 + ), + amesim_total_mass_derivative_fd_g_s=amesim_total_mass_derivative_fd, + amesim_total_mass_derivative_backward_g_s=( + amesim_total_mass_derivative_backward + ), + amesim_total_mass_derivative_forward_g_s=( + amesim_total_mass_derivative_forward + ), + amesim_total_storage_mass_derivative_g_s=( + amesim_total_storage_mass_derivative + ), + amesim_total_storage_mass_derivative_residual_g_s=( + amesim_total_storage_mass_derivative - amesim_total_mass_derivative_fd + ), + amesim_center_flow_python_sign_equivalent_g_s=( + amesim_center_python_sign_equivalent_g_s + ), + amesim_center_flow_python_sign_error_g_s=( + python_center_mass_flow_g_s - amesim_center_python_sign_equivalent_g_s + ), + python_center_mass_flow_g_s=python_center_mass_flow_g_s, + amesim_port_1_center_mass_derivative_g_s=( + amesim_port_1_center_mass_derivative_g_s + ), + amesim_port_2_center_mass_derivative_g_s=( + amesim_port_2_center_mass_derivative_g_s + ), + python_port_1_external_mass_flow_g_s=python_port_1_external_mass_flow_g_s, + python_port_1_external_mass_flow_error_g_s=( + python_port_1_external_mass_flow_g_s - amesim_dm1 + ), + python_port_1_center_mass_derivative_g_s=( + python_port_1_center_mass_derivative_g_s + ), + python_port_1_center_mass_derivative_error_g_s=( + python_port_1_center_mass_derivative_g_s + - amesim_port_1_center_mass_derivative_g_s + ), + python_port_1_storage_mass_derivative_g_s=( + python_port_1_storage_mass_derivative_g_s + ), + python_port_1_storage_mass_derivative_error_g_s=( + python_port_1_storage_mass_derivative_g_s - amesim_sdm1_kg_s * 1.0e3 + ), + python_port_2_external_mass_flow_g_s=python_port_2_external_mass_flow_g_s, + python_port_2_external_mass_flow_error_g_s=( + python_port_2_external_mass_flow_g_s - amesim_dm2 + ), + python_port_2_center_mass_derivative_g_s=( + python_port_2_center_mass_derivative_g_s + ), + python_port_2_center_mass_derivative_error_g_s=( + python_port_2_center_mass_derivative_g_s + - amesim_port_2_center_mass_derivative_g_s + ), + python_port_2_storage_mass_derivative_g_s=( + python_port_2_storage_mass_derivative_g_s + ), + python_port_2_storage_mass_derivative_error_g_s=( + python_port_2_storage_mass_derivative_g_s - amesim_sdm2_kg_s * 1.0e3 + ), + python_amesim_state_darcy_center_mass_flow_g_s=( + amesim_state_darcy_center_flow * 1.0e3 + ), + python_amesim_state_darcy_center_mass_flow_error_g_s=( + amesim_state_darcy_center_flow * 1.0e3 + - amesim_center_python_sign_equivalent_g_s + ), + python_amesim_state_pn2pipefr_center_mass_flow_g_s=( + amesim_state_pn2pipefr_center_flow * 1.0e3 + ), + python_amesim_state_pn2pipefr_center_mass_flow_error_g_s=( + amesim_state_pn2pipefr_center_flow * 1.0e3 + - amesim_center_python_sign_equivalent_g_s + ), + amesim_center_real_gas_reference_port_1_estimate_w=amesim_center_real_port_1, + amesim_center_real_gas_reference_port_1_error_w=( + amesim_center_real_port_1 - amesim_dhctr + ), + python_center_ideal_reference_port_1_estimate_w=python_center_ideal_port_1, + python_center_ideal_reference_port_1_error_w=( + python_center_ideal_port_1 - amesim_dhctr + ), + python_center_ideal_reference_port_2_estimate_w=python_center_ideal_port_2, + python_center_ideal_reference_port_2_error_w=( + python_center_ideal_port_2 - amesim_dhctr + ), + python_center_real_gas_reference_port_1_estimate_w=python_center_real_port_1, + python_center_real_gas_reference_port_1_error_w=( + python_center_real_port_1 - amesim_dhctr + ), + python_center_real_gas_reference_port_2_estimate_w=python_center_real_port_2, + python_center_real_gas_reference_port_2_error_w=( + python_center_real_port_2 - amesim_dhctr + ), + python_total_mass_derivative_kg_s=( + current_derivative_1.m + current_derivative_2.m + ), + python_port_1_reference_external_enthalpy_flow_w=python_port_1_dh, + python_port_1_reference_center_enthalpy_flow_w=python_center_dh_1, + python_port_1_reference_storage_enthalpy_sum_w=( + python_port_1_dh + python_center_dh_1 + ), + python_port_1_reference_storage_enthalpy_error_w=( + python_port_1_dh + python_center_dh_1 - amesim_sdh1 + ), + python_port_2_reference_external_enthalpy_flow_w=python_port_2_dh, + python_port_2_reference_center_enthalpy_flow_w=python_center_dh_2, + python_port_2_reference_storage_enthalpy_sum_w=( + python_port_2_dh + python_center_dh_2 + ), + python_port_2_reference_storage_enthalpy_error_w=( + python_port_2_dh + python_center_dh_2 - amesim_sdh2 + ), + amesim_port_1_external_enthalpy_dtemp_component_k_s=( + amesim_port_1_external_dtemp_component + ), + amesim_port_1_center_enthalpy_dtemp_component_k_s=( + amesim_port_1_center_dtemp_component + ), + amesim_port_1_mass_offset_dtemp_component_k_s=( + amesim_port_1_mass_offset_dtemp_component + ), + amesim_port_1_heat_dtemp_component_k_s=amesim_port_1_heat_dtemp_component, + python_port_1_external_enthalpy_dtemp_component_k_s=( + python_port_1_external_dtemp_component + ), + python_port_1_center_enthalpy_dtemp_component_k_s=( + python_port_1_center_dtemp_component + ), + python_port_1_mass_offset_dtemp_component_k_s=( + python_port_1_mass_offset_dtemp_component + ), + python_port_1_heat_dtemp_component_k_s=python_port_1_heat_dtemp_component, + amesim_port_2_external_enthalpy_dtemp_component_k_s=( + amesim_port_2_external_dtemp_component + ), + amesim_port_2_center_enthalpy_dtemp_component_k_s=( + amesim_port_2_center_dtemp_component + ), + amesim_port_2_mass_offset_dtemp_component_k_s=( + amesim_port_2_mass_offset_dtemp_component + ), + amesim_port_2_heat_dtemp_component_k_s=amesim_port_2_heat_dtemp_component, + python_port_2_external_enthalpy_dtemp_component_k_s=( + python_port_2_external_dtemp_component + ), + python_port_2_center_enthalpy_dtemp_component_k_s=( + python_port_2_center_dtemp_component + ), + python_port_2_mass_offset_dtemp_component_k_s=( + python_port_2_mass_offset_dtemp_component + ), + python_port_2_heat_dtemp_component_k_s=python_port_2_heat_dtemp_component, + amesim_port_1_temperature_derivative_fd_k_s=amesim_fd_dtemp_1, + amesim_port_1_temperature_derivative_backward_k_s=amesim_backward_dtemp_1, + amesim_port_1_temperature_derivative_forward_k_s=amesim_forward_dtemp_1, + amesim_port_2_temperature_derivative_fd_k_s=amesim_fd_dtemp_2, + amesim_port_2_temperature_derivative_backward_k_s=amesim_backward_dtemp_2, + amesim_port_2_temperature_derivative_forward_k_s=amesim_forward_dtemp_2, + amesim_port_1_pn2vol_dtemp_k_s=amesim_pn2vol_dtemp_1, + amesim_port_2_pn2vol_dtemp_k_s=amesim_pn2vol_dtemp_2, + amesim_port_1_pn2vol_dtemp_residual_k_s=( + amesim_pn2vol_dtemp_1 - amesim_fd_dtemp_1 + ), + amesim_port_2_pn2vol_dtemp_residual_k_s=( + amesim_pn2vol_dtemp_2 - amesim_fd_dtemp_2 + ), + amesim_port_1_pressure_derivative_fd_pa_s=amesim_fd_dpressure_1, + amesim_port_2_pressure_derivative_fd_pa_s=amesim_fd_dpressure_2, + amesim_port_1_pressure_derivative_mass_component_pa_s=( + amesim_dpressure_1_mass + ), + amesim_port_1_pressure_derivative_temperature_component_pa_s=( + amesim_dpressure_1_temperature + ), + amesim_port_1_pressure_derivative_eos_sum_pa_s=amesim_dpressure_1_sum, + amesim_port_1_pressure_derivative_eos_residual_pa_s=( + amesim_dpressure_1_sum - amesim_fd_dpressure_1 + ), + amesim_port_2_pressure_derivative_mass_component_pa_s=( + amesim_dpressure_2_mass + ), + amesim_port_2_pressure_derivative_temperature_component_pa_s=( + amesim_dpressure_2_temperature + ), + amesim_port_2_pressure_derivative_eos_sum_pa_s=amesim_dpressure_2_sum, + amesim_port_2_pressure_derivative_eos_residual_pa_s=( + amesim_dpressure_2_sum - amesim_fd_dpressure_2 + ), + python_port_1_pressure_derivative_mass_component_pa_s=( + python_dpressure_1_mass + ), + python_port_1_pressure_derivative_temperature_component_pa_s=( + python_dpressure_1_temperature + ), + python_port_1_pressure_derivative_eos_sum_pa_s=python_dpressure_1_sum, + python_port_2_pressure_derivative_mass_component_pa_s=( + python_dpressure_2_mass + ), + python_port_2_pressure_derivative_temperature_component_pa_s=( + python_dpressure_2_temperature + ), + python_port_2_pressure_derivative_eos_sum_pa_s=python_dpressure_2_sum, + python_port_1_temperature_k=port_1.T, + python_port_2_temperature_k=port_2.T, + python_port_1_pressure_pa=port_1.p, + python_port_2_pressure_pa=port_2.p, + python_port_1_mass_derivative_kg_s=current_derivative_1.m, + python_port_2_mass_derivative_kg_s=current_derivative_2.m, + python_port_1_current_dtemp_k_s=python_current_dtemp_1, + python_port_2_current_dtemp_k_s=python_current_dtemp_2, + python_port_1_real_gas_reference_dtemp_k_s=python_ref_dtemp_1, + python_port_2_real_gas_reference_dtemp_k_s=python_ref_dtemp_2, + python_port_2_amesim_center_counterfactual_dtemp_k_s=( + python_dtemp_2_amesim_center + ), + python_port_2_amesim_external_counterfactual_dtemp_k_s=( + python_dtemp_2_amesim_external + ), + python_port_2_amesim_external_center_counterfactual_dtemp_k_s=( + python_dtemp_2_amesim_external_center + ), + python_port_2_temperature_error_to_amesim_k=port_2.T - amesim_t2, + ) + + +def _pneumatic_96_inlet_line_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + line_alias: str, + node_alias: str, + orifice_alias: str, + chamber_alias: str, + time_s: float, +) -> TestMqlPneumatic96InletLineDiagnostic: + if line_alias != "pneumatic_96": + raise KeyError(f"Unsupported inlet line diagnostic: {line_alias}") + if node_alias != "pn_node3_8" or orifice_alias != "pn_orifice_18": + raise KeyError( + f"Unsupported inlet path diagnostic: {node_alias}/{orifice_alias}" + ) + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + line = closure.pneumatic_closure.components.inlet_line + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_line_pressure = ( + amesim_value(f"p2@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_line_temperature = amesim_value(f"t2@{line_alias}") + amesim_line_mass = amesim_value(f"mgas@{line_alias}") + amesim_line_mass_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"mgas@{line_alias}"), + time_s=time_s, + ) + amesim_line_to_node_flow = amesim_value(f"dm1@{line_alias}") + amesim_orifice_to_line_flow = amesim_value(f"dm2@{orifice_alias}") + amesim_storage_from_ports = ( + amesim_orifice_to_line_flow - amesim_line_to_node_flow + ) + amesim_chamber_pressure = ( + amesim_value(f"press@{chamber_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_chamber_temperature = amesim_value(f"temp@{chamber_alias}") + amesim_node_pressure = ( + amesim_value(f"press1@{node_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_node_temperature = amesim_value(f"temp1@{node_alias}") + + python_line_to_node_flow = -snapshot.inlet_node_to_line_flow * 1.0e3 + python_orifice_to_line_flow = -snapshot.inlet_line_to_chamber_flow * 1.0e3 + python_storage_derivative = ( + snapshot.inlet_node_to_line_flow - snapshot.inlet_line_to_chamber_flow + ) * 1.0e3 + + orifice = closure.pneumatic_closure.components.inlet_orifice + + def node_to_line_flow_from_states( + *, + node_pressure_pa: float, + node_temperature_k: float, + line_pressure_pa: float, + line_temperature_k: float, + ) -> float: + pressure_difference = node_pressure_pa - line_pressure_pa + if pressure_difference == 0.0: + return 0.0 + if line.calibrated_linear_conductance is not None: + return ( + line.calibrated_linear_conductance + * pressure_difference + / sqrt(line_temperature_k) + ) + upstream_pressure = max(node_pressure_pa, line_pressure_pa) + upstream_temperature = ( + node_temperature_k if pressure_difference > 0.0 else line_temperature_k + ) + density = line.gas.density(upstream_pressure, upstream_temperature) + magnitude = line._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def line_to_node_flow_from_states_g_s( + *, + node_pressure_pa: float, + node_temperature_k: float, + line_pressure_pa: float, + line_temperature_k: float, + ) -> float: + return -1.0e3 * node_to_line_flow_from_states( + node_pressure_pa=node_pressure_pa, + node_temperature_k=node_temperature_k, + line_pressure_pa=line_pressure_pa, + line_temperature_k=line_temperature_k, + ) + + def orifice_to_line_flow_from_states_g_s( + *, + line_pressure_pa: float, + line_temperature_k: float, + chamber_pressure_pa: float, + chamber_temperature_k: float, + ) -> float: + upstream_temperature = ( + line_temperature_k + if line_pressure_pa >= chamber_pressure_pa + else chamber_temperature_k + ) + line_to_chamber = orifice.mass_flow( + line_pressure_pa, + chamber_pressure_pa, + upstream_temperature, + ) + return -1.0e3 * line_to_chamber + + python_line_to_node_amesim_node_flow = line_to_node_flow_from_states_g_s( + node_pressure_pa=amesim_node_pressure, + node_temperature_k=amesim_node_temperature, + line_pressure_pa=snapshot.inlet_line.p, + line_temperature_k=snapshot.inlet_line.T, + ) + python_line_to_node_amesim_line_flow = line_to_node_flow_from_states_g_s( + node_pressure_pa=snapshot.inlet_node.p, + node_temperature_k=snapshot.inlet_node.T, + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + ) + python_line_to_node_amesim_node_line_flow = line_to_node_flow_from_states_g_s( + node_pressure_pa=amesim_node_pressure, + node_temperature_k=amesim_node_temperature, + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + ) + python_orifice_to_line_amesim_line_flow = orifice_to_line_flow_from_states_g_s( + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + chamber_pressure_pa=snapshot.chamber.p, + chamber_temperature_k=snapshot.chamber.T, + ) + python_orifice_to_line_amesim_chamber_flow = orifice_to_line_flow_from_states_g_s( + line_pressure_pa=snapshot.inlet_line.p, + line_temperature_k=snapshot.inlet_line.T, + chamber_pressure_pa=amesim_chamber_pressure, + chamber_temperature_k=amesim_chamber_temperature, + ) + python_orifice_to_line_amesim_line_chamber_flow = ( + orifice_to_line_flow_from_states_g_s( + line_pressure_pa=amesim_line_pressure, + line_temperature_k=amesim_line_temperature, + chamber_pressure_pa=amesim_chamber_pressure, + chamber_temperature_k=amesim_chamber_temperature, + ) + ) + python_storage_amesim_boundary_states = ( + python_orifice_to_line_amesim_line_chamber_flow + - python_line_to_node_amesim_node_line_flow + ) + + amesim_line_temperature_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"t2@{line_alias}"), + time_s=time_s, + ) + amesim_line_pressure_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"p2@{line_alias}"), + time_s=time_s, + ) + amesim_dh1 = amesim_value(f"dh1@{line_alias}") + amesim_dh2 = amesim_value(f"dh2@{orifice_alias}") + amesim_line_mass_kg = amesim_line_mass * 1.0e-3 + amesim_storage_kg_s = amesim_storage_from_ports * 1.0e-3 + amesim_reference_temperature_k = 298.15 + amesim_mass_offset_flow = ( + line.gas.cp * amesim_reference_temperature_k + - line.gas.cv * amesim_line_temperature + ) * amesim_storage_kg_s + + def amesim_dtemp_component(flow_w: float) -> float: + return flow_w / (amesim_line_mass_kg * line.gas.cv) + + amesim_port_1_dtemp = amesim_dtemp_component(-amesim_dh1) + amesim_port_2_dtemp = amesim_dtemp_component(amesim_dh2) + amesim_mass_offset_dtemp = amesim_dtemp_component(amesim_mass_offset_flow) + amesim_reference_dtemp_sum = ( + amesim_port_1_dtemp + amesim_port_2_dtemp + amesim_mass_offset_dtemp + ) + ( + amesim_pressure_mass_component, + amesim_pressure_temperature_component, + amesim_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=amesim_line_mass_kg / line.volume, + temperature_k=amesim_line_temperature, + density_derivative_kg_m3_s=amesim_storage_kg_s / line.volume, + temperature_derivative_k_s=amesim_line_temperature_fd, + ) + + python_port_1_flow = snapshot.inlet_node_to_line_flow + python_port_2_flow = -snapshot.inlet_line_to_chamber_flow + python_heat_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - snapshot.inlet_line.T) + ) + python_port_1_u = ( + snapshot.inlet_node.h / line.gas.gamma + if python_port_1_flow > 0.0 + else snapshot.inlet_line.u + ) + python_port_2_u = ( + snapshot.chamber.h / line.gas.gamma + if python_port_2_flow > 0.0 + else snapshot.inlet_line.u + ) + + def python_current_dtemp_component( + *, + mass_flow_kg_s: float, + inlet_u_j_kg: float, + ) -> float: + return ( + mass_flow_kg_s + * (inlet_u_j_kg - snapshot.inlet_line.u) + / (line.state.m * line.gas.cv) + ) + + python_port_1_dtemp = python_current_dtemp_component( + mass_flow_kg_s=python_port_1_flow, + inlet_u_j_kg=python_port_1_u, + ) + python_port_2_dtemp = python_current_dtemp_component( + mass_flow_kg_s=python_port_2_flow, + inlet_u_j_kg=python_port_2_u, + ) + python_heat_dtemp = python_heat_flow / (line.state.m * line.gas.cv) + python_current_dtemp = ( + python_port_1_dtemp + python_port_2_dtemp + python_heat_dtemp + ) + python_density_derivative = ( + (snapshot.inlet_node_to_line_flow - snapshot.inlet_line_to_chamber_flow) + / line.volume + ) + ( + python_pressure_mass_component, + python_pressure_temperature_component, + python_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state.m / line.volume, + temperature_k=snapshot.inlet_line.T, + density_derivative_kg_m3_s=python_density_derivative, + temperature_derivative_k_s=python_current_dtemp, + ) + + python_reference_port_1_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=python_port_1_flow, + connected_pressure_pa=snapshot.inlet_node.p, + connected_temperature_k=snapshot.inlet_node.T, + internal_pressure_pa=snapshot.inlet_line.p, + internal_temperature_k=snapshot.inlet_line.T, + reference_temperature_k=amesim_reference_temperature_k, + ) + python_reference_port_2_dh = _actual_reference_enthalpy_flow( + gas=line.gas, + port_m_flow=python_port_2_flow, + connected_pressure_pa=snapshot.chamber.p, + connected_temperature_k=snapshot.chamber.T, + internal_pressure_pa=snapshot.inlet_line.p, + internal_temperature_k=snapshot.inlet_line.T, + reference_temperature_k=amesim_reference_temperature_k, + ) + python_reference_mass_offset_flow = ( + line.gas.cp * amesim_reference_temperature_k + - line.gas.cv * snapshot.inlet_line.T + ) * (python_port_1_flow + python_port_2_flow) + + def python_reference_dtemp_component(flow_w: float) -> float: + return flow_w / (line.state.m * line.gas.cv) + + python_reference_port_1_dtemp = python_reference_dtemp_component( + python_reference_port_1_dh + ) + python_reference_port_2_dtemp = python_reference_dtemp_component( + python_reference_port_2_dh + ) + python_reference_mass_offset_dtemp = python_reference_dtemp_component( + python_reference_mass_offset_flow + ) + python_reference_dtemp = ( + python_reference_port_1_dtemp + + python_reference_port_2_dtemp + + python_reference_mass_offset_dtemp + + python_heat_dtemp + ) + ( + python_reference_pressure_mass_component, + python_reference_pressure_temperature_component, + python_reference_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state.m / line.volume, + temperature_k=snapshot.inlet_line.T, + density_derivative_kg_m3_s=python_density_derivative, + temperature_derivative_k_s=python_reference_dtemp, + ) + + python_reference_amesim_storage_mass_offset_flow = ( + line.gas.cp * amesim_reference_temperature_k + - line.gas.cv * snapshot.inlet_line.T + ) * amesim_storage_kg_s + python_reference_amesim_storage_mass_offset_dtemp = ( + python_reference_dtemp_component( + python_reference_amesim_storage_mass_offset_flow + ) + ) + python_reference_amesim_storage_dtemp = ( + python_reference_port_1_dtemp + + python_reference_port_2_dtemp + + python_reference_amesim_storage_mass_offset_dtemp + + python_heat_dtemp + ) + use_reference_rhs = bool( + getattr(closure.pneumatic_closure, "use_inlet_line_reference_rhs", False) + ) + python_active_dtemp = ( + python_reference_dtemp if use_reference_rhs else python_current_dtemp + ) + ( + python_reference_amesim_storage_pressure_mass_component, + python_reference_amesim_storage_pressure_temperature_component, + python_reference_amesim_storage_pressure_eos_sum, + ) = _pressure_derivative_components_from_density_temperature( + gas=line.gas, + density_kg_m3=line.state.m / line.volume, + temperature_k=snapshot.inlet_line.T, + density_derivative_kg_m3_s=amesim_storage_kg_s / line.volume, + temperature_derivative_k_s=python_reference_amesim_storage_dtemp, + ) + + return TestMqlPneumatic96InletLineDiagnostic( + line_alias=line_alias, + node_alias=node_alias, + orifice_alias=orifice_alias, + chamber_alias=chamber_alias, + time_s=time_s, + amesim_line_pressure_pa=amesim_line_pressure, + amesim_line_temperature_k=amesim_line_temperature, + amesim_line_mass_g=amesim_line_mass, + amesim_line_mass_derivative_fd_g_s=amesim_line_mass_fd, + amesim_line_to_node_mass_flow_g_s=amesim_line_to_node_flow, + amesim_orifice_to_line_mass_flow_g_s=amesim_orifice_to_line_flow, + amesim_storage_mass_derivative_from_ports_g_s=amesim_storage_from_ports, + amesim_storage_mass_derivative_residual_g_s=( + amesim_storage_from_ports - amesim_line_mass_fd + ), + amesim_chamber_pressure_pa=amesim_chamber_pressure, + amesim_chamber_temperature_k=amesim_chamber_temperature, + amesim_node_pressure_pa=amesim_node_pressure, + amesim_node_temperature_k=amesim_node_temperature, + amesim_line_temperature_derivative_fd_k_s=amesim_line_temperature_fd, + amesim_line_pressure_derivative_fd_pa_s=amesim_line_pressure_fd, + amesim_port_1_enthalpy_dtemp_component_k_s=amesim_port_1_dtemp, + amesim_port_2_enthalpy_dtemp_component_k_s=amesim_port_2_dtemp, + amesim_mass_offset_dtemp_component_k_s=amesim_mass_offset_dtemp, + amesim_reference_temperature_derivative_sum_k_s=( + amesim_reference_dtemp_sum + ), + amesim_reference_temperature_derivative_residual_k_s=( + amesim_reference_dtemp_sum - amesim_line_temperature_fd + ), + amesim_pressure_derivative_mass_component_pa_s=( + amesim_pressure_mass_component + ), + amesim_pressure_derivative_temperature_component_pa_s=( + amesim_pressure_temperature_component + ), + amesim_pressure_derivative_eos_sum_pa_s=amesim_pressure_eos_sum, + amesim_pressure_derivative_eos_residual_pa_s=( + amesim_pressure_eos_sum - amesim_line_pressure_fd + ), + python_line_pressure_pa=snapshot.inlet_line.p, + python_line_temperature_k=snapshot.inlet_line.T, + python_line_mass_g=line.state.m * 1.0e3, + python_line_pressure_error_pa=snapshot.inlet_line.p - amesim_line_pressure, + python_line_temperature_error_k=( + snapshot.inlet_line.T - amesim_line_temperature + ), + python_line_mass_error_g=line.state.m * 1.0e3 - amesim_line_mass, + python_line_to_node_mass_flow_g_s=python_line_to_node_flow, + python_line_to_node_mass_flow_error_g_s=( + python_line_to_node_flow - amesim_line_to_node_flow + ), + python_orifice_to_line_mass_flow_g_s=python_orifice_to_line_flow, + python_orifice_to_line_mass_flow_error_g_s=( + python_orifice_to_line_flow - amesim_orifice_to_line_flow + ), + python_storage_mass_derivative_g_s=python_storage_derivative, + python_storage_mass_derivative_error_g_s=( + python_storage_derivative - amesim_storage_from_ports + ), + python_line_to_node_amesim_node_mass_flow_g_s=( + python_line_to_node_amesim_node_flow + ), + python_line_to_node_amesim_node_mass_flow_error_g_s=( + python_line_to_node_amesim_node_flow - amesim_line_to_node_flow + ), + python_line_to_node_amesim_line_mass_flow_g_s=( + python_line_to_node_amesim_line_flow + ), + python_line_to_node_amesim_line_mass_flow_error_g_s=( + python_line_to_node_amesim_line_flow - amesim_line_to_node_flow + ), + python_line_to_node_amesim_node_line_mass_flow_g_s=( + python_line_to_node_amesim_node_line_flow + ), + python_line_to_node_amesim_node_line_mass_flow_error_g_s=( + python_line_to_node_amesim_node_line_flow - amesim_line_to_node_flow + ), + python_orifice_to_line_amesim_line_mass_flow_g_s=( + python_orifice_to_line_amesim_line_flow + ), + python_orifice_to_line_amesim_line_mass_flow_error_g_s=( + python_orifice_to_line_amesim_line_flow - amesim_orifice_to_line_flow + ), + python_orifice_to_line_amesim_chamber_mass_flow_g_s=( + python_orifice_to_line_amesim_chamber_flow + ), + python_orifice_to_line_amesim_chamber_mass_flow_error_g_s=( + python_orifice_to_line_amesim_chamber_flow - amesim_orifice_to_line_flow + ), + python_orifice_to_line_amesim_line_chamber_mass_flow_g_s=( + python_orifice_to_line_amesim_line_chamber_flow + ), + python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s=( + python_orifice_to_line_amesim_line_chamber_flow + - amesim_orifice_to_line_flow + ), + python_storage_amesim_boundary_states_mass_derivative_g_s=( + python_storage_amesim_boundary_states + ), + python_storage_amesim_boundary_states_mass_derivative_error_g_s=( + python_storage_amesim_boundary_states - amesim_storage_from_ports + ), + python_chamber_pressure_pa=snapshot.chamber.p, + python_chamber_temperature_k=snapshot.chamber.T, + python_current_temperature_derivative_k_s=python_current_dtemp, + python_port_1_current_dtemp_component_k_s=python_port_1_dtemp, + python_port_2_current_dtemp_component_k_s=python_port_2_dtemp, + python_heat_dtemp_component_k_s=python_heat_dtemp, + python_temperature_derivative_error_k_s=( + python_current_dtemp - amesim_line_temperature_fd + ), + python_pressure_derivative_mass_component_pa_s=( + python_pressure_mass_component + ), + python_pressure_derivative_temperature_component_pa_s=( + python_pressure_temperature_component + ), + python_pressure_derivative_eos_sum_pa_s=python_pressure_eos_sum, + python_pressure_derivative_eos_error_pa_s=( + python_pressure_eos_sum - amesim_line_pressure_fd + ), + python_reference_port_1_enthalpy_flow_w=python_reference_port_1_dh, + python_reference_port_2_enthalpy_flow_w=python_reference_port_2_dh, + python_reference_port_1_dtemp_component_k_s=( + python_reference_port_1_dtemp + ), + python_reference_port_2_dtemp_component_k_s=( + python_reference_port_2_dtemp + ), + python_reference_mass_offset_dtemp_component_k_s=( + python_reference_mass_offset_dtemp + ), + python_reference_temperature_derivative_k_s=python_reference_dtemp, + python_reference_temperature_derivative_error_k_s=( + python_reference_dtemp - amesim_line_temperature_fd + ), + python_reference_pressure_derivative_mass_component_pa_s=( + python_reference_pressure_mass_component + ), + python_reference_pressure_derivative_temperature_component_pa_s=( + python_reference_pressure_temperature_component + ), + python_reference_pressure_derivative_eos_sum_pa_s=( + python_reference_pressure_eos_sum + ), + python_reference_pressure_derivative_eos_error_pa_s=( + python_reference_pressure_eos_sum - amesim_line_pressure_fd + ), + python_reference_amesim_storage_mass_offset_dtemp_component_k_s=( + python_reference_amesim_storage_mass_offset_dtemp + ), + python_reference_amesim_storage_temperature_derivative_k_s=( + python_reference_amesim_storage_dtemp + ), + python_reference_amesim_storage_temperature_derivative_error_k_s=( + python_reference_amesim_storage_dtemp - amesim_line_temperature_fd + ), + python_reference_amesim_storage_pressure_derivative_mass_component_pa_s=( + python_reference_amesim_storage_pressure_mass_component + ), + python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s=( + python_reference_amesim_storage_pressure_temperature_component + ), + python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s=( + python_reference_amesim_storage_pressure_eos_sum + ), + python_reference_amesim_storage_pressure_derivative_eos_error_pa_s=( + python_reference_amesim_storage_pressure_eos_sum - amesim_line_pressure_fd + ), + python_active_temperature_derivative_k_s=python_active_dtemp, + python_active_temperature_derivative_error_k_s=( + python_active_dtemp - amesim_line_temperature_fd + ), + python_active_pressure_derivative_eos_sum_pa_s=( + python_reference_pressure_eos_sum + if use_reference_rhs + else python_pressure_eos_sum + ), + python_active_pressure_derivative_eos_error_pa_s=( + (python_reference_pressure_eos_sum if use_reference_rhs else python_pressure_eos_sum) + - amesim_line_pressure_fd + ), + python_chamber_pressure_error_pa=( + snapshot.chamber.p - amesim_chamber_pressure + ), + python_chamber_temperature_error_k=( + snapshot.chamber.T - amesim_chamber_temperature + ), + ) + + +def _pnch012_energy_equation_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + rhs_diagnostic: TestMqlVariableChamberRhsDiagnostic, + chamber_alias: str, + piston_alias: str, + line_alias: str, + time_s: float, +) -> TestMqlPnch012EnergyEquationDiagnostic: + if chamber_alias != "pn_c1_8" or piston_alias != "pn_brp2_8": + raise KeyError(f"Unsupported PNCH012 energy diagnostic: {chamber_alias}") + if line_alias != "pneumatic_69": + raise KeyError(f"Unsupported PNCH012 line diagnostic: {line_alias}") + + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + chamber = closure.pneumatic_closure.components.p4_port3_remote_primary_chamber + chamber_properties = snapshot.p4_port3_remote_primary_chamber + reference_temperature_k = 298.15 + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_dh1 = amesim_value(f"dh1@{line_alias}") + amesim_dm1_g_s = amesim_value(f"dm1@{line_alias}") + amesim_chamber_temperature = amesim_value(f"temp@{chamber_alias}") + amesim_chamber_pressure_abs = ( + amesim_value(f"press@{chamber_alias}") + AMESIM_REFERENCE_PRESSURE_PA + ) + amesim_mgas_kg = amesim_value(f"mgas1@{chamber_alias}") * 1.0e-3 + amesim_mass_derivative_fd_g_s = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"mgas1@{chamber_alias}"), + time_s=time_s, + ) + amesim_temperature_derivative_fd = _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"temp@{chamber_alias}"), + time_s=time_s, + ) + amesim_volume_rate_fd = ( + _series_finite_difference_at( + times=amesim_results.times, + values=amesim_results.series(f"vol@{chamber_alias}"), + time_s=time_s, + ) + * 1.0e-6 + ) + amesim_piston_volume_rate = amesim_value(f"vvol1@{piston_alias}") * (1.0 / 60000.0) + amesim_heat_flow = ( + chamber.heat_transfer_coefficient + * chamber.heat_transfer_area + * (chamber.external_temperature - amesim_chamber_temperature) + ) + + def reference_pn2vol_dtemp(volume_rate_m3_s: float) -> float: + mass_flow_kg_s = amesim_dm1_g_s * 1.0e-3 + reference_offset_flow_w = ( + chamber.gas.cp * reference_temperature_k + - chamber.gas.cv * amesim_chamber_temperature + ) * mass_flow_kg_s + return ( + amesim_dh1 + + reference_offset_flow_w + + amesim_heat_flow + - amesim_chamber_pressure_abs * volume_rate_m3_s + ) / (amesim_mgas_kg * chamber.gas.cv) + + dtemp_from_fd_volume = reference_pn2vol_dtemp(amesim_volume_rate_fd) + dtemp_from_piston_volume = reference_pn2vol_dtemp(amesim_piston_volume_rate) + python_current_dtemp = ( + rhs_diagnostic.energy_derivative_w + - chamber_properties.u * rhs_diagnostic.mass_derivative_kg_s + ) / (chamber.state.m * chamber.gas.cv) + + return TestMqlPnch012EnergyEquationDiagnostic( + chamber_alias=chamber_alias, + piston_alias=piston_alias, + line_alias=line_alias, + time_s=time_s, + amesim_port_1_enthalpy_flow_w=amesim_dh1, + amesim_port_1_mass_flow_g_s=amesim_dm1_g_s, + amesim_chamber_mass_derivative_fd_g_s=amesim_mass_derivative_fd_g_s, + amesim_chamber_mass_derivative_residual_g_s=( + amesim_dm1_g_s - amesim_mass_derivative_fd_g_s + ), + amesim_chamber_temperature_derivative_fd_k_s=amesim_temperature_derivative_fd, + amesim_chamber_volume_rate_fd_m3_s=amesim_volume_rate_fd, + amesim_piston_volume_rate_m3_s=amesim_piston_volume_rate, + amesim_heat_flow_w=amesim_heat_flow, + amesim_boundary_work_fd_volume_w=( + -amesim_chamber_pressure_abs * amesim_volume_rate_fd + ), + amesim_pn2vol_reference_dtemp_fd_volume_k_s=dtemp_from_fd_volume, + amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s=( + dtemp_from_fd_volume - amesim_temperature_derivative_fd + ), + amesim_pn2vol_reference_dtemp_piston_volume_k_s=dtemp_from_piston_volume, + amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s=( + dtemp_from_piston_volume - amesim_temperature_derivative_fd + ), + python_port_a_mass_flow_kg_s=rhs_diagnostic.port_a_mass_flow_kg_s, + python_current_energy_derivative_w=rhs_diagnostic.energy_derivative_w, + python_current_chamber_temperature_derivative_k_s=python_current_dtemp, + reference_temperature_k=reference_temperature_k, + ) + +def _pnvo_flow_parameter_diagnostic( + *, + closure: object, + amesim_results: AmesimResults, + state_vector: list[float], + orifice_alias: str, + time_s: float, +) -> TestMqlPnvoFlowParameterDiagnostic: + if orifice_alias != "pn_morifice_1": + raise KeyError(f"Unsupported PNVO flow parameter diagnostic: {orifice_alias}") + snapshot = closure.snapshot_at(time_s, state_vector).pneumatic + orifice = closure.pneumatic_closure.components.p4_port3_remote_orifice + if orifice.name != orifice_alias: + raise KeyError(f"Unexpected PNVO diagnostic orifice: {orifice.name}") + line_properties = snapshot.p4_port3_remote_orifice_line_port_2 + boundary_properties = snapshot.p4_port3_remote_primary_line + upstream_properties = ( + line_properties if line_properties.p >= boundary_properties.p else boundary_properties + ) + python_mass_flow_kg_s = snapshot.p4_port3_remote_orifice_to_node_flow + denominator = ( + orifice.flow_coefficient * orifice.effective_area * upstream_properties.p + ) + python_cm = ( + abs(python_mass_flow_kg_s) * sqrt(upstream_properties.T) / denominator + if denominator > 0.0 and upstream_properties.T > 0.0 + else 0.0 + ) + + def amesim_value(data_path: str) -> float: + return interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + time_s, + ) + + amesim_cm = amesim_value(f"cm@{orifice_alias}") + cm_ratio = python_cm / amesim_cm if amesim_cm != 0.0 else 0.0 + return TestMqlPnvoFlowParameterDiagnostic( + orifice_alias=orifice_alias, + time_s=time_s, + amesim_cm=amesim_cm, + amesim_dm2_g_s=amesim_value(f"dm2@{orifice_alias}"), + amesim_opening=amesim_value(f"xv@{orifice_alias}"), + amesim_gas_velocity_m_s=amesim_value(f"gasvel@{orifice_alias}"), + python_opening=orifice.opening, + python_flow_coefficient=orifice.flow_coefficient, + python_effective_area_m2=orifice.effective_area, + python_line_pressure_pa=line_properties.p, + python_boundary_pressure_pa=boundary_properties.p, + python_upstream_pressure_pa=upstream_properties.p, + python_upstream_temperature_k=upstream_properties.T, + python_mass_flow_kg_s=python_mass_flow_kg_s, + python_cm=python_cm, + python_to_amesim_cm_ratio=cm_ratio, + ) + + +def run_test_mql_pnvo_event_window_diagnostic( + config: TestMqlFullStateComparisonScriptConfig | None = None, + *, + orifice_alias: str = "pn_morifice_1", + final_time_s: float = 0.05, +) -> TestMqlPnvoEventWindowDiagnostic: + config = config or TestMqlFullStateComparisonScriptConfig() + system = TestMqlSystem(archive_path=config.paths.archive_path) + control = system.pneumatic_assembly.variable_orifice_controls[orifice_alias] + event_time_s = control.step.step_time_s + if final_time_s <= event_time_s: + raise ValueError("final_time_s must be greater than the PNVO event time") + spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + closure_kwargs = { + "inlet_node_pressure_pa": config.execution.inlet_node_pressure_pa, + "resistance_boundary_pressure_pa": ( + config.execution.resistance_boundary_pressure_pa + ), + "inlet_node_temperature_k": config.execution.inlet_node_temperature_k, + "resistance_boundary_temperature_k": ( + config.execution.resistance_boundary_temperature_k + ), + "use_pneumatic_96_reference_rhs": ( + config.execution.use_pneumatic_96_reference_rhs + ), + "use_pneumatic_69_reference_rhs": ( + config.execution.use_pneumatic_69_reference_rhs + ), + } + closure = system.full_state_closure_from_spec(spec, **closure_kwargs) + state_vector = closure.initial_state_vector() + solver_template = config.execution.solver + segment_templates = ( + SolveIVPConfig( + t_start=solver_template.t_start, + t_stop=nextafter(event_time_s, 0.0), + method=solver_template.method, + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=solver_template.max_step, + ), + SolveIVPConfig( + t_start=event_time_s, + t_stop=0.040001, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-7, + first_step=1.0e-10, + ), + SolveIVPConfig( + t_start=0.040001, + t_stop=0.04001, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-6, + ), + SolveIVPConfig( + t_start=0.04001, + t_stop=0.0401, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-5, + ), + SolveIVPConfig( + t_start=0.0401, + t_stop=0.041, + method="Radau", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-5, + ), + SolveIVPConfig( + t_start=0.041, + t_stop=0.048, + method="BDF", + rtol=solver_template.rtol, + atol=solver_template.atol, + max_step=1.0e-5, + ), + SolveIVPConfig( + t_start=0.048, + t_stop=0.05, + method="BDF", + rtol=1.0e-5, + atol=1.0e-8, + max_step=1.0e-5, + ), + ) + segments = tuple( + replace(segment, t_stop=min(segment.t_stop, final_time_s)) + for segment in segment_templates + if segment.t_start < final_time_s + ) + data_paths = ( + "press@pn_c1_8", + "temp@pn_c1_8", + "vol@pn_c1_8", + "mgas1@pn_c1_8", + "p2@pneumatic_69", + "t2@pneumatic_69", + "mgas@pneumatic_69", + "re@pneumatic_69", + "v@pneumatic_69", + "ff@pneumatic_69", + "dm1@pneumatic_69", + f"xv@{orifice_alias}", + f"dm2@{orifice_alias}", + ) + sample_times = tuple( + sorted( + { + sample_time + for sample_time in ( + nextafter(event_time_s, 0.0), + 0.0401, + 0.0402, + 0.041, + 0.042, + 0.045, + 0.048, + final_time_s, + ) + if sample_time <= final_time_s + } + ) + ) + state_vector_by_sample_time: dict[float, list[float]] = {} + segment_diagnostics: list[TestMqlPnvoEventWindowSegmentDiagnostic] = [] + for segment in segments: + rhs_evaluations = 0 + segment_sample_times = tuple( + sample_time + for sample_time in sample_times + if segment.t_start <= sample_time <= segment.t_stop + ) + t_eval = tuple( + sorted({segment.t_start, segment.t_stop, *segment_sample_times}) + ) + + def rhs(time_s, values): + nonlocal rhs_evaluations + rhs_evaluations += 1 + return closure.rhs_at(time_s, values) + + solution = integrate_ode( + rhs=rhs, + initial_state=state_vector, + config=segment, + t_eval=list(t_eval), + ) + segment_diagnostics.append( + TestMqlPnvoEventWindowSegmentDiagnostic( + t_start=segment.t_start, + t_stop=segment.t_stop, + method=segment.method, + rtol=segment.rtol, + atol=segment.atol, + max_step=segment.max_step, + rhs_evaluations=rhs_evaluations, + success=bool(solution.success), + message=str(solution.message), + ) + ) + for sample_time in segment_sample_times: + if sample_time in solution.t: + sample_index = list(solution.t).index(sample_time) + state_vector_by_sample_time[sample_time] = [ + row[sample_index] for row in solution.y + ] + state_vector = [row[-1] for row in solution.y] + if not solution.success: + break + amesim_results = load_test_mql_amesim_results( + config.paths.resolved_amesim_results_archive_path, + time_stop_s=final_time_s, + ) + sample_diagnostics = [] + for sample_time in sample_times: + if sample_time not in state_vector_by_sample_time: + continue + python_sample_values = closure.data_path_values( + time_s=sample_time, + state_vector=state_vector_by_sample_time[sample_time], + data_paths=data_paths, + ) + amesim_sample_values = { + data_path: interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + sample_time, + ) + for data_path in data_paths + } + pnl0001_rhs_diagnostics = ( + closure.pnl0001_line_rhs_diagnostic( + line_alias="pneumatic_69", + state_vector=state_vector_by_sample_time[sample_time], + time_s=sample_time, + ), + ) + pnl0001_pressure_loss_diagnostics = ( + _pnl0001_pressure_loss_calibration_diagnostic( + closure=closure, + amesim_results=amesim_results, + python_values_by_data_path=python_sample_values, + line_alias="pneumatic_69", + chamber_alias="pn_c1_8", + time_s=sample_time, + ), + ) + pnvo_flow_parameter_diagnostics = ( + _pnvo_flow_parameter_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + orifice_alias=orifice_alias, + time_s=sample_time, + ), + ) + pnl0001_energy_flow_diagnostics = ( + _pnl0001_energy_flow_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + rhs_diagnostic=pnl0001_rhs_diagnostics[0], + line_alias="pneumatic_69", + chamber_alias="pn_c1_8", + node_alias="pnnode4_16", + time_s=sample_time, + ), + ) + p4_node_enthalpy_breakdown_diagnostics = ( + _p4_node_enthalpy_breakdown_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + node_alias="pnnode4_16", + time_s=sample_time, + ), + ) + pnvo_upstream_enthalpy_diagnostics = ( + _pnvo_upstream_enthalpy_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + orifice_alias=orifice_alias, + upstream_line_alias="pneumatic_87", + downstream_node_alias="pnnode4_16", + time_s=sample_time, + ), + ) + pnl0003_energy_diagnostics = ( + _pnl0003_energy_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + line_alias="pneumatic_87", + time_s=sample_time, + ), + ) + pneumatic_96_inlet_line_diagnostics = ( + _pneumatic_96_inlet_line_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + line_alias="pneumatic_96", + node_alias="pn_node3_8", + orifice_alias="pn_orifice_18", + chamber_alias="pn_general_chamber", + time_s=sample_time, + ), + ) + pnch012_rhs_diagnostics = ( + closure.variable_chamber_rhs_diagnostic( + chamber_alias="pn_c1_8", + state_vector=state_vector_by_sample_time[sample_time], + time_s=sample_time, + ), + ) + pnch012_energy_equation_diagnostics = ( + _pnch012_energy_equation_diagnostic( + closure=closure, + amesim_results=amesim_results, + state_vector=state_vector_by_sample_time[sample_time], + rhs_diagnostic=pnch012_rhs_diagnostics[0], + chamber_alias="pn_c1_8", + piston_alias="pn_brp2_8", + line_alias="pneumatic_69", + time_s=sample_time, + ), + ) + ( + amesim_sample_left_time_s, + amesim_sample_right_time_s, + ) = _series_sample_bracket(times=amesim_results.times, time_s=sample_time) + sample_diagnostics.append( + TestMqlPnvoEventWindowSampleDiagnostic( + time_s=sample_time, + data_paths=data_paths, + python_values_by_data_path=python_sample_values, + amesim_values_by_data_path=amesim_sample_values, + amesim_sample_left_time_s=amesim_sample_left_time_s, + amesim_sample_right_time_s=amesim_sample_right_time_s, + pnl0001_rhs_diagnostics=pnl0001_rhs_diagnostics, + pnl0001_pressure_loss_diagnostics=pnl0001_pressure_loss_diagnostics, + pnvo_flow_parameter_diagnostics=pnvo_flow_parameter_diagnostics, + pnl0001_energy_flow_diagnostics=pnl0001_energy_flow_diagnostics, + p4_node_enthalpy_breakdown_diagnostics=( + p4_node_enthalpy_breakdown_diagnostics + ), + pnvo_upstream_enthalpy_diagnostics=( + pnvo_upstream_enthalpy_diagnostics + ), + pnl0003_energy_diagnostics=pnl0003_energy_diagnostics, + pneumatic_96_inlet_line_diagnostics=( + pneumatic_96_inlet_line_diagnostics + ), + pnch012_energy_equation_diagnostics=( + pnch012_energy_equation_diagnostics + ), + ) + ) + python_values = closure.data_path_values( + time_s=final_time_s, + state_vector=state_vector, + data_paths=data_paths, + ) + amesim_values = { + data_path: interpolate_series_value( + amesim_results.times, + amesim_results.series(data_path), + final_time_s, + ) + for data_path in data_paths + } + return TestMqlPnvoEventWindowDiagnostic( + orifice_alias=orifice_alias, + event_time_s=event_time_s, + final_time_s=final_time_s, + data_paths=data_paths, + segment_diagnostics=tuple(segment_diagnostics), + sample_diagnostics=tuple(sample_diagnostics), + python_values_by_data_path=python_values, + amesim_values_by_data_path=amesim_values, + ) + + + +def _event_window_config_with_pneumatic_96_reference_rhs( + config: TestMqlFullStateComparisonScriptConfig | None, + *, + enabled: bool, +) -> TestMqlFullStateComparisonScriptConfig: + base = config or TestMqlFullStateComparisonScriptConfig() + return replace( + base, + execution=replace( + base.execution, + use_pneumatic_96_reference_rhs=enabled, + ), + ) + + +def _pnvo_event_window_sample_by_time( + diagnostic: TestMqlPnvoEventWindowDiagnostic, +) -> dict[float, TestMqlPnvoEventWindowSampleDiagnostic]: + return {sample.time_s: sample for sample in diagnostic.sample_diagnostics} + + +def _append_metric_comparison( + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], + *, + time_s: float, + metric_name: str, + default_abs_error: float, + candidate_abs_error: float, +) -> None: + comparisons.append( + TestMqlPnvoEventWindowCandidateMetricComparison( + time_s=time_s, + metric_name=metric_name, + default_abs_error=default_abs_error, + candidate_abs_error=candidate_abs_error, + ) + ) + + +def _append_pneumatic_96_candidate_metric_comparisons( + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], + *, + time_s: float, + default_sample: TestMqlPnvoEventWindowSampleDiagnostic, + candidate_sample: TestMqlPnvoEventWindowSampleDiagnostic, +) -> None: + if ( + not default_sample.pneumatic_96_inlet_line_diagnostics + or not candidate_sample.pneumatic_96_inlet_line_diagnostics + ): + return + default_line = default_sample.pneumatic_96_inlet_line_diagnostics[0] + candidate_line = candidate_sample.pneumatic_96_inlet_line_diagnostics[0] + for metric_name, default_value, candidate_value in ( + ( + "p96:p2_abs_error_pa", + default_line.python_line_pressure_error_pa, + candidate_line.python_line_pressure_error_pa, + ), + ( + "p96:t2_abs_error_k", + default_line.python_line_temperature_error_k, + candidate_line.python_line_temperature_error_k, + ), + ( + "p96:storage_dm_abs_error_g_s", + default_line.python_storage_mass_derivative_error_g_s, + candidate_line.python_storage_mass_derivative_error_g_s, + ), + ( + "p96:active_dT_abs_error_k_s", + default_line.python_active_temperature_derivative_error_k_s, + candidate_line.python_active_temperature_derivative_error_k_s, + ), + ( + "p96:active_dP_abs_error_pa_s", + default_line.python_active_pressure_derivative_eos_error_pa_s, + candidate_line.python_active_pressure_derivative_eos_error_pa_s, + ), + ): + _append_metric_comparison( + comparisons, + time_s=time_s, + metric_name=metric_name, + default_abs_error=abs(default_value), + candidate_abs_error=abs(candidate_value), + ) + + +def _append_pneumatic_87_candidate_metric_comparisons( + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], + *, + time_s: float, + default_sample: TestMqlPnvoEventWindowSampleDiagnostic, + candidate_sample: TestMqlPnvoEventWindowSampleDiagnostic, +) -> None: + if ( + not default_sample.pnl0003_energy_diagnostics + or not candidate_sample.pnl0003_energy_diagnostics + ): + return + default_line = default_sample.pnl0003_energy_diagnostics[0] + candidate_line = candidate_sample.pnl0003_energy_diagnostics[0] + for metric_name, default_value, candidate_value in ( + ( + "p87:node_to_line_dm_abs_error_g_s", + default_line.python_pn3_node_to_line_mass_flow_error_g_s, + candidate_line.python_pn3_node_to_line_mass_flow_error_g_s, + ), + ( + "p87:center_dm_abs_error_g_s", + default_line.amesim_center_flow_python_sign_error_g_s, + candidate_line.amesim_center_flow_python_sign_error_g_s, + ), + ( + "p87:sdm1_abs_error_g_s", + default_line.python_port_1_storage_mass_derivative_error_g_s, + candidate_line.python_port_1_storage_mass_derivative_error_g_s, + ), + ( + "p87:sdm2_abs_error_g_s", + default_line.python_port_2_storage_mass_derivative_error_g_s, + candidate_line.python_port_2_storage_mass_derivative_error_g_s, + ), + ( + "p87:t2_abs_error_k", + default_line.python_port_2_temperature_error_to_amesim_k, + candidate_line.python_port_2_temperature_error_to_amesim_k, + ), + ): + _append_metric_comparison( + comparisons, + time_s=time_s, + metric_name=metric_name, + default_abs_error=abs(default_value), + candidate_abs_error=abs(candidate_value), + ) + + +def _pnvo_event_window_candidate_metric_comparisons( + default_diagnostic: TestMqlPnvoEventWindowDiagnostic, + candidate_diagnostic: TestMqlPnvoEventWindowDiagnostic, +) -> tuple[TestMqlPnvoEventWindowCandidateMetricComparison, ...]: + candidate_samples = _pnvo_event_window_sample_by_time(candidate_diagnostic) + comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison] = [] + for default_sample in default_diagnostic.sample_diagnostics: + candidate_sample = candidate_samples.get(default_sample.time_s) + if candidate_sample is None: + continue + for data_path in default_sample.data_paths: + _append_metric_comparison( + comparisons, + time_s=default_sample.time_s, + metric_name=f"path:{data_path}", + default_abs_error=default_sample.abs_error(data_path), + candidate_abs_error=candidate_sample.abs_error(data_path), + ) + _append_pneumatic_96_candidate_metric_comparisons( + comparisons, + time_s=default_sample.time_s, + default_sample=default_sample, + candidate_sample=candidate_sample, + ) + _append_pneumatic_87_candidate_metric_comparisons( + comparisons, + time_s=default_sample.time_s, + default_sample=default_sample, + candidate_sample=candidate_sample, + ) + return tuple(comparisons) + + +def run_test_mql_pnvo_event_window_candidate_comparison( + config: TestMqlFullStateComparisonScriptConfig | None = None, + *, + orifice_alias: str = "pn_morifice_1", + final_time_s: float = 0.041, +) -> TestMqlPnvoEventWindowCandidateComparisonDiagnostic: + default_diagnostic = run_test_mql_pnvo_event_window_diagnostic( + _event_window_config_with_pneumatic_96_reference_rhs( + config, + enabled=False, + ), + orifice_alias=orifice_alias, + final_time_s=final_time_s, + ) + candidate_diagnostic = run_test_mql_pnvo_event_window_diagnostic( + _event_window_config_with_pneumatic_96_reference_rhs( + config, + enabled=True, + ), + orifice_alias=orifice_alias, + final_time_s=final_time_s, + ) + return TestMqlPnvoEventWindowCandidateComparisonDiagnostic( + orifice_alias=orifice_alias, + event_time_s=default_diagnostic.event_time_s, + final_time_s=default_diagnostic.final_time_s, + default_diagnostic=default_diagnostic, + candidate_diagnostic=candidate_diagnostic, + metric_comparisons=_pnvo_event_window_candidate_metric_comparisons( + default_diagnostic, + candidate_diagnostic, + ), + ) + + +def format_test_mql_pnvo_event_window_summary( + diagnostic: TestMqlPnvoEventWindowDiagnostic, +) -> str: + lines = [ + "Model: test_mql", + f"Mode: PNVO event window diagnostic ({diagnostic.orifice_alias})", + f"Event time: {diagnostic.event_time_s}", + f"Final time: {diagnostic.final_time_s}", + "Segments:", + ] + for segment in diagnostic.segment_diagnostics: + lines.append( + f" - {segment.t_start} -> {segment.t_stop}: " + f"method={segment.method}, rtol={segment.rtol}, " + f"atol={segment.atol}, max_step={segment.max_step}, " + f"rhs={segment.rhs_evaluations}, success={segment.success}" + ) + if diagnostic.sample_diagnostics: + lines.append("Sample comparisons:") + for sample in diagnostic.sample_diagnostics: + if sample.amesim_values_are_interpolated: + amesim_sample_source = ( + "linear-interpolation@" + f"{sample.amesim_sample_left_time_s}" + f"..{sample.amesim_sample_right_time_s}" + ) + else: + amesim_sample_source = f"exact@{sample.amesim_sample_left_time_s}" + lines.append( + f" t={sample.time_s} (amesim={amesim_sample_source})" + ) + for data_path in sample.data_paths: + lines.append( + f" - {data_path}: " + f"python={sample.python_values_by_data_path[data_path]}, " + f"amesim={sample.amesim_values_by_data_path[data_path]}, " + f"abs_error={sample.abs_error(data_path)}" + ) + for rhs in sample.pnl0001_rhs_diagnostics: + lines.append( + f" - rhs@{rhs.line_alias}: " + f"chamber_to_line={rhs.chamber_to_line_flow_kg_s}, " + f"node_to_line={rhs.node_to_line_flow_kg_s}, " + f"dm_dt={rhs.mass_derivative_kg_s}, " + f"dU_dt={rhs.energy_derivative_w}" + ) + for pressure_loss in sample.pnl0001_pressure_loss_diagnostics: + lines.append( + f" - pressure_loss@{pressure_loss.line_alias}: " + f"cm={pressure_loss.amesim_cm}, " + f"amesim_dm1={pressure_loss.amesim_dm1_g_s}, " + f"amesim_dp={pressure_loss.amesim_pressure_drop_pa}, " + f"darcy_dp={pressure_loss.current_darcy_pressure_drop_pa}, " + f"dp_multiplier={pressure_loss.pressure_drop_multiplier}, " + f"candidate_pn2pipefr_dm1=" + f"{pressure_loss.candidate_pn2pipefr_dm1_g_s}, " + f"candidate_pn2pipefr_ratio=" + f"{pressure_loss.candidate_pn2pipefr_to_amesim_dm1_ratio}, " + f"amesim_linear_k=" + f"{pressure_loss.amesim_linear_conductance_kg_s_sqrt_k_per_pa}, " + f"python_linear_k=" + f"{pressure_loss.python_linear_conductance_kg_s_sqrt_k_per_pa}, " + f"linear_k_ratio=" + f"{pressure_loss.python_to_amesim_linear_conductance_ratio}" + ) + for flow_parameter in sample.pnvo_flow_parameter_diagnostics: + lines.append( + f" - flow_parameter@{flow_parameter.orifice_alias}: " + f"amesim_cm={flow_parameter.amesim_cm}, " + f"python_cm={flow_parameter.python_cm}, " + f"cm_ratio={flow_parameter.python_to_amesim_cm_ratio}, " + f"amesim_dm2={flow_parameter.amesim_dm2_g_s}, " + f"python_m={flow_parameter.python_mass_flow_kg_s}, " + f"opening={flow_parameter.python_opening}, " + f"effective_area={flow_parameter.python_effective_area_m2}, " + f"upstream_p={flow_parameter.python_upstream_pressure_pa}, " + f"upstream_t={flow_parameter.python_upstream_temperature_k}, " + f"amesim_gasvel={flow_parameter.amesim_gas_velocity_m_s}" + ) + for pnvo_enthalpy in sample.pnvo_upstream_enthalpy_diagnostics: + lines.append( + f" - pnvo_upstream_enthalpy@{pnvo_enthalpy.orifice_alias}: " + f"amesim_dh2=" + f"{pnvo_enthalpy.amesim_orifice_port_2_enthalpy_flow_w}, " + f"amesim_dm2=" + f"{pnvo_enthalpy.amesim_orifice_port_2_mass_flow_g_s}, " + f"amesim_dh3=" + f"{pnvo_enthalpy.amesim_orifice_port_3_enthalpy_flow_w}, " + f"amesim_dm3=" + f"{pnvo_enthalpy.amesim_orifice_port_3_mass_flow_g_s}, " + f"amesim_line_dhctr=" + f"{pnvo_enthalpy.amesim_upstream_line_center_enthalpy_flow_w}, " + f"amesim_line_dmctr=" + f"{pnvo_enthalpy.amesim_upstream_line_center_mass_flow_g_s}, " + f"amesim_line_t2=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_temperature_k}, " + f"amesim_line_p2_abs=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_pressure_pa}, " + f"amesim_implied_t2=" + f"{pnvo_enthalpy.amesim_orifice_port_2_implied_reference_temperature_k}, " + f"amesim_implied_t2_delta=" + f"{pnvo_enthalpy.amesim_orifice_port_2_implied_temperature_delta_to_line_k}, " + f"amesim_ref_dh2=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_reference_enthalpy_flow_w}, " + f"amesim_ref_dh2_error=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_reference_enthalpy_error_w}, " + f"amesim_real_ref_dh2=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w}, " + f"amesim_real_ref_dh2_error=" + f"{pnvo_enthalpy.amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w}, " + f"python_flow=" + f"{pnvo_enthalpy.python_orifice_to_node_flow_kg_s}, " + f"python_line_t2=" + f"{pnvo_enthalpy.python_upstream_line_port_2_temperature_k}, " + f"python_line_p2_abs=" + f"{pnvo_enthalpy.python_upstream_line_port_2_pressure_pa}, " + f"python_current_dh2=" + f"{pnvo_enthalpy.python_current_port_2_enthalpy_flow_w}, " + f"python_ref_dh2=" + f"{pnvo_enthalpy.python_reference_port_2_enthalpy_flow_w}, " + f"python_real_ref_dh2=" + f"{pnvo_enthalpy.python_real_gas_reference_port_2_enthalpy_flow_w}, " + f"python_current_dh2_error=" + f"{pnvo_enthalpy.python_current_port_2_enthalpy_error_w}, " + f"python_ref_dh2_error=" + f"{pnvo_enthalpy.python_reference_port_2_enthalpy_error_w}, " + f"python_real_ref_dh2_error=" + f"{pnvo_enthalpy.python_real_gas_reference_port_2_enthalpy_error_w}" + ) + for inlet_line in sample.pneumatic_96_inlet_line_diagnostics: + lines.append( + f" - inlet_line@{inlet_line.line_alias}: " + f"node={inlet_line.node_alias}, " + f"orifice={inlet_line.orifice_alias}, " + f"chamber={inlet_line.chamber_alias}, " + f"amesim_p2_abs={inlet_line.amesim_line_pressure_pa}, " + f"amesim_t2={inlet_line.amesim_line_temperature_k}, " + f"amesim_mgas={inlet_line.amesim_line_mass_g}, " + f"amesim_fd_dmgas={inlet_line.amesim_line_mass_derivative_fd_g_s}, " + f"amesim_line_to_node_dm1={inlet_line.amesim_line_to_node_mass_flow_g_s}, " + f"amesim_orifice_to_line_dm2={inlet_line.amesim_orifice_to_line_mass_flow_g_s}, " + f"amesim_storage_dm_ports={inlet_line.amesim_storage_mass_derivative_from_ports_g_s}, " + f"amesim_storage_dm_residual={inlet_line.amesim_storage_mass_derivative_residual_g_s}, " + f"amesim_chamber_p_abs={inlet_line.amesim_chamber_pressure_pa}, " + f"amesim_chamber_t={inlet_line.amesim_chamber_temperature_k}, " + f"python_p2_abs={inlet_line.python_line_pressure_pa}, " + f"python_t2={inlet_line.python_line_temperature_k}, " + f"python_mgas={inlet_line.python_line_mass_g}, " + f"python_p2_error={inlet_line.python_line_pressure_error_pa}, " + f"python_t2_error={inlet_line.python_line_temperature_error_k}, " + f"python_mgas_error={inlet_line.python_line_mass_error_g}, " + f"python_line_to_node_dm={inlet_line.python_line_to_node_mass_flow_g_s}, " + f"python_line_to_node_dm_error={inlet_line.python_line_to_node_mass_flow_error_g_s}, " + f"python_orifice_to_line_dm={inlet_line.python_orifice_to_line_mass_flow_g_s}, " + f"python_orifice_to_line_dm_error={inlet_line.python_orifice_to_line_mass_flow_error_g_s}, " + f"python_storage_dm={inlet_line.python_storage_mass_derivative_g_s}, " + f"python_storage_dm_error={inlet_line.python_storage_mass_derivative_error_g_s}, " + f"amesim_node_p_abs={inlet_line.amesim_node_pressure_pa}, " + f"amesim_node_t={inlet_line.amesim_node_temperature_k}, " + f"python_line_to_node_amesim_node_dm={inlet_line.python_line_to_node_amesim_node_mass_flow_g_s}, " + f"python_line_to_node_amesim_node_dm_error={inlet_line.python_line_to_node_amesim_node_mass_flow_error_g_s}, " + f"python_line_to_node_amesim_line_dm={inlet_line.python_line_to_node_amesim_line_mass_flow_g_s}, " + f"python_line_to_node_amesim_line_dm_error={inlet_line.python_line_to_node_amesim_line_mass_flow_error_g_s}, " + f"python_line_to_node_amesim_node_line_dm={inlet_line.python_line_to_node_amesim_node_line_mass_flow_g_s}, " + f"python_line_to_node_amesim_node_line_dm_error={inlet_line.python_line_to_node_amesim_node_line_mass_flow_error_g_s}, " + f"python_orifice_to_line_amesim_line_dm={inlet_line.python_orifice_to_line_amesim_line_mass_flow_g_s}, " + f"python_orifice_to_line_amesim_line_dm_error={inlet_line.python_orifice_to_line_amesim_line_mass_flow_error_g_s}, " + f"python_orifice_to_line_amesim_chamber_dm={inlet_line.python_orifice_to_line_amesim_chamber_mass_flow_g_s}, " + f"python_orifice_to_line_amesim_chamber_dm_error={inlet_line.python_orifice_to_line_amesim_chamber_mass_flow_error_g_s}, " + f"python_orifice_to_line_amesim_line_chamber_dm={inlet_line.python_orifice_to_line_amesim_line_chamber_mass_flow_g_s}, " + f"python_orifice_to_line_amesim_line_chamber_dm_error={inlet_line.python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s}, " + f"python_storage_amesim_boundary_states_dm={inlet_line.python_storage_amesim_boundary_states_mass_derivative_g_s}, " + f"python_storage_amesim_boundary_states_dm_error={inlet_line.python_storage_amesim_boundary_states_mass_derivative_error_g_s}, " + f"python_chamber_p_abs={inlet_line.python_chamber_pressure_pa}, " + f"python_chamber_t={inlet_line.python_chamber_temperature_k}, " + f"amesim_fd_dT={inlet_line.amesim_line_temperature_derivative_fd_k_s}, " + f"amesim_dT_p1_h={inlet_line.amesim_port_1_enthalpy_dtemp_component_k_s}, " + f"amesim_dT_p2_h={inlet_line.amesim_port_2_enthalpy_dtemp_component_k_s}, " + f"amesim_dT_m_offset={inlet_line.amesim_mass_offset_dtemp_component_k_s}, " + f"amesim_ref_dT_sum={inlet_line.amesim_reference_temperature_derivative_sum_k_s}, " + f"amesim_ref_dT_residual={inlet_line.amesim_reference_temperature_derivative_residual_k_s}, " + f"python_current_dT={inlet_line.python_current_temperature_derivative_k_s}, " + f"python_dT_p1={inlet_line.python_port_1_current_dtemp_component_k_s}, " + f"python_dT_p2={inlet_line.python_port_2_current_dtemp_component_k_s}, " + f"python_dT_heat={inlet_line.python_heat_dtemp_component_k_s}, " + f"python_dT_error={inlet_line.python_temperature_derivative_error_k_s}, " + f"amesim_fd_dP={inlet_line.amesim_line_pressure_derivative_fd_pa_s}, " + f"amesim_dP_m={inlet_line.amesim_pressure_derivative_mass_component_pa_s}, " + f"amesim_dP_T={inlet_line.amesim_pressure_derivative_temperature_component_pa_s}, " + f"amesim_dP_sum={inlet_line.amesim_pressure_derivative_eos_sum_pa_s}, " + f"amesim_dP_residual={inlet_line.amesim_pressure_derivative_eos_residual_pa_s}, " + f"python_dP_m={inlet_line.python_pressure_derivative_mass_component_pa_s}, " + f"python_dP_T={inlet_line.python_pressure_derivative_temperature_component_pa_s}, " + f"python_dP_sum={inlet_line.python_pressure_derivative_eos_sum_pa_s}, " + f"python_dP_error={inlet_line.python_pressure_derivative_eos_error_pa_s}, " + f"python_ref_dh1={inlet_line.python_reference_port_1_enthalpy_flow_w}, " + f"python_ref_dh2={inlet_line.python_reference_port_2_enthalpy_flow_w}, " + f"python_ref_dT_p1={inlet_line.python_reference_port_1_dtemp_component_k_s}, " + f"python_ref_dT_p2={inlet_line.python_reference_port_2_dtemp_component_k_s}, " + f"python_ref_dT_m_offset={inlet_line.python_reference_mass_offset_dtemp_component_k_s}, " + f"python_ref_dT={inlet_line.python_reference_temperature_derivative_k_s}, " + f"python_ref_dT_error={inlet_line.python_reference_temperature_derivative_error_k_s}, " + f"python_ref_dP_m={inlet_line.python_reference_pressure_derivative_mass_component_pa_s}, " + f"python_ref_dP_T={inlet_line.python_reference_pressure_derivative_temperature_component_pa_s}, " + f"python_ref_dP_sum={inlet_line.python_reference_pressure_derivative_eos_sum_pa_s}, " + f"python_ref_dP_error={inlet_line.python_reference_pressure_derivative_eos_error_pa_s}, " + f"python_ref_amesim_storage_dT_m_offset={inlet_line.python_reference_amesim_storage_mass_offset_dtemp_component_k_s}, " + f"python_ref_amesim_storage_dT={inlet_line.python_reference_amesim_storage_temperature_derivative_k_s}, " + f"python_ref_amesim_storage_dT_error={inlet_line.python_reference_amesim_storage_temperature_derivative_error_k_s}, " + f"python_ref_amesim_storage_dP_m={inlet_line.python_reference_amesim_storage_pressure_derivative_mass_component_pa_s}, " + f"python_ref_amesim_storage_dP_T={inlet_line.python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s}, " + f"python_ref_amesim_storage_dP_sum={inlet_line.python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s}, " + f"python_ref_amesim_storage_dP_error={inlet_line.python_reference_amesim_storage_pressure_derivative_eos_error_pa_s}, " + f"python_active_dT={inlet_line.python_active_temperature_derivative_k_s}, " + f"python_active_dT_error={inlet_line.python_active_temperature_derivative_error_k_s}, " + f"python_active_dP_sum={inlet_line.python_active_pressure_derivative_eos_sum_pa_s}, " + f"python_active_dP_error={inlet_line.python_active_pressure_derivative_eos_error_pa_s}, " + f"python_chamber_p_error={inlet_line.python_chamber_pressure_error_pa}, " + f"python_chamber_t_error={inlet_line.python_chamber_temperature_error_k}" + ) + for pnl0003_energy in sample.pnl0003_energy_diagnostics: + lines.append( + f" - pnl0003_energy@{pnl0003_energy.line_alias}: " + f"amesim_t1={pnl0003_energy.amesim_port_1_temperature_k}, " + f"amesim_t2={pnl0003_energy.amesim_port_2_temperature_k}, " + f"amesim_p1_abs={pnl0003_energy.amesim_port_1_pressure_pa}, " + f"amesim_p2_abs={pnl0003_energy.amesim_port_2_pressure_pa}, " + f"amesim_dm1={pnl0003_energy.amesim_port_1_mass_flow_g_s}, " + f"amesim_dh1={pnl0003_energy.amesim_port_1_enthalpy_flow_w}, " + f"amesim_dmctr={pnl0003_energy.amesim_center_mass_flow_g_s}, " + f"amesim_dhctr={pnl0003_energy.amesim_center_enthalpy_flow_w}, " + f"amesim_dm2={pnl0003_energy.amesim_port_2_mass_flow_g_s}, " + f"amesim_dh2={pnl0003_energy.amesim_port_2_enthalpy_flow_w}, " + f"amesim_sdm1={pnl0003_energy.amesim_port_1_storage_mass_derivative_g_s}, " + f"amesim_sdh1={pnl0003_energy.amesim_port_1_storage_enthalpy_sum_w}, " + f"amesim_sdm2={pnl0003_energy.amesim_port_2_storage_mass_derivative_g_s}, " + f"amesim_sdh2={pnl0003_energy.amesim_port_2_storage_enthalpy_sum_w}, " + f"python_node_dm1={pnl0003_energy.python_pn3_node_port_1_mass_flow_g_s}, " + f"python_node_dm3={pnl0003_energy.python_pn3_node_port_3_mass_flow_g_s}, " + f"python_node_to_line_dm={pnl0003_energy.python_pn3_node_to_line_mass_flow_g_s}, " + f"python_node_to_line_dm_error={pnl0003_energy.python_pn3_node_to_line_mass_flow_error_g_s}, " + f"amesim_m1={pnl0003_energy.amesim_port_1_mass_estimate_g}, " + f"amesim_m2={pnl0003_energy.amesim_port_2_mass_estimate_g}, " + f"python_m1={pnl0003_energy.python_port_1_mass_g}, " + f"python_m2={pnl0003_energy.python_port_2_mass_g}, " + f"python_m1_error={pnl0003_energy.python_port_1_mass_error_to_amesim_g}, " + f"python_m2_error={pnl0003_energy.python_port_2_mass_error_to_amesim_g}, " + f"amesim_fd_dmgas={pnl0003_energy.amesim_total_mass_derivative_fd_g_s}, " + f"amesim_back_dmgas={pnl0003_energy.amesim_total_mass_derivative_backward_g_s}, " + f"amesim_fwd_dmgas={pnl0003_energy.amesim_total_mass_derivative_forward_g_s}, " + f"amesim_storage_dmgas={pnl0003_energy.amesim_total_storage_mass_derivative_g_s}, " + f"amesim_storage_dmgas_residual={pnl0003_energy.amesim_total_storage_mass_derivative_residual_g_s}, " + f"amesim_dmctr_py_sign={pnl0003_energy.amesim_center_flow_python_sign_equivalent_g_s}, " + f"python_center_dm={pnl0003_energy.python_center_mass_flow_g_s}, " + f"python_center_dm_error={pnl0003_energy.amesim_center_flow_python_sign_error_g_s}, " + f"amesim_sdm1_center={pnl0003_energy.amesim_port_1_center_mass_derivative_g_s}, " + f"amesim_sdm2_center={pnl0003_energy.amesim_port_2_center_mass_derivative_g_s}, " + f"python_sdm1_ext={pnl0003_energy.python_port_1_external_mass_flow_g_s}, " + f"python_sdm1_ext_error={pnl0003_energy.python_port_1_external_mass_flow_error_g_s}, " + f"python_sdm1_center={pnl0003_energy.python_port_1_center_mass_derivative_g_s}, " + f"python_sdm1_center_error={pnl0003_energy.python_port_1_center_mass_derivative_error_g_s}, " + f"python_sdm1={pnl0003_energy.python_port_1_storage_mass_derivative_g_s}, " + f"python_sdm1_error={pnl0003_energy.python_port_1_storage_mass_derivative_error_g_s}, " + f"python_sdm2_ext={pnl0003_energy.python_port_2_external_mass_flow_g_s}, " + f"python_sdm2_ext_error={pnl0003_energy.python_port_2_external_mass_flow_error_g_s}, " + f"python_sdm2_center={pnl0003_energy.python_port_2_center_mass_derivative_g_s}, " + f"python_sdm2_center_error={pnl0003_energy.python_port_2_center_mass_derivative_error_g_s}, " + f"python_sdm2={pnl0003_energy.python_port_2_storage_mass_derivative_g_s}, " + f"python_sdm2_error={pnl0003_energy.python_port_2_storage_mass_derivative_error_g_s}, " + f"python_amesim_state_darcy_center_dm=" + f"{pnl0003_energy.python_amesim_state_darcy_center_mass_flow_g_s}, " + f"python_amesim_state_darcy_center_dm_error=" + f"{pnl0003_energy.python_amesim_state_darcy_center_mass_flow_error_g_s}, " + f"python_amesim_state_pn2pipefr_center_dm=" + f"{pnl0003_energy.python_amesim_state_pn2pipefr_center_mass_flow_g_s}, " + f"python_amesim_state_pn2pipefr_center_dm_error=" + f"{pnl0003_energy.python_amesim_state_pn2pipefr_center_mass_flow_error_g_s}, " + f"amesim_center_real_p1=" + f"{pnl0003_energy.amesim_center_real_gas_reference_port_1_estimate_w}, " + f"amesim_center_real_p1_error=" + f"{pnl0003_energy.amesim_center_real_gas_reference_port_1_error_w}, " + f"python_center_ideal_p1=" + f"{pnl0003_energy.python_center_ideal_reference_port_1_estimate_w}, " + f"python_center_ideal_p1_error=" + f"{pnl0003_energy.python_center_ideal_reference_port_1_error_w}, " + f"python_center_ideal_p2=" + f"{pnl0003_energy.python_center_ideal_reference_port_2_estimate_w}, " + f"python_center_ideal_p2_error=" + f"{pnl0003_energy.python_center_ideal_reference_port_2_error_w}, " + f"python_center_real_p1=" + f"{pnl0003_energy.python_center_real_gas_reference_port_1_estimate_w}, " + f"python_center_real_p1_error=" + f"{pnl0003_energy.python_center_real_gas_reference_port_1_error_w}, " + f"python_center_real_p2=" + f"{pnl0003_energy.python_center_real_gas_reference_port_2_estimate_w}, " + f"python_center_real_p2_error=" + f"{pnl0003_energy.python_center_real_gas_reference_port_2_error_w}, " + f"python_dmgas_dt={pnl0003_energy.python_total_mass_derivative_kg_s}, " + f"python_ref_ext1={pnl0003_energy.python_port_1_reference_external_enthalpy_flow_w}, " + f"python_ref_ctr1={pnl0003_energy.python_port_1_reference_center_enthalpy_flow_w}, " + f"python_ref_sdh1={pnl0003_energy.python_port_1_reference_storage_enthalpy_sum_w}, " + f"python_ref_sdh1_error={pnl0003_energy.python_port_1_reference_storage_enthalpy_error_w}, " + f"python_ref_ext2={pnl0003_energy.python_port_2_reference_external_enthalpy_flow_w}, " + f"python_ref_ctr2={pnl0003_energy.python_port_2_reference_center_enthalpy_flow_w}, " + f"python_ref_sdh2={pnl0003_energy.python_port_2_reference_storage_enthalpy_sum_w}, " + f"python_ref_sdh2_error={pnl0003_energy.python_port_2_reference_storage_enthalpy_error_w}, " + f"amesim_dT1_ext_h={pnl0003_energy.amesim_port_1_external_enthalpy_dtemp_component_k_s}, " + f"amesim_dT1_ctr_h={pnl0003_energy.amesim_port_1_center_enthalpy_dtemp_component_k_s}, " + f"amesim_dT1_m_offset={pnl0003_energy.amesim_port_1_mass_offset_dtemp_component_k_s}, " + f"amesim_dT1_heat={pnl0003_energy.amesim_port_1_heat_dtemp_component_k_s}, " + f"python_dT1_ext_h={pnl0003_energy.python_port_1_external_enthalpy_dtemp_component_k_s}, " + f"python_dT1_ctr_h={pnl0003_energy.python_port_1_center_enthalpy_dtemp_component_k_s}, " + f"python_dT1_m_offset={pnl0003_energy.python_port_1_mass_offset_dtemp_component_k_s}, " + f"python_dT1_heat={pnl0003_energy.python_port_1_heat_dtemp_component_k_s}, " + f"amesim_dT2_ext_h={pnl0003_energy.amesim_port_2_external_enthalpy_dtemp_component_k_s}, " + f"amesim_dT2_ctr_h={pnl0003_energy.amesim_port_2_center_enthalpy_dtemp_component_k_s}, " + f"amesim_dT2_m_offset={pnl0003_energy.amesim_port_2_mass_offset_dtemp_component_k_s}, " + f"amesim_dT2_heat={pnl0003_energy.amesim_port_2_heat_dtemp_component_k_s}, " + f"python_dT2_ext_h={pnl0003_energy.python_port_2_external_enthalpy_dtemp_component_k_s}, " + f"python_dT2_ctr_h={pnl0003_energy.python_port_2_center_enthalpy_dtemp_component_k_s}, " + f"python_dT2_m_offset={pnl0003_energy.python_port_2_mass_offset_dtemp_component_k_s}, " + f"python_dT2_heat={pnl0003_energy.python_port_2_heat_dtemp_component_k_s}, " + f"amesim_fd_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_fd_k_s}, " + f"amesim_back_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_backward_k_s}, " + f"amesim_fwd_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_forward_k_s}, " + f"amesim_fd_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_fd_k_s}, " + f"amesim_back_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_backward_k_s}, " + f"amesim_fwd_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_forward_k_s}, " + f"amesim_pn2vol_dT1={pnl0003_energy.amesim_port_1_pn2vol_dtemp_k_s}, " + f"amesim_pn2vol_dT2={pnl0003_energy.amesim_port_2_pn2vol_dtemp_k_s}, " + f"amesim_pn2vol_dT1_residual={pnl0003_energy.amesim_port_1_pn2vol_dtemp_residual_k_s}, " + f"amesim_pn2vol_dT2_residual={pnl0003_energy.amesim_port_2_pn2vol_dtemp_residual_k_s}, " + f"amesim_fd_dP1={pnl0003_energy.amesim_port_1_pressure_derivative_fd_pa_s}, " + f"amesim_fd_dP2={pnl0003_energy.amesim_port_2_pressure_derivative_fd_pa_s}, " + f"amesim_dP1_m={pnl0003_energy.amesim_port_1_pressure_derivative_mass_component_pa_s}, " + f"amesim_dP1_T={pnl0003_energy.amesim_port_1_pressure_derivative_temperature_component_pa_s}, " + f"amesim_dP1_sum={pnl0003_energy.amesim_port_1_pressure_derivative_eos_sum_pa_s}, " + f"amesim_dP1_residual={pnl0003_energy.amesim_port_1_pressure_derivative_eos_residual_pa_s}, " + f"amesim_dP2_m={pnl0003_energy.amesim_port_2_pressure_derivative_mass_component_pa_s}, " + f"amesim_dP2_T={pnl0003_energy.amesim_port_2_pressure_derivative_temperature_component_pa_s}, " + f"amesim_dP2_sum={pnl0003_energy.amesim_port_2_pressure_derivative_eos_sum_pa_s}, " + f"amesim_dP2_residual={pnl0003_energy.amesim_port_2_pressure_derivative_eos_residual_pa_s}, " + f"python_dP1_m={pnl0003_energy.python_port_1_pressure_derivative_mass_component_pa_s}, " + f"python_dP1_T={pnl0003_energy.python_port_1_pressure_derivative_temperature_component_pa_s}, " + f"python_dP1_sum={pnl0003_energy.python_port_1_pressure_derivative_eos_sum_pa_s}, " + f"python_dP2_m={pnl0003_energy.python_port_2_pressure_derivative_mass_component_pa_s}, " + f"python_dP2_T={pnl0003_energy.python_port_2_pressure_derivative_temperature_component_pa_s}, " + f"python_dP2_sum={pnl0003_energy.python_port_2_pressure_derivative_eos_sum_pa_s}, " + f"python_t1={pnl0003_energy.python_port_1_temperature_k}, " + f"python_t2={pnl0003_energy.python_port_2_temperature_k}, " + f"python_p1_abs={pnl0003_energy.python_port_1_pressure_pa}, " + f"python_p2_abs={pnl0003_energy.python_port_2_pressure_pa}, " + f"python_dm1_dt={pnl0003_energy.python_port_1_mass_derivative_kg_s}, " + f"python_dm2_dt={pnl0003_energy.python_port_2_mass_derivative_kg_s}, " + f"python_current_dT1={pnl0003_energy.python_port_1_current_dtemp_k_s}, " + f"python_current_dT2={pnl0003_energy.python_port_2_current_dtemp_k_s}, " + f"python_real_ref_dT1={pnl0003_energy.python_port_1_real_gas_reference_dtemp_k_s}, " + f"python_real_ref_dT2={pnl0003_energy.python_port_2_real_gas_reference_dtemp_k_s}, " + f"python_amesim_center_dT2=" + f"{pnl0003_energy.python_port_2_amesim_center_counterfactual_dtemp_k_s}, " + f"python_amesim_ext_dT2=" + f"{pnl0003_energy.python_port_2_amesim_external_counterfactual_dtemp_k_s}, " + f"python_amesim_ext_ctr_dT2=" + f"{pnl0003_energy.python_port_2_amesim_external_center_counterfactual_dtemp_k_s}, " + f"python_t2_error={pnl0003_energy.python_port_2_temperature_error_to_amesim_k}" + ) + for energy_flow in sample.pnl0001_energy_flow_diagnostics: + lines.append( + f" - energy_flow@{energy_flow.line_alias}: " + f"amesim_dh1={energy_flow.amesim_port_1_enthalpy_flow_w}, " + f"amesim_dm1={energy_flow.amesim_port_1_mass_flow_g_s}, " + f"amesim_node_dh2={energy_flow.amesim_node_port_2_enthalpy_flow_w}, " + f"amesim_node_dm2={energy_flow.amesim_node_port_2_mass_flow_g_s}, " + f"amesim_observed_sdh=" + f"{energy_flow.amesim_observed_port_enthalpy_sum_w}, " + f"python_port1_u=" + f"{energy_flow.python_port_1_internal_energy_flow_w}, " + f"python_port2_u=" + f"{energy_flow.python_port_2_internal_energy_flow_w}, " + f"python_dU_dt={energy_flow.python_current_energy_derivative_w}, " + f"direct_h_dU_dt=" + f"{energy_flow.python_direct_enthalpy_energy_derivative_w}, " + f"direct_minus_current=" + f"{energy_flow.python_direct_minus_current_energy_derivative_w}, " + f"python_p4_node_dh2=" + f"{energy_flow.python_p4_node_port_2_enthalpy_flow_w}, " + f"python_p4_node_dm2=" + f"{energy_flow.python_p4_node_port_2_mass_flow_g_s}, " + f"python_p4_node_h2=" + f"{energy_flow.python_p4_node_port_2_connected_h_j_kg}, " + f"python_p4_node_t2=" + f"{energy_flow.python_p4_node_port_2_connected_temperature_k}, " + f"python_p4_node_h2_delta=" + f"{energy_flow.python_p4_node_port_2_connected_h_delta_to_line_h_j_kg}, " + f"python_p4_node_counterfactual_dT=" + f"{energy_flow.python_p4_node_port_2_counterfactual_dtemp_k_s}, " + f"href_t={energy_flow.reference_temperature_k}, " + f"ref_dh1_est=" + f"{energy_flow.amesim_port_1_reference_enthalpy_estimate_w}, " + f"ref_dh1_error=" + f"{energy_flow.amesim_port_1_reference_enthalpy_error_w}, " + f"candidate_dm2i=" + f"{energy_flow.amesim_candidate_pn2pipefr_dm2i_g_s}, " + f"candidate_dh2i=" + f"{energy_flow.amesim_candidate_pn2pipefr_dh2i_w}, " + f"candidate_storage_sdh=" + f"{energy_flow.amesim_candidate_storage_enthalpy_sum_w}, " + f"candidate_pn2pipefr_dT=" + f"{energy_flow.amesim_candidate_pn2pipefr_dtemp_k_s}, " + f"candidate_pn2pipefr_dT_residual=" + f"{energy_flow.amesim_candidate_pn2pipefr_dtemp_residual_k_s}, " + f"python_ref_dh1=" + f"{energy_flow.python_reference_port_1_enthalpy_flow_w}, " + f"python_ref_node_dh2=" + f"{energy_flow.python_reference_node_port_2_enthalpy_flow_w}, " + f"python_ref_sdh=" + f"{energy_flow.python_reference_observed_port_enthalpy_sum_w}, " + f"python_ref_dh1_error=" + f"{energy_flow.python_reference_port_1_to_amesim_error_w}, " + f"python_ref_node_dh2_error=" + f"{energy_flow.python_reference_node_port_2_to_amesim_error_w}, " + f"python_ref_sdh_error=" + f"{energy_flow.python_reference_sum_to_amesim_error_w}, " + f"python_current_dT=" + f"{energy_flow.python_current_line_temperature_derivative_k_s}, " + f"amesim_fd_dT=" + f"{energy_flow.amesim_line_temperature_derivative_fd_k_s}, " + f"amesim_pn2vol2_dT_node_plus_dh1=" + f"{energy_flow.amesim_pn2vol2_dtemp_node_plus_dh1_k_s}, " + f"amesim_pn2vol2_dT_node_minus_dh1=" + f"{energy_flow.amesim_pn2vol2_dtemp_node_minus_dh1_k_s}, " + f"python_ref_pn2vol2_dT_node_minus_dh1=" + f"{energy_flow.python_reference_pn2vol2_dtemp_node_minus_dh1_k_s}" + ) + for p4_node in sample.p4_node_enthalpy_breakdown_diagnostics: + lines.append( + f" - p4_node_enthalpy@{p4_node.node_alias}: " + f"amesim_p1_dh={p4_node.amesim_port_1_enthalpy_flow_w}, " + f"amesim_p1_dm={p4_node.amesim_port_1_mass_flow_g_s}, " + f"amesim_p3_dh={p4_node.amesim_port_3_enthalpy_flow_w}, " + f"amesim_p3_dm={p4_node.amesim_port_3_mass_flow_g_s}, " + f"amesim_p4_dh={p4_node.amesim_port_4_enthalpy_flow_w}, " + f"amesim_p4_dm={p4_node.amesim_port_4_mass_flow_g_s}, " + f"amesim_p2_dh={p4_node.amesim_port_2_enthalpy_flow_w}, " + f"amesim_p2_dm={p4_node.amesim_port_2_mass_flow_g_s}, " + f"python_p1_dh={p4_node.python_port_1_enthalpy_flow_w}, " + f"python_p1_dm={p4_node.python_port_1_mass_flow_g_s}, " + f"python_p3_dh={p4_node.python_port_3_enthalpy_flow_w}, " + f"python_p3_dm={p4_node.python_port_3_mass_flow_g_s}, " + f"python_p4_dh={p4_node.python_port_4_enthalpy_flow_w}, " + f"python_p4_dm={p4_node.python_port_4_mass_flow_g_s}, " + f"python_p2_dh={p4_node.python_port_2_enthalpy_flow_w}, " + f"python_p2_dm={p4_node.python_port_2_mass_flow_g_s}, " + f"python_ref_p1_dh=" + f"{p4_node.python_reference_port_1_enthalpy_flow_w}, " + f"python_ref_p3_dh=" + f"{p4_node.python_reference_port_3_enthalpy_flow_w}, " + f"python_ref_p4_dh=" + f"{p4_node.python_reference_port_4_enthalpy_flow_w}, " + f"python_ref_p2_dh=" + f"{p4_node.python_reference_port_2_enthalpy_flow_w}, " + f"python_real_ref_p1_dh=" + f"{p4_node.python_real_gas_reference_port_1_enthalpy_flow_w}, " + f"python_real_ref_p3_dh=" + f"{p4_node.python_real_gas_reference_port_3_enthalpy_flow_w}, " + f"python_real_ref_p4_dh=" + f"{p4_node.python_real_gas_reference_port_4_enthalpy_flow_w}, " + f"python_real_ref_p2_dh=" + f"{p4_node.python_real_gas_reference_port_2_enthalpy_flow_w}, " + f"python_p2_dh_error=" + f"{p4_node.python_port_2_enthalpy_error_w}, " + f"python_ref_p2_dh_error=" + f"{p4_node.python_reference_port_2_enthalpy_error_w}, " + f"python_real_ref_p2_dh_error=" + f"{p4_node.python_real_gas_reference_port_2_enthalpy_error_w}" + ) + for chamber_energy in sample.pnch012_energy_equation_diagnostics: + lines.append( + f" - pnch012_energy@{chamber_energy.chamber_alias}: " + f"amesim_dh1={chamber_energy.amesim_port_1_enthalpy_flow_w}, " + f"amesim_dm1={chamber_energy.amesim_port_1_mass_flow_g_s}, " + f"amesim_fd_dmgas=" + f"{chamber_energy.amesim_chamber_mass_derivative_fd_g_s}, " + f"amesim_dmgas_residual=" + f"{chamber_energy.amesim_chamber_mass_derivative_residual_g_s}, " + f"amesim_fd_dT=" + f"{chamber_energy.amesim_chamber_temperature_derivative_fd_k_s}, " + f"amesim_fd_dvol=" + f"{chamber_energy.amesim_chamber_volume_rate_fd_m3_s}, " + f"amesim_piston_dvol=" + f"{chamber_energy.amesim_piston_volume_rate_m3_s}, " + f"amesim_dq={chamber_energy.amesim_heat_flow_w}, " + f"amesim_boundary_work_fd=" + f"{chamber_energy.amesim_boundary_work_fd_volume_w}, " + f"amesim_pn2vol_ref_dT_fdvol=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_fd_volume_k_s}, " + f"amesim_pn2vol_ref_dT_fdvol_residual=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s}, " + f"amesim_pn2vol_ref_dT_pistonvol=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_piston_volume_k_s}, " + f"amesim_pn2vol_ref_dT_pistonvol_residual=" + f"{chamber_energy.amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s}, " + f"python_port_a_dm=" + f"{chamber_energy.python_port_a_mass_flow_kg_s}, " + f"python_dU_dt=" + f"{chamber_energy.python_current_energy_derivative_w}, " + f"python_current_dT=" + f"{chamber_energy.python_current_chamber_temperature_derivative_k_s}, " + f"href_t={chamber_energy.reference_temperature_k}" + ) + lines.append("Final comparison:") + for data_path in diagnostic.data_paths: + lines.append( + f" - {data_path}: " + f"python={diagnostic.python_values_by_data_path[data_path]}, " + f"amesim={diagnostic.amesim_values_by_data_path[data_path]}, " + f"abs_error={diagnostic.abs_error(data_path)}" + ) + return "\n".join(lines) + "\n" + + + +def format_test_mql_pnvo_event_window_candidate_comparison_summary( + diagnostic: TestMqlPnvoEventWindowCandidateComparisonDiagnostic, +) -> str: + lines = [ + "Model: test_mql", + f"Mode: PNVO event window candidate comparison ({diagnostic.orifice_alias})", + f"Event time: {diagnostic.event_time_s}", + f"Final time: {diagnostic.final_time_s}", + "Default RHS: current pneumatic_96 internal-energy RHS", + "Candidate RHS: pneumatic_96 reference-enthalpy/mass-offset RHS", + "Metric comparisons: negative delta means the candidate reduced error", + ] + for comparison in diagnostic.metric_comparisons: + lines.append( + f" t={comparison.time_s} - {comparison.metric_name}: " + f"default_abs_error={comparison.default_abs_error}, " + f"candidate_abs_error={comparison.candidate_abs_error}, " + f"delta={comparison.abs_error_delta}" + ) + lines.append("Final comparison:") + for data_path in diagnostic.default_diagnostic.data_paths: + lines.append( + f" - {data_path}: " + f"default_abs_error={diagnostic.default_diagnostic.abs_error(data_path)}, " + f"candidate_abs_error={diagnostic.candidate_diagnostic.abs_error(data_path)}, " + f"delta=" + f"{diagnostic.candidate_diagnostic.abs_error(data_path) - diagnostic.default_diagnostic.abs_error(data_path)}" + ) + return "\n".join(lines) + "\n" + + +def format_test_mql_full_state_comparison_summary( + run: TestMqlFullStateComparisonRun, +) -> str: + lines = [ + "Model: test_mql", + "Mode: Python 132 full-state closure comparison", + f"Samples: {run.sample_count}", + f"Compared signals: {run.signal_count}", + f"Output schema signals: {run.output_schema.signal_count}", + f"AMESim first saved interval: " + f"{run.amesim_results.times[1] - run.amesim_results.times[0]}", + f"Final time aligns with AMESim sample: " + f"{_matches_amesim_sample(run, run.output.times[-1])}", + f"Max absolute error: {run.comparison.max_abs_error}", + f"Max relative error: {run.comparison.max_rel_error}", + ] + largest_metric = run.largest_abs_error_metric + if largest_metric is not None: + lines.append( + "Largest absolute error: " + f"{largest_metric.data_path}={largest_metric.max_abs_error}" + ) + largest_diagnostic = run.largest_final_abs_error_diagnostic + if largest_diagnostic is not None: + lines.append( + "Largest final endpoint error: " + f"{largest_diagnostic.data_path}={largest_diagnostic.final_abs_error}" + ) + lines.append("Metrics by max absolute error:") + for metric in run.metrics_by_max_abs_error(): + lines.append( + f" - {metric.data_path}: max_abs_error={metric.max_abs_error}, " + f"final_abs_error={metric.final_abs_error}" + ) + lines.append("Endpoint diagnostics by final absolute error:") + for diagnostic in run.diagnostics_by_final_abs_error(): + lines.append( + f" - {diagnostic.data_path}: " + f"initial_python={diagnostic.initial_python_value}, " + f"initial_amesim={diagnostic.initial_amesim_value}, " + f"final_python={diagnostic.final_python_value}, " + f"final_amesim={diagnostic.final_amesim_value}, " + f"final_abs_error={diagnostic.final_abs_error}" + ) + flow_diagnostic = _pneumatic_69_flow_diagnostic(run) + if flow_diagnostic is not None: + lines.append( + "PNL0001 canonical mass-flow diagnostic: " + f"{flow_diagnostic.data_path}" + ) + lines.extend( + [ + " - convention=Python chamber-to-line flow " + "equals -AMESim dm1 * 1e-3", + f" - initial_python_kg_s=" + f"{flow_diagnostic.initial_python_canonical_kg_s}", + f" - initial_amesim_kg_s=" + f"{flow_diagnostic.initial_amesim_canonical_kg_s}", + f" - final_python_kg_s=" + f"{flow_diagnostic.final_python_canonical_kg_s}", + f" - final_amesim_kg_s=" + f"{flow_diagnostic.final_amesim_canonical_kg_s}", + f" - final_abs_error_kg_s=" + f"{flow_diagnostic.final_canonical_abs_error_kg_s}", + ] + ) + pnvo_diagnostic = _pn_morifice_1_diagnostic(run) + if pnvo_diagnostic is not None: + lines.append(f"PNVO diagnostic: {pnvo_diagnostic.alias}") + lines.extend( + [ + f" - initial_python_opening=" + f"{pnvo_diagnostic.initial_python_opening}", + f" - initial_amesim_opening=" + f"{pnvo_diagnostic.initial_amesim_opening}", + f" - final_python_opening=" + f"{pnvo_diagnostic.final_python_opening}", + f" - final_amesim_opening=" + f"{pnvo_diagnostic.final_amesim_opening}", + f" - final_python_mass_flow_kg_s=" + f"{pnvo_diagnostic.final_python_mass_flow_kg_s}", + f" - final_amesim_mass_flow_kg_s=" + f"{pnvo_diagnostic.final_amesim_mass_flow_kg_s}", + f" - final_mass_flow_abs_error_kg_s=" + f"{pnvo_diagnostic.final_mass_flow_abs_error_kg_s}", + ] + ) + chamber_diagnostic = _largest_chamber_rhs_diagnostic(run) + if chamber_diagnostic is not None: + lines.append( + "Largest endpoint chamber RHS breakdown: " + f"{chamber_diagnostic.chamber_alias}" + ) + lines.extend( + [ + f" - piston_alias={chamber_diagnostic.piston_alias}", + f" - pressure_pa={chamber_diagnostic.chamber_pressure_pa}", + f" - volume_m3={chamber_diagnostic.chamber_volume_m3}", + f" - volume_rate_m3_s={chamber_diagnostic.chamber_volume_rate_m3_s}", + f" - mass_derivative_kg_s={chamber_diagnostic.mass_derivative_kg_s}", + f" - port_a_energy_flow_w={chamber_diagnostic.port_a_energy_flow_w}", + f" - boundary_work_w={chamber_diagnostic.boundary_work_w}", + f" - thermal_energy_flow_w=" + f"{chamber_diagnostic.thermal_energy_flow_w}", + f" - energy_derivative_w={chamber_diagnostic.energy_derivative_w}", + ] + ) + return "\n".join(lines) + "\n" + + +def _matches_amesim_sample( + run: TestMqlFullStateComparisonRun, + time_s: float, +) -> bool: + return any( + abs(float(sample_time) - float(time_s)) <= 1.0e-12 + for sample_time in run.amesim_results.times + ) + + +def _pneumatic_69_flow_diagnostic(run: TestMqlFullStateComparisonRun): + try: + return run.pnl0001_mass_flow_diagnostic() + except KeyError: + return None + + +def _pn_morifice_1_diagnostic(run: TestMqlFullStateComparisonRun): + try: + return run.pnvo_diagnostic() + except KeyError: + return None + + +def _largest_chamber_rhs_diagnostic(run: TestMqlFullStateComparisonRun): + diagnostic = run.largest_final_abs_error_diagnostic + if diagnostic is None or "@" not in diagnostic.data_path: + return None + _signal, alias = diagnostic.data_path.split("@", 1) + try: + return run.chamber_rhs_diagnostic(alias) + except KeyError: + return None + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--pnvo-event-boundary", + action="store_true", + help="compare the STEP0 left-state/right-opening boundary at t=0.04 s", + ) + parser.add_argument( + "--pnvo-event-window", + action="store_true", + help="run the segmented PNVO opening window through the t=0.05 s save point", + ) + parser.add_argument( + "--pnvo-event-window-candidate-comparison", + action="store_true", + help="compare the PNVO event window with and without the pneumatic_96 RHS candidate", + ) + parser.add_argument( + "--pnvo-event-window-final-time", + type=float, + help="override PNVO event window final time; candidate comparison defaults to 0.041 s", + ) + parser.add_argument( + "--amesim-results-archive", + type=Path, + help="read AMESim observations from a separate .ame results package", + ) + parser.add_argument( + "--pneumatic-96-reference-rhs", + action="store_true", + help="enable the local reference/mass-offset RHS candidate for pneumatic_96", + ) + parser.add_argument( + "--pneumatic-69-reference-rhs", + action="store_true", + help="enable the local reference/mass-offset RHS candidate for pneumatic_69", + ) + args = parser.parse_args() + config = None + if ( + args.amesim_results_archive is not None + or args.pneumatic_96_reference_rhs + or args.pneumatic_69_reference_rhs + ): + config = TestMqlFullStateComparisonScriptConfig( + paths=TestMqlFullStateComparisonPathConfig( + amesim_results_archive_path=args.amesim_results_archive, + ), + execution=TestMqlFullStateComparisonExecutionConfig( + use_pneumatic_96_reference_rhs=args.pneumatic_96_reference_rhs, + use_pneumatic_69_reference_rhs=args.pneumatic_69_reference_rhs, + ), + ) + if args.pnvo_event_boundary: + diagnostic = run_test_mql_pnvo_event_boundary_diagnostic(config) + print(format_test_mql_pnvo_event_boundary_summary(diagnostic), end="") + return + if args.pnvo_event_window_candidate_comparison: + diagnostic = run_test_mql_pnvo_event_window_candidate_comparison( + config, + final_time_s=args.pnvo_event_window_final_time or 0.041, + ) + print( + format_test_mql_pnvo_event_window_candidate_comparison_summary( + diagnostic + ), + end="", + ) + return + if args.pnvo_event_window: + if args.pnvo_event_window_final_time is None: + diagnostic = run_test_mql_pnvo_event_window_diagnostic(config) + else: + diagnostic = run_test_mql_pnvo_event_window_diagnostic( + config, + final_time_s=args.pnvo_event_window_final_time, + ) + print(format_test_mql_pnvo_event_window_summary(diagnostic), end="") + return + + run, output_dir = run_test_mql_full_state_comparison(config) + print(format_test_mql_full_state_comparison_summary(run), end="") + print(f"Output directory: {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/app/simulation/examples/testmodel/test_mql.py b/app/simulation/examples/testmodel/test_mql.py new file mode 100644 index 0000000..1bd736b --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql.py @@ -0,0 +1,6334 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from types import SimpleNamespace + +from PythonModels.core.base import Component +from PythonModels.core.network import SimulationNetwork +from PythonModels.core.solver import SolveIVPConfig, integrate_ode + + +MODEL_NAME = "test_mql" +AMESIM_ARCHIVE_RELATIVE_PATH = Path("AmesimModels/test_mql.ame") + +# Extracted from AmesimModels/test_mql.ame on the model-development branch. +# This is a structural Python port scaffold: it preserves the AMESim component +# inventory, parameters, and connection topology without changing the existing +# Testmodel implementation. The detailed AMESim submodel equations should be +# replaced incrementally per submodel family. +MODEL_INFO = {'num_states': 132, 'num_discrete_states': 24, 'is_explicit': 1} +SIMULATION_SETTINGS = {'raw': '0 10 0.01 1e+30 1e-07 0.001 1 0.1\r\n0 0 0 0 8 0 0 0 0 0', + 't_start': 0.0, + 't_stop': 10.0, + 'print_interval': 0.01, + 'max_step': 0.001, + 'sample_step': 0.1} +GLOBAL_PARAMETERS = {'D2': '20', 'D1': '20', 'D3': '14', 'Pdq': '1', 'P0': '153', 'V': '15', 'cf': '0.45'} +SUBMODEL_COUNTS = {'PNRP17': 8, + 'F000': 16, + 'MECMAS21': 10, + 'LMECHN1': 2, + 'LSTP00A': 8, + 'PNCH012': 8, + 'PNPL01': 16, + 'FORC': 2, + 'PNCH023': 4, + 'PNOR001': 8, + 'PN3NODE2': 8, + 'P4NODE2': 8, + 'PNVO001': 8, + 'STEP0': 8, + 'UD00': 2, + 'PNGD00': 1} +LINE_SUBMODEL_COUNTS = {'DIRECT': 44, 'PNL0001': 20, 'PNL0003': 8, 'PNL0002': 8, 'PNL00R': 4} +COMPONENT_SPECS = [{'index': 0, + 'alias': 'pn_brp2_8', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 1, + 'alias': 'pn_brp2_9', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 2, + 'alias': 'pn_brp2_10', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 3, + 'alias': 'pn_brp2_11', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 4, + 'alias': 'pn_brp2_12', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 5, + 'alias': 'pn_brp2_13', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 6, + 'alias': 'pn_brp2_14', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 7, + 'alias': 'pn_brp2_15', + 'component_name': 'pn_brp2', + 'library_id': 'pcd.pn_brp2', + 'submodel': 'PNRP17', + 'label': 'pneumatic piston with moving body', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'dp', + 'title': 'piston diameter', + 'value': '2.00000000000000e+02', + 'units': 'mm'}, + {'name': 'dr', + 'title': 'rod diameter', + 'value': '1.00000000000000e+00', + 'units': 'mm'}, + {'name': 'x0', + 'title': 'chamber length at zero relative displacement', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 8, + 'alias': 'zeroforcesource_17', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 9, + 'alias': 'mass_friction_endstops_10', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 10, + 'alias': 'zeroforcesource_18', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 11, + 'alias': 'mass_friction_endstops_11', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 12, + 'alias': 'zeroforcesource_19', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 13, + 'alias': 'mass_friction_endstops_12', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 14, + 'alias': 'zeroforcesource_20', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 15, + 'alias': 'mass_friction_endstops_13', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 16, + 'alias': 'zeroforcesource_21', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 17, + 'alias': 'mass_friction_endstops_14', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 18, + 'alias': 'zeroforcesource_22', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 19, + 'alias': 'mass_friction_endstops_15', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 20, + 'alias': 'zeroforcesource_23', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 21, + 'alias': 'mass_friction_endstops_16', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 22, + 'alias': 'zeroforcesource_24', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 23, + 'alias': 'mass_friction_endstops_17', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-1.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '8.00000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 24, + 'alias': 'dynamic_mechanical_node_alternative_2', + 'component_name': 'dynamic_mechanical_node_alternative', + 'library_id': 'meca.dynamic_mechanical_node_alternative', + 'submodel': 'LMECHN1', + 'label': 'dynamic linear mechanical node (velocity and displacement)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_6', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_7', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_8', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_9', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'v1', 'title': 'number of ports at right', 'value': '8', 'units': ''}, + {'name': 'sum', 'title': 'node sum', 'value': '1', 'units': ''}]}, + {'index': 25, + 'alias': 'mass_friction_endstops_18', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '1.70000000000000e+05', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '0.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '3.70000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 26, + 'alias': 'elasticendstop_8', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 27, + 'alias': 'elasticendstop_9', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 28, + 'alias': 'elasticendstop_10', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 29, + 'alias': 'elasticendstop_11', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 30, + 'alias': 'elasticendstop_12', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 31, + 'alias': 'elasticendstop_13', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 32, + 'alias': 'elasticendstop_14', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 33, + 'alias': 'elasticendstop_15', + 'component_name': 'elasticendstop', + 'library_id': 'meca.elasticendstop', + 'submodel': 'LSTP00A', + 'label': 'elastic contact (no states)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': [{'name': 'na', + 'title': 'number of active coils', + 'value': '1.00000000000000e+01', + 'units': 'null'}, + {'name': 'gap0', + 'title': 'gap or clearance with both displacements zero', + 'value': '0.00000000000000e+00', + 'units': 'mm'}, + {'name': 'kcont', + 'title': 'contact stiffness', + 'value': '1.00000000000000e+11', + 'units': 'N/m'}, + {'name': 'G', + 'title': 'material shear modulus', + 'value': '8.57000000000000e+10', + 'units': 'N/m**2'}, + {'name': 'sdiam', + 'title': 'spring diameter', + 'value': '2.00000000000000e+01', + 'units': 'mm'}, + {'name': 'wdiam', + 'title': 'wire diameter', + 'value': '2.00000000000000e+00', + 'units': 'mm'}, + {'name': 'rcont', + 'title': 'contact damping', + 'value': '1.00000000000000e+11', + 'units': 'N/(m/s)'}, + {'name': 'Pdis', + 'title': 'penetration for full damping', + 'value': '1.00000000000000e-04', + 'units': 'mm'}, + {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}]}, + {'index': 34, + 'alias': 'dynamic_mechanical_node_alternative_3', + 'component_name': 'dynamic_mechanical_node_alternative', + 'library_id': 'meca.dynamic_mechanical_node_alternative', + 'submodel': 'LMECHN1', + 'label': 'dynamic linear mechanical node (velocity and displacement)', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_3', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_5', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_6', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_7', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_8', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_9', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'v1', 'title': 'number of ports at right', 'value': '8', 'units': ''}, + {'name': 'sum', 'title': 'node sum', 'value': '1', 'units': ''}]}, + {'index': 35, + 'alias': 'mass_friction_endstops_19', + 'component_name': 'mass_friction_endstops', + 'library_id': 'meca.mass_friction_endstops', + 'submodel': 'MECMAS21', + 'label': '1D translation mass', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '9.00000000000000e+04', 'units': 'kg'}, + {'name': 'fstick', + 'title': 'stiction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'fcoul', + 'title': 'Coulomb friction force', + 'value': '0.00000000000000e+00', + 'units': 'N'}, + {'name': 'rvisc', + 'title': 'coefficient of viscous friction', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)'}, + {'name': 'wind', + 'title': 'coefficient of windage', + 'value': '0.00000000000000e+00', + 'units': 'N/(m/s)**2'}, + {'name': 'dvel', + 'title': 'stick velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restdvel', + 'title': 'velocity threshold', + 'value': '1.00000000000000e-06', + 'units': 'm/s'}, + {'name': 'restcoeff', + 'title': 'restitution coefficient', + 'value': '6.50000000000000e-01', + 'units': 'null'}, + {'name': 'astrib', + 'title': 'Stribeck constant', + 'value': '1.00000000000000e-03', + 'units': 'm/s'}, + {'name': 'xmin', + 'title': 'lower displacement limit', + 'value': '-7.20000000000000e-01', + 'units': 'm'}, + {'name': 'Kbmin', + 'title': 'lower limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmin', + 'title': 'lower limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmin', + 'title': 'lower limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'xmax', + 'title': 'higher displacement limit', + 'value': '0.00000000000000e+00', + 'units': 'm'}, + {'name': 'Kbmax', + 'title': 'higher limit stiffness', + 'value': '1.00000000000000e+06', + 'units': 'N/mm'}, + {'name': 'Dbmax', + 'title': 'higher limit contact damping coefficient', + 'value': '1.00000000000000e+01', + 'units': 'N/(mm/s)'}, + {'name': 'Pdmax', + 'title': 'higher limit penetration for full damping', + 'value': '1.00000000000000e-01', + 'units': 'mm'}, + {'name': 'theta', + 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', + 'value': '0.00000000000000e+00', + 'units': 'degree'}, + {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, + {'name': 'stoptype', 'title': 'endstop type', 'value': '1', 'units': ''}, + {'name': 'discContactOption', + 'title': 'negative contact force', + 'value': '1', + 'units': ''}, + {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, + {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, + {'index': 36, + 'alias': 'pn_c1_8', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 37, + 'alias': 'pn_plug_16', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 38, + 'alias': 'pn_plug_17', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 39, + 'alias': 'pn_c1_9', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 40, + 'alias': 'pn_plug_18', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 41, + 'alias': 'pn_plug_19', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 42, + 'alias': 'pn_c1_10', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 43, + 'alias': 'pn_plug_20', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 44, + 'alias': 'pn_plug_21', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 45, + 'alias': 'pn_c1_11', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 46, + 'alias': 'pn_plug_22', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 47, + 'alias': 'pn_plug_23', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 48, + 'alias': 'pn_c1_12', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 49, + 'alias': 'pn_plug_24', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 50, + 'alias': 'pn_plug_25', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 51, + 'alias': 'pn_c1_13', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 52, + 'alias': 'pn_plug_26', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 53, + 'alias': 'pn_plug_27', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 54, + 'alias': 'pn_c1_14', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 55, + 'alias': 'pn_plug_28', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 56, + 'alias': 'pn_plug_29', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 57, + 'alias': 'pn_c1_15', + 'component_name': 'pn_c1', + 'library_id': 'pcd.pn_c1', + 'submodel': 'PNCH012', + 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, + {'name': 'port_2', 'type': 'pflow', 'face': '1'}, + {'name': 'port_3', 'type': 'pflow', 'face': '3'}, + {'name': 'port_4', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '1.50000000000000e+03', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '7.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 58, + 'alias': 'pn_plug_30', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], + 'parameters': []}, + {'index': 59, + 'alias': 'pn_plug_31', + 'component_name': 'pn_plug', + 'library_id': 'pn.pn_plug', + 'submodel': 'PNPL01', + 'label': 'zero pneumatic flow source', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 60, + 'alias': 'zeroforcesource_26', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 61, + 'alias': 'zeroforcesource_27', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 62, + 'alias': 'zeroforcesource_28', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 63, + 'alias': 'zeroforcesource_29', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 64, + 'alias': 'zeroforcesource_30', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 65, + 'alias': 'zeroforcesource_31', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 66, + 'alias': 'zeroforcesource_32', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 67, + 'alias': 'zeroforcesource_33', + 'component_name': 'zeroforcesource', + 'library_id': 'meca.zeroforcesource', + 'submodel': 'F000', + 'label': 'zero force source', + 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 68, + 'alias': 'forcecon_1', + 'component_name': 'forcecon', + 'library_id': 'meca.forcecon', + 'submodel': 'FORC', + 'label': 'conversion of signal input into a force in N', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '2'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], + 'parameters': []}, + {'index': 69, + 'alias': 'pn_general_chamber', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 70, + 'alias': 'pn_orifice_18', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 71, + 'alias': 'pn_orifice_19', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 72, + 'alias': 'pn_node3_8', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 73, + 'alias': 'pn_node3_9', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 74, + 'alias': 'pn_general_chamber_2', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 75, + 'alias': 'pn_orifice_20', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 76, + 'alias': 'pn_orifice_21', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 77, + 'alias': 'pn_node3_10', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 78, + 'alias': 'pn_node3_11', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 79, + 'alias': 'pn_general_chamber_4', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 80, + 'alias': 'pn_orifice_22', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 81, + 'alias': 'pn_orifice_23', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 82, + 'alias': 'pn_node3_12', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 83, + 'alias': 'pn_node3_13', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 84, + 'alias': 'pn_general_chamber_5', + 'component_name': 'pn_general_chamber', + 'library_id': 'pn.pn_general_chamber', + 'submodel': 'PNCH023', + 'label': 'simple pneumatic chamber with heat exchange', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, + {'name': 'kth', + 'title': 'thermal exchange coefficient', + 'value': '0.00000000000000e+00', + 'units': 'J/m**2/K/s'}, + {'name': 'sth', + 'title': 'thermal exchange area', + 'value': '1.00000000000000e-01', + 'units': 'm**2'}, + {'name': 'extemp', + 'title': 'external temperature', + 'value': '2.93150000000000e+02', + 'units': 'K'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, + {'index': 85, + 'alias': 'pn_orifice_24', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 86, + 'alias': 'pn_orifice_25', + 'component_name': 'pn_orifice', + 'library_id': 'pn.pn_orifice', + 'submodel': 'PNOR001', + 'label': 'pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '0'}], + 'parameters': [{'name': 'cq', + 'title': 'flow coefficient (Cq)', + 'value': '9.00000000000000e-01', + 'units': 'null'}, + {'name': 'area', + 'title': 'orifice area', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 87, + 'alias': 'pn_node3_14', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 88, + 'alias': 'pn_node3_15', + 'component_name': 'pn_node3', + 'library_id': 'pn.pn_node3', + 'submodel': 'PN3NODE2', + 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}], + 'parameters': []}, + {'index': 89, + 'alias': 'pnnode4_16', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 90, + 'alias': 'pnnode4_17', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 91, + 'alias': 'pnnode4_18', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 92, + 'alias': 'pnnode4_19', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 93, + 'alias': 'pnnode4_20', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 94, + 'alias': 'pnnode4_21', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 95, + 'alias': 'pnnode4_22', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 96, + 'alias': 'pnnode4_23', + 'component_name': 'pnnode4', + 'library_id': 'pn.pnnode4', + 'submodel': 'P4NODE2', + 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', + 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '0'}, + {'name': 'port_4', 'type': 'pflow', 'face': '2'}], + 'parameters': []}, + {'index': 97, + 'alias': 'pn_morifice_11', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 98, + 'alias': 'step_8', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 99, + 'alias': 'pn_morifice_12', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 100, + 'alias': 'step_9', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 101, + 'alias': 'pn_morifice_13', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 102, + 'alias': 'step_10', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 103, + 'alias': 'pn_morifice_14', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 104, + 'alias': 'step_11', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 105, + 'alias': 'forcecon_2', + 'component_name': 'forcecon', + 'library_id': 'meca.forcecon', + 'submodel': 'FORC', + 'label': 'conversion of signal input into a force in N', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}, + {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], + 'parameters': []}, + {'index': 106, + 'alias': 'piecewiselinear', + 'component_name': 'piecewiselinear', + 'library_id': 'sig.piecewiselinear', + 'submodel': 'UD00', + 'label': 'piecewise linear signal source', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'tstart', + 'title': 'time at which duty cycle starts', + 'value': '0.00000000000000e+00', + 'units': 's'}, + {'name': 'start1', + 'title': 'output at start of stage 1', + 'value': '1.00000000000000e+17', + 'units': 'null'}, + {'name': 'end1', + 'title': 'output at end of stage 1', + 'value': '1.00000000000000e+17', + 'units': 'null'}, + {'name': 't1', + 'title': 'duration of stage 1', + 'value': '8.00000000000000e-01', + 'units': 's'}, + {'name': 'start2', + 'title': 'output at start of stage 2', + 'value': '4.90000000000000e+04', + 'units': 'null'}, + {'name': 'end2', + 'title': 'output at end of stage 2', + 'value': '4.90000000000000e+04', + 'units': 'null'}, + {'name': 't2', + 'title': 'duration of stage 2', + 'value': '1.00000000000000e+01', + 'units': 's'}, + {'name': 'start3', + 'title': 'output at start of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end3', + 'title': 'output at end of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't3', + 'title': 'duration of stage 3', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start4', + 'title': 'output at start of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end4', + 'title': 'output at end of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't4', + 'title': 'duration of stage 4', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start5', + 'title': 'output at start of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end5', + 'title': 'output at end of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't5', + 'title': 'duration of stage 5', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start6', + 'title': 'output at start of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end6', + 'title': 'output at end of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't6', + 'title': 'duration of stage 6', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start7', + 'title': 'output at start of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end7', + 'title': 'output at end of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't7', + 'title': 'duration of stage 7', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start8', + 'title': 'output at start of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end8', + 'title': 'output at end of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't8', + 'title': 'duration of stage 8', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'nstages', 'title': 'number of stages', 'value': '2', 'units': ''}, + {'name': 'iscyclic', 'title': 'cyclic', 'value': '1', 'units': ''}]}, + {'index': 107, + 'alias': 'piecewiselinear_1', + 'component_name': 'piecewiselinear', + 'library_id': 'sig.piecewiselinear', + 'submodel': 'UD00', + 'label': 'piecewise linear signal source', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '2'}], + 'parameters': [{'name': 'tstart', + 'title': 'time at which duty cycle starts', + 'value': '0.00000000000000e+00', + 'units': 's'}, + {'name': 'start1', + 'title': 'output at start of stage 1', + 'value': '1.00000000000000e+12', + 'units': 'null'}, + {'name': 'end1', + 'title': 'output at end of stage 1', + 'value': '1.00000000000000e+12', + 'units': 'null'}, + {'name': 't1', + 'title': 'duration of stage 1', + 'value': '8.00000000000000e-01', + 'units': 's'}, + {'name': 'start2', + 'title': 'output at start of stage 2', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end2', + 'title': 'output at end of stage 2', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't2', + 'title': 'duration of stage 2', + 'value': '1.00000000000000e+01', + 'units': 's'}, + {'name': 'start3', + 'title': 'output at start of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end3', + 'title': 'output at end of stage 3', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't3', + 'title': 'duration of stage 3', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start4', + 'title': 'output at start of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end4', + 'title': 'output at end of stage 4', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't4', + 'title': 'duration of stage 4', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start5', + 'title': 'output at start of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end5', + 'title': 'output at end of stage 5', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't5', + 'title': 'duration of stage 5', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start6', + 'title': 'output at start of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end6', + 'title': 'output at end of stage 6', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't6', + 'title': 'duration of stage 6', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start7', + 'title': 'output at start of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end7', + 'title': 'output at end of stage 7', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't7', + 'title': 'duration of stage 7', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'start8', + 'title': 'output at start of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'end8', + 'title': 'output at end of stage 8', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 't8', + 'title': 'duration of stage 8', + 'value': '3.00000000000000e+00', + 'units': 's'}, + {'name': 'nstages', 'title': 'number of stages', 'value': '2', 'units': ''}, + {'name': 'iscyclic', 'title': 'cyclic', 'value': '1', 'units': ''}]}, + {'index': 108, + 'alias': 'pn_morifice_1', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 109, + 'alias': 'step_12', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 110, + 'alias': 'pn_morifice_9', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 111, + 'alias': 'step_13', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 112, + 'alias': 'pn_morifice_10', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 113, + 'alias': 'step_14', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 114, + 'alias': 'pn_morifice_15', + 'component_name': 'pn_morifice', + 'library_id': 'pn.pn_morifice', + 'submodel': 'PNVO001', + 'label': 'modulated pneumatic orifice (constant flow coefficient)', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, + {'name': 'port_2', 'type': 'pflow', 'face': '3'}, + {'name': 'port_3', 'type': 'pflow', 'face': '2'}], + 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, + {'name': 'area0', + 'title': 'orifice area at maximum opening', + 'value': '3.14*10^2/4', + 'units': 'mm**2'}, + {'name': 'Cv', + 'title': 'maximum flow coefficient (Cv)', + 'value': '5.00000000000000e-01', + 'units': 'null'}, + {'name': 'Kv', + 'title': 'maximum flow coefficient (Kv)', + 'value': '4.00000000000000e-01', + 'units': 'null'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'flowset', + 'title': 'flow coefficient setting', + 'value': '1', + 'units': ''}]}, + {'index': 115, + 'alias': 'step_15', + 'component_name': 'step', + 'library_id': 'sig.step', + 'submodel': 'STEP0', + 'label': 'step function', + 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], + 'parameters': [{'name': 'out0', + 'title': 'value before step', + 'value': '0.00000000000000e+00', + 'units': 'null'}, + {'name': 'out1', + 'title': 'value after step', + 'value': '1.00000000000000e+00', + 'units': 'null'}, + {'name': 't0', + 'title': 'step time', + 'value': '4.00000000000000e-02', + 'units': 's'}, + {'name': 'td', + 'title': 'transition duration', + 'value': '1.00000000000000e-01', + 'units': 's'}, + {'name': 'transitionType', + 'title': 'transition type', + 'value': '1', + 'units': ''}]}, + {'index': 116, + 'alias': 'pn_gas_data', + 'component_name': 'pn_gas_data', + 'library_id': 'pn.pn_gas_data', + 'submodel': 'PNGD00', + 'label': 'gas definition for helium (He)', + 'ports': [], + 'parameters': [{'name': 'cp', + 'title': 'constant-pressure specific heat (Cp)', + 'value': '1.00520000000000e+03', + 'units': 'J/kg/K'}, + {'name': 'cv', + 'title': 'constant-volume specific heat (Cv)', + 'value': '7.18000000000000e+02', + 'units': 'J/kg/K'}, + {'name': 'gamma', + 'title': 'specific heat ratio (gamma)', + 'value': '1.40000000000000e+00', + 'units': 'null'}, + {'name': 'rGas', + 'title': 'specific gas constant (r)', + 'value': '2.87200000000000e+02', + 'units': 'J/kg/K'}, + {'name': 'mu', + 'title': 'absolute viscosity (mu)', + 'value': '1.82000000000000e-02', + 'units': 'cP'}, + {'name': 'lam', + 'title': 'thermal conductivity', + 'value': '2.64000000000000e-02', + 'units': 'W/m/K'}, + {'name': 'M', + 'title': 'molar mass', + 'value': '2.89651300000000e+01', + 'units': 'g/mol'}, + {'name': 'operatingTempK', + 'title': 'operating temperature', + 'value': '2.98150000000000e+02', + 'units': 'K'}, + {'name': 'enthForm', + 'title': 'enthalpy of formation', + 'value': '-1.25530000000000e+02', + 'units': 'J/mol'}, + {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, + {'name': 'fluidType', 'title': 'fluid definition', 'value': '12', 'units': ''}, + {'name': 'eosType', 'title': 'properties definition', 'value': '6', 'units': ''}, + {'name': 'gasSetting', + 'title': 'setting properties through', + 'value': '1', + 'units': ''}]}] +CONNECTION_SPECS = [{'index': 0, + 'alias': 'linear_24', + 'source_component': 'pn_brp2_8', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_8', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 1, + 'alias': 'linear_25', + 'source_component': 'elasticendstop_8', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 2, + 'alias': 'linear_26', + 'source_component': 'pn_brp2_9', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_9', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 3, + 'alias': 'linear_27', + 'source_component': 'elasticendstop_9', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_2', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 4, + 'alias': 'linear_28', + 'source_component': 'pn_brp2_10', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_10', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 5, + 'alias': 'linear_29', + 'source_component': 'pn_brp2_11', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_11', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 6, + 'alias': 'linear_30', + 'source_component': 'pn_brp2_12', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_12', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 7, + 'alias': 'linear_31', + 'source_component': 'pn_brp2_13', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_13', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 8, + 'alias': 'linear_32', + 'source_component': 'pn_brp2_14', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_14', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 9, + 'alias': 'linear_33', + 'source_component': 'pn_brp2_15', + 'source_port': 'port_5', + 'target_component': 'elasticendstop_15', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 10, + 'alias': 'linear_34', + 'source_component': 'elasticendstop_10', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 11, + 'alias': 'linear_35', + 'source_component': 'elasticendstop_11', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 12, + 'alias': 'linear_36', + 'source_component': 'elasticendstop_12', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_5', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 13, + 'alias': 'linear_37', + 'source_component': 'elasticendstop_13', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_6', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 14, + 'alias': 'linear_38', + 'source_component': 'elasticendstop_14', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_7', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 15, + 'alias': 'linear_39', + 'source_component': 'elasticendstop_15', + 'source_port': 'port_2', + 'target_component': 'dynamic_mechanical_node_alternative_2', + 'target_port': 'port_8', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 16, + 'alias': 'linear_40', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_8', + 'target_component': 'pn_brp2_8', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 17, + 'alias': 'linear_41', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_7', + 'target_component': 'pn_brp2_9', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 18, + 'alias': 'linear_42', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_6', + 'target_component': 'pn_brp2_10', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 19, + 'alias': 'linear_43', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_5', + 'target_component': 'pn_brp2_11', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 20, + 'alias': 'linear_44', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_4', + 'target_component': 'pn_brp2_12', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 21, + 'alias': 'linear_45', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_13', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 22, + 'alias': 'linear_46', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_2', + 'target_component': 'pn_brp2_14', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 23, + 'alias': 'linear_47', + 'source_component': 'dynamic_mechanical_node_alternative_3', + 'source_port': 'port_1', + 'target_component': 'pn_brp2_15', + 'target_port': 'port_3', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 24, + 'alias': 'pneumatic_56', + 'source_component': 'pn_c1_8', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_8', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 25, + 'alias': 'pneumatic_57', + 'source_component': 'pn_c1_9', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_9', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 26, + 'alias': 'pneumatic_58', + 'source_component': 'pn_c1_10', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_10', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 27, + 'alias': 'pneumatic_59', + 'source_component': 'pn_c1_11', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_11', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 28, + 'alias': 'pneumatic_60', + 'source_component': 'pn_c1_12', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_12', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 29, + 'alias': 'pneumatic_61', + 'source_component': 'pn_c1_13', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_13', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 30, + 'alias': 'pneumatic_62', + 'source_component': 'pn_c1_14', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_14', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 31, + 'alias': 'pneumatic_63', + 'source_component': 'pn_c1_15', + 'source_port': 'port_3', + 'target_component': 'pn_brp2_15', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 32, + 'alias': 'pneumatic_64', + 'source_component': 'pn_morifice_15', + 'source_port': 'port_2', + 'target_component': 'pnnode4_19', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 33, + 'alias': 'pneumatic_65', + 'source_component': 'pnnode4_19', + 'source_port': 'port_2', + 'target_component': 'pn_c1_11', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 34, + 'alias': 'pneumatic_66', + 'source_component': 'pnnode4_18', + 'source_port': 'port_2', + 'target_component': 'pn_c1_10', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 35, + 'alias': 'pneumatic_67', + 'source_component': 'pn_morifice_10', + 'source_port': 'port_2', + 'target_component': 'pnnode4_18', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 36, + 'alias': 'pneumatic_68', + 'source_component': 'pnnode4_17', + 'source_port': 'port_2', + 'target_component': 'pn_c1_9', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 37, + 'alias': 'pneumatic_69', + 'source_component': 'pnnode4_16', + 'source_port': 'port_2', + 'target_component': 'pn_c1_8', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 38, + 'alias': 'pneumatic_70', + 'source_component': 'pn_morifice_12', + 'source_port': 'port_2', + 'target_component': 'pnnode4_20', + 'target_port': 'port_4', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 39, + 'alias': 'pneumatic_71', + 'source_component': 'pnnode4_23', + 'source_port': 'port_2', + 'target_component': 'pn_c1_12', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 40, + 'alias': 'pneumatic_72', + 'source_component': 'pnnode4_20', + 'source_port': 'port_2', + 'target_component': 'pn_c1_13', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 41, + 'alias': 'pneumatic_73', + 'source_component': 'pnnode4_21', + 'source_port': 'port_2', + 'target_component': 'pn_c1_14', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 42, + 'alias': 'pneumatic_74', + 'source_component': 'pnnode4_22', + 'source_port': 'port_2', + 'target_component': 'pn_c1_15', + 'target_port': 'port_1', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 43, + 'alias': 'pneumatic_75', + 'source_component': 'pn_node3_10', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_10', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 44, + 'alias': 'pneumatic_76', + 'source_component': 'pn_node3_11', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_15', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 45, + 'alias': 'pneumatic_77', + 'source_component': 'pn_node3_12', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_11', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 46, + 'alias': 'pneumatic_78', + 'source_component': 'pn_node3_13', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_12', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 47, + 'alias': 'pneumatic_79', + 'source_component': 'pnnode4_23', + 'source_port': 'port_4', + 'target_component': 'pn_morifice_11', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 48, + 'alias': 'pneumatic_80', + 'source_component': 'pnnode4_18', + 'source_port': 'port_1', + 'target_component': 'pnnode4_19', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 49, + 'alias': 'pneumatic_81', + 'source_component': 'pnnode4_23', + 'source_port': 'port_1', + 'target_component': 'pnnode4_20', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 50, + 'alias': 'pneumatic_82', + 'source_component': 'pnnode4_21', + 'source_port': 'port_1', + 'target_component': 'pnnode4_22', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 51, + 'alias': 'pneumatic_83', + 'source_component': 'pnnode4_16', + 'source_port': 'port_1', + 'target_component': 'pnnode4_17', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 52, + 'alias': 'pneumatic_84', + 'source_component': 'pnnode4_20', + 'source_port': 'port_1', + 'target_component': 'pnnode4_21', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 53, + 'alias': 'pneumatic_85', + 'source_component': 'pnnode4_19', + 'source_port': 'port_1', + 'target_component': 'pnnode4_23', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 54, + 'alias': 'pneumatic_86', + 'source_component': 'pnnode4_17', + 'source_port': 'port_1', + 'target_component': 'pnnode4_18', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 55, + 'alias': 'pneumatic_87', + 'source_component': 'pn_node3_8', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_1', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 56, + 'alias': 'pneumatic_88', + 'source_component': 'pn_node3_9', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_9', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 57, + 'alias': 'pneumatic_89', + 'source_component': 'pn_morifice_1', + 'source_port': 'port_2', + 'target_component': 'pnnode4_16', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 58, + 'alias': 'pneumatic_90', + 'source_component': 'pn_morifice_9', + 'source_port': 'port_2', + 'target_component': 'pnnode4_17', + 'target_port': 'port_4', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 59, + 'alias': 'pneumatic_91', + 'source_component': 'pn_node3_14', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_13', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 60, + 'alias': 'pneumatic_92', + 'source_component': 'pn_node3_15', + 'source_port': 'port_2', + 'target_component': 'pn_morifice_14', + 'target_port': 'port_3', + 'submodel': 'PNL0003', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, + {'index': 61, + 'alias': 'pneumatic_93', + 'source_component': 'pn_morifice_13', + 'source_port': 'port_2', + 'target_component': 'pnnode4_21', + 'target_port': 'port_4', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 62, + 'alias': 'pneumatic_94', + 'source_component': 'pn_morifice_14', + 'source_port': 'port_2', + 'target_component': 'pnnode4_22', + 'target_port': 'port_4', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 63, + 'alias': 'pneumatic_95', + 'source_component': 'pnnode4_22', + 'source_port': 'port_1', + 'target_component': 'pnnode4_16', + 'target_port': 'port_3', + 'submodel': 'PNL0002', + 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, + {'index': 64, + 'alias': 'pneumatic_96', + 'source_component': 'pn_node3_8', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_18', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 65, + 'alias': 'pneumatic_97', + 'source_component': 'pn_orifice_19', + 'source_port': 'port_1', + 'target_component': 'pn_node3_9', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 66, + 'alias': 'pneumatic_98', + 'source_component': 'pn_node3_10', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_20', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 67, + 'alias': 'pneumatic_99', + 'source_component': 'pn_orifice_21', + 'source_port': 'port_1', + 'target_component': 'pn_node3_11', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 68, + 'alias': 'pneumatic_100', + 'source_component': 'pn_node3_9', + 'source_port': 'port_1', + 'target_component': 'pn_node3_10', + 'target_port': 'port_3', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 69, + 'alias': 'pneumatic_101', + 'source_component': 'pn_node3_12', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_22', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 70, + 'alias': 'pneumatic_102', + 'source_component': 'pn_orifice_23', + 'source_port': 'port_1', + 'target_component': 'pn_node3_13', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 71, + 'alias': 'pneumatic_103', + 'source_component': 'pn_node3_14', + 'source_port': 'port_1', + 'target_component': 'pn_orifice_24', + 'target_port': 'port_2', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 72, + 'alias': 'pneumatic_104', + 'source_component': 'pn_orifice_25', + 'source_port': 'port_1', + 'target_component': 'pn_node3_15', + 'target_port': 'port_3', + 'submodel': 'PNL0001', + 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, + {'index': 73, + 'alias': 'pneumatic_105', + 'source_component': 'pn_node3_8', + 'source_port': 'port_3', + 'target_component': 'pn_node3_15', + 'target_port': 'port_1', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 74, + 'alias': 'pneumatic_106', + 'source_component': 'pn_node3_13', + 'source_port': 'port_1', + 'target_component': 'pn_node3_14', + 'target_port': 'port_3', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 75, + 'alias': 'pneumatic_107', + 'source_component': 'pn_node3_11', + 'source_port': 'port_1', + 'target_component': 'pn_node3_12', + 'target_port': 'port_3', + 'submodel': 'PNL00R', + 'label': 'Friction submodel of pneumatic pipe (R)'}, + {'index': 76, + 'alias': 'control_8', + 'source_component': 'step_8', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_11', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 77, + 'alias': 'control_9', + 'source_component': 'step_9', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_12', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 78, + 'alias': 'control_10', + 'source_component': 'step_10', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_13', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 79, + 'alias': 'control_11', + 'source_component': 'step_11', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_14', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 80, + 'alias': 'control_12', + 'source_component': 'step_12', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_1', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 81, + 'alias': 'control_13', + 'source_component': 'step_13', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_9', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 82, + 'alias': 'control_14', + 'source_component': 'step_14', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_10', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}, + {'index': 83, + 'alias': 'control_15', + 'source_component': 'step_15', + 'source_port': 'port_1', + 'target_component': 'pn_morifice_15', + 'target_port': 'port_1', + 'submodel': 'DIRECT', + 'label': 'Direct connection'}] + + +@dataclass(frozen=True) +class TestMqlRunConfig: + t_start: float = SIMULATION_SETTINGS["t_start"] + t_stop: float = SIMULATION_SETTINGS["t_stop"] + sample_step: float = SIMULATION_SETTINGS["sample_step"] + + def sample_times(self) -> list[float]: + if self.sample_step <= 0.0: + raise ValueError("sample_step must be positive") + point_count = int(round((self.t_stop - self.t_start) / self.sample_step)) + return [self.t_start + index * self.sample_step for index in range(point_count + 1)] + + +@dataclass(frozen=True) +class TestMqlSnapshot: + model_name: str + component_count: int + connection_count: int + continuous_state_count: int + discrete_state_count: int + global_parameters: dict[str, str] + submodel_counts: dict[str, int] + + +@dataclass(frozen=True) +class TestMqlSimulationResult: + t: list[float] + y: list[list[float]] + series: dict[str, list[float]] + + +@dataclass(frozen=True) +class TestMqlFullStateSnapshot: + pneumatic: object + mechanical: object + mechanical_node_total_force_by_alias: dict[str, float] + + @property + def state_count(self) -> int: + return self.pneumatic_state_count + self.mechanical_state_count + + @property + def pneumatic_state_count(self) -> int: + return 112 + + @property + def mechanical_state_count(self) -> int: + return self.mechanical.state_count + + +@dataclass(frozen=True) +class TestMqlVariableChamberRhsDiagnostic: + chamber_alias: str + piston_alias: str + chamber_pressure_pa: float + chamber_temperature_k: float + chamber_volume_m3: float + chamber_volume_rate_m3_s: float + port_a_mass_flow_kg_s: float + port_b_mass_flow_kg_s: float + mass_derivative_kg_s: float + port_a_energy_flow_w: float + port_b_energy_flow_w: float + boundary_work_w: float + thermal_energy_flow_w: float + energy_derivative_w: float + + +@dataclass(frozen=True) +class TestMqlPnl0001LineRhsDiagnostic: + line_alias: str + chamber_alias: str + line_pressure_pa: float + line_temperature_k: float + chamber_pressure_pa: float + chamber_temperature_k: float + chamber_to_line_flow_kg_s: float + node_to_line_flow_kg_s: float + mass_derivative_kg_s: float + port_1_energy_flow_w: float + port_2_energy_flow_w: float + thermal_energy_flow_w: float + energy_derivative_w: float + + +_PISTON_FORCE_BINDINGS = ( + ("mass_friction_endstops_10", "pn_brp2_8", "p4_port3_remote_primary_chamber"), + ("mass_friction_endstops_11", "pn_brp2_9", "p4_primary_chamber"), + ("mass_friction_endstops_12", "pn_brp2_10", "p4_port1_remote_primary_chamber"), + ("mass_friction_endstops_13", "pn_brp2_11", "p4_port1_far_primary_chamber"), + ("mass_friction_endstops_14", "pn_brp2_12", "p4_port1_next_primary_chamber"), + ("mass_friction_endstops_15", "pn_brp2_13", "p4_bridge_primary_chamber"), + ("mass_friction_endstops_16", "pn_brp2_14", "p4_port3_next_primary_chamber"), + ("mass_friction_endstops_17", "pn_brp2_15", "p4_port3_far_primary_chamber"), +) + +_ELASTIC_ENDSTOP_FORCE_BINDINGS = ( + ("mass_friction_endstops_10", "mass_friction_endstops_18", "elasticendstop_8"), + ("mass_friction_endstops_11", "mass_friction_endstops_18", "elasticendstop_9"), + ("mass_friction_endstops_12", "mass_friction_endstops_18", "elasticendstop_10"), + ("mass_friction_endstops_13", "mass_friction_endstops_18", "elasticendstop_11"), + ("mass_friction_endstops_14", "mass_friction_endstops_18", "elasticendstop_12"), + ("mass_friction_endstops_15", "mass_friction_endstops_18", "elasticendstop_13"), + ("mass_friction_endstops_16", "mass_friction_endstops_18", "elasticendstop_14"), + ("mass_friction_endstops_17", "mass_friction_endstops_18", "elasticendstop_15"), +) + + +def _default_full_state_data_paths() -> tuple[str, ...]: + chamber_paths: list[str] = [] + piston_paths: list[str] = [] + for _mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + chamber_alias = _PNCH012_ALIAS_BY_SNAPSHOT_FIELD[chamber_field] + chamber_paths.extend([f"press@{chamber_alias}", f"vol@{chamber_alias}"]) + piston_paths.extend([f"vol1@{piston_alias}", f"vvol1@{piston_alias}"]) + mass_paths = [ + f"{signal}@mass_friction_endstops_{index}" + for index in range(10, 20) + for signal in ("x1", "v1", "acc1") + ] + return tuple( + [ + *chamber_paths, + *piston_paths, + *mass_paths, + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", + ] + ) + + +_PNCH012_ALIAS_BY_SNAPSHOT_FIELD = { + "p4_port3_remote_primary_chamber": "pn_c1_8", + "p4_primary_chamber": "pn_c1_9", + "p4_port1_remote_primary_chamber": "pn_c1_10", + "p4_port1_far_primary_chamber": "pn_c1_11", + "p4_port1_next_primary_chamber": "pn_c1_12", + "p4_bridge_primary_chamber": "pn_c1_13", + "p4_port3_next_primary_chamber": "pn_c1_14", + "p4_port3_far_primary_chamber": "pn_c1_15", +} + + +_PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS = { + "pn_c1_8": ( + "p4_port3_remote_primary_chamber", + "p4_port3_remote_primary_line", + "p4_port3_remote_chamber_to_line_flow", + ), + "pn_c1_9": ( + "p4_primary_chamber", + "p4_primary_line", + "p4_primary_chamber_to_line_flow", + ), + "pn_c1_10": ( + "p4_port1_remote_primary_chamber", + "p4_port1_remote_primary_line", + "p4_port1_remote_chamber_to_line_flow", + ), + "pn_c1_11": ( + "p4_port1_far_primary_chamber", + "p4_port1_far_primary_line", + "p4_port1_far_chamber_to_line_flow", + ), + "pn_c1_12": ( + "p4_port1_next_primary_chamber", + "p4_port1_next_primary_line", + "p4_port1_next_chamber_to_line_flow", + ), + "pn_c1_13": ( + "p4_bridge_primary_chamber", + "p4_bridge_primary_line", + "p4_bridge_chamber_to_line_flow", + ), + "pn_c1_14": ( + "p4_port3_next_primary_chamber", + "p4_port3_next_primary_line", + "p4_port3_next_chamber_to_line_flow", + ), + "pn_c1_15": ( + "p4_port3_far_primary_chamber", + "p4_port3_far_primary_line", + "p4_port3_far_chamber_to_line_flow", + ), +} + + +_PNL0001_LINE_DIAGNOSTIC_FIELDS = { + "pneumatic_69": ( + "p4_port3_remote_primary_line", + "p4_port3_remote_primary_chamber", + "p4_port3_remote_chamber_to_line_flow", + "p4_port3_remote_node_to_primary_line_flow", + ), +} + +_VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS = { + "pn_morifice_1": "p4_port3_remote_orifice_to_node_flow", +} + + +class TestMqlFullStateClosure: + def __init__( + self, + *, + pneumatic_closure: object, + mechanical_closure: object, + pneumatic_assembly: object, + ) -> None: + self.pneumatic_closure = pneumatic_closure + self.mechanical_closure = mechanical_closure + self.pneumatic_assembly = pneumatic_assembly + self.pneumatic_state_count = len(pneumatic_closure.initial_state_vector()) + self.mechanical_state_count = len(mechanical_closure.initial_state_vector()) + + @property + def state_count(self) -> int: + return self.pneumatic_state_count + self.mechanical_state_count + + def initial_state_vector(self) -> list[float]: + return [ + *self.pneumatic_closure.initial_state_vector(), + *self.mechanical_closure.initial_state_vector(), + ] + + def _split_state(self, state_vector: list[float]) -> tuple[list[float], list[float]]: + if len(state_vector) != self.state_count: + raise ValueError("full test_mql state vector requires 132 values") + return ( + state_vector[: self.pneumatic_state_count], + state_vector[self.pneumatic_state_count :], + ) + + def snapshot(self, state_vector: list[float] | None = None) -> TestMqlFullStateSnapshot: + return self.snapshot_at(0.0, state_vector) + + def snapshot_at( + self, + time_s: float, + state_vector: list[float] | None = None, + ) -> TestMqlFullStateSnapshot: + values = self.initial_state_vector() if state_vector is None else list(state_vector) + pneumatic_state, mechanical_state = self._split_state(values) + mechanical_snapshot = self.mechanical_closure.snapshot(mechanical_state) + self._sync_variable_chamber_kinematics(mechanical_snapshot) + self.pneumatic_assembly.set_variable_orifice_openings(time_s) + pneumatic_snapshot = self.pneumatic_closure.snapshot(pneumatic_state) + return TestMqlFullStateSnapshot( + pneumatic=pneumatic_snapshot, + mechanical=mechanical_snapshot, + mechanical_node_total_force_by_alias=( + self._mechanical_node_total_force_by_alias( + pneumatic_snapshot, + mechanical_snapshot, + ) + ), + ) + + def rhs(self, state_vector: list[float]) -> list[float]: + return self.rhs_at(0.0, state_vector) + + def rhs_at(self, time_s: float, state_vector: list[float]) -> list[float]: + pneumatic_state, mechanical_state = self._split_state(list(state_vector)) + mechanical_snapshot = self.mechanical_closure.snapshot(mechanical_state) + self._sync_variable_chamber_kinematics(mechanical_snapshot) + self.pneumatic_assembly.set_variable_orifice_openings(time_s) + pneumatic_snapshot = self.pneumatic_closure.snapshot(pneumatic_state) + return [ + *self.pneumatic_closure.rhs(pneumatic_state), + *self.mechanical_closure.rhs( + mechanical_state, + force_by_mass_alias=self._force_by_mass_alias( + time_s, + pneumatic_snapshot, + mechanical_snapshot, + ), + constrained_mass_aliases={ + "mass_friction_endstops_18", + "mass_friction_endstops_19", + }, + ), + ] + + def _sync_variable_chamber_kinematics(self, mechanical_snapshot: object) -> None: + for _mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + piston = self.mechanical_closure.assembly.pistons[piston_alias] + kinematics = mechanical_snapshot.piston_kinematics_by_alias[piston_alias] + chamber = getattr(self.pneumatic_closure.components, chamber_field) + chamber.set_external_volume_m3( + piston.geometry().chamber_volume_m3( + kinematics.port_3_displacement_m, + kinematics.port_2_displacement_m, + ), + piston.geometry().chamber_volume_rate_m3_s( + kinematics.port_3_velocity_m_s, + kinematics.port_2_velocity_m_s, + ), + ) + + def variable_chamber_rhs_diagnostic( + self, + *, + chamber_alias: str, + state_vector: list[float], + time_s: float = 0.0, + ) -> TestMqlVariableChamberRhsDiagnostic: + if chamber_alias not in _PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS: + raise KeyError(chamber_alias) + chamber_field, line_field, flow_field = _PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS[ + chamber_alias + ] + piston_alias = next( + piston_alias + for _mass_alias, piston_alias, bound_chamber_field in _PISTON_FORCE_BINDINGS + if bound_chamber_field == chamber_field + ) + snapshot = self.snapshot_at(time_s, state_vector) + chamber = getattr(self.pneumatic_closure.components, chamber_field) + chamber_properties = getattr(snapshot.pneumatic, chamber_field) + line_properties = getattr(snapshot.pneumatic, line_field) + chamber_to_line_flow = getattr(snapshot.pneumatic, flow_field) + port_a_mass_flow = -chamber_to_line_flow + port_b_mass_flow = 0.0 + port_a_inlet_h = chamber.connection_inlet_enthalpy( + port_m_flow=port_a_mass_flow, + connected_h=line_properties.h, + internal_h=chamber_properties.h, + ) + port_b_inlet_h = chamber.connection_inlet_enthalpy( + port_m_flow=port_b_mass_flow, + connected_h=chamber_properties.h, + internal_h=chamber_properties.h, + ) + port_a_energy_flow = port_a_mass_flow * port_a_inlet_h + port_b_energy_flow = port_b_mass_flow * port_b_inlet_h + boundary_work = -chamber_properties.p * chamber.volume_rate_m3_s() + thermal_energy_flow = chamber.thermal_energy_flow_w( + chamber_properties.T + ) + return TestMqlVariableChamberRhsDiagnostic( + chamber_alias=chamber_alias, + piston_alias=piston_alias, + chamber_pressure_pa=chamber_properties.p, + chamber_temperature_k=chamber_properties.T, + chamber_volume_m3=chamber.volume, + chamber_volume_rate_m3_s=chamber.volume_rate_m3_s(), + port_a_mass_flow_kg_s=port_a_mass_flow, + port_b_mass_flow_kg_s=port_b_mass_flow, + mass_derivative_kg_s=port_a_mass_flow + port_b_mass_flow, + port_a_energy_flow_w=port_a_energy_flow, + port_b_energy_flow_w=port_b_energy_flow, + boundary_work_w=boundary_work, + thermal_energy_flow_w=thermal_energy_flow, + energy_derivative_w=( + port_a_energy_flow + + port_b_energy_flow + + boundary_work + + thermal_energy_flow + ), + ) + + def pnl0001_line_rhs_diagnostic( + self, + *, + line_alias: str, + state_vector: list[float], + time_s: float = 0.0, + ) -> TestMqlPnl0001LineRhsDiagnostic: + if line_alias not in _PNL0001_LINE_DIAGNOSTIC_FIELDS: + raise KeyError(line_alias) + ( + line_field, + chamber_field, + chamber_flow_field, + node_flow_field, + ) = _PNL0001_LINE_DIAGNOSTIC_FIELDS[line_alias] + chamber_alias = _PNCH012_ALIAS_BY_SNAPSHOT_FIELD[chamber_field] + snapshot = self.snapshot_at(time_s, state_vector) + line = getattr(self.pneumatic_closure.components, line_field) + line_properties = getattr(snapshot.pneumatic, line_field) + chamber_properties = getattr(snapshot.pneumatic, chamber_field) + chamber_to_line_flow = getattr(snapshot.pneumatic, chamber_flow_field) + node_to_line_flow = getattr(snapshot.pneumatic, node_flow_field) + p4_balance = snapshot.pneumatic.p4_port3_remote_balance + p4_port_2_mass_flow_kg_s = p4_balance.port_2_mass_flow_g_s * 1.0e-3 + node_connected_h = ( + line_properties.h + if abs(p4_port_2_mass_flow_kg_s) <= 1.0e-12 + else p4_balance.port_2_enthalpy_flow_w / p4_port_2_mass_flow_kg_s + ) + line_derivative = line.derivatives_from_transport_enthalpy_connections( + port_1_m_flow=chamber_to_line_flow, + connected_h_1=chamber_properties.h, + port_2_m_flow=node_to_line_flow, + connected_h_2=node_connected_h, + ) + port_1_inlet_h = ( + chamber_properties.h if chamber_to_line_flow > 0.0 else line_properties.h + ) + port_2_inlet_h = ( + node_connected_h if node_to_line_flow > 0.0 else line_properties.h + ) + thermal_energy_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - line_properties.T) + ) + return TestMqlPnl0001LineRhsDiagnostic( + line_alias=line_alias, + chamber_alias=chamber_alias, + line_pressure_pa=line_properties.p, + line_temperature_k=line_properties.T, + chamber_pressure_pa=chamber_properties.p, + chamber_temperature_k=chamber_properties.T, + chamber_to_line_flow_kg_s=chamber_to_line_flow, + node_to_line_flow_kg_s=node_to_line_flow, + mass_derivative_kg_s=line_derivative.m, + port_1_energy_flow_w=chamber_to_line_flow * port_1_inlet_h, + port_2_energy_flow_w=node_to_line_flow * port_2_inlet_h, + thermal_energy_flow_w=thermal_energy_flow, + energy_derivative_w=line_derivative.U, + ) + + def data_path_values( + self, + *, + time_s: float, + state_vector: list[float], + data_paths: tuple[str, ...] | list[str] | None = None, + ) -> dict[str, float]: + from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa + + selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths() + snapshot = self.snapshot_at(time_s, state_vector) + rhs = self.rhs_at(time_s, state_vector) + chamber_properties_by_alias = {} + chamber_by_alias = {} + for _mass_alias, _piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + chamber = getattr(self.pneumatic_closure.components, chamber_field) + chamber_by_alias[chamber.name] = chamber + chamber_properties_by_alias[chamber.name] = getattr(snapshot.pneumatic, chamber_field) + + state_by_alias = {state.alias: state for state in snapshot.mechanical.states} + acceleration_by_mass_alias = { + alias: rhs[self.pneumatic_state_count + 2 * index] + for index, alias in enumerate(self.mechanical_closure.mass_aliases) + } + values_by_data_path = {} + for data_path in selected_paths: + signal, alias = self._split_data_path(data_path) + if alias in _PNL0001_LINE_DIAGNOSTIC_FIELDS: + ( + line_field, + _chamber_field, + chamber_flow_field, + _node_flow_field, + ) = _PNL0001_LINE_DIAGNOSTIC_FIELDS[alias] + if signal == "dm1": + canonical_flow_kg_s = getattr(snapshot.pneumatic, chamber_flow_field) + values_by_data_path[data_path] = -canonical_flow_kg_s * 1.0e3 + elif signal == "p2": + line_properties = getattr(snapshot.pneumatic, line_field) + values_by_data_path[data_path] = pressure_to_amesim_gauge_pa( + line_properties.p + ) + elif signal == "t2": + line_properties = getattr(snapshot.pneumatic, line_field) + values_by_data_path[data_path] = line_properties.T + elif signal == "mgas": + line = getattr(self.pneumatic_closure.components, line_field) + values_by_data_path[data_path] = line.gas_mass_g() + elif signal in {"re", "v", "ff"}: + line = getattr(self.pneumatic_closure.components, line_field) + line_properties = getattr(snapshot.pneumatic, line_field) + canonical_flow_kg_s = getattr(snapshot.pneumatic, chamber_flow_field) + diagnostics = line.diagnostics( + mass_flow_kg_s=-canonical_flow_kg_s, + temperature_k=line_properties.T, + ) + if signal == "re": + values_by_data_path[data_path] = diagnostics.reynolds_number + elif signal == "v": + values_by_data_path[data_path] = diagnostics.gas_velocity_m_s + else: + values_by_data_path[data_path] = diagnostics.friction_factor + else: + raise KeyError(data_path) + elif alias in self.pneumatic_assembly.variable_orifices: + orifice = self.pneumatic_assembly.variable_orifices[alias] + if signal == "xv": + values_by_data_path[data_path] = orifice.opening + elif ( + signal == "dm2" + and alias in _VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS + ): + flow_kg_s = getattr( + snapshot.pneumatic, + _VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS[alias], + ) + values_by_data_path[data_path] = flow_kg_s * 1.0e3 + else: + raise KeyError(data_path) + elif alias in chamber_properties_by_alias: + chamber_properties = chamber_properties_by_alias[alias] + chamber = chamber_by_alias[alias] + if signal == "press": + values_by_data_path[data_path] = pressure_to_amesim_gauge_pa( + chamber_properties.p + ) + elif signal == "temp": + values_by_data_path[data_path] = chamber_properties.T + elif signal == "vol": + values_by_data_path[data_path] = chamber.volume_cm3() + elif signal in {"mgas", "mgas1"}: + values_by_data_path[data_path] = chamber.gas_mass_g() + else: + raise KeyError(data_path) + elif alias in self.mechanical_closure.assembly.pistons: + piston = self.mechanical_closure.assembly.pistons[alias] + kinematics = snapshot.mechanical.piston_kinematics_by_alias[alias] + geometry = piston.geometry() + if signal == "vol1": + values_by_data_path[data_path] = geometry.chamber_volume_cm3( + kinematics.port_3_displacement_m, + kinematics.port_2_displacement_m, + ) + elif signal == "vvol1": + values_by_data_path[data_path] = geometry.chamber_volume_rate_l_min( + kinematics.port_3_velocity_m_s, + kinematics.port_2_velocity_m_s, + ) + elif signal == "length": + values_by_data_path[data_path] = geometry.chamber_length_mm( + kinematics.port_3_displacement_m, + kinematics.port_2_displacement_m, + ) + elif signal == "x4": + values_by_data_path[data_path] = kinematics.port_3_displacement_m + elif signal == "x5": + values_by_data_path[data_path] = kinematics.port_2_displacement_m + elif signal == "v4": + values_by_data_path[data_path] = kinematics.port_3_velocity_m_s + elif signal == "v5": + values_by_data_path[data_path] = kinematics.port_2_velocity_m_s + else: + raise KeyError(data_path) + elif alias in state_by_alias: + mass_state = state_by_alias[alias] + if signal == "x1": + values_by_data_path[data_path] = mass_state.displacement_m + elif signal == "v1": + values_by_data_path[data_path] = mass_state.velocity_m_s + elif signal == "acc1": + values_by_data_path[data_path] = acceleration_by_mass_alias[alias] + else: + raise KeyError(data_path) + else: + raise KeyError(data_path) + return values_by_data_path + + def data_path_series( + self, + *, + times: tuple[float, ...] | list[float], + state_rows: list[list[float]], + data_paths: tuple[str, ...] | list[str] | None = None, + ) -> dict[str, list[float]]: + selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths() + series = {data_path: [] for data_path in selected_paths} + for sample_index, time_value in enumerate(times): + state_vector = [row[sample_index] for row in state_rows] + values = self.data_path_values( + time_s=float(time_value), + state_vector=state_vector, + data_paths=selected_paths, + ) + for data_path in selected_paths: + series[data_path].append(values[data_path]) + return series + + @staticmethod + def _split_data_path(data_path: str) -> tuple[str, str]: + if "@" not in data_path: + raise KeyError(data_path) + signal, alias = data_path.split("@", 1) + return signal, alias + + def _force_by_mass_alias( + self, + time_s: float, + pneumatic_snapshot: object, + mechanical_snapshot: object, + ) -> dict[str, float]: + force_by_mass_alias = self._piston_force_by_mass_alias(pneumatic_snapshot) + elastic_force_by_mass_alias = self._elastic_endstop_force_by_mass_alias( + mechanical_snapshot + ) + for mass_alias, contact_force in elastic_force_by_mass_alias.items(): + force_by_mass_alias[mass_alias] = ( + force_by_mass_alias.get(mass_alias, 0.0) - contact_force + ) + force_by_mass_alias.update( + self._large_mass_force_by_alias( + time_s, + pneumatic_snapshot, + mechanical_snapshot, + ) + ) + return force_by_mass_alias + + def _mechanical_node_total_force_by_alias( + self, + pneumatic_snapshot: object, + mechanical_snapshot: object, + ) -> dict[str, float]: + return { + "dynamic_mechanical_node_alternative_2": sum( + self._elastic_endstop_force_by_mass_alias(mechanical_snapshot).values() + ), + "dynamic_mechanical_node_alternative_3": sum( + self._piston_force_by_mass_alias(pneumatic_snapshot).values() + ), + } + + def _large_mass_force_by_alias( + self, + time_s: float, + pneumatic_snapshot: object, + mechanical_snapshot: object, + ) -> dict[str, float]: + node_force = self._mechanical_node_total_force_by_alias( + pneumatic_snapshot, + mechanical_snapshot, + ) + forcecon_1 = self.mechanical_closure.assembly.force_connectors["forcecon_1"] + forcecon_2 = self.mechanical_closure.assembly.force_connectors["forcecon_2"] + signals = self.mechanical_closure.assembly.piecewise_signals + return { + "mass_friction_endstops_18": ( + node_force["dynamic_mechanical_node_alternative_2"] + - forcecon_2.force_at(time_s, signals) + ), + "mass_friction_endstops_19": ( + forcecon_1.force_at(time_s, signals) + - node_force["dynamic_mechanical_node_alternative_3"] + ), + } + + def _piston_force_by_mass_alias(self, pneumatic_snapshot: object) -> dict[str, float]: + from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa + + force_by_mass_alias: dict[str, float] = {} + for mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: + chamber = getattr(pneumatic_snapshot, chamber_field) + piston = self.mechanical_closure.assembly.pistons[piston_alias] + force_by_mass_alias[mass_alias] = ( + pressure_to_amesim_gauge_pa(chamber.p) * piston.annulus_area_m2 + ) + return force_by_mass_alias + + def _elastic_endstop_force_by_mass_alias( + self, + mechanical_snapshot: object, + ) -> dict[str, float]: + state_by_alias = {state.alias: state for state in mechanical_snapshot.states} + force_by_mass_alias: dict[str, float] = {} + for mass_alias, support_mass_alias, endstop_alias in _ELASTIC_ENDSTOP_FORCE_BINDINGS: + mass_state = state_by_alias[mass_alias] + support_state = state_by_alias[support_mass_alias] + endstop = self.mechanical_closure.assembly.elastic_endstops[ + endstop_alias + ].endstop() + gap_mm = (support_state.displacement_m - mass_state.displacement_m) * 1.0e3 + penetration_velocity_m_s = ( + mass_state.velocity_m_s - support_state.velocity_m_s + ) + force_by_mass_alias[mass_alias] = endstop.contact_force( + gap_mm, + penetration_velocity_m_s, + ) + return force_by_mass_alias + + +class AmesimStructuralComponent(Component): + def __init__(self, spec: dict[str, object]) -> None: + super().__init__(name=str(spec["alias"])) + self.spec = spec + self.submodel = str(spec["submodel"]) + self.component_name = str(spec["component_name"]) + self.parameters = { + str(parameter["name"]): parameter + for parameter in spec.get("parameters", []) + } + + @property + def port_names(self) -> tuple[str, ...]: + return tuple(str(port["name"]) for port in self.spec.get("ports", [])) + + +class TestMqlSystem: + """Structural Python port scaffold for AMESim model ``test_mql``.""" + + def __init__(self, archive_path: Path | None = None) -> None: + self.archive_path = archive_path or Path(__file__).resolve().parents[2] / AMESIM_ARCHIVE_RELATIVE_PATH + self.network = SimulationNetwork(name=MODEL_NAME) + self.pnl0001_assembly = self._build_pnl0001_assembly() + self.pnl0002_assembly = self._build_pnl0002_assembly() + self.pnl0003_assembly = self._build_pnl0003_assembly() + self.pnl00r_assembly = self._build_pnl00r_assembly() + self.node3_assembly = self._build_node3_assembly() + self.node4_assembly = self._build_node4_assembly() + self.mechanical_assembly = self._build_mechanical_assembly() + self.pneumatic_assembly = self._build_pneumatic_assembly() + pneumatic_components = self._pneumatic_components_by_alias() + for spec in COMPONENT_SPECS: + alias = str(spec["alias"]) + self.network.add_component( + pneumatic_components.get(alias, AmesimStructuralComponent(spec)) + ) + for spec in CONNECTION_SPECS: + self.network.connect( + str(spec["source_component"]), + str(spec["source_port"]), + str(spec["target_component"]), + str(spec["target_port"]), + ) + + @staticmethod + def _build_pneumatic_assembly(): + # Local import avoids a module cycle: test_mql_config reads constants from this module. + from PythonModels.systems.test_mql_pneumatic import build_test_mql_pneumatic_assembly + + return build_test_mql_pneumatic_assembly() + + @staticmethod + def _build_mechanical_assembly(): + # Local import avoids a module cycle: test_mql_config reads constants from this module. + from PythonModels.systems.test_mql_mechanical import build_test_mql_mechanical_assembly + + return build_test_mql_mechanical_assembly() + + def _build_pnl0001_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl0001_assembly, + ) + + return build_test_mql_pnl0001_assembly(self.archive_path) + + def _build_pnl0002_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl0002_assembly, + ) + + return build_test_mql_pnl0002_assembly(self.archive_path) + + def _build_pnl0003_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl0003_assembly, + ) + + return build_test_mql_pnl0003_assembly(self.archive_path) + + def _build_pnl00r_assembly(self): + from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl00r_assembly, + ) + + return build_test_mql_pnl00r_assembly(self.archive_path) + + @staticmethod + def _build_node3_assembly(): + from PythonModels.systems.test_mql_nodes import build_test_mql_node3_assembly + + return build_test_mql_node3_assembly() + + @staticmethod + def _build_node4_assembly(): + from PythonModels.systems.test_mql_nodes import build_test_mql_node4_assembly + + return build_test_mql_node4_assembly() + + def _pneumatic_components_by_alias(self) -> dict[str, Component]: + return { + **self.pneumatic_assembly.fixed_chambers, + **self.pneumatic_assembly.variable_chambers, + **self.pneumatic_assembly.fixed_orifices, + **self.pneumatic_assembly.variable_orifices, + } + + @property + def typed_pneumatic_component_count(self) -> int: + return len(self._pneumatic_components_by_alias()) + + @property + def typed_pnl0001_line_count(self) -> int: + return len(self.pnl0001_assembly.lines) + + @property + def typed_pnl0002_line_count(self) -> int: + return len(self.pnl0002_assembly.lines) + + @property + def typed_pnl0003_line_count(self) -> int: + return len(self.pnl0003_assembly.lines) + + @property + def typed_pnl00r_line_count(self) -> int: + return len(self.pnl00r_assembly.lines) + + @property + def typed_node3_count(self) -> int: + return len(self.node3_assembly) + + @property + def typed_node4_count(self) -> int: + return len(self.node4_assembly) + + def _pnl0001_spec_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl0001_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return spec + if spec.target_component == node_alias and spec.target_port == port_name: + return spec + raise KeyError(f"No PNL0001 line attached to {node_alias}.{port_name}") + + def _pnl0001_line_for_node_port(self, node_alias: str, port_name: str): + spec = self._pnl0001_spec_for_node_port(node_alias, port_name) + return self.pnl0001_assembly.lines[spec.alias] + + def _pnl0002_connection_for_node_port(self, node_alias: str, port_name: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodePortConnection + + spec = self._pnl0002_spec_for_node_port(node_alias, port_name) + if spec.source_component == node_alias and spec.source_port == port_name: + remote_node_alias = spec.target_component + remote_port = spec.target_port + elif spec.target_component == node_alias and spec.target_port == port_name: + remote_node_alias = spec.source_component + remote_port = spec.source_port + else: + raise KeyError(f"No PNL0002 line attached to {node_alias}.{port_name}") + if remote_node_alias not in self.node4_assembly: + raise KeyError( + f"PNL0002 line {spec.alias} remote endpoint is not a P4 node: " + f"{remote_node_alias}.{remote_port}" + ) + return TestMqlP4NodePortConnection( + line_alias=spec.alias, + local_node_alias=node_alias, + local_port=port_name, + remote_node_alias=remote_node_alias, + remote_port=remote_port, + ) + + def _pnl0002_spec_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl0002_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return spec + if spec.target_component == node_alias and spec.target_port == port_name: + return spec + raise KeyError(f"No PNL0002 line attached to {node_alias}.{port_name}") + + def _pnl0002_line_for_node_port(self, node_alias: str, port_name: str): + spec = self._pnl0002_spec_for_node_port(node_alias, port_name) + return self.pnl0002_assembly.lines[spec.alias] + + def _p4_primary_connection_for_node(self, node_alias: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodePrimaryConnection + + spec = self._pnl0001_spec_for_node_port(node_alias, "port_2") + if spec.source_component == node_alias and spec.source_port == "port_2": + chamber_alias = spec.target_component + chamber_port = spec.target_port + elif spec.target_component == node_alias and spec.target_port == "port_2": + chamber_alias = spec.source_component + chamber_port = spec.source_port + else: + raise KeyError(f"No PNL0001 primary line attached to {node_alias}.port_2") + return TestMqlP4NodePrimaryConnection( + line_alias=spec.alias, + node_alias=node_alias, + node_port="port_2", + chamber_alias=chamber_alias, + chamber_port=chamber_port, + ) + + def _p4_port4_orifice_connection_for_node(self, node_alias: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodeOrificeConnection + + for spec in CONNECTION_SPECS: + if spec["submodel"] != "DIRECT": + continue + if spec["target_component"] == node_alias and spec["target_port"] == "port_4": + orifice_alias = str(spec["source_component"]) + if orifice_alias in self.pneumatic_assembly.variable_orifices: + return TestMqlP4NodeOrificeConnection( + orifice_alias=orifice_alias, + node_alias=node_alias, + node_port="port_4", + direct_line_alias=str(spec["alias"]), + ) + if spec["source_component"] == node_alias and spec["source_port"] == "port_4": + orifice_alias = str(spec["target_component"]) + if orifice_alias in self.pneumatic_assembly.variable_orifices: + return TestMqlP4NodeOrificeConnection( + orifice_alias=orifice_alias, + node_alias=node_alias, + node_port="port_4", + direct_line_alias=str(spec["alias"]), + ) + raise KeyError(f"No PNVO orifice attached to {node_alias}.port_4") + + def p4_node_neighborhood(self, node_alias: str): + from PythonModels.systems.test_mql_nodes import TestMqlP4NodeNeighborhood + + if node_alias not in self.node4_assembly: + raise KeyError(f"Unknown P4 node: {node_alias}") + return TestMqlP4NodeNeighborhood( + node_alias=node_alias, + primary=self._p4_primary_connection_for_node(node_alias), + port_1=self._pnl0002_connection_for_node_port(node_alias, "port_1"), + port_3=self._pnl0002_connection_for_node_port(node_alias, "port_3"), + port_4=self._p4_port4_orifice_connection_for_node(node_alias), + ) + + def _p4_node_alias_for_orifice_port_2(self, orifice_alias: str) -> str: + for spec in CONNECTION_SPECS: + if spec["submodel"] != "DIRECT": + continue + if ( + spec["source_component"] == orifice_alias + and spec["source_port"] == "port_2" + and str(spec["target_component"]) in self.node4_assembly + ): + return str(spec["target_component"]) + if ( + spec["target_component"] == orifice_alias + and spec["target_port"] == "port_2" + and str(spec["source_component"]) in self.node4_assembly + ): + return str(spec["source_component"]) + raise KeyError(f"No P4 node attached to {orifice_alias}.port_2") + + def _pnl0003_spec_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl0003_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return spec + if spec.target_component == node_alias and spec.target_port == port_name: + return spec + raise KeyError(f"No PNL0003 line attached to {node_alias}.{port_name}") + + def _pnl0003_line_for_node_port(self, node_alias: str, port_name: str): + spec = self._pnl0003_spec_for_node_port(node_alias, port_name) + return self.pnl0003_assembly.lines[spec.alias] + + def _pnl00r_line_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl00r_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return self.pnl00r_assembly.lines[spec.alias] + if spec.target_component == node_alias and spec.target_port == port_name: + return self.pnl00r_assembly.lines[spec.alias] + raise KeyError(f"No PNL00R line attached to {node_alias}.{port_name}") + + def pneumatic_state_vector(self) -> list[float]: + return self.network.initial_state_vector() + + def mechanical_mass_closure(self): + from PythonModels.systems.test_mql_mechanical import TestMqlMechanicalMassClosure + + return TestMqlMechanicalMassClosure(self.mechanical_assembly) + + def mechanical_state_vector(self) -> list[float]: + return self.mechanical_mass_closure().initial_state_vector() + + def simulate_mechanical_masses( + self, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.mechanical_mass_closure() + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=config or SolveIVPConfig(), + t_eval=t_eval, + ) + + def full_state_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + use_pneumatic_96_reference_rhs: bool = False, + use_pneumatic_69_reference_rhs: bool = False, + ) -> TestMqlFullStateClosure: + return TestMqlFullStateClosure( + pneumatic_closure=self.pn3_p4_node_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + use_inlet_line_reference_rhs=use_pneumatic_96_reference_rhs, + use_p4_port3_remote_primary_reference_rhs=( + use_pneumatic_69_reference_rhs + ), + ), + mechanical_closure=self.mechanical_mass_closure(), + pneumatic_assembly=self.pneumatic_assembly, + ) + + def simulate_full_state_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + use_pneumatic_96_reference_rhs: bool = False, + use_pneumatic_69_reference_rhs: bool = False, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.full_state_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + use_pneumatic_96_reference_rhs=use_pneumatic_96_reference_rhs, + use_pneumatic_69_reference_rhs=use_pneumatic_69_reference_rhs, + ) + return integrate_ode( + rhs=lambda t, state: closure.rhs_at(t, state), + initial_state=closure.initial_state_vector(), + config=config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5), + t_eval=t_eval, + ) + + def simulate_full_state_series_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + data_paths: tuple[str, ...] | list[str] | None = None, + use_pneumatic_96_reference_rhs: bool = False, + use_pneumatic_69_reference_rhs: bool = False, + ) -> TestMqlSimulationResult: + closure = self.full_state_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + use_pneumatic_96_reference_rhs=use_pneumatic_96_reference_rhs, + use_pneumatic_69_reference_rhs=use_pneumatic_69_reference_rhs, + ) + solution = integrate_ode( + rhs=lambda t, state: closure.rhs_at(t, state), + initial_state=closure.initial_state_vector(), + config=config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5), + t_eval=t_eval, + ) + times = [float(value) for value in solution.t] + state_rows = [list(row) for row in solution.y] + series = { + "time": times, + **closure.data_path_series( + times=times, + state_rows=state_rows, + data_paths=data_paths, + ), + } + return TestMqlSimulationResult(t=times, y=state_rows, series=series) + + @staticmethod + def pneumatic_branch_spec( + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + source: str = "manual", + ): + from PythonModels.systems.test_mql_closure import TestMqlPneumaticBranchSpec + + return TestMqlPneumaticBranchSpec( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + source=source, + ) + + def discover_pneumatic_branch_topology(self): + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticTopologyCandidate, + TestMqlPneumaticTopologyDiscovery, + ) + from PythonModels.systems.test_mql_pneumatic_topology import ( + discover_fixed_chamber_segments, + ) + from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology + + topology = load_test_mql_cir_topology(self.archive_path) + chamber_segment_specs = discover_fixed_chamber_segments( + topology, + COMPONENT_SPECS, + CONNECTION_SPECS, + ) + + typed_aliases = set(self._pneumatic_components_by_alias()) + component_submodels = { + str(component["alias"]): str(component["submodel"]) + for component in COMPONENT_SPECS + } + blocked_candidates = [] + for spec in CONNECTION_SPECS: + source_component = str(spec["source_component"]) + target_component = str(spec["target_component"]) + source_is_typed = source_component in typed_aliases + target_is_typed = target_component in typed_aliases + if not (source_is_typed or target_is_typed): + continue + + line_submodel = str(spec["submodel"]) + endpoint_submodels = { + component_submodels.get(source_component, ""), + component_submodels.get(target_component, ""), + } + if "PNRP17" in endpoint_submodels: + category = "piston-boundary" + reason = "requires pneumatic piston and mechanical coupling interpretation" + elif "STEP0" in endpoint_submodels: + category = "control-boundary" + reason = "requires modulated-orifice control signal evaluation" + elif endpoint_submodels & {"PN3NODE2", "P4NODE2"}: + category = "node-line-boundary" + reason = "requires AMESim node/line equations before full-network closure" + elif source_is_typed and target_is_typed: + category = "typed-direct" + reason = "direct typed connection is not a complete simulated subnetwork" + elif line_submodel != "DIRECT": + category = "line-boundary" + reason = "requires AMESim line equations before full-network closure" + else: + category = "component-boundary" + reason = "requires AMESim boundary component interpretation" + + blocked_candidates.append( + TestMqlPneumaticTopologyCandidate( + connection_alias=str(spec["alias"]), + line_submodel=line_submodel, + source_component=source_component, + source_port=str(spec["source_port"]), + target_component=target_component, + target_port=str(spec["target_port"]), + source_is_typed_pneumatic=source_is_typed, + target_is_typed_pneumatic=target_is_typed, + category=category, + reason=reason, + ) + ) + + return TestMqlPneumaticTopologyDiscovery( + branch_specs=(), + chamber_segment_specs=chamber_segment_specs, + blocked_candidates=tuple(blocked_candidates), + ) + + def pneumatic_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_pressure_pa: float, + outlet_pressure_pa: float, + inlet_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + ): + """Build a reduced segment using absolute-pressure line boundaries. + + Boundary values apply at the PNL0001/orifice interfaces, not at the nodes. + """ + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPneumaticChamberSegmentClosure, + TestMqlPneumaticChamberSegmentComponents, + ) + + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPneumaticChamberSegmentClosure( + components=TestMqlPneumaticChamberSegmentComponents( + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_pressure_pa, + temperature_k=inlet_temperature_k, + ), + outlet_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=outlet_pressure_pa, + temperature_k=outlet_temperature_k, + ), + ) + + def simulate_pneumatic_chamber_segment_from_spec( + self, + spec, + *, + inlet_pressure_pa: float, + outlet_pressure_pa: float, + inlet_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + """Integrate a reduced segment with absolute-pressure line boundaries.""" + closure = self.pneumatic_chamber_segment_closure_from_spec( + spec, + inlet_pressure_pa=inlet_pressure_pa, + outlet_pressure_pa=outlet_pressure_pa, + inlet_temperature_k=inlet_temperature_k, + outlet_temperature_k=outlet_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pnl0001_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + ): + """Insert the topology-derived inlet PNL0001 into a chamber segment.""" + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPnl0001ChamberSegmentClosure, + TestMqlPnl0001ChamberSegmentComponents, + ) + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPnl0001ChamberSegmentClosure( + components=TestMqlPnl0001ChamberSegmentComponents( + inlet_line=inlet_line, + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + outlet_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=outlet_pressure_pa, + temperature_k=outlet_temperature_k, + ), + ) + + def simulate_pnl0001_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pnl0001_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + outlet_pressure_pa=outlet_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + outlet_temperature_k=outlet_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pnl0001_pair_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_node_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_node_temperature_k: float = 293.15, + ): + """Insert both topology-derived PNL0001 states into the segment.""" + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPnl0001PairChamberSegmentClosure, + TestMqlPnl0001PairChamberSegmentComponents, + ) + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPnl0001PairChamberSegmentClosure( + components=TestMqlPnl0001PairChamberSegmentComponents( + inlet_line=inlet_line, + outlet_line=outlet_line, + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + outlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=outlet_node_pressure_pa, + temperature_k=outlet_node_temperature_k, + ), + ) + + def simulate_pnl0001_pair_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + outlet_node_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + outlet_node_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pnl0001_pair_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + outlet_node_pressure_pa=outlet_node_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + outlet_node_temperature_k=outlet_node_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pn3_node_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + p4_boundary_pressure_pa: float = 15.3e6, + p4_boundary_temperature_k: float = 293.15, + ): + """Close a PNL0001 chamber segment with the real outlet PN3 node. + + The PN3 primary pressure/temperature is taken from the attached PNL0003 + port-1 compliance. The PNL0003 port-2 side is connected through the + real PNVO001 orifice to a prescribed P4 pressure boundary. + """ + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPn3NodeChamberSegmentClosure, + TestMqlPn3NodeChamberSegmentComponents, + ) + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] + node_line_spec = self._pnl0003_spec_for_node_port(spec.outlet_node_alias, "port_2") + node_line = self.pnl0003_assembly.lines[node_line_spec.alias] + node_orifice_alias = ( + node_line_spec.target_component + if node_line_spec.source_component == spec.outlet_node_alias + else node_line_spec.source_component + ) + node_orifice = self.pneumatic_assembly.variable_orifices[node_orifice_alias] + node_resistance = self._pnl00r_line_for_node_port( + spec.outlet_node_alias, "port_1" + ) + node = self.node3_assembly[spec.outlet_node_alias] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPn3NodeChamberSegmentClosure( + components=TestMqlPn3NodeChamberSegmentComponents( + inlet_line=inlet_line, + outlet_line=outlet_line, + node_line=node_line, + node_resistance=node_resistance, + node_orifice=node_orifice, + node=node, + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + resistance_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=resistance_boundary_pressure_pa, + temperature_k=resistance_boundary_temperature_k, + ), + p4_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=p4_boundary_pressure_pa, + temperature_k=p4_boundary_temperature_k, + ), + ) + + def simulate_pn3_node_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + p4_boundary_pressure_pa: float = 15.3e6, + p4_boundary_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pn3_node_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + p4_boundary_pressure_pa=p4_boundary_pressure_pa, + p4_boundary_temperature_k=p4_boundary_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pn3_p4_node_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + use_inlet_line_reference_rhs: bool = False, + use_p4_port3_remote_primary_reference_rhs: bool = False, + ): + """Close a PN3 chamber segment through the adjacent real P4 node. + + The P4 primary pressure/temperature is supplied by the PNL0001 line on + P4 port 2. Ports 1 and 3 use the adjacent PNL0002 center compliances. + The next P4 nodes contribute their primary PNL0001/chamber states, and + PNVO upstream endpoints are closed through their adjacent PN3/PNL0003 + subnets. + """ + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPn3P4NodeChamberSegmentClosure, + TestMqlPn3P4NodeChamberSegmentComponents, + ) + + def pnl0003_line_and_node_for_orifice(orifice_alias: str): + for line_spec in self.pnl0003_assembly.specs: + if ( + line_spec.target_component == orifice_alias + and line_spec.target_port == "port_3" + and line_spec.source_component in self.node3_assembly + ): + return ( + self.pnl0003_assembly.lines[line_spec.alias], + self.node3_assembly[line_spec.source_component], + ) + if ( + line_spec.source_component == orifice_alias + and line_spec.source_port == "port_3" + and line_spec.target_component in self.node3_assembly + ): + return ( + self.pnl0003_assembly.lines[line_spec.alias], + self.node3_assembly[line_spec.target_component], + ) + raise KeyError(f"No PN3/PNL0003 line attached to {orifice_alias}.port_3") + + def other_node3_for_pnl00r(line_alias: str, local_node_alias: str): + for line_spec in self.pnl00r_assembly.specs: + if line_spec.alias != line_alias: + continue + if line_spec.source_component == local_node_alias: + return self.node3_assembly[line_spec.target_component] + if line_spec.target_component == local_node_alias: + return self.node3_assembly[line_spec.source_component] + raise KeyError(f"No remote PN3 node found for {line_alias}") + + def chamber_segment_for_inlet_node(node_alias: str): + chamber_specs = ( + self.discover_pneumatic_branch_topology().chamber_segment_specs + ) + for chamber_spec in chamber_specs: + if chamber_spec.inlet_node_alias == node_alias: + return chamber_spec + raise KeyError(f"No fixed chamber segment starts at {node_alias}") + + def chamber_segment_for_outlet_node(node_alias: str): + chamber_specs = ( + self.discover_pneumatic_branch_topology().chamber_segment_specs + ) + for chamber_spec in chamber_specs: + if chamber_spec.outlet_node_alias == node_alias: + return chamber_spec + raise KeyError(f"No fixed chamber segment ends at {node_alias}") + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] + node_line_spec = self._pnl0003_spec_for_node_port( + spec.outlet_node_alias, + "port_2", + ) + node_line = self.pnl0003_assembly.lines[node_line_spec.alias] + node_orifice_alias = ( + node_line_spec.target_component + if node_line_spec.source_component == spec.outlet_node_alias + else node_line_spec.source_component + ) + p4_node_alias = self._p4_node_alias_for_orifice_port_2(node_orifice_alias) + p4_neighborhood = self.p4_node_neighborhood(p4_node_alias) + if p4_neighborhood.port_4.orifice_alias != node_orifice_alias: + raise ValueError( + f"P4 neighborhood for {p4_node_alias} does not match " + f"{node_orifice_alias}" + ) + node_orifice = self.pneumatic_assembly.variable_orifices[ + p4_neighborhood.port_4.orifice_alias + ] + node_resistance = self._pnl00r_line_for_node_port( + spec.outlet_node_alias, + "port_1", + ) + node = self.node3_assembly[spec.outlet_node_alias] + p4_node = self.node4_assembly[p4_neighborhood.node_alias] + p4_primary_line = self.pnl0001_assembly.lines[ + p4_neighborhood.primary.line_alias + ] + p4_primary_chamber_alias = p4_neighborhood.primary.chamber_alias + p4_primary_chamber = self.network.components[p4_primary_chamber_alias] + p4_port1_line = self.pnl0002_assembly.lines[ + p4_neighborhood.port_1.line_alias + ] + p4_port3_line = self.pnl0002_assembly.lines[ + p4_neighborhood.port_3.line_alias + ] + p4_port1_remote_neighborhood = self.p4_node_neighborhood( + p4_neighborhood.port_1.remote_node_alias + ) + p4_port1_remote_node = self.node4_assembly[ + p4_port1_remote_neighborhood.node_alias + ] + p4_port1_remote_primary_line = self.pnl0001_assembly.lines[ + p4_port1_remote_neighborhood.primary.line_alias + ] + p4_port1_remote_primary_chamber_alias = ( + p4_port1_remote_neighborhood.primary.chamber_alias + ) + p4_port1_remote_primary_chamber = self.network.components[ + p4_port1_remote_primary_chamber_alias + ] + p4_port1_remote_port1_line = self.pnl0002_assembly.lines[ + p4_port1_remote_neighborhood.port_1.line_alias + ] + p4_port1_far_node_alias = p4_port1_remote_neighborhood.port_1.remote_node_alias + p4_port1_far_node = self.node4_assembly[p4_port1_far_node_alias] + p4_port1_far_primary = self._p4_primary_connection_for_node( + p4_port1_far_node_alias + ) + p4_port1_far_primary_line = self.pnl0001_assembly.lines[ + p4_port1_far_primary.line_alias + ] + p4_port1_far_primary_chamber_alias = p4_port1_far_primary.chamber_alias + p4_port1_far_primary_chamber = self.network.components[ + p4_port1_far_primary_chamber_alias + ] + p4_port1_far_port1 = self._pnl0002_connection_for_node_port( + p4_port1_far_node_alias, + "port_1", + ) + p4_port1_far_port1_line = self.pnl0002_assembly.lines[ + p4_port1_far_port1.line_alias + ] + p4_port1_next_node_alias = p4_port1_far_port1.remote_node_alias + p4_port1_next_node = self.node4_assembly[p4_port1_next_node_alias] + p4_port1_next_primary = self._p4_primary_connection_for_node( + p4_port1_next_node_alias + ) + p4_port1_next_primary_line = self.pnl0001_assembly.lines[ + p4_port1_next_primary.line_alias + ] + p4_port1_next_primary_chamber_alias = p4_port1_next_primary.chamber_alias + p4_port1_next_primary_chamber = self.network.components[ + p4_port1_next_primary_chamber_alias + ] + p4_port1_next_port1 = self._pnl0002_connection_for_node_port( + p4_port1_next_node_alias, + "port_1", + ) + p4_port1_next_port1_line = self.pnl0002_assembly.lines[ + p4_port1_next_port1.line_alias + ] + p4_port1_next_orifice_output_spec = self._pnl0001_spec_for_node_port( + p4_port1_next_node_alias, + "port_4", + ) + p4_port1_next_orifice_output_line = self.pnl0001_assembly.lines[ + p4_port1_next_orifice_output_spec.alias + ] + p4_bridge_node_alias = p4_port1_next_port1.remote_node_alias + p4_bridge_node = self.node4_assembly[p4_bridge_node_alias] + p4_bridge_primary = self._p4_primary_connection_for_node( + p4_bridge_node_alias + ) + p4_bridge_primary_line = self.pnl0001_assembly.lines[ + p4_bridge_primary.line_alias + ] + p4_bridge_primary_chamber_alias = p4_bridge_primary.chamber_alias + p4_bridge_primary_chamber = self.network.components[ + p4_bridge_primary_chamber_alias + ] + p4_port1_remote_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port1_remote_neighborhood.port_4.orifice_alias + ] + ( + p4_port1_remote_orifice_line, + p4_port1_remote_orifice_node, + ) = pnl0003_line_and_node_for_orifice( + p4_port1_remote_neighborhood.port_4.orifice_alias + ) + p4_port3_remote_neighborhood = self.p4_node_neighborhood( + p4_neighborhood.port_3.remote_node_alias + ) + p4_port3_remote_node = self.node4_assembly[ + p4_port3_remote_neighborhood.node_alias + ] + p4_port3_remote_primary_line = self.pnl0001_assembly.lines[ + p4_port3_remote_neighborhood.primary.line_alias + ] + p4_port3_remote_primary_chamber_alias = ( + p4_port3_remote_neighborhood.primary.chamber_alias + ) + p4_port3_remote_primary_chamber = self.network.components[ + p4_port3_remote_primary_chamber_alias + ] + p4_port3_remote_port3_line = self.pnl0002_assembly.lines[ + p4_port3_remote_neighborhood.port_3.line_alias + ] + p4_port3_far_node_alias = p4_port3_remote_neighborhood.port_3.remote_node_alias + p4_port3_far_node = self.node4_assembly[p4_port3_far_node_alias] + p4_port3_far_primary = self._p4_primary_connection_for_node( + p4_port3_far_node_alias + ) + p4_port3_far_primary_line = self.pnl0001_assembly.lines[ + p4_port3_far_primary.line_alias + ] + p4_port3_far_primary_chamber_alias = p4_port3_far_primary.chamber_alias + p4_port3_far_primary_chamber = self.network.components[ + p4_port3_far_primary_chamber_alias + ] + p4_port3_far_port3 = self._pnl0002_connection_for_node_port( + p4_port3_far_node_alias, + "port_3", + ) + p4_port3_far_port3_line = self.pnl0002_assembly.lines[ + p4_port3_far_port3.line_alias + ] + p4_port3_next_node_alias = p4_port3_far_port3.remote_node_alias + p4_port3_next_node = self.node4_assembly[p4_port3_next_node_alias] + p4_port3_next_primary = self._p4_primary_connection_for_node( + p4_port3_next_node_alias + ) + p4_port3_next_primary_line = self.pnl0001_assembly.lines[ + p4_port3_next_primary.line_alias + ] + p4_port3_next_primary_chamber_alias = p4_port3_next_primary.chamber_alias + p4_port3_next_primary_chamber = self.network.components[ + p4_port3_next_primary_chamber_alias + ] + p4_port3_next_port3 = self._pnl0002_connection_for_node_port( + p4_port3_next_node_alias, + "port_3", + ) + if p4_port3_next_port3.remote_node_alias != p4_bridge_node_alias: + raise ValueError( + f"{p4_port3_next_port3.line_alias} does not connect back to " + f"{p4_bridge_node_alias}" + ) + p4_port3_next_port3_line = self.pnl0002_assembly.lines[ + p4_port3_next_port3.line_alias + ] + p4_port3_next_orifice_output_spec = self._pnl0001_spec_for_node_port( + p4_port3_next_node_alias, + "port_4", + ) + p4_port3_next_orifice_alias = ( + p4_port3_next_orifice_output_spec.source_component + if p4_port3_next_orifice_output_spec.target_component + == p4_port3_next_node_alias + else p4_port3_next_orifice_output_spec.target_component + ) + p4_port3_next_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_next_orifice_alias + ] + ( + p4_port3_next_orifice_line, + p4_port3_next_orifice_node, + ) = pnl0003_line_and_node_for_orifice(p4_port3_next_orifice_alias) + p4_port3_next_orifice_output_line = self.pnl0001_assembly.lines[ + p4_port3_next_orifice_output_spec.alias + ] + p4_port3_next_chamber_spec = chamber_segment_for_inlet_node( + p4_port3_next_orifice_node.alias + ) + p4_port3_next_chamber_inlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_chamber_spec.inlet_line_alias + ] + p4_port3_next_chamber = self.network.components[ + p4_port3_next_chamber_spec.volume_alias + ] + p4_port3_next_chamber_outlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_chamber_spec.outlet_line_alias + ] + p4_port3_next_chamber_inlet_orifice = self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_chamber_spec.inlet_orifice_alias + ] + p4_port3_next_chamber_outlet_orifice = self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_chamber_spec.outlet_orifice_alias + ] + p4_port3_next_orifice_resistance = self._pnl00r_line_for_node_port( + p4_port3_next_orifice_node.alias, + "port_3", + ) + p4_port3_next_resistance_node = other_node3_for_pnl00r( + p4_port3_next_orifice_resistance.name, + p4_port3_next_orifice_node.alias, + ) + p4_port3_next_resistance_node_line_spec = self._pnl0003_spec_for_node_port( + p4_port3_next_resistance_node.alias, + "port_2", + ) + p4_port3_next_resistance_node_line = self.pnl0003_assembly.lines[ + p4_port3_next_resistance_node_line_spec.alias + ] + p4_port3_next_resistance_orifice_alias = ( + p4_port3_next_resistance_node_line_spec.target_component + if p4_port3_next_resistance_node_line_spec.source_component + == p4_port3_next_resistance_node.alias + else p4_port3_next_resistance_node_line_spec.source_component + ) + p4_port3_next_resistance_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_next_resistance_orifice_alias + ] + p4_port3_next_resistance_output_spec = self._pnl0001_spec_for_node_port( + p4_bridge_node_alias, + "port_4", + ) + if ( + p4_port3_next_resistance_output_spec.source_component + != p4_port3_next_resistance_orifice_alias + ): + raise ValueError( + f"{p4_port3_next_resistance_output_spec.alias} is not fed by " + f"{p4_port3_next_resistance_orifice_alias}" + ) + p4_port3_next_resistance_output_line = self.pnl0001_assembly.lines[ + p4_port3_next_resistance_output_spec.alias + ] + p4_port3_next_resistance_chamber_spec = chamber_segment_for_outlet_node( + p4_port3_next_resistance_node.alias + ) + p4_port3_next_resistance_chamber_inlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_resistance_chamber_spec.inlet_line_alias + ] + p4_port3_next_resistance_chamber = self.network.components[ + p4_port3_next_resistance_chamber_spec.volume_alias + ] + p4_port3_next_resistance_chamber_outlet_line = self.pnl0001_assembly.lines[ + p4_port3_next_resistance_chamber_spec.outlet_line_alias + ] + p4_port3_next_resistance_chamber_inlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_resistance_chamber_spec.inlet_orifice_alias + ] + ) + p4_port3_next_resistance_chamber_outlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port3_next_resistance_chamber_spec.outlet_orifice_alias + ] + ) + p4_port1_next_orifice_node = self.node3_assembly[ + p4_port3_next_resistance_chamber_spec.inlet_node_alias + ] + p4_port1_next_orifice_line_spec = self._pnl0003_spec_for_node_port( + p4_port1_next_orifice_node.alias, + "port_2", + ) + p4_port1_next_orifice_line = self.pnl0003_assembly.lines[ + p4_port1_next_orifice_line_spec.alias + ] + p4_port1_next_orifice_alias = ( + p4_port1_next_orifice_line_spec.target_component + if p4_port1_next_orifice_line_spec.source_component + == p4_port1_next_orifice_node.alias + else p4_port1_next_orifice_line_spec.source_component + ) + p4_port1_next_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port1_next_orifice_alias + ] + if ( + p4_port1_next_orifice_output_spec.source_component != p4_port1_next_node_alias + or p4_port1_next_orifice_output_spec.target_component + != p4_port1_next_orifice_alias + ): + raise ValueError( + f"{p4_port1_next_orifice_output_spec.alias} does not connect " + f"{p4_port1_next_node_alias}.port_4 to {p4_port1_next_orifice_alias}" + ) + p4_port1_next_orifice_resistance = self._pnl00r_line_for_node_port( + p4_port1_next_orifice_node.alias, + "port_3", + ) + p4_port1_next_orifice_resistance_node = other_node3_for_pnl00r( + p4_port1_next_orifice_resistance.name, + p4_port1_next_orifice_node.alias, + ) + p4_port1_next_orifice_resistance_line_spec = self._pnl0003_spec_for_node_port( + p4_port1_next_orifice_resistance_node.alias, + "port_2", + ) + p4_port1_next_orifice_resistance_line = self.pnl0003_assembly.lines[ + p4_port1_next_orifice_resistance_line_spec.alias + ] + p4_port1_next_orifice_resistance_orifice_alias = ( + p4_port1_next_orifice_resistance_line_spec.target_component + if p4_port1_next_orifice_resistance_line_spec.source_component + == p4_port1_next_orifice_resistance_node.alias + else p4_port1_next_orifice_resistance_line_spec.source_component + ) + p4_port1_next_orifice_resistance_orifice = ( + self.pneumatic_assembly.variable_orifices[ + p4_port1_next_orifice_resistance_orifice_alias + ] + ) + p4_port1_far_orifice_direct = self.p4_node_neighborhood( + p4_port1_far_node_alias + ).port_4 + if ( + p4_port1_far_orifice_direct.orifice_alias + != p4_port1_next_orifice_resistance_orifice_alias + ): + raise ValueError( + f"{p4_port1_far_node_alias}.port_4 does not use " + f"{p4_port1_next_orifice_resistance_orifice_alias}" + ) + p4_port1_next_orifice_resistance_chamber_spec = ( + chamber_segment_for_outlet_node( + p4_port1_next_orifice_resistance_node.alias + ) + ) + p4_port1_next_orifice_resistance_chamber_inlet_line = ( + self.pnl0001_assembly.lines[ + p4_port1_next_orifice_resistance_chamber_spec.inlet_line_alias + ] + ) + p4_port1_next_orifice_resistance_chamber = self.network.components[ + p4_port1_next_orifice_resistance_chamber_spec.volume_alias + ] + p4_port1_next_orifice_resistance_chamber_outlet_line = ( + self.pnl0001_assembly.lines[ + p4_port1_next_orifice_resistance_chamber_spec.outlet_line_alias + ] + ) + p4_port1_next_orifice_resistance_chamber_inlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port1_next_orifice_resistance_chamber_spec.inlet_orifice_alias + ] + ) + p4_port1_next_orifice_resistance_chamber_outlet_orifice = ( + self.pneumatic_assembly.fixed_orifices[ + p4_port1_next_orifice_resistance_chamber_spec.outlet_orifice_alias + ] + ) + p4_port3_remote_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_remote_neighborhood.port_4.orifice_alias + ] + ( + p4_port3_remote_orifice_line, + p4_port3_remote_orifice_node, + ) = pnl0003_line_and_node_for_orifice( + p4_port3_remote_neighborhood.port_4.orifice_alias + ) + p4_port3_remote_orifice_resistance = self._pnl00r_line_for_node_port( + p4_port3_remote_orifice_node.alias, + "port_3", + ) + p4_port3_remote_resistance_node = other_node3_for_pnl00r( + p4_port3_remote_orifice_resistance.name, + p4_port3_remote_orifice_node.alias, + ) + p4_port3_remote_resistance_node_line_spec = self._pnl0003_spec_for_node_port( + p4_port3_remote_resistance_node.alias, + "port_2", + ) + p4_port3_remote_resistance_node_line = self.pnl0003_assembly.lines[ + p4_port3_remote_resistance_node_line_spec.alias + ] + p4_port3_remote_resistance_orifice_alias = ( + p4_port3_remote_resistance_node_line_spec.target_component + if p4_port3_remote_resistance_node_line_spec.source_component + == p4_port3_remote_resistance_node.alias + else p4_port3_remote_resistance_node_line_spec.source_component + ) + p4_port3_remote_resistance_orifice = self.pneumatic_assembly.variable_orifices[ + p4_port3_remote_resistance_orifice_alias + ] + p4_port3_remote_resistance_output_spec = self._pnl0001_spec_for_node_port( + p4_port3_far_node_alias, + "port_4", + ) + if ( + p4_port3_remote_resistance_output_spec.source_component + != p4_port3_remote_resistance_orifice_alias + ): + raise ValueError( + f"{p4_port3_remote_resistance_output_spec.alias} is not fed by " + f"{p4_port3_remote_resistance_orifice_alias}" + ) + p4_port3_remote_resistance_output_line = self.pnl0001_assembly.lines[ + p4_port3_remote_resistance_output_spec.alias + ] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(p4_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port1_remote_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port1_remote_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_remote_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_remote_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port1_far_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port1_far_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_far_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_far_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port1_next_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port1_next_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_next_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_next_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_next_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_next_chamber_spec.volume_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(p4_port3_next_chamber_inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_chamber_outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_resistance_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_next_resistance_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_next_resistance_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_port3_next_resistance_chamber_spec.volume_alias} is not an AMESim pneumatic volume" + ) + if not isinstance( + p4_port3_next_resistance_chamber_inlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port3_next_resistance_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port3_next_resistance_chamber_outlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port3_next_resistance_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port1_next_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port1_next_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port1_next_orifice_resistance_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port1_next_orifice_resistance_chamber, + AmesimPneumaticVolume, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_chamber_spec.volume_alias} is not an AMESim pneumatic volume" + ) + if not isinstance( + p4_port1_next_orifice_resistance_chamber_inlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance( + p4_port1_next_orifice_resistance_chamber_outlet_orifice, + AmesimPneumaticOrifice, + ): + raise TypeError( + f"{p4_port1_next_orifice_resistance_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_bridge_primary_chamber, AmesimPneumaticVolume): + raise TypeError( + f"{p4_bridge_primary_chamber_alias} is not an AMESim pneumatic volume" + ) + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(p4_port3_remote_resistance_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{p4_port3_remote_resistance_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPn3P4NodeChamberSegmentClosure( + components=TestMqlPn3P4NodeChamberSegmentComponents( + inlet_line=inlet_line, + outlet_line=outlet_line, + node_line=node_line, + node_resistance=node_resistance, + node_orifice=node_orifice, + node=node, + p4_node=p4_node, + p4_primary_line=p4_primary_line, + p4_primary_chamber=p4_primary_chamber, + p4_port1_line=p4_port1_line, + p4_port1_remote_node=p4_port1_remote_node, + p4_port1_remote_primary_line=p4_port1_remote_primary_line, + p4_port1_remote_primary_chamber=p4_port1_remote_primary_chamber, + p4_port1_remote_port1_line=p4_port1_remote_port1_line, + p4_port1_far_node=p4_port1_far_node, + p4_port1_far_primary_line=p4_port1_far_primary_line, + p4_port1_far_primary_chamber=p4_port1_far_primary_chamber, + p4_port1_far_port1_line=p4_port1_far_port1_line, + p4_port1_next_node=p4_port1_next_node, + p4_port1_next_primary_line=p4_port1_next_primary_line, + p4_port1_next_primary_chamber=p4_port1_next_primary_chamber, + p4_port1_next_port1_line=p4_port1_next_port1_line, + p4_port1_next_orifice=p4_port1_next_orifice, + p4_port1_next_orifice_line=p4_port1_next_orifice_line, + p4_port1_next_orifice_node=p4_port1_next_orifice_node, + p4_port1_next_orifice_output_line=p4_port1_next_orifice_output_line, + p4_port1_next_orifice_resistance=p4_port1_next_orifice_resistance, + p4_port1_next_orifice_resistance_node=( + p4_port1_next_orifice_resistance_node + ), + p4_port1_next_orifice_resistance_line=( + p4_port1_next_orifice_resistance_line + ), + p4_port1_next_orifice_resistance_orifice=( + p4_port1_next_orifice_resistance_orifice + ), + p4_port1_next_orifice_resistance_chamber_inlet_line=( + p4_port1_next_orifice_resistance_chamber_inlet_line + ), + p4_port1_next_orifice_resistance_chamber=( + p4_port1_next_orifice_resistance_chamber + ), + p4_port1_next_orifice_resistance_chamber_outlet_line=( + p4_port1_next_orifice_resistance_chamber_outlet_line + ), + p4_port1_next_orifice_resistance_chamber_inlet_orifice=( + p4_port1_next_orifice_resistance_chamber_inlet_orifice + ), + p4_port1_next_orifice_resistance_chamber_outlet_orifice=( + p4_port1_next_orifice_resistance_chamber_outlet_orifice + ), + p4_port1_next_orifice_resistance_chamber_spec=( + p4_port1_next_orifice_resistance_chamber_spec + ), + p4_bridge_node=p4_bridge_node, + p4_bridge_primary_line=p4_bridge_primary_line, + p4_bridge_primary_chamber=p4_bridge_primary_chamber, + p4_port1_remote_orifice=p4_port1_remote_orifice, + p4_port1_remote_orifice_line=p4_port1_remote_orifice_line, + p4_port1_remote_orifice_node=p4_port1_remote_orifice_node, + p4_port3_line=p4_port3_line, + p4_port3_remote_node=p4_port3_remote_node, + p4_port3_remote_primary_line=p4_port3_remote_primary_line, + p4_port3_remote_primary_chamber=p4_port3_remote_primary_chamber, + p4_port3_remote_port3_line=p4_port3_remote_port3_line, + p4_port3_far_node=p4_port3_far_node, + p4_port3_far_primary_line=p4_port3_far_primary_line, + p4_port3_far_primary_chamber=p4_port3_far_primary_chamber, + p4_port3_far_port3_line=p4_port3_far_port3_line, + p4_port3_next_node=p4_port3_next_node, + p4_port3_next_primary_line=p4_port3_next_primary_line, + p4_port3_next_primary_chamber=p4_port3_next_primary_chamber, + p4_port3_next_port3_line=p4_port3_next_port3_line, + p4_port3_next_orifice=p4_port3_next_orifice, + p4_port3_next_orifice_line=p4_port3_next_orifice_line, + p4_port3_next_orifice_node=p4_port3_next_orifice_node, + p4_port3_next_orifice_output_line=p4_port3_next_orifice_output_line, + p4_port3_next_chamber_inlet_line=p4_port3_next_chamber_inlet_line, + p4_port3_next_chamber=p4_port3_next_chamber, + p4_port3_next_chamber_outlet_line=p4_port3_next_chamber_outlet_line, + p4_port3_next_chamber_inlet_orifice=p4_port3_next_chamber_inlet_orifice, + p4_port3_next_chamber_outlet_orifice=p4_port3_next_chamber_outlet_orifice, + p4_port3_next_chamber_spec=p4_port3_next_chamber_spec, + p4_port3_next_orifice_resistance=p4_port3_next_orifice_resistance, + p4_port3_next_resistance_node=p4_port3_next_resistance_node, + p4_port3_next_resistance_node_line=p4_port3_next_resistance_node_line, + p4_port3_next_resistance_orifice=p4_port3_next_resistance_orifice, + p4_port3_next_resistance_output_line=p4_port3_next_resistance_output_line, + p4_port3_next_resistance_chamber_inlet_line=( + p4_port3_next_resistance_chamber_inlet_line + ), + p4_port3_next_resistance_chamber=p4_port3_next_resistance_chamber, + p4_port3_next_resistance_chamber_outlet_line=( + p4_port3_next_resistance_chamber_outlet_line + ), + p4_port3_next_resistance_chamber_inlet_orifice=( + p4_port3_next_resistance_chamber_inlet_orifice + ), + p4_port3_next_resistance_chamber_outlet_orifice=( + p4_port3_next_resistance_chamber_outlet_orifice + ), + p4_port3_next_resistance_chamber_spec=( + p4_port3_next_resistance_chamber_spec + ), + p4_port3_remote_orifice=p4_port3_remote_orifice, + p4_port3_remote_orifice_line=p4_port3_remote_orifice_line, + p4_port3_remote_orifice_node=p4_port3_remote_orifice_node, + p4_port3_remote_orifice_resistance=p4_port3_remote_orifice_resistance, + p4_port3_remote_resistance_node=p4_port3_remote_resistance_node, + p4_port3_remote_resistance_node_line=( + p4_port3_remote_resistance_node_line + ), + p4_port3_remote_resistance_orifice=( + p4_port3_remote_resistance_orifice + ), + p4_port3_remote_resistance_output_line=( + p4_port3_remote_resistance_output_line + ), + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + resistance_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=resistance_boundary_pressure_pa, + temperature_k=resistance_boundary_temperature_k, + ), + use_inlet_line_reference_rhs=use_inlet_line_reference_rhs, + use_p4_port3_remote_primary_reference_rhs=( + use_p4_port3_remote_primary_reference_rhs + ), + ) + + def simulate_pn3_p4_node_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pn3_p4_node_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def pneumatic_branch_closure_from_spec(self, spec): + return self.pneumatic_branch_closure( + name=spec.name, + upstream_volume_alias=spec.upstream_volume_alias, + orifice_alias=spec.orifice_alias, + downstream_volume_alias=spec.downstream_volume_alias, + spec=spec, + ) + + def pneumatic_branch_closure( + self, + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + spec=None, + ): + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBranchComponents, + TestMqlPneumaticBranchSpec, + TestMqlPneumaticClosure, + ) + + upstream_volume = self.network.components[upstream_volume_alias] + orifice = self.network.components[orifice_alias] + downstream_volume = self.network.components[downstream_volume_alias] + if not isinstance(upstream_volume, AmesimPneumaticVolume): + raise TypeError(f"{upstream_volume_alias} is not an AMESim pneumatic volume") + if not isinstance(downstream_volume, AmesimPneumaticVolume): + raise TypeError(f"{downstream_volume_alias} is not an AMESim pneumatic volume") + if not isinstance(orifice, AmesimPneumaticOrifice): + raise TypeError(f"{orifice_alias} is not an AMESim pneumatic orifice") + + def initial_state_vector() -> list[float]: + return [ + *upstream_volume.get_state_vector(), + *downstream_volume.get_state_vector(), + ] + + def apply_state_vector(values: list[float]) -> None: + if len(values) != 4: + raise ValueError("two-volume pneumatic branch state vector requires four values") + upstream_volume.set_state_vector(values[:2]) + downstream_volume.set_state_vector(values[2:]) + + branch_spec = spec or TestMqlPneumaticBranchSpec( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + ) + return TestMqlPneumaticClosure( + components=TestMqlPneumaticBranchComponents( + name=name, + upstream_volume=upstream_volume, + orifice=orifice, + downstream_volume=downstream_volume, + spec=branch_spec, + ), + initial_state_vector=initial_state_vector, + apply_state_vector=apply_state_vector, + ) + + def simulate_pneumatic_branch( + self, + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pneumatic_branch_closure( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + + def simulate_pneumatic_branch_from_spec( + self, + spec, + *, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + return self.simulate_pneumatic_branch( + name=spec.name, + upstream_volume_alias=spec.upstream_volume_alias, + orifice_alias=spec.orifice_alias, + downstream_volume_alias=spec.downstream_volume_alias, + config=config, + t_eval=t_eval, + ) + + def evaluate_pneumatic_branch_solution_from_spec( + self, + solution: object, + spec, + ) -> dict[str, list[float]]: + return self.evaluate_pneumatic_branch_solution( + solution, + name=spec.name, + upstream_volume_alias=spec.upstream_volume_alias, + orifice_alias=spec.orifice_alias, + downstream_volume_alias=spec.downstream_volume_alias, + ) + + def evaluate_pneumatic_branch_solution( + self, + solution: object, + *, + name: str, + upstream_volume_alias: str, + orifice_alias: str, + downstream_volume_alias: str, + ) -> dict[str, list[float]]: + closure = self.pneumatic_branch_closure( + name=name, + upstream_volume_alias=upstream_volume_alias, + orifice_alias=orifice_alias, + downstream_volume_alias=downstream_volume_alias, + ) + series = { + "time": [], + f"{name}.flow": [], + f"{upstream_volume_alias}.p": [], + f"{upstream_volume_alias}.T": [], + f"{upstream_volume_alias}.m_flow": [], + f"{downstream_volume_alias}.p": [], + f"{downstream_volume_alias}.T": [], + f"{downstream_volume_alias}.m_flow": [], + } + for index, time_value in enumerate(solution.t): + state_vector = [row[index] for row in solution.y] + snapshot = closure.snapshot(state_vector) + series["time"].append(float(time_value)) + series[f"{name}.flow"].append(snapshot.flow) + series[f"{upstream_volume_alias}.p"].append(snapshot.upstream.p) + series[f"{upstream_volume_alias}.T"].append(snapshot.upstream.T) + series[f"{upstream_volume_alias}.m_flow"].append( + closure.components.upstream_volume.port_a.m_flow + ) + series[f"{downstream_volume_alias}.p"].append(snapshot.downstream.p) + series[f"{downstream_volume_alias}.T"].append(snapshot.downstream.T) + series[f"{downstream_volume_alias}.m_flow"].append( + closure.components.downstream_volume.port_a.m_flow + ) + return series + + def snapshot(self) -> TestMqlSnapshot: + return TestMqlSnapshot( + model_name=MODEL_NAME, + component_count=len(COMPONENT_SPECS), + connection_count=len(CONNECTION_SPECS), + continuous_state_count=int(MODEL_INFO.get("num_states", 0)), + discrete_state_count=int(MODEL_INFO.get("num_discrete_states", 0)), + global_parameters=dict(GLOBAL_PARAMETERS), + submodel_counts=dict(SUBMODEL_COUNTS), + ) + + def initial_state_vector(self) -> list[float]: + return [0.0] * int(MODEL_INFO.get("num_states", 0)) + + def simulate(self, config: TestMqlRunConfig | None = None) -> TestMqlSimulationResult: + run_config = config or TestMqlRunConfig() + times = run_config.sample_times() + state_count = int(MODEL_INFO.get("num_states", 0)) + y = [[0.0 for _ in times] for _ in range(state_count)] + series = self.evaluate_solution(SimpleNamespace(t=times, y=y)) + return TestMqlSimulationResult(t=times, y=y, series=series) + + def evaluate_solution(self, solution: object) -> dict[str, list[float]]: + times = [float(value) for value in solution.t] + return { + "time": times, + "component_count": [float(len(COMPONENT_SPECS)) for _ in times], + "connection_count": [float(len(CONNECTION_SPECS)) for _ in times], + "continuous_state_count": [float(MODEL_INFO.get("num_states", 0)) for _ in times], + "discrete_state_count": [float(MODEL_INFO.get("num_discrete_states", 0)) for _ in times], + } + + def component_specs_by_submodel(self) -> dict[str, list[dict[str, object]]]: + grouped: dict[str, list[dict[str, object]]] = {} + for spec in COMPONENT_SPECS: + grouped.setdefault(str(spec["submodel"]), []).append(spec) + return grouped + + def connection_specs_by_submodel(self) -> dict[str, list[dict[str, object]]]: + grouped: dict[str, list[dict[str, object]]] = {} + for spec in CONNECTION_SPECS: + grouped.setdefault(str(spec["submodel"]), []).append(spec) + return grouped + + +def build_test_mql() -> SimulationNetwork: + return TestMqlSystem().network diff --git a/app/simulation/examples/testmodel/test_mql_baseline.py b/app/simulation/examples/testmodel/test_mql_baseline.py new file mode 100644 index 0000000..fff6c38 --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_baseline.py @@ -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, + ) diff --git a/app/simulation/examples/testmodel/test_mql_closure.py b/app/simulation/examples/testmodel/test_mql_closure.py new file mode 100644 index 0000000..984d9f7 --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_closure.py @@ -0,0 +1,4213 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticGas, + AmesimPneumaticOrifice, + AmesimPneumaticVolume, +) +from PythonModels.components.amesim_pneumatic_line import ( + AmesimPnl0001Pipe, + AmesimPnl0002Pipe, + AmesimPnl0003Pipe, + AmesimPnl00rPipe, +) +from PythonModels.core.medium import ThermodynamicProperties +from PythonModels.core.ports import PortState +from PythonModels.core.state import VolumeState +from PythonModels.systems.test_mql_nodes import ( + TestMqlPneumaticNode3, + TestMqlPneumaticNode3Balance, + TestMqlPneumaticNode4, + TestMqlPneumaticNode4Balance, +) + + +@dataclass(frozen=True) +class TestMqlPneumaticBranchSpec: + name: str + upstream_volume_alias: str + orifice_alias: str + downstream_volume_alias: str + source: str = "manual" + + +@dataclass(frozen=True) +class TestMqlPneumaticTopologyCandidate: + connection_alias: str + line_submodel: str + source_component: str + source_port: str + target_component: str + target_port: str + source_is_typed_pneumatic: bool + target_is_typed_pneumatic: bool + category: str + reason: str + + +@dataclass(frozen=True) +class TestMqlPneumaticChamberSegmentSpec: + name: str + inlet_node_alias: str + inlet_line_alias: str + inlet_orifice_alias: str + inlet_orifice_boundary_port: str + inlet_orifice_volume_port: str + volume_alias: str + volume_inlet_port: str + volume_outlet_port: str + outlet_orifice_alias: str + outlet_orifice_volume_port: str + outlet_orifice_boundary_port: str + outlet_line_alias: str + outlet_node_alias: str + source: str = "amesim-cir-topology" + + +@dataclass(frozen=True) +class TestMqlPneumaticTopologyDiscovery: + branch_specs: tuple[TestMqlPneumaticBranchSpec, ...] + chamber_segment_specs: tuple[TestMqlPneumaticChamberSegmentSpec, ...] + blocked_candidates: tuple[TestMqlPneumaticTopologyCandidate, ...] + + +@dataclass(frozen=True) +class TestMqlPneumaticBoundaryCondition: + pressure_pa: float + temperature_k: float + + def properties(self, gas: AmesimPneumaticGas) -> ThermodynamicProperties: + if self.pressure_pa <= 0.0: + raise ValueError("boundary pressure must be positive") + if self.temperature_k <= 0.0: + raise ValueError("boundary temperature must be positive") + return ThermodynamicProperties( + p=self.pressure_pa, + T=self.temperature_k, + rho=gas.density(self.pressure_pa, self.temperature_k), + u=gas.specific_internal_energy(self.temperature_k), + h=gas.specific_enthalpy(self.temperature_k), + ) + + +@dataclass(frozen=True) +class TestMqlPneumaticChamberSegmentComponents: + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPneumaticChamberSegmentSnapshot: + chamber: ThermodynamicProperties + inlet_boundary: ThermodynamicProperties + outlet_boundary: ThermodynamicProperties + inlet_flow: float + outlet_flow: float + + +@dataclass(frozen=True) +class TestMqlPnl0001ChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPnl0001ChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + inlet_node: ThermodynamicProperties + outlet_boundary: ThermodynamicProperties + node_to_line_flow: float + line_to_chamber_flow: float + outlet_flow: float + + +@dataclass(frozen=True) +class TestMqlPnl0001PairChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + outlet_line: AmesimPnl0001Pipe + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPnl0001PairChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + outlet_line: ThermodynamicProperties + inlet_node: ThermodynamicProperties + outlet_node: ThermodynamicProperties + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + + +@dataclass(frozen=True) +class TestMqlPn3NodeChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + outlet_line: AmesimPnl0001Pipe + node_line: AmesimPnl0003Pipe + node_resistance: AmesimPnl00rPipe + node_orifice: AmesimPneumaticOrifice + node: TestMqlPneumaticNode3 + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPn3NodeChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + outlet_line: ThermodynamicProperties + node_line_port_1: ThermodynamicProperties + node_line_port_2: ThermodynamicProperties + inlet_node: ThermodynamicProperties + resistance_boundary: ThermodynamicProperties + p4_boundary: ThermodynamicProperties + node_balance: TestMqlPneumaticNode3Balance + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + node_to_resistance_flow: float + node_to_pnl0003_flow: float + pnl0003_center_flow: float + pnl0003_to_p4_flow: float + + +@dataclass(frozen=True) +class TestMqlPn3P4NodeChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + outlet_line: AmesimPnl0001Pipe + node_line: AmesimPnl0003Pipe + node_resistance: AmesimPnl00rPipe + node_orifice: AmesimPneumaticOrifice + node: TestMqlPneumaticNode3 + p4_node: TestMqlPneumaticNode4 + p4_primary_line: AmesimPnl0001Pipe + p4_primary_chamber: AmesimPneumaticVolume + p4_port1_line: AmesimPnl0002Pipe + p4_port1_remote_node: TestMqlPneumaticNode4 + p4_port1_remote_primary_line: AmesimPnl0001Pipe + p4_port1_remote_primary_chamber: AmesimPneumaticVolume + p4_port1_remote_port1_line: AmesimPnl0002Pipe + p4_port1_far_node: TestMqlPneumaticNode4 + p4_port1_far_primary_line: AmesimPnl0001Pipe + p4_port1_far_primary_chamber: AmesimPneumaticVolume + p4_port1_far_port1_line: AmesimPnl0002Pipe + p4_port1_next_node: TestMqlPneumaticNode4 + p4_port1_next_primary_line: AmesimPnl0001Pipe + p4_port1_next_primary_chamber: AmesimPneumaticVolume + p4_port1_next_port1_line: AmesimPnl0002Pipe + p4_port1_next_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_line: AmesimPnl0003Pipe + p4_port1_next_orifice_node: TestMqlPneumaticNode3 + p4_port1_next_orifice_output_line: AmesimPnl0001Pipe + p4_port1_next_orifice_resistance: AmesimPnl00rPipe + p4_port1_next_orifice_resistance_node: TestMqlPneumaticNode3 + p4_port1_next_orifice_resistance_line: AmesimPnl0003Pipe + p4_port1_next_orifice_resistance_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_resistance_chamber_inlet_line: AmesimPnl0001Pipe + p4_port1_next_orifice_resistance_chamber: AmesimPneumaticVolume + p4_port1_next_orifice_resistance_chamber_outlet_line: AmesimPnl0001Pipe + p4_port1_next_orifice_resistance_chamber_inlet_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_resistance_chamber_outlet_orifice: AmesimPneumaticOrifice + p4_port1_next_orifice_resistance_chamber_spec: TestMqlPneumaticChamberSegmentSpec + p4_bridge_node: TestMqlPneumaticNode4 + p4_bridge_primary_line: AmesimPnl0001Pipe + p4_bridge_primary_chamber: AmesimPneumaticVolume + p4_port1_remote_orifice: AmesimPneumaticOrifice + p4_port1_remote_orifice_line: AmesimPnl0003Pipe + p4_port1_remote_orifice_node: TestMqlPneumaticNode3 + p4_port3_line: AmesimPnl0002Pipe + p4_port3_remote_node: TestMqlPneumaticNode4 + p4_port3_remote_primary_line: AmesimPnl0001Pipe + p4_port3_remote_primary_chamber: AmesimPneumaticVolume + p4_port3_remote_port3_line: AmesimPnl0002Pipe + p4_port3_far_node: TestMqlPneumaticNode4 + p4_port3_far_primary_line: AmesimPnl0001Pipe + p4_port3_far_primary_chamber: AmesimPneumaticVolume + p4_port3_far_port3_line: AmesimPnl0002Pipe + p4_port3_next_node: TestMqlPneumaticNode4 + p4_port3_next_primary_line: AmesimPnl0001Pipe + p4_port3_next_primary_chamber: AmesimPneumaticVolume + p4_port3_next_port3_line: AmesimPnl0002Pipe + p4_port3_next_orifice: AmesimPneumaticOrifice + p4_port3_next_orifice_line: AmesimPnl0003Pipe + p4_port3_next_orifice_node: TestMqlPneumaticNode3 + p4_port3_next_orifice_output_line: AmesimPnl0001Pipe + p4_port3_next_chamber_inlet_line: AmesimPnl0001Pipe + p4_port3_next_chamber: AmesimPneumaticVolume + p4_port3_next_chamber_outlet_line: AmesimPnl0001Pipe + p4_port3_next_chamber_inlet_orifice: AmesimPneumaticOrifice + p4_port3_next_chamber_outlet_orifice: AmesimPneumaticOrifice + p4_port3_next_chamber_spec: TestMqlPneumaticChamberSegmentSpec + p4_port3_next_orifice_resistance: AmesimPnl00rPipe + p4_port3_next_resistance_node: TestMqlPneumaticNode3 + p4_port3_next_resistance_node_line: AmesimPnl0003Pipe + p4_port3_next_resistance_orifice: AmesimPneumaticOrifice + p4_port3_next_resistance_output_line: AmesimPnl0001Pipe + p4_port3_next_resistance_chamber_inlet_line: AmesimPnl0001Pipe + p4_port3_next_resistance_chamber: AmesimPneumaticVolume + p4_port3_next_resistance_chamber_outlet_line: AmesimPnl0001Pipe + p4_port3_next_resistance_chamber_inlet_orifice: AmesimPneumaticOrifice + p4_port3_next_resistance_chamber_outlet_orifice: AmesimPneumaticOrifice + p4_port3_next_resistance_chamber_spec: TestMqlPneumaticChamberSegmentSpec + p4_port3_remote_orifice: AmesimPneumaticOrifice + p4_port3_remote_orifice_line: AmesimPnl0003Pipe + p4_port3_remote_orifice_node: TestMqlPneumaticNode3 + p4_port3_remote_orifice_resistance: AmesimPnl00rPipe + p4_port3_remote_resistance_node: TestMqlPneumaticNode3 + p4_port3_remote_resistance_node_line: AmesimPnl0003Pipe + p4_port3_remote_resistance_orifice: AmesimPneumaticOrifice + p4_port3_remote_resistance_output_line: AmesimPnl0001Pipe + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlTwoStateOrificeLineFlows: + line_center_flow: float + line_to_boundary_flow: float + + +@dataclass(frozen=True) +class TestMqlP4NeighborhoodLineFlows: + primary_chamber_to_line_flow: float + port_1_line_flow: float + port_3_line_flow: float + + +@dataclass(frozen=True) +class TestMqlFixedChamberSegmentFlows: + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + + +@dataclass(frozen=True) +class TestMqlPn3NodeBalancePort: + flow_kg_s: float + node_h: float + connected_h: float + port_mass_flow_kg_s: float + + +@dataclass(frozen=True) +class TestMqlP4NodeBalancePort: + flow_kg_s: float + node_h: float + connected_h: float + port_mass_flow_kg_s: float + + +@dataclass(frozen=True) +class TestMqlPn3P4NodeChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + outlet_line: ThermodynamicProperties + node_line_port_1: ThermodynamicProperties + node_line_port_2: ThermodynamicProperties + p4_primary_line: ThermodynamicProperties + p4_boundary: ThermodynamicProperties + p4_primary_chamber: ThermodynamicProperties + p4_port1_line: ThermodynamicProperties + p4_port1_remote_primary_line: ThermodynamicProperties + p4_port1_remote_primary_chamber: ThermodynamicProperties + p4_port1_remote_port1_line: ThermodynamicProperties + p4_port1_far_primary_line: ThermodynamicProperties + p4_port1_far_primary_chamber: ThermodynamicProperties + p4_port1_far_port1_line: ThermodynamicProperties + p4_port1_next_primary_line: ThermodynamicProperties + p4_port1_next_primary_chamber: ThermodynamicProperties + p4_port1_next_port1_line: ThermodynamicProperties + p4_port1_next_orifice_line_port_1: ThermodynamicProperties + p4_port1_next_orifice_line_port_2: ThermodynamicProperties + p4_port1_next_orifice_output_line: ThermodynamicProperties + p4_port1_next_orifice_resistance_line_port_1: ThermodynamicProperties + p4_port1_next_orifice_resistance_line_port_2: ThermodynamicProperties + p4_port1_next_orifice_resistance_chamber_inlet_line: ThermodynamicProperties + p4_port1_next_orifice_resistance_chamber: ThermodynamicProperties + p4_port1_next_orifice_resistance_chamber_outlet_line: ThermodynamicProperties + p4_bridge_primary_line: ThermodynamicProperties + p4_bridge_primary_chamber: ThermodynamicProperties + p4_port1_remote_orifice_line_port_1: ThermodynamicProperties + p4_port1_remote_orifice_line_port_2: ThermodynamicProperties + p4_port3_line: ThermodynamicProperties + p4_port3_remote_primary_line: ThermodynamicProperties + p4_port3_remote_primary_chamber: ThermodynamicProperties + p4_port3_remote_port3_line: ThermodynamicProperties + p4_port3_far_primary_line: ThermodynamicProperties + p4_port3_far_primary_chamber: ThermodynamicProperties + p4_port3_far_port3_line: ThermodynamicProperties + p4_port3_next_primary_line: ThermodynamicProperties + p4_port3_next_primary_chamber: ThermodynamicProperties + p4_port3_next_port3_line: ThermodynamicProperties + p4_port3_next_orifice_line_port_1: ThermodynamicProperties + p4_port3_next_orifice_line_port_2: ThermodynamicProperties + p4_port3_next_orifice_output_line: ThermodynamicProperties + p4_port3_next_chamber_inlet_line: ThermodynamicProperties + p4_port3_next_chamber: ThermodynamicProperties + p4_port3_next_chamber_outlet_line: ThermodynamicProperties + p4_port3_next_resistance_node_line_port_1: ThermodynamicProperties + p4_port3_next_resistance_node_line_port_2: ThermodynamicProperties + p4_port3_next_resistance_output_line: ThermodynamicProperties + p4_port3_next_resistance_chamber_inlet_line: ThermodynamicProperties + p4_port3_next_resistance_chamber: ThermodynamicProperties + p4_port3_next_resistance_chamber_outlet_line: ThermodynamicProperties + p4_port3_remote_orifice_line_port_1: ThermodynamicProperties + p4_port3_remote_orifice_line_port_2: ThermodynamicProperties + p4_port3_remote_resistance_node_line_port_1: ThermodynamicProperties + p4_port3_remote_resistance_node_line_port_2: ThermodynamicProperties + p4_port3_remote_resistance_output_line: ThermodynamicProperties + inlet_node: ThermodynamicProperties + resistance_boundary: ThermodynamicProperties + node_balance: TestMqlPneumaticNode3Balance + p4_balance: TestMqlPneumaticNode4Balance + p4_port1_remote_balance: TestMqlPneumaticNode4Balance + p4_port1_far_balance: TestMqlPneumaticNode4Balance + p4_port1_next_balance: TestMqlPneumaticNode4Balance + p4_bridge_balance: TestMqlPneumaticNode4Balance + p4_port1_remote_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port1_next_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port1_next_orifice_resistance_node_balance: TestMqlPneumaticNode3Balance + p4_port3_remote_balance: TestMqlPneumaticNode4Balance + p4_port3_far_balance: TestMqlPneumaticNode4Balance + p4_port3_next_balance: TestMqlPneumaticNode4Balance + p4_port3_next_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port3_next_resistance_node_balance: TestMqlPneumaticNode3Balance + p4_port3_remote_orifice_node_balance: TestMqlPneumaticNode3Balance + p4_port3_remote_resistance_node_balance: TestMqlPneumaticNode3Balance + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + node_to_resistance_flow: float + node_to_pnl0003_flow: float + pnl0003_center_flow: float + pnl0003_to_p4_flow: float + p4_primary_chamber_to_line_flow: float + p4_node_to_primary_line_flow: float + p4_to_port1_line_flow: float + p4_port1_remote_node_to_line_flow: float + p4_port1_remote_chamber_to_line_flow: float + p4_port1_remote_node_to_primary_line_flow: float + p4_port1_remote_to_port1_line_flow: float + p4_port1_far_node_to_line_flow: float + p4_port1_far_chamber_to_line_flow: float + p4_port1_far_node_to_primary_line_flow: float + p4_port1_far_to_next_line_flow: float + p4_port1_next_node_to_line_flow: float + p4_port1_next_chamber_to_line_flow: float + p4_port1_next_node_to_primary_line_flow: float + p4_port1_next_to_bridge_line_flow: float + p4_port1_next_orifice_p4_to_output_line_flow: float + p4_port1_next_orifice_node_to_line_flow: float + p4_port1_next_orifice_line_center_flow: float + p4_port1_next_orifice_to_output_line_flow: float + p4_port1_next_orifice_resistance_node_to_next_flow: float + p4_port1_next_orifice_resistance_node_to_line_flow: float + p4_port1_next_orifice_resistance_line_center_flow: float + p4_port1_next_orifice_resistance_to_p4_flow: float + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow: float + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow: float + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow: float + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow: float + p4_bridge_to_port1_next_line_flow: float + p4_bridge_chamber_to_line_flow: float + p4_bridge_node_to_primary_line_flow: float + p4_port1_remote_node_to_orifice_line_flow: float + p4_port1_remote_orifice_line_center_flow: float + p4_port1_remote_orifice_to_node_flow: float + p4_to_port3_line_flow: float + p4_port3_remote_node_to_line_flow: float + p4_port3_remote_chamber_to_line_flow: float + p4_port3_remote_node_to_primary_line_flow: float + p4_port3_remote_to_port3_line_flow: float + p4_port3_far_node_to_line_flow: float + p4_port3_far_chamber_to_line_flow: float + p4_port3_far_node_to_primary_line_flow: float + p4_port3_far_to_next_line_flow: float + p4_port3_next_node_to_line_flow: float + p4_port3_next_chamber_to_line_flow: float + p4_port3_next_node_to_primary_line_flow: float + p4_bridge_to_port3_next_line_flow: float + p4_port3_next_to_bridge_line_flow: float + p4_port3_next_orifice_node_to_line_flow: float + p4_port3_next_orifice_line_center_flow: float + p4_port3_next_orifice_to_output_line_flow: float + p4_port3_next_orifice_output_to_p4_flow: float + p4_port3_next_chamber_inlet_node_to_line_flow: float + p4_port3_next_chamber_inlet_line_to_chamber_flow: float + p4_port3_next_chamber_outlet_node_to_line_flow: float + p4_port3_next_chamber_outlet_line_to_chamber_flow: float + p4_port3_next_resistance_node_to_next_flow: float + p4_port3_next_resistance_node_to_line_flow: float + p4_port3_next_resistance_line_center_flow: float + p4_port3_next_resistance_to_output_line_flow: float + p4_port3_next_resistance_output_to_p4_flow: float + p4_port3_next_resistance_chamber_inlet_node_to_line_flow: float + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow: float + p4_port3_next_resistance_chamber_outlet_node_to_line_flow: float + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow: float + p4_port3_remote_node_to_orifice_line_flow: float + p4_port3_remote_node_to_resistance_flow: float + p4_port3_remote_resistance_node_to_line_flow: float + p4_port3_remote_resistance_line_center_flow: float + p4_port3_remote_resistance_to_output_line_flow: float + p4_port3_remote_resistance_output_to_p4_flow: float + p4_port3_remote_orifice_line_center_flow: float + p4_port3_remote_orifice_to_node_flow: float + + +class TestMqlPn3P4NodeChamberSegmentClosure: + """PN3 chamber segment closed through the adjacent real P4 node. + + The P4 primary pressure/temperature comes from the PNL0001 storage on + ``port_2``. P4 ports 1 and 3 are represented by the adjacent PNL0002 center + compliances; their far nodes remain prescribed pressure boundaries. + """ + + def __init__( + self, + *, + components: TestMqlPn3P4NodeChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + resistance_boundary: TestMqlPneumaticBoundaryCondition, + use_inlet_line_reference_rhs: bool = False, + use_p4_port3_remote_primary_reference_rhs: bool = False, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.resistance_boundary = resistance_boundary + self.use_inlet_line_reference_rhs = use_inlet_line_reference_rhs + self.use_p4_port3_remote_primary_reference_rhs = ( + use_p4_port3_remote_primary_reference_rhs + ) + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + *self.components.outlet_line.get_state_vector(), + *self.components.node_line.get_state_vector(), + *self.components.p4_primary_line.get_state_vector(), + *self.components.p4_primary_chamber.get_state_vector(), + *self.components.p4_port1_line.get_state_vector(), + *self.components.p4_port1_remote_primary_line.get_state_vector(), + *self.components.p4_port1_remote_primary_chamber.get_state_vector(), + *self.components.p4_port1_remote_port1_line.get_state_vector(), + *self.components.p4_port1_far_primary_line.get_state_vector(), + *self.components.p4_port1_far_primary_chamber.get_state_vector(), + *self.components.p4_port1_far_port1_line.get_state_vector(), + *self.components.p4_port1_next_primary_line.get_state_vector(), + *self.components.p4_port1_next_primary_chamber.get_state_vector(), + *self.components.p4_port1_next_port1_line.get_state_vector(), + *self.components.p4_bridge_primary_line.get_state_vector(), + *self.components.p4_bridge_primary_chamber.get_state_vector(), + *self.components.p4_port1_remote_orifice_line.get_state_vector(), + *self.components.p4_port3_line.get_state_vector(), + *self.components.p4_port3_remote_primary_line.get_state_vector(), + *self.components.p4_port3_remote_primary_chamber.get_state_vector(), + *self.components.p4_port3_remote_port3_line.get_state_vector(), + *self.components.p4_port3_far_primary_line.get_state_vector(), + *self.components.p4_port3_far_primary_chamber.get_state_vector(), + *self.components.p4_port3_far_port3_line.get_state_vector(), + *self.components.p4_port3_next_primary_line.get_state_vector(), + *self.components.p4_port3_next_primary_chamber.get_state_vector(), + *self.components.p4_port3_next_port3_line.get_state_vector(), + *self.components.p4_port3_remote_resistance_node_line.get_state_vector(), + *self.components.p4_port3_remote_resistance_output_line.get_state_vector(), + *self.components.p4_port3_remote_orifice_line.get_state_vector(), + *self.components.p4_port3_next_orifice_line.get_state_vector(), + *self.components.p4_port3_next_orifice_output_line.get_state_vector(), + *self.components.p4_port3_next_chamber_inlet_line.get_state_vector(), + *self.components.p4_port3_next_chamber.get_state_vector(), + *self.components.p4_port3_next_chamber_outlet_line.get_state_vector(), + *self.components.p4_port3_next_resistance_node_line.get_state_vector(), + *self.components.p4_port3_next_resistance_output_line.get_state_vector(), + *self.components.p4_port3_next_resistance_chamber_inlet_line.get_state_vector(), + *self.components.p4_port3_next_resistance_chamber.get_state_vector(), + *self.components.p4_port3_next_resistance_chamber_outlet_line.get_state_vector(), + *self.components.p4_port1_next_orifice_line.get_state_vector(), + *self.components.p4_port1_next_orifice_output_line.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_line.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_chamber.get_state_vector(), + *self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 112: + raise ValueError("PN3/P4 node chamber segment state vector requires one hundred twelve values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:4]) + self.components.outlet_line.set_state_vector(values[4:6]) + self.components.node_line.set_state_vector(values[6:10]) + self.components.p4_primary_line.set_state_vector(values[10:12]) + self.components.p4_primary_chamber.set_state_vector(values[12:14]) + self.components.p4_port1_line.set_state_vector(values[14:16]) + self.components.p4_port1_remote_primary_line.set_state_vector(values[16:18]) + self.components.p4_port1_remote_primary_chamber.set_state_vector(values[18:20]) + self.components.p4_port1_remote_port1_line.set_state_vector(values[20:22]) + self.components.p4_port1_far_primary_line.set_state_vector(values[22:24]) + self.components.p4_port1_far_primary_chamber.set_state_vector(values[24:26]) + self.components.p4_port1_far_port1_line.set_state_vector(values[26:28]) + self.components.p4_port1_next_primary_line.set_state_vector(values[28:30]) + self.components.p4_port1_next_primary_chamber.set_state_vector(values[30:32]) + self.components.p4_port1_next_port1_line.set_state_vector(values[32:34]) + self.components.p4_bridge_primary_line.set_state_vector(values[34:36]) + self.components.p4_bridge_primary_chamber.set_state_vector(values[36:38]) + self.components.p4_port1_remote_orifice_line.set_state_vector(values[38:42]) + self.components.p4_port3_line.set_state_vector(values[42:44]) + self.components.p4_port3_remote_primary_line.set_state_vector(values[44:46]) + self.components.p4_port3_remote_primary_chamber.set_state_vector(values[46:48]) + self.components.p4_port3_remote_port3_line.set_state_vector(values[48:50]) + self.components.p4_port3_far_primary_line.set_state_vector(values[50:52]) + self.components.p4_port3_far_primary_chamber.set_state_vector(values[52:54]) + self.components.p4_port3_far_port3_line.set_state_vector(values[54:56]) + self.components.p4_port3_next_primary_line.set_state_vector(values[56:58]) + self.components.p4_port3_next_primary_chamber.set_state_vector(values[58:60]) + self.components.p4_port3_next_port3_line.set_state_vector(values[60:62]) + self.components.p4_port3_remote_resistance_node_line.set_state_vector(values[62:66]) + self.components.p4_port3_remote_resistance_output_line.set_state_vector(values[66:68]) + self.components.p4_port3_remote_orifice_line.set_state_vector(values[68:72]) + self.components.p4_port3_next_orifice_line.set_state_vector(values[72:76]) + self.components.p4_port3_next_orifice_output_line.set_state_vector(values[76:78]) + self.components.p4_port3_next_chamber_inlet_line.set_state_vector(values[78:80]) + self.components.p4_port3_next_chamber.set_state_vector(values[80:82]) + self.components.p4_port3_next_chamber_outlet_line.set_state_vector(values[82:84]) + self.components.p4_port3_next_resistance_node_line.set_state_vector(values[84:88]) + self.components.p4_port3_next_resistance_output_line.set_state_vector(values[88:90]) + self.components.p4_port3_next_resistance_chamber_inlet_line.set_state_vector(values[90:92]) + self.components.p4_port3_next_resistance_chamber.set_state_vector(values[92:94]) + self.components.p4_port3_next_resistance_chamber_outlet_line.set_state_vector(values[94:96]) + self.components.p4_port1_next_orifice_line.set_state_vector(values[96:100]) + self.components.p4_port1_next_orifice_output_line.set_state_vector(values[100:102]) + self.components.p4_port1_next_orifice_resistance_line.set_state_vector(values[102:106]) + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.set_state_vector(values[106:108]) + self.components.p4_port1_next_orifice_resistance_chamber.set_state_vector(values[108:110]) + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.set_state_vector(values[110:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPn3P4NodeChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + inlet_line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + outlet_line = self.components.outlet_line.properties() + node_line_port_1 = self.components.node_line.properties_1() + node_line_port_2 = self.components.node_line.properties_2() + p4_primary_line = self.components.p4_primary_line.properties() + p4_primary_chamber = self.components.p4_primary_chamber.properties() + p4_port1_line = self.components.p4_port1_line.properties() + p4_port1_remote_primary_line = ( + self.components.p4_port1_remote_primary_line.properties() + ) + p4_port1_remote_primary_chamber = ( + self.components.p4_port1_remote_primary_chamber.properties() + ) + p4_port1_remote_port1_line = ( + self.components.p4_port1_remote_port1_line.properties() + ) + p4_port1_far_primary_line = self.components.p4_port1_far_primary_line.properties() + p4_port1_far_primary_chamber = ( + self.components.p4_port1_far_primary_chamber.properties() + ) + p4_port1_far_port1_line = self.components.p4_port1_far_port1_line.properties() + p4_port1_next_primary_line = self.components.p4_port1_next_primary_line.properties() + p4_port1_next_primary_chamber = ( + self.components.p4_port1_next_primary_chamber.properties() + ) + p4_port1_next_port1_line = self.components.p4_port1_next_port1_line.properties() + p4_port1_next_orifice_line_port_1 = ( + self.components.p4_port1_next_orifice_line.properties_1() + ) + p4_port1_next_orifice_line_port_2 = ( + self.components.p4_port1_next_orifice_line.properties_2() + ) + p4_port1_next_orifice_output_line = ( + self.components.p4_port1_next_orifice_output_line.properties() + ) + p4_port1_next_orifice_resistance_line_port_1 = ( + self.components.p4_port1_next_orifice_resistance_line.properties_1() + ) + p4_port1_next_orifice_resistance_line_port_2 = ( + self.components.p4_port1_next_orifice_resistance_line.properties_2() + ) + p4_port1_next_orifice_resistance_chamber_inlet_line = ( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.properties() + ) + p4_port1_next_orifice_resistance_chamber = ( + self.components.p4_port1_next_orifice_resistance_chamber.properties() + ) + p4_port1_next_orifice_resistance_chamber_outlet_line = ( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.properties() + ) + p4_bridge_primary_line = self.components.p4_bridge_primary_line.properties() + p4_bridge_primary_chamber = self.components.p4_bridge_primary_chamber.properties() + p4_port1_remote_orifice_line_port_1 = ( + self.components.p4_port1_remote_orifice_line.properties_1() + ) + p4_port1_remote_orifice_line_port_2 = ( + self.components.p4_port1_remote_orifice_line.properties_2() + ) + p4_port3_line = self.components.p4_port3_line.properties() + p4_port3_remote_primary_line = ( + self.components.p4_port3_remote_primary_line.properties() + ) + p4_port3_remote_primary_chamber = ( + self.components.p4_port3_remote_primary_chamber.properties() + ) + p4_port3_remote_port3_line = ( + self.components.p4_port3_remote_port3_line.properties() + ) + p4_port3_far_primary_line = self.components.p4_port3_far_primary_line.properties() + p4_port3_far_primary_chamber = ( + self.components.p4_port3_far_primary_chamber.properties() + ) + p4_port3_far_port3_line = self.components.p4_port3_far_port3_line.properties() + p4_port3_next_primary_line = self.components.p4_port3_next_primary_line.properties() + p4_port3_next_primary_chamber = ( + self.components.p4_port3_next_primary_chamber.properties() + ) + p4_port3_next_port3_line = self.components.p4_port3_next_port3_line.properties() + p4_port3_next_orifice_line_port_1 = ( + self.components.p4_port3_next_orifice_line.properties_1() + ) + p4_port3_next_orifice_line_port_2 = ( + self.components.p4_port3_next_orifice_line.properties_2() + ) + p4_port3_next_orifice_output_line = ( + self.components.p4_port3_next_orifice_output_line.properties() + ) + p4_port3_next_chamber_inlet_line = ( + self.components.p4_port3_next_chamber_inlet_line.properties() + ) + p4_port3_next_chamber = self.components.p4_port3_next_chamber.properties() + p4_port3_next_chamber_outlet_line = ( + self.components.p4_port3_next_chamber_outlet_line.properties() + ) + p4_port3_next_resistance_node_line_port_1 = ( + self.components.p4_port3_next_resistance_node_line.properties_1() + ) + p4_port3_next_resistance_node_line_port_2 = ( + self.components.p4_port3_next_resistance_node_line.properties_2() + ) + p4_port3_next_resistance_output_line = ( + self.components.p4_port3_next_resistance_output_line.properties() + ) + p4_port3_next_resistance_chamber_inlet_line = ( + self.components.p4_port3_next_resistance_chamber_inlet_line.properties() + ) + p4_port3_next_resistance_chamber = ( + self.components.p4_port3_next_resistance_chamber.properties() + ) + p4_port3_next_resistance_chamber_outlet_line = ( + self.components.p4_port3_next_resistance_chamber_outlet_line.properties() + ) + p4_port3_remote_orifice_line_port_1 = ( + self.components.p4_port3_remote_orifice_line.properties_1() + ) + p4_port3_remote_orifice_line_port_2 = ( + self.components.p4_port3_remote_orifice_line.properties_2() + ) + p4_port3_remote_resistance_node_line_port_1 = ( + self.components.p4_port3_remote_resistance_node_line.properties_1() + ) + p4_port3_remote_resistance_node_line_port_2 = ( + self.components.p4_port3_remote_resistance_node_line.properties_2() + ) + p4_port3_remote_resistance_output_line = ( + self.components.p4_port3_remote_resistance_output_line.properties() + ) + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + resistance_boundary = self.resistance_boundary.properties( + self.components.node_resistance.gas + ) + chamber_segment_flows = self._fixed_chamber_segment_flows( + chamber_properties=chamber, + inlet_line=self.components.inlet_line, + inlet_line_properties=inlet_line, + inlet_node_properties=p4_port3_remote_orifice_line_port_1, + inlet_orifice=self.components.inlet_orifice, + outlet_line=self.components.outlet_line, + outlet_line_properties=outlet_line, + outlet_node_properties=node_line_port_1, + outlet_orifice=self.components.outlet_orifice, + ) + inlet_node_to_line_flow = chamber_segment_flows.inlet_node_to_line_flow + inlet_line_to_chamber_flow = chamber_segment_flows.inlet_line_to_chamber_flow + outlet_node_to_line_flow = chamber_segment_flows.outlet_node_to_line_flow + outlet_line_to_chamber_flow = chamber_segment_flows.outlet_line_to_chamber_flow + node_to_resistance_flow = self._resistance_flow( + resistance=self.components.node_resistance, + port_1_properties=node_line_port_1, + port_2_properties=p4_port1_remote_orifice_line_port_1, + ) + node_balance = self._pn3_node_balance( + node=self.components.node, + primary_properties=node_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=node_to_resistance_flow, + node_h=node_line_port_1.h, + connected_h=p4_port1_remote_orifice_line_port_1.h, + port_mass_flow_kg_s=node_to_resistance_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=outlet_node_to_line_flow, + node_h=node_line_port_1.h, + connected_h=outlet_line.h, + port_mass_flow_kg_s=outlet_node_to_line_flow, + ), + ) + node_to_pnl0003_flow = -node_balance.port_2_mass_flow_g_s * 1.0e-3 + pnl0003_center_flow = self.components.node_line.resistance_mass_flow() + pnl0003_to_p4_flow = self._line_to_p4_flow( + line=node_line_port_2, + p4_boundary=p4_primary_line, + orifice=self.components.node_orifice, + ) + + p4_node_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_primary_line, + primary_chamber_properties=p4_primary_chamber, + port_1_line=self.components.p4_port1_line, + port_1_properties=p4_primary_line, + port_3_line=self.components.p4_port3_line, + port_3_properties=p4_primary_line, + ) + p4_primary_chamber_to_line_flow = ( + p4_node_line_flows.primary_chamber_to_line_flow + ) + p4_to_port1_line_flow = p4_node_line_flows.port_1_line_flow + p4_to_port3_line_flow = p4_node_line_flows.port_3_line_flow + p4_port1_remote_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port1_remote_primary_line, + primary_chamber_properties=p4_port1_remote_primary_chamber, + port_1_line=self.components.p4_port1_remote_port1_line, + port_1_properties=p4_port1_remote_primary_line, + port_3_line=self.components.p4_port1_line, + port_3_properties=p4_port1_remote_primary_line, + ) + p4_port1_remote_chamber_to_line_flow = ( + p4_port1_remote_line_flows.primary_chamber_to_line_flow + ) + p4_port1_remote_to_port1_line_flow = ( + p4_port1_remote_line_flows.port_1_line_flow + ) + p4_port1_remote_node_to_line_flow = ( + p4_port1_remote_line_flows.port_3_line_flow + ) + p4_port1_far_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port1_far_primary_line, + primary_chamber_properties=p4_port1_far_primary_chamber, + port_1_line=self.components.p4_port1_far_port1_line, + port_1_properties=p4_port1_far_primary_line, + port_3_line=self.components.p4_port1_remote_port1_line, + port_3_properties=p4_port1_far_primary_line, + ) + p4_port1_far_chamber_to_line_flow = ( + p4_port1_far_line_flows.primary_chamber_to_line_flow + ) + p4_port1_far_to_next_line_flow = p4_port1_far_line_flows.port_1_line_flow + p4_port1_far_node_to_line_flow = p4_port1_far_line_flows.port_3_line_flow + p4_port1_next_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port1_next_primary_line, + primary_chamber_properties=p4_port1_next_primary_chamber, + port_1_line=self.components.p4_port1_next_port1_line, + port_1_properties=p4_port1_next_primary_line, + port_3_line=self.components.p4_port1_far_port1_line, + port_3_properties=p4_port1_next_primary_line, + ) + p4_port1_next_chamber_to_line_flow = ( + p4_port1_next_line_flows.primary_chamber_to_line_flow + ) + p4_port1_next_to_bridge_line_flow = ( + p4_port1_next_line_flows.port_1_line_flow + ) + p4_port1_next_node_to_line_flow = p4_port1_next_line_flows.port_3_line_flow + p4_port1_next_orifice_p4_to_output_line_flow = ( + self.components.p4_port1_next_orifice_output_line.resistance_mass_flow( + port_1_pressure_pa=p4_port1_next_primary_line.p, + port_1_temperature_k=p4_port1_next_primary_line.T, + ) + ) + p4_bridge_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_bridge_primary_line, + primary_chamber_properties=p4_bridge_primary_chamber, + port_1_line=self.components.p4_port3_next_port3_line, + port_1_properties=p4_bridge_primary_line, + port_3_line=self.components.p4_port1_next_port1_line, + port_3_properties=p4_bridge_primary_line, + ) + p4_bridge_chamber_to_line_flow = ( + p4_bridge_line_flows.primary_chamber_to_line_flow + ) + p4_bridge_to_port3_next_line_flow = p4_bridge_line_flows.port_1_line_flow + p4_bridge_to_port1_next_line_flow = p4_bridge_line_flows.port_3_line_flow + p4_port1_next_orifice_resistance_chamber_flows = ( + self._fixed_chamber_segment_flows( + chamber_properties=p4_port1_next_orifice_resistance_chamber, + inlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_line_properties=( + p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_node_properties=p4_port1_remote_orifice_line_port_1, + inlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice + ), + outlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_line_properties=( + p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_node_properties=p4_port1_next_orifice_resistance_line_port_1, + outlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice + ), + ) + ) + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.inlet_node_to_line_flow + ) + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.inlet_line_to_chamber_flow + ) + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.outlet_node_to_line_flow + ) + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow = ( + p4_port1_next_orifice_resistance_chamber_flows.outlet_line_to_chamber_flow + ) + p4_port1_remote_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port1_remote_orifice_node, + primary_properties=p4_port1_remote_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + node_h=p4_port1_remote_orifice_line_port_1.h, + connected_h=p4_port1_next_orifice_resistance_chamber_inlet_line.h, + port_mass_flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=-node_to_resistance_flow, + node_h=p4_port1_remote_orifice_line_port_1.h, + connected_h=node_line_port_1.h, + port_mass_flow_kg_s=-node_to_resistance_flow, + ), + ) + p4_port1_remote_node_to_orifice_line_flow = ( + -p4_port1_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_remote_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port1_remote_orifice_line, + line_port_2_properties=p4_port1_remote_orifice_line_port_2, + boundary_properties=p4_port1_remote_primary_line, + orifice=self.components.p4_port1_remote_orifice, + ) + p4_port1_remote_orifice_line_center_flow = ( + p4_port1_remote_orifice_line_flows.line_center_flow + ) + p4_port1_remote_orifice_to_node_flow = ( + p4_port1_remote_orifice_line_flows.line_to_boundary_flow + ) + p4_port3_remote_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port3_remote_primary_line, + primary_chamber_properties=p4_port3_remote_primary_chamber, + port_1_line=self.components.p4_port3_line, + port_1_properties=p4_port3_remote_primary_line, + port_3_line=self.components.p4_port3_remote_port3_line, + port_3_properties=p4_port3_remote_primary_line, + ) + p4_port3_remote_chamber_to_line_flow = ( + p4_port3_remote_line_flows.primary_chamber_to_line_flow + ) + p4_port3_remote_node_to_line_flow = ( + p4_port3_remote_line_flows.port_1_line_flow + ) + p4_port3_remote_to_port3_line_flow = ( + p4_port3_remote_line_flows.port_3_line_flow + ) + p4_port3_far_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port3_far_primary_line, + primary_chamber_properties=p4_port3_far_primary_chamber, + port_1_line=self.components.p4_port3_remote_port3_line, + port_1_properties=p4_port3_far_primary_line, + port_3_line=self.components.p4_port3_far_port3_line, + port_3_properties=p4_port3_far_primary_line, + ) + p4_port3_far_chamber_to_line_flow = ( + p4_port3_far_line_flows.primary_chamber_to_line_flow + ) + p4_port3_far_node_to_line_flow = p4_port3_far_line_flows.port_1_line_flow + p4_port3_far_to_next_line_flow = p4_port3_far_line_flows.port_3_line_flow + p4_port3_next_line_flows = self._p4_neighborhood_line_flows( + primary_line=self.components.p4_port3_next_primary_line, + primary_chamber_properties=p4_port3_next_primary_chamber, + port_1_line=self.components.p4_port3_far_port3_line, + port_1_properties=p4_port3_next_primary_line, + port_3_line=self.components.p4_port3_next_port3_line, + port_3_properties=p4_port3_next_primary_line, + ) + p4_port3_next_chamber_to_line_flow = ( + p4_port3_next_line_flows.primary_chamber_to_line_flow + ) + p4_port3_next_node_to_line_flow = p4_port3_next_line_flows.port_1_line_flow + p4_port3_next_to_bridge_line_flow = ( + p4_port3_next_line_flows.port_3_line_flow + ) + p4_port3_next_chamber_flows = self._fixed_chamber_segment_flows( + chamber_properties=p4_port3_next_chamber, + inlet_line=self.components.p4_port3_next_chamber_inlet_line, + inlet_line_properties=p4_port3_next_chamber_inlet_line, + inlet_node_properties=p4_port3_next_orifice_line_port_1, + inlet_orifice=self.components.p4_port3_next_chamber_inlet_orifice, + outlet_line=self.components.p4_port3_next_chamber_outlet_line, + outlet_line_properties=p4_port3_next_chamber_outlet_line, + outlet_node_properties=p4_port3_remote_resistance_node_line_port_1, + outlet_orifice=self.components.p4_port3_next_chamber_outlet_orifice, + ) + p4_port3_next_chamber_inlet_node_to_line_flow = ( + p4_port3_next_chamber_flows.inlet_node_to_line_flow + ) + p4_port3_next_chamber_inlet_line_to_chamber_flow = ( + p4_port3_next_chamber_flows.inlet_line_to_chamber_flow + ) + p4_port3_next_chamber_outlet_node_to_line_flow = ( + p4_port3_next_chamber_flows.outlet_node_to_line_flow + ) + p4_port3_next_chamber_outlet_line_to_chamber_flow = ( + p4_port3_next_chamber_flows.outlet_line_to_chamber_flow + ) + p4_port3_next_resistance_node_to_next_flow = self._resistance_flow( + resistance=self.components.p4_port3_next_orifice_resistance, + port_1_properties=p4_port3_next_resistance_node_line_port_1, + port_2_properties=p4_port3_next_orifice_line_port_1, + ) + p4_port3_next_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_next_orifice_node, + primary_properties=p4_port3_next_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_chamber_inlet_node_to_line_flow, + node_h=p4_port3_next_orifice_line_port_1.h, + connected_h=p4_port3_next_chamber_inlet_line.h, + port_mass_flow_kg_s=p4_port3_next_chamber_inlet_node_to_line_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=-p4_port3_next_resistance_node_to_next_flow, + node_h=p4_port3_next_orifice_line_port_1.h, + connected_h=p4_port3_next_resistance_node_line_port_1.h, + port_mass_flow_kg_s=-p4_port3_next_resistance_node_to_next_flow, + ), + ) + p4_port3_next_orifice_node_to_line_flow = ( + -p4_port3_next_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_next_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_next_orifice_line, + line_port_2_properties=p4_port3_next_orifice_line_port_2, + boundary_properties=p4_port3_next_orifice_output_line, + orifice=self.components.p4_port3_next_orifice, + ) + p4_port3_next_orifice_line_center_flow = ( + p4_port3_next_orifice_line_flows.line_center_flow + ) + p4_port3_next_orifice_to_output_line_flow = ( + p4_port3_next_orifice_line_flows.line_to_boundary_flow + ) + p4_port3_next_orifice_output_to_p4_flow = ( + p4_port3_next_orifice_to_output_line_flow + ) + p4_port3_next_resistance_chamber_flows = ( + self._fixed_chamber_segment_flows( + chamber_properties=p4_port3_next_resistance_chamber, + inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, + inlet_line_properties=p4_port3_next_resistance_chamber_inlet_line, + inlet_node_properties=p4_port1_next_orifice_line_port_1, + inlet_orifice=( + self.components.p4_port3_next_resistance_chamber_inlet_orifice + ), + outlet_line=( + self.components.p4_port3_next_resistance_chamber_outlet_line + ), + outlet_line_properties=p4_port3_next_resistance_chamber_outlet_line, + outlet_node_properties=p4_port3_next_resistance_node_line_port_1, + outlet_orifice=( + self.components.p4_port3_next_resistance_chamber_outlet_orifice + ), + ) + ) + p4_port3_next_resistance_chamber_inlet_node_to_line_flow = ( + p4_port3_next_resistance_chamber_flows.inlet_node_to_line_flow + ) + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow = ( + p4_port3_next_resistance_chamber_flows.inlet_line_to_chamber_flow + ) + p4_port3_next_resistance_chamber_outlet_node_to_line_flow = ( + p4_port3_next_resistance_chamber_flows.outlet_node_to_line_flow + ) + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow = ( + p4_port3_next_resistance_chamber_flows.outlet_line_to_chamber_flow + ) + p4_port1_next_orifice_resistance_node_to_next_flow = self._resistance_flow( + resistance=self.components.p4_port1_next_orifice_resistance, + port_1_properties=p4_port1_next_orifice_resistance_line_port_1, + port_2_properties=p4_port1_next_orifice_line_port_1, + ) + p4_port1_next_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port1_next_orifice_node, + primary_properties=p4_port1_next_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_chamber_inlet_node_to_line_flow, + node_h=p4_port1_next_orifice_line_port_1.h, + connected_h=p4_port3_next_resistance_chamber_inlet_line.h, + port_mass_flow_kg_s=( + p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=-p4_port1_next_orifice_resistance_node_to_next_flow, + node_h=p4_port1_next_orifice_line_port_1.h, + connected_h=p4_port1_next_orifice_resistance_line_port_1.h, + port_mass_flow_kg_s=( + -p4_port1_next_orifice_resistance_node_to_next_flow + ), + ), + ) + p4_port1_next_orifice_node_to_line_flow = ( + -p4_port1_next_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_next_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port1_next_orifice_line, + line_port_2_properties=p4_port1_next_orifice_line_port_2, + boundary_properties=p4_port1_next_orifice_output_line, + orifice=self.components.p4_port1_next_orifice, + ) + p4_port1_next_orifice_line_center_flow = ( + p4_port1_next_orifice_line_flows.line_center_flow + ) + p4_port1_next_orifice_to_output_line_flow = ( + p4_port1_next_orifice_line_flows.line_to_boundary_flow + ) + p4_port1_next_orifice_resistance_node_balance = self._pn3_node_balance( + node=self.components.p4_port1_next_orifice_resistance_node, + primary_properties=p4_port1_next_orifice_resistance_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port1_next_orifice_resistance_node_to_next_flow, + node_h=p4_port1_next_orifice_resistance_line_port_1.h, + connected_h=p4_port1_next_orifice_line_port_1.h, + port_mass_flow_kg_s=p4_port1_next_orifice_resistance_node_to_next_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + node_h=p4_port1_next_orifice_resistance_line_port_1.h, + connected_h=p4_port1_next_orifice_resistance_chamber_outlet_line.h, + port_mass_flow_kg_s=( + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + ), + ) + p4_port1_next_orifice_resistance_node_to_line_flow = ( + -p4_port1_next_orifice_resistance_node_balance.port_2_mass_flow_g_s + * 1.0e-3 + ) + p4_port1_next_orifice_resistance_line_flows = ( + self._two_state_orifice_line_flows( + line=self.components.p4_port1_next_orifice_resistance_line, + line_port_2_properties=( + p4_port1_next_orifice_resistance_line_port_2 + ), + boundary_properties=p4_port1_far_primary_line, + orifice=self.components.p4_port1_next_orifice_resistance_orifice, + ) + ) + p4_port1_next_orifice_resistance_line_center_flow = ( + p4_port1_next_orifice_resistance_line_flows.line_center_flow + ) + p4_port1_next_orifice_resistance_to_p4_flow = ( + p4_port1_next_orifice_resistance_line_flows.line_to_boundary_flow + ) + p4_port3_next_resistance_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_next_resistance_node, + primary_properties=p4_port3_next_resistance_node_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_node_to_next_flow, + node_h=p4_port3_next_resistance_node_line_port_1.h, + connected_h=p4_port3_next_orifice_line_port_1.h, + port_mass_flow_kg_s=p4_port3_next_resistance_node_to_next_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_chamber_outlet_node_to_line_flow, + node_h=p4_port3_next_resistance_node_line_port_1.h, + connected_h=p4_port3_next_resistance_chamber_outlet_line.h, + port_mass_flow_kg_s=( + p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + ), + ) + p4_port3_next_resistance_node_to_line_flow = ( + -p4_port3_next_resistance_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_next_resistance_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_next_resistance_node_line, + line_port_2_properties=p4_port3_next_resistance_node_line_port_2, + boundary_properties=p4_port3_next_resistance_output_line, + orifice=self.components.p4_port3_next_resistance_orifice, + ) + p4_port3_next_resistance_line_center_flow = ( + p4_port3_next_resistance_line_flows.line_center_flow + ) + p4_port3_next_resistance_to_output_line_flow = ( + p4_port3_next_resistance_line_flows.line_to_boundary_flow + ) + p4_port3_next_resistance_output_to_p4_flow = ( + p4_port3_next_resistance_to_output_line_flow + ) + p4_port3_remote_node_to_resistance_flow = self._resistance_flow( + resistance=self.components.p4_port3_remote_orifice_resistance, + port_1_properties=p4_port3_remote_orifice_line_port_1, + port_2_properties=p4_port3_remote_resistance_node_line_port_1, + ) + p4_port3_remote_orifice_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_remote_orifice_node, + primary_properties=p4_port3_remote_orifice_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=inlet_node_to_line_flow, + node_h=p4_port3_remote_orifice_line_port_1.h, + connected_h=inlet_line.h, + port_mass_flow_kg_s=inlet_node_to_line_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_remote_node_to_resistance_flow, + node_h=p4_port3_remote_orifice_line_port_1.h, + connected_h=p4_port3_remote_resistance_node_line_port_1.h, + port_mass_flow_kg_s=p4_port3_remote_node_to_resistance_flow, + ), + ) + p4_port3_remote_node_to_orifice_line_flow = ( + -p4_port3_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_remote_resistance_node_balance = self._pn3_node_balance( + node=self.components.p4_port3_remote_resistance_node, + primary_properties=p4_port3_remote_resistance_node_line_port_1, + port_1=TestMqlPn3NodeBalancePort( + flow_kg_s=-p4_port3_remote_node_to_resistance_flow, + node_h=p4_port3_remote_resistance_node_line_port_1.h, + connected_h=p4_port3_remote_orifice_line_port_1.h, + port_mass_flow_kg_s=-p4_port3_remote_node_to_resistance_flow, + ), + port_3=TestMqlPn3NodeBalancePort( + flow_kg_s=p4_port3_next_chamber_outlet_node_to_line_flow, + node_h=p4_port3_remote_resistance_node_line_port_1.h, + connected_h=p4_port3_next_chamber_outlet_line.h, + port_mass_flow_kg_s=p4_port3_next_chamber_outlet_node_to_line_flow, + ), + ) + p4_port3_remote_resistance_node_to_line_flow = ( + -p4_port3_remote_resistance_node_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_remote_resistance_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_remote_resistance_node_line, + line_port_2_properties=p4_port3_remote_resistance_node_line_port_2, + boundary_properties=p4_port3_remote_resistance_output_line, + orifice=self.components.p4_port3_remote_resistance_orifice, + ) + p4_port3_remote_resistance_line_center_flow = ( + p4_port3_remote_resistance_line_flows.line_center_flow + ) + p4_port3_remote_resistance_to_output_line_flow = ( + p4_port3_remote_resistance_line_flows.line_to_boundary_flow + ) + p4_port3_remote_resistance_output_to_p4_flow = ( + p4_port3_remote_resistance_to_output_line_flow + ) + p4_port3_remote_orifice_line_flows = self._two_state_orifice_line_flows( + line=self.components.p4_port3_remote_orifice_line, + line_port_2_properties=p4_port3_remote_orifice_line_port_2, + boundary_properties=p4_port3_remote_primary_line, + orifice=self.components.p4_port3_remote_orifice, + ) + p4_port3_remote_orifice_line_center_flow = ( + p4_port3_remote_orifice_line_flows.line_center_flow + ) + p4_port3_remote_orifice_to_node_flow = ( + p4_port3_remote_orifice_line_flows.line_to_boundary_flow + ) + p4_balance = self._p4_node_balance( + node=self.components.p4_node, + primary_properties=p4_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_to_port1_line_flow, + node_h=p4_primary_line.h, + connected_h=p4_port1_line.h, + port_mass_flow_kg_s=-p4_to_port1_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_to_port3_line_flow, + node_h=p4_primary_line.h, + connected_h=p4_port3_line.h, + port_mass_flow_kg_s=-p4_to_port3_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=pnl0003_to_p4_flow, + node_h=node_line_port_2.h, + connected_h=p4_primary_line.h, + port_mass_flow_kg_s=pnl0003_to_p4_flow, + ), + ) + p4_node_to_primary_line_flow = p4_balance.port_2_mass_flow_g_s * 1.0e-3 + p4_port1_remote_balance = self._p4_node_balance( + node=self.components.p4_port1_remote_node, + primary_properties=p4_port1_remote_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_remote_to_port1_line_flow, + node_h=p4_port1_remote_primary_line.h, + connected_h=p4_port1_remote_port1_line.h, + port_mass_flow_kg_s=-p4_port1_remote_to_port1_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_remote_node_to_line_flow, + node_h=p4_port1_remote_primary_line.h, + connected_h=p4_port1_line.h, + port_mass_flow_kg_s=-p4_port1_remote_node_to_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_remote_orifice_to_node_flow, + node_h=p4_port1_remote_orifice_line_port_2.h, + connected_h=p4_port1_remote_primary_line.h, + port_mass_flow_kg_s=p4_port1_remote_orifice_to_node_flow, + ), + ) + p4_port1_remote_node_to_primary_line_flow = ( + p4_port1_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_far_balance = self._p4_node_balance( + node=self.components.p4_port1_far_node, + primary_properties=p4_port1_far_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_far_to_next_line_flow, + node_h=p4_port1_far_primary_line.h, + connected_h=p4_port1_far_port1_line.h, + port_mass_flow_kg_s=-p4_port1_far_to_next_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_far_node_to_line_flow, + node_h=p4_port1_far_primary_line.h, + connected_h=p4_port1_remote_port1_line.h, + port_mass_flow_kg_s=-p4_port1_far_node_to_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_orifice_resistance_to_p4_flow, + node_h=p4_port1_next_orifice_resistance_line_port_2.h, + connected_h=p4_port1_far_primary_line.h, + port_mass_flow_kg_s=p4_port1_next_orifice_resistance_to_p4_flow, + ), + ) + p4_port1_far_node_to_primary_line_flow = ( + p4_port1_far_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port1_next_balance = self._p4_node_balance( + node=self.components.p4_port1_next_node, + primary_properties=p4_port1_next_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_to_bridge_line_flow, + node_h=p4_port1_next_primary_line.h, + connected_h=p4_port1_next_port1_line.h, + port_mass_flow_kg_s=-p4_port1_next_to_bridge_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_node_to_line_flow, + node_h=p4_port1_next_primary_line.h, + connected_h=p4_port1_far_port1_line.h, + port_mass_flow_kg_s=-p4_port1_next_node_to_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port1_next_orifice_p4_to_output_line_flow, + node_h=p4_port1_next_primary_line.h, + connected_h=p4_port1_next_orifice_output_line.h, + port_mass_flow_kg_s=-p4_port1_next_orifice_p4_to_output_line_flow, + ), + ) + p4_port1_next_node_to_primary_line_flow = ( + p4_port1_next_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_remote_balance = self._p4_node_balance( + node=self.components.p4_port3_remote_node, + primary_properties=p4_port3_remote_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_node_to_line_flow, + node_h=p4_port3_remote_primary_line.h, + connected_h=p4_port3_line.h, + port_mass_flow_kg_s=-p4_port3_remote_node_to_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_to_port3_line_flow, + node_h=p4_port3_remote_primary_line.h, + connected_h=p4_port3_remote_port3_line.h, + port_mass_flow_kg_s=-p4_port3_remote_to_port3_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_orifice_to_node_flow, + node_h=p4_port3_remote_orifice_line_port_2.h, + connected_h=p4_port3_remote_primary_line.h, + port_mass_flow_kg_s=p4_port3_remote_orifice_to_node_flow, + ), + ) + p4_port3_remote_node_to_primary_line_flow = ( + p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_far_balance = self._p4_node_balance( + node=self.components.p4_port3_far_node, + primary_properties=p4_port3_far_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_far_node_to_line_flow, + node_h=p4_port3_far_primary_line.h, + connected_h=p4_port3_remote_port3_line.h, + port_mass_flow_kg_s=-p4_port3_far_node_to_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_far_to_next_line_flow, + node_h=p4_port3_far_primary_line.h, + connected_h=p4_port3_far_port3_line.h, + port_mass_flow_kg_s=-p4_port3_far_to_next_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_remote_resistance_output_to_p4_flow, + node_h=p4_port3_remote_resistance_output_line.h, + connected_h=p4_port3_far_primary_line.h, + port_mass_flow_kg_s=p4_port3_remote_resistance_output_to_p4_flow, + ), + ) + p4_port3_far_node_to_primary_line_flow = ( + p4_port3_far_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_port3_next_balance = self._p4_node_balance( + node=self.components.p4_port3_next_node, + primary_properties=p4_port3_next_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_node_to_line_flow, + node_h=p4_port3_next_primary_line.h, + connected_h=p4_port3_far_port3_line.h, + port_mass_flow_kg_s=-p4_port3_next_node_to_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_to_bridge_line_flow, + node_h=p4_port3_next_primary_line.h, + connected_h=p4_port3_next_port3_line.h, + port_mass_flow_kg_s=-p4_port3_next_to_bridge_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_orifice_output_to_p4_flow, + node_h=p4_port3_next_orifice_output_line.h, + connected_h=p4_port3_next_primary_line.h, + port_mass_flow_kg_s=p4_port3_next_orifice_output_to_p4_flow, + ), + ) + p4_port3_next_node_to_primary_line_flow = ( + p4_port3_next_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + p4_bridge_balance = self._p4_node_balance( + node=self.components.p4_bridge_node, + primary_properties=p4_bridge_primary_line, + port_1=TestMqlP4NodeBalancePort( + flow_kg_s=p4_bridge_to_port3_next_line_flow, + node_h=p4_bridge_primary_line.h, + connected_h=p4_port3_next_port3_line.h, + port_mass_flow_kg_s=-p4_bridge_to_port3_next_line_flow, + ), + port_3=TestMqlP4NodeBalancePort( + flow_kg_s=p4_bridge_to_port1_next_line_flow, + node_h=p4_bridge_primary_line.h, + connected_h=p4_port1_next_port1_line.h, + port_mass_flow_kg_s=-p4_bridge_to_port1_next_line_flow, + ), + port_4=TestMqlP4NodeBalancePort( + flow_kg_s=p4_port3_next_resistance_output_to_p4_flow, + node_h=p4_port3_next_resistance_output_line.h, + connected_h=p4_bridge_primary_line.h, + port_mass_flow_kg_s=p4_port3_next_resistance_output_to_p4_flow, + ), + ) + p4_bridge_node_to_primary_line_flow = ( + p4_bridge_balance.port_2_mass_flow_g_s * 1.0e-3 + ) + + snapshot = TestMqlPn3P4NodeChamberSegmentSnapshot( + inlet_line=inlet_line, + chamber=chamber, + outlet_line=outlet_line, + node_line_port_1=node_line_port_1, + node_line_port_2=node_line_port_2, + p4_primary_line=p4_primary_line, + p4_boundary=p4_primary_line, + p4_primary_chamber=p4_primary_chamber, + p4_port1_line=p4_port1_line, + p4_port1_remote_primary_line=p4_port1_remote_primary_line, + p4_port1_remote_primary_chamber=p4_port1_remote_primary_chamber, + p4_port1_remote_port1_line=p4_port1_remote_port1_line, + p4_port1_far_primary_line=p4_port1_far_primary_line, + p4_port1_far_primary_chamber=p4_port1_far_primary_chamber, + p4_port1_far_port1_line=p4_port1_far_port1_line, + p4_port1_next_primary_line=p4_port1_next_primary_line, + p4_port1_next_primary_chamber=p4_port1_next_primary_chamber, + p4_port1_next_port1_line=p4_port1_next_port1_line, + p4_port1_next_orifice_line_port_1=p4_port1_next_orifice_line_port_1, + p4_port1_next_orifice_line_port_2=p4_port1_next_orifice_line_port_2, + p4_port1_next_orifice_output_line=p4_port1_next_orifice_output_line, + p4_port1_next_orifice_resistance_line_port_1=( + p4_port1_next_orifice_resistance_line_port_1 + ), + p4_port1_next_orifice_resistance_line_port_2=( + p4_port1_next_orifice_resistance_line_port_2 + ), + p4_port1_next_orifice_resistance_chamber_inlet_line=( + p4_port1_next_orifice_resistance_chamber_inlet_line + ), + p4_port1_next_orifice_resistance_chamber=( + p4_port1_next_orifice_resistance_chamber + ), + p4_port1_next_orifice_resistance_chamber_outlet_line=( + p4_port1_next_orifice_resistance_chamber_outlet_line + ), + p4_bridge_primary_line=p4_bridge_primary_line, + p4_bridge_primary_chamber=p4_bridge_primary_chamber, + p4_port1_remote_orifice_line_port_1=p4_port1_remote_orifice_line_port_1, + p4_port1_remote_orifice_line_port_2=p4_port1_remote_orifice_line_port_2, + p4_port3_line=p4_port3_line, + p4_port3_remote_primary_line=p4_port3_remote_primary_line, + p4_port3_remote_primary_chamber=p4_port3_remote_primary_chamber, + p4_port3_remote_port3_line=p4_port3_remote_port3_line, + p4_port3_far_primary_line=p4_port3_far_primary_line, + p4_port3_far_primary_chamber=p4_port3_far_primary_chamber, + p4_port3_far_port3_line=p4_port3_far_port3_line, + p4_port3_next_primary_line=p4_port3_next_primary_line, + p4_port3_next_primary_chamber=p4_port3_next_primary_chamber, + p4_port3_next_port3_line=p4_port3_next_port3_line, + p4_port3_next_orifice_line_port_1=p4_port3_next_orifice_line_port_1, + p4_port3_next_orifice_line_port_2=p4_port3_next_orifice_line_port_2, + p4_port3_next_orifice_output_line=p4_port3_next_orifice_output_line, + p4_port3_next_chamber_inlet_line=p4_port3_next_chamber_inlet_line, + p4_port3_next_chamber=p4_port3_next_chamber, + p4_port3_next_chamber_outlet_line=p4_port3_next_chamber_outlet_line, + p4_port3_next_resistance_node_line_port_1=( + p4_port3_next_resistance_node_line_port_1 + ), + p4_port3_next_resistance_node_line_port_2=( + p4_port3_next_resistance_node_line_port_2 + ), + p4_port3_next_resistance_output_line=p4_port3_next_resistance_output_line, + p4_port3_next_resistance_chamber_inlet_line=( + p4_port3_next_resistance_chamber_inlet_line + ), + p4_port3_next_resistance_chamber=p4_port3_next_resistance_chamber, + p4_port3_next_resistance_chamber_outlet_line=( + p4_port3_next_resistance_chamber_outlet_line + ), + p4_port3_remote_orifice_line_port_1=p4_port3_remote_orifice_line_port_1, + p4_port3_remote_orifice_line_port_2=p4_port3_remote_orifice_line_port_2, + p4_port3_remote_resistance_node_line_port_1=( + p4_port3_remote_resistance_node_line_port_1 + ), + p4_port3_remote_resistance_node_line_port_2=( + p4_port3_remote_resistance_node_line_port_2 + ), + p4_port3_remote_resistance_output_line=( + p4_port3_remote_resistance_output_line + ), + inlet_node=inlet_node, + resistance_boundary=resistance_boundary, + node_balance=node_balance, + p4_balance=p4_balance, + p4_port1_remote_balance=p4_port1_remote_balance, + p4_port1_far_balance=p4_port1_far_balance, + p4_port1_next_balance=p4_port1_next_balance, + p4_bridge_balance=p4_bridge_balance, + p4_port1_remote_orifice_node_balance=p4_port1_remote_orifice_node_balance, + p4_port1_next_orifice_node_balance=p4_port1_next_orifice_node_balance, + p4_port1_next_orifice_resistance_node_balance=( + p4_port1_next_orifice_resistance_node_balance + ), + p4_port3_remote_balance=p4_port3_remote_balance, + p4_port3_far_balance=p4_port3_far_balance, + p4_port3_next_balance=p4_port3_next_balance, + p4_port3_next_orifice_node_balance=p4_port3_next_orifice_node_balance, + p4_port3_next_resistance_node_balance=p4_port3_next_resistance_node_balance, + p4_port3_remote_orifice_node_balance=p4_port3_remote_orifice_node_balance, + p4_port3_remote_resistance_node_balance=( + p4_port3_remote_resistance_node_balance + ), + inlet_node_to_line_flow=inlet_node_to_line_flow, + inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, + outlet_node_to_line_flow=outlet_node_to_line_flow, + outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, + node_to_resistance_flow=node_to_resistance_flow, + node_to_pnl0003_flow=node_to_pnl0003_flow, + pnl0003_center_flow=pnl0003_center_flow, + pnl0003_to_p4_flow=pnl0003_to_p4_flow, + p4_primary_chamber_to_line_flow=p4_primary_chamber_to_line_flow, + p4_node_to_primary_line_flow=p4_node_to_primary_line_flow, + p4_to_port1_line_flow=p4_to_port1_line_flow, + p4_port1_remote_node_to_line_flow=p4_port1_remote_node_to_line_flow, + p4_port1_remote_chamber_to_line_flow=p4_port1_remote_chamber_to_line_flow, + p4_port1_remote_node_to_primary_line_flow=( + p4_port1_remote_node_to_primary_line_flow + ), + p4_port1_remote_to_port1_line_flow=p4_port1_remote_to_port1_line_flow, + p4_port1_far_node_to_line_flow=p4_port1_far_node_to_line_flow, + p4_port1_far_chamber_to_line_flow=p4_port1_far_chamber_to_line_flow, + p4_port1_far_node_to_primary_line_flow=( + p4_port1_far_node_to_primary_line_flow + ), + p4_port1_far_to_next_line_flow=p4_port1_far_to_next_line_flow, + p4_port1_next_node_to_line_flow=p4_port1_next_node_to_line_flow, + p4_port1_next_chamber_to_line_flow=p4_port1_next_chamber_to_line_flow, + p4_port1_next_node_to_primary_line_flow=( + p4_port1_next_node_to_primary_line_flow + ), + p4_port1_next_to_bridge_line_flow=p4_port1_next_to_bridge_line_flow, + p4_port1_next_orifice_p4_to_output_line_flow=( + p4_port1_next_orifice_p4_to_output_line_flow + ), + p4_port1_next_orifice_node_to_line_flow=( + p4_port1_next_orifice_node_to_line_flow + ), + p4_port1_next_orifice_line_center_flow=( + p4_port1_next_orifice_line_center_flow + ), + p4_port1_next_orifice_to_output_line_flow=( + p4_port1_next_orifice_to_output_line_flow + ), + p4_port1_next_orifice_resistance_node_to_next_flow=( + p4_port1_next_orifice_resistance_node_to_next_flow + ), + p4_port1_next_orifice_resistance_node_to_line_flow=( + p4_port1_next_orifice_resistance_node_to_line_flow + ), + p4_port1_next_orifice_resistance_line_center_flow=( + p4_port1_next_orifice_resistance_line_center_flow + ), + p4_port1_next_orifice_resistance_to_p4_flow=( + p4_port1_next_orifice_resistance_to_p4_flow + ), + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow=( + p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow=( + p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow + ), + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow=( + p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow=( + p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow + ), + p4_bridge_to_port1_next_line_flow=p4_bridge_to_port1_next_line_flow, + p4_bridge_chamber_to_line_flow=p4_bridge_chamber_to_line_flow, + p4_bridge_node_to_primary_line_flow=p4_bridge_node_to_primary_line_flow, + p4_port1_remote_node_to_orifice_line_flow=( + p4_port1_remote_node_to_orifice_line_flow + ), + p4_port1_remote_orifice_line_center_flow=( + p4_port1_remote_orifice_line_center_flow + ), + p4_port1_remote_orifice_to_node_flow=p4_port1_remote_orifice_to_node_flow, + p4_to_port3_line_flow=p4_to_port3_line_flow, + p4_port3_remote_node_to_line_flow=p4_port3_remote_node_to_line_flow, + p4_port3_remote_chamber_to_line_flow=p4_port3_remote_chamber_to_line_flow, + p4_port3_remote_node_to_primary_line_flow=( + p4_port3_remote_node_to_primary_line_flow + ), + p4_port3_remote_to_port3_line_flow=p4_port3_remote_to_port3_line_flow, + p4_port3_far_node_to_line_flow=p4_port3_far_node_to_line_flow, + p4_port3_far_chamber_to_line_flow=p4_port3_far_chamber_to_line_flow, + p4_port3_far_node_to_primary_line_flow=( + p4_port3_far_node_to_primary_line_flow + ), + p4_port3_far_to_next_line_flow=p4_port3_far_to_next_line_flow, + p4_port3_next_node_to_line_flow=p4_port3_next_node_to_line_flow, + p4_port3_next_chamber_to_line_flow=p4_port3_next_chamber_to_line_flow, + p4_port3_next_node_to_primary_line_flow=( + p4_port3_next_node_to_primary_line_flow + ), + p4_bridge_to_port3_next_line_flow=p4_bridge_to_port3_next_line_flow, + p4_port3_next_to_bridge_line_flow=p4_port3_next_to_bridge_line_flow, + p4_port3_next_orifice_node_to_line_flow=( + p4_port3_next_orifice_node_to_line_flow + ), + p4_port3_next_orifice_line_center_flow=( + p4_port3_next_orifice_line_center_flow + ), + p4_port3_next_orifice_to_output_line_flow=( + p4_port3_next_orifice_to_output_line_flow + ), + p4_port3_next_orifice_output_to_p4_flow=( + p4_port3_next_orifice_output_to_p4_flow + ), + p4_port3_next_chamber_inlet_node_to_line_flow=( + p4_port3_next_chamber_inlet_node_to_line_flow + ), + p4_port3_next_chamber_inlet_line_to_chamber_flow=( + p4_port3_next_chamber_inlet_line_to_chamber_flow + ), + p4_port3_next_chamber_outlet_node_to_line_flow=( + p4_port3_next_chamber_outlet_node_to_line_flow + ), + p4_port3_next_chamber_outlet_line_to_chamber_flow=( + p4_port3_next_chamber_outlet_line_to_chamber_flow + ), + p4_port3_next_resistance_node_to_next_flow=( + p4_port3_next_resistance_node_to_next_flow + ), + p4_port3_next_resistance_node_to_line_flow=( + p4_port3_next_resistance_node_to_line_flow + ), + p4_port3_next_resistance_line_center_flow=( + p4_port3_next_resistance_line_center_flow + ), + p4_port3_next_resistance_to_output_line_flow=( + p4_port3_next_resistance_to_output_line_flow + ), + p4_port3_next_resistance_output_to_p4_flow=( + p4_port3_next_resistance_output_to_p4_flow + ), + p4_port3_next_resistance_chamber_inlet_node_to_line_flow=( + p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow=( + p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow + ), + p4_port3_next_resistance_chamber_outlet_node_to_line_flow=( + p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow=( + p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow + ), + p4_port3_remote_node_to_orifice_line_flow=( + p4_port3_remote_node_to_orifice_line_flow + ), + p4_port3_remote_node_to_resistance_flow=( + p4_port3_remote_node_to_resistance_flow + ), + p4_port3_remote_resistance_node_to_line_flow=( + p4_port3_remote_resistance_node_to_line_flow + ), + p4_port3_remote_resistance_line_center_flow=( + p4_port3_remote_resistance_line_center_flow + ), + p4_port3_remote_resistance_to_output_line_flow=( + p4_port3_remote_resistance_to_output_line_flow + ), + p4_port3_remote_resistance_output_to_p4_flow=( + p4_port3_remote_resistance_output_to_p4_flow + ), + p4_port3_remote_orifice_line_center_flow=( + p4_port3_remote_orifice_line_center_flow + ), + p4_port3_remote_orifice_to_node_flow=p4_port3_remote_orifice_to_node_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_chamber_flow( + *, + line: ThermodynamicProperties, + chamber: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + return TestMqlPn3NodeChamberSegmentClosure._line_to_chamber_flow( + line=line, + chamber=chamber, + orifice=orifice, + ) + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + return TestMqlPn3NodeChamberSegmentClosure._line_to_p4_flow( + line=line, + p4_boundary=p4_boundary, + orifice=orifice, + ) + + @staticmethod + def _boundary_to_p4_flow( + *, + boundary: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = boundary.T if boundary.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(boundary.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPn3NodeChamberSegmentClosure._port(component, port_name) + + @staticmethod + def _enthalpy_flow_from_node( + *, + flow_kg_s: float, + node_h: float, + connected_h: float, + ) -> float: + return TestMqlPn3NodeChamberSegmentClosure._enthalpy_flow_from_node( + flow_kg_s=flow_kg_s, + node_h=node_h, + connected_h=connected_h, + ) + + @staticmethod + def _enthalpy_flow_for_node_port( + *, + flow_kg_s: float, + node_h: float, + connected_h: float, + port_mass_flow_kg_s: float, + ) -> float: + upstream_h = node_h if flow_kg_s >= 0.0 else connected_h + return port_mass_flow_kg_s * upstream_h + + def _two_state_orifice_line_flows( + self, + *, + line: AmesimPnl0003Pipe, + line_port_2_properties: ThermodynamicProperties, + boundary_properties: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> TestMqlTwoStateOrificeLineFlows: + return TestMqlTwoStateOrificeLineFlows( + line_center_flow=line.resistance_mass_flow(), + line_to_boundary_flow=self._line_to_p4_flow( + line=line_port_2_properties, + p4_boundary=boundary_properties, + orifice=orifice, + ), + ) + + @staticmethod + def _resistance_flow( + *, + resistance: AmesimPnl00rPipe, + port_1_properties: ThermodynamicProperties, + port_2_properties: ThermodynamicProperties, + ) -> float: + return resistance.mass_flow( + port_1_pressure_pa=port_1_properties.p, + port_1_temperature_k=port_1_properties.T, + port_2_pressure_pa=port_2_properties.p, + port_2_temperature_k=port_2_properties.T, + ) + + @staticmethod + def _p4_neighborhood_line_flows( + *, + primary_line: AmesimPnl0001Pipe, + primary_chamber_properties: ThermodynamicProperties, + port_1_line: AmesimPnl0002Pipe, + port_1_properties: ThermodynamicProperties, + port_3_line: AmesimPnl0002Pipe, + port_3_properties: ThermodynamicProperties, + ) -> TestMqlP4NeighborhoodLineFlows: + return TestMqlP4NeighborhoodLineFlows( + primary_chamber_to_line_flow=primary_line.resistance_mass_flow( + port_1_pressure_pa=primary_chamber_properties.p, + port_1_temperature_k=primary_chamber_properties.T, + ), + port_1_line_flow=port_1_line.port_mass_flow( + port_pressure_pa=port_1_properties.p, + port_temperature_k=port_1_properties.T, + ), + port_3_line_flow=port_3_line.port_mass_flow( + port_pressure_pa=port_3_properties.p, + port_temperature_k=port_3_properties.T, + ), + ) + + def _fixed_chamber_segment_flows( + self, + *, + chamber_properties: ThermodynamicProperties, + inlet_line: AmesimPnl0001Pipe, + inlet_line_properties: ThermodynamicProperties, + inlet_node_properties: ThermodynamicProperties, + inlet_orifice: AmesimPneumaticOrifice, + outlet_line: AmesimPnl0001Pipe, + outlet_line_properties: ThermodynamicProperties, + outlet_node_properties: ThermodynamicProperties, + outlet_orifice: AmesimPneumaticOrifice, + ) -> TestMqlFixedChamberSegmentFlows: + return TestMqlFixedChamberSegmentFlows( + inlet_node_to_line_flow=inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node_properties.p, + port_1_temperature_k=inlet_node_properties.T, + ), + inlet_line_to_chamber_flow=self._line_to_chamber_flow( + line=inlet_line_properties, + chamber=chamber_properties, + orifice=inlet_orifice, + ), + outlet_node_to_line_flow=outlet_line.resistance_mass_flow( + port_1_pressure_pa=outlet_node_properties.p, + port_1_temperature_k=outlet_node_properties.T, + ), + outlet_line_to_chamber_flow=self._line_to_chamber_flow( + line=outlet_line_properties, + chamber=chamber_properties, + orifice=outlet_orifice, + ), + ) + + def _pn3_node_balance( + self, + *, + node: TestMqlPneumaticNode3, + primary_properties: ThermodynamicProperties, + port_1: TestMqlPn3NodeBalancePort, + port_3: TestMqlPn3NodeBalancePort, + ) -> TestMqlPneumaticNode3Balance: + return node.balance( + port_2_temperature_k=primary_properties.T, + port_2_pressure_pa=primary_properties.p, + port_1_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_1.flow_kg_s, + node_h=port_1.node_h, + connected_h=port_1.connected_h, + port_mass_flow_kg_s=port_1.port_mass_flow_kg_s, + ), + port_1_mass_flow_g_s=port_1.port_mass_flow_kg_s * 1.0e3, + port_3_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_3.flow_kg_s, + node_h=port_3.node_h, + connected_h=port_3.connected_h, + port_mass_flow_kg_s=port_3.port_mass_flow_kg_s, + ), + port_3_mass_flow_g_s=port_3.port_mass_flow_kg_s * 1.0e3, + ) + + @staticmethod + def _pn3_node_port_2_connected_h( + *, + balance: TestMqlPneumaticNode3Balance, + fallback_h: float, + ) -> float: + mass_flow_kg_s = balance.port_2_mass_flow_g_s * 1.0e-3 + if abs(mass_flow_kg_s) <= 1.0e-12: + return fallback_h + return balance.port_2_enthalpy_flow_w / mass_flow_kg_s + + @staticmethod + def _p4_node_port_2_connected_h( + *, + balance: TestMqlPneumaticNode4Balance, + fallback_h: float, + ) -> float: + mass_flow_kg_s = balance.port_2_mass_flow_g_s * 1.0e-3 + if abs(mass_flow_kg_s) <= 1.0e-12: + return fallback_h + return balance.port_2_enthalpy_flow_w / mass_flow_kg_s + + @staticmethod + def _reference_enthalpy_flow_for_node_port( + *, + gas, + node_properties: ThermodynamicProperties, + connected_properties: ThermodynamicProperties, + flow_kg_s: float, + port_mass_flow_kg_s: float, + reference_temperature_k: float = 298.15, + ) -> float: + stream_properties = ( + node_properties if flow_kg_s >= 0.0 else connected_properties + ) + return port_mass_flow_kg_s * gas.pressure_reference_enthalpy( + stream_properties.p, + stream_properties.T, + reference_temperature=reference_temperature_k, + ) + + def _p4_port3_remote_port_2_reference_h( + self, + *, + snapshot: TestMqlPn3P4NodeChamberSegmentSnapshot, + reference_temperature_k: float = 298.15, + ) -> float: + gas = self.components.p4_port3_remote_primary_line.gas + port_2_mass_flow = snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 + if abs(port_2_mass_flow) <= 1.0e-12: + return gas.pressure_reference_enthalpy( + snapshot.p4_port3_remote_primary_line.p, + snapshot.p4_port3_remote_primary_line.T, + reference_temperature=reference_temperature_k, + ) + port_2_enthalpy_flow = sum( + ( + self._reference_enthalpy_flow_for_node_port( + gas=gas, + node_properties=snapshot.p4_port3_remote_primary_line, + connected_properties=snapshot.p4_port3_line, + flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, + reference_temperature_k=reference_temperature_k, + ), + self._reference_enthalpy_flow_for_node_port( + gas=gas, + node_properties=snapshot.p4_port3_remote_primary_line, + connected_properties=snapshot.p4_port3_remote_port3_line, + flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, + port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, + reference_temperature_k=reference_temperature_k, + ), + self._reference_enthalpy_flow_for_node_port( + gas=gas, + node_properties=snapshot.p4_port3_remote_orifice_line_port_2, + connected_properties=snapshot.p4_port3_remote_primary_line, + flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, + reference_temperature_k=reference_temperature_k, + ), + ) + ) + return port_2_enthalpy_flow / port_2_mass_flow + + def _p4_node_balance( + self, + *, + node: TestMqlPneumaticNode4, + primary_properties: ThermodynamicProperties, + port_1: TestMqlP4NodeBalancePort, + port_3: TestMqlP4NodeBalancePort, + port_4: TestMqlP4NodeBalancePort, + ) -> TestMqlPneumaticNode4Balance: + return node.balance( + port_2_temperature_k=primary_properties.T, + port_2_pressure_pa=primary_properties.p, + port_1_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_1.flow_kg_s, + node_h=port_1.node_h, + connected_h=port_1.connected_h, + port_mass_flow_kg_s=port_1.port_mass_flow_kg_s, + ), + port_1_mass_flow_g_s=port_1.port_mass_flow_kg_s * 1.0e3, + port_3_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_3.flow_kg_s, + node_h=port_3.node_h, + connected_h=port_3.connected_h, + port_mass_flow_kg_s=port_3.port_mass_flow_kg_s, + ), + port_3_mass_flow_g_s=port_3.port_mass_flow_kg_s * 1.0e3, + port_4_enthalpy_flow_w=self._enthalpy_flow_for_node_port( + flow_kg_s=port_4.flow_kg_s, + node_h=port_4.node_h, + connected_h=port_4.connected_h, + port_mass_flow_kg_s=port_4.port_mass_flow_kg_s, + ), + port_4_mass_flow_g_s=port_4.port_mass_flow_kg_s * 1.0e3, + ) + + def _write_fixed_chamber_segment( + self, + *, + spec: TestMqlPneumaticChamberSegmentSpec, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + inlet_line: AmesimPnl0001Pipe, + inlet_line_properties: ThermodynamicProperties, + inlet_node_properties: ThermodynamicProperties, + inlet_node_to_line_flow: float, + inlet_line_to_chamber_flow: float, + inlet_orifice: AmesimPneumaticOrifice, + outlet_line: AmesimPnl0001Pipe, + outlet_line_properties: ThermodynamicProperties, + outlet_node_properties: ThermodynamicProperties, + outlet_node_to_line_flow: float, + outlet_line_to_chamber_flow: float, + outlet_orifice: AmesimPneumaticOrifice, + ) -> None: + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=inlet_line, + line_properties=inlet_line_properties, + node_properties=inlet_node_properties, + node_to_line_flow=inlet_node_to_line_flow, + line_to_chamber_flow=inlet_line_to_chamber_flow, + orifice=inlet_orifice, + orifice_boundary_port=spec.inlet_orifice_boundary_port, + orifice_volume_port=spec.inlet_orifice_volume_port, + chamber_port=chamber_inlet_port, + chamber_properties=chamber_properties, + ) + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=outlet_line, + line_properties=outlet_line_properties, + node_properties=outlet_node_properties, + node_to_line_flow=outlet_node_to_line_flow, + line_to_chamber_flow=outlet_line_to_chamber_flow, + orifice=outlet_orifice, + orifice_boundary_port=spec.outlet_orifice_boundary_port, + orifice_volume_port=spec.outlet_orifice_volume_port, + chamber_port=chamber_outlet_port, + chamber_properties=chamber_properties, + ) + + def _fixed_chamber_segment_derivatives( + self, + *, + spec: TestMqlPneumaticChamberSegmentSpec, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + inlet_line: AmesimPnl0001Pipe, + inlet_line_properties: ThermodynamicProperties, + inlet_connected_properties: ThermodynamicProperties, + inlet_node_to_line_flow: float, + inlet_line_to_chamber_flow: float, + outlet_line: AmesimPnl0001Pipe, + outlet_line_properties: ThermodynamicProperties, + outlet_connected_properties: ThermodynamicProperties, + outlet_node_to_line_flow: float, + outlet_line_to_chamber_flow: float, + ) -> tuple[VolumeState, VolumeState, VolumeState]: + inlet_derivative = inlet_line.derivatives_from_connections( + port_1_m_flow=inlet_node_to_line_flow, + connected_h_1=inlet_connected_properties.h, + port_2_m_flow=-inlet_line_to_chamber_flow, + connected_h_2=chamber_properties.h, + ) + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + inlet_line_properties.h + if spec.volume_inlet_port == "port_1" + else outlet_line_properties.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + inlet_line_properties.h + if spec.volume_inlet_port == "port_2" + else outlet_line_properties.h + ), + internal_h=chamber_properties.h, + ) + outlet_derivative = outlet_line.derivatives_from_connections( + port_1_m_flow=outlet_node_to_line_flow, + connected_h_1=outlet_connected_properties.h, + port_2_m_flow=-outlet_line_to_chamber_flow, + connected_h_2=chamber_properties.h, + ) + return inlet_derivative, chamber_derivative, outlet_derivative + + @staticmethod + def _write_primary_chamber_line( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + chamber_to_line_flow: float, + node_to_line_flow: float, + ) -> None: + line.port_1.p = chamber_properties.p + line.port_1.m_flow = chamber_to_line_flow + line.port_1.h_outflow = line_properties.h + line.port_2.p = line_properties.p + line.port_2.m_flow = node_to_line_flow + line.port_2.h_outflow = line_properties.h + + chamber.port_a.p = chamber_properties.p + chamber.port_a.m_flow = -chamber_to_line_flow + chamber.port_a.h_outflow = chamber_properties.h + + @staticmethod + def _pnl0001_reference_enthalpy_derivative( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + port_1_connected_reference_h: float | None = None, + port_2_connected_reference_h: float | None = None, + reference_temperature_k: float = 298.15, + ) -> VolumeState: + def specific_reference_h( + *, + port_flow: float, + connected: ThermodynamicProperties, + connected_reference_h: float | None, + ) -> float: + if port_flow > 0.0 and connected_reference_h is not None: + return connected_reference_h + if port_flow > 0.0: + pressure = connected.p + temperature = connected.T + else: + pressure = line_properties.p + temperature = line_properties.T + return line.gas.pressure_reference_enthalpy( + pressure, + temperature, + reference_temperature=reference_temperature_k, + ) + + def reference_enthalpy_flow( + *, + port_flow: float, + connected: ThermodynamicProperties, + connected_reference_h: float | None, + ) -> float: + return port_flow * specific_reference_h( + port_flow=port_flow, + connected=connected, + connected_reference_h=connected_reference_h, + ) + + mass_derivative = port_1_flow + port_2_flow + heat_flow = ( + line.heat_transfer_coefficient + * line.heat_transfer_area + * (line.external_temperature - line_properties.T) + ) + enthalpy_flow = reference_enthalpy_flow( + port_flow=port_1_flow, + connected=port_1_properties, + connected_reference_h=port_1_connected_reference_h, + ) + reference_enthalpy_flow( + port_flow=port_2_flow, + connected=port_2_properties, + connected_reference_h=port_2_connected_reference_h, + ) + reference_offset_flow = ( + line.gas.cp * reference_temperature_k - line.gas.cv * line_properties.T + ) * mass_derivative + temperature_derivative = ( + enthalpy_flow + reference_offset_flow + heat_flow + ) / (line.state.m * line.gas.cv) + return VolumeState( + m=mass_derivative, + U=line.gas.cv + * (line.state.m * temperature_derivative + line_properties.T * mass_derivative), + ) + + @staticmethod + def _primary_chamber_line_derivatives( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + chamber: AmesimPneumaticVolume, + chamber_properties: ThermodynamicProperties, + chamber_to_line_flow: float, + node_to_line_flow: float, + node_connected_h: float | None = None, + ) -> tuple[VolumeState, VolumeState]: + derivative_method = ( + line.derivatives_from_connections + if node_connected_h is None + else line.derivatives_from_transport_enthalpy_connections + ) + line_derivative = derivative_method( + port_1_m_flow=chamber_to_line_flow, + connected_h_1=chamber_properties.h, + port_2_m_flow=node_to_line_flow, + connected_h_2=( + line_properties.h if node_connected_h is None else node_connected_h + ), + ) + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=-chamber_to_line_flow, + connected_h_a=line_properties.h, + port_b_m_flow=0.0, + connected_h_b=chamber_properties.h, + internal_h=chamber_properties.h, + ) + return line_derivative, chamber_derivative + + @staticmethod + def _write_connection_line( + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> None: + line.port_1.p = port_1_properties.p + line.port_1.m_flow = port_1_flow + line.port_1.h_outflow = line_properties.h + line.port_2.p = port_2_properties.p + line.port_2.m_flow = port_2_flow + line.port_2.h_outflow = line_properties.h + + @staticmethod + def _connection_line_derivative( + *, + line: AmesimPnl0001Pipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> VolumeState: + return line.derivatives_from_connections( + port_1_m_flow=port_1_flow, + connected_h_1=port_1_properties.h, + port_2_m_flow=port_2_flow, + connected_h_2=port_2_properties.h, + ) + + @staticmethod + def _write_two_state_connection_line( + *, + line: AmesimPnl0003Pipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> None: + line.port_1.p = port_1_properties.p + line.port_1.m_flow = port_1_flow + line.port_1.h_outflow = port_1_properties.h + line.port_2.p = port_2_properties.p + line.port_2.m_flow = port_2_flow + line.port_2.h_outflow = port_2_properties.h + + @staticmethod + def _write_orifice_ports( + *, + orifice: AmesimPneumaticOrifice, + port_a_properties: ThermodynamicProperties, + port_a_flow: float, + port_b_properties: ThermodynamicProperties, + port_b_flow: float, + ) -> None: + orifice.port_a.p = port_a_properties.p + orifice.port_a.m_flow = port_a_flow + orifice.port_a.h_outflow = port_a_properties.h + orifice.port_b.p = port_b_properties.p + orifice.port_b.m_flow = port_b_flow + orifice.port_b.h_outflow = port_b_properties.h + + @staticmethod + def _write_resistance_ports( + *, + resistance: AmesimPnl00rPipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_properties: ThermodynamicProperties, + port_2_flow: float, + ) -> None: + resistance.port_1.p = port_1_properties.p + resistance.port_1.m_flow = port_1_flow + resistance.port_1.h_outflow = port_1_properties.h + resistance.port_2.p = port_2_properties.p + resistance.port_2.m_flow = port_2_flow + resistance.port_2.h_outflow = port_2_properties.h + + @staticmethod + def _two_state_connection_line_derivatives( + *, + line: AmesimPnl0003Pipe, + port_1_properties: ThermodynamicProperties, + port_1_flow: float, + port_2_connected_properties: ThermodynamicProperties, + port_2_flow: float, + port_1_connected_h: float | None = None, + ) -> tuple[VolumeState, VolumeState]: + return line.derivatives_from_connections( + port_1_m_flow=port_1_flow, + connected_h_1=( + port_1_properties.h + if port_1_connected_h is None + else port_1_connected_h + ), + port_2_m_flow=port_2_flow, + connected_h_2=port_2_connected_properties.h, + ) + + def _write_port_states( + self, + snapshot: TestMqlPn3P4NodeChamberSegmentSnapshot, + ) -> None: + self._write_fixed_chamber_segment( + spec=self.components.spec, + chamber=self.components.volume, + chamber_properties=snapshot.chamber, + inlet_line=self.components.inlet_line, + inlet_line_properties=snapshot.inlet_line, + inlet_node_properties=snapshot.p4_port3_remote_orifice_line_port_1, + inlet_node_to_line_flow=snapshot.inlet_node_to_line_flow, + inlet_line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + inlet_orifice=self.components.inlet_orifice, + outlet_line=self.components.outlet_line, + outlet_line_properties=snapshot.outlet_line, + outlet_node_properties=snapshot.node_line_port_1, + outlet_node_to_line_flow=snapshot.outlet_node_to_line_flow, + outlet_line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + outlet_orifice=self.components.outlet_orifice, + ) + + self._write_two_state_connection_line( + line=self.components.node_line, + port_1_properties=snapshot.node_line_port_1, + port_1_flow=snapshot.node_to_pnl0003_flow, + port_2_properties=snapshot.node_line_port_2, + port_2_flow=-snapshot.pnl0003_to_p4_flow, + ) + + self._write_orifice_ports( + orifice=self.components.node_orifice, + port_a_properties=snapshot.node_line_port_2, + port_a_flow=snapshot.pnl0003_to_p4_flow, + port_b_properties=snapshot.p4_primary_line, + port_b_flow=-snapshot.pnl0003_to_p4_flow, + ) + + self._write_resistance_ports( + resistance=self.components.node_resistance, + port_1_properties=snapshot.node_line_port_1, + port_1_flow=snapshot.node_to_resistance_flow, + port_2_properties=snapshot.p4_port1_remote_orifice_line_port_1, + port_2_flow=-snapshot.node_to_resistance_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_primary_line, + line_properties=snapshot.p4_primary_line, + chamber=self.components.p4_primary_chamber, + chamber_properties=snapshot.p4_primary_chamber, + chamber_to_line_flow=snapshot.p4_primary_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_line, + line_properties=snapshot.p4_port1_line, + port_1_properties=snapshot.p4_primary_line, + port_1_flow=snapshot.p4_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_remote_primary_line, + port_2_flow=snapshot.p4_port1_remote_node_to_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port1_remote_primary_line, + line_properties=snapshot.p4_port1_remote_primary_line, + chamber=self.components.p4_port1_remote_primary_chamber, + chamber_properties=snapshot.p4_port1_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_remote_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_remote_port1_line, + line_properties=snapshot.p4_port1_remote_port1_line, + port_1_properties=snapshot.p4_port1_remote_primary_line, + port_1_flow=snapshot.p4_port1_remote_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_far_primary_line, + port_2_flow=snapshot.p4_port1_far_node_to_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port1_far_primary_line, + line_properties=snapshot.p4_port1_far_primary_line, + chamber=self.components.p4_port1_far_primary_chamber, + chamber_properties=snapshot.p4_port1_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_far_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_far_port1_line, + line_properties=snapshot.p4_port1_far_port1_line, + port_1_properties=snapshot.p4_port1_far_primary_line, + port_1_flow=snapshot.p4_port1_far_to_next_line_flow, + port_2_properties=snapshot.p4_port1_next_primary_line, + port_2_flow=snapshot.p4_port1_next_node_to_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port1_next_primary_line, + line_properties=snapshot.p4_port1_next_primary_line, + chamber=self.components.p4_port1_next_primary_chamber, + chamber_properties=snapshot.p4_port1_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_next_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_next_port1_line, + line_properties=snapshot.p4_port1_next_port1_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_to_bridge_line_flow, + port_2_properties=snapshot.p4_bridge_primary_line, + port_2_flow=snapshot.p4_bridge_to_port1_next_line_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port1_next_orifice_line, + port_1_properties=snapshot.p4_port1_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_node_to_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_line_port_2, + port_2_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port1_next_orifice, + port_a_properties=snapshot.p4_port1_next_orifice_line_port_2, + port_a_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, + port_b_properties=snapshot.p4_port1_next_orifice_output_line, + port_b_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port1_next_orifice_output_line, + line_properties=snapshot.p4_port1_next_orifice_output_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_orifice_p4_to_output_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_output_line, + port_2_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + + self._write_resistance_ports( + resistance=self.components.p4_port1_next_orifice_resistance, + port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, + port_1_flow=( + snapshot.p4_port1_next_orifice_resistance_node_to_next_flow + ), + port_2_properties=snapshot.p4_port1_next_orifice_line_port_1, + port_2_flow=( + -snapshot.p4_port1_next_orifice_resistance_node_to_next_flow + ), + ) + + self._write_two_state_connection_line( + line=self.components.p4_port1_next_orifice_resistance_line, + port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_resistance_node_to_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_resistance_line_port_2, + port_2_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port1_next_orifice_resistance_orifice, + port_a_properties=snapshot.p4_port1_next_orifice_resistance_line_port_2, + port_a_flow=snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + port_b_properties=snapshot.p4_port1_far_primary_line, + port_b_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + ) + + self._write_fixed_chamber_segment( + spec=self.components.p4_port1_next_orifice_resistance_chamber_spec, + chamber=self.components.p4_port1_next_orifice_resistance_chamber, + chamber_properties=snapshot.p4_port1_next_orifice_resistance_chamber, + inlet_line=self.components.p4_port1_next_orifice_resistance_chamber_inlet_line, + inlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_node_properties=snapshot.p4_port1_remote_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow + ), + inlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice + ), + outlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_node_properties=( + snapshot.p4_port1_next_orifice_resistance_line_port_1 + ), + outlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow + ), + outlet_orifice=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice + ), + ) + + self._write_primary_chamber_line( + line=self.components.p4_bridge_primary_line, + line_properties=snapshot.p4_bridge_primary_line, + chamber=self.components.p4_bridge_primary_chamber, + chamber_properties=snapshot.p4_bridge_primary_chamber, + chamber_to_line_flow=snapshot.p4_bridge_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_bridge_node_to_primary_line_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port1_remote_orifice_line, + port_1_properties=snapshot.p4_port1_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_remote_node_to_orifice_line_flow, + port_2_properties=snapshot.p4_port1_remote_orifice_line_port_2, + port_2_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port1_remote_orifice, + port_a_properties=snapshot.p4_port1_remote_orifice_line_port_2, + port_a_flow=snapshot.p4_port1_remote_orifice_to_node_flow, + port_b_properties=snapshot.p4_port1_remote_primary_line, + port_b_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_line, + line_properties=snapshot.p4_port3_line, + port_1_properties=snapshot.p4_port3_remote_primary_line, + port_1_flow=snapshot.p4_port3_remote_node_to_line_flow, + port_2_properties=snapshot.p4_primary_line, + port_2_flow=snapshot.p4_to_port3_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port3_remote_primary_line, + line_properties=snapshot.p4_port3_remote_primary_line, + chamber=self.components.p4_port3_remote_primary_chamber, + chamber_properties=snapshot.p4_port3_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_remote_port3_line, + line_properties=snapshot.p4_port3_remote_port3_line, + port_1_properties=snapshot.p4_port3_far_primary_line, + port_1_flow=snapshot.p4_port3_far_node_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=snapshot.p4_port3_remote_to_port3_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port3_far_primary_line, + line_properties=snapshot.p4_port3_far_primary_line, + chamber=self.components.p4_port3_far_primary_chamber, + chamber_properties=snapshot.p4_port3_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_far_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_far_port3_line, + line_properties=snapshot.p4_port3_far_port3_line, + port_1_properties=snapshot.p4_port3_next_primary_line, + port_1_flow=snapshot.p4_port3_next_node_to_line_flow, + port_2_properties=snapshot.p4_port3_far_primary_line, + port_2_flow=snapshot.p4_port3_far_to_next_line_flow, + ) + + self._write_primary_chamber_line( + line=self.components.p4_port3_next_primary_line, + line_properties=snapshot.p4_port3_next_primary_line, + chamber=self.components.p4_port3_next_primary_chamber, + chamber_properties=snapshot.p4_port3_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_next_node_to_primary_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_next_port3_line, + line_properties=snapshot.p4_port3_next_port3_line, + port_1_properties=snapshot.p4_bridge_primary_line, + port_1_flow=snapshot.p4_bridge_to_port3_next_line_flow, + port_2_properties=snapshot.p4_port3_next_primary_line, + port_2_flow=snapshot.p4_port3_next_to_bridge_line_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_next_orifice_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_next_orifice_node_to_line_flow, + port_2_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_2_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_next_orifice, + port_a_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_a_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, + port_b_properties=snapshot.p4_port3_next_orifice_output_line, + port_b_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_next_orifice_output_line, + line_properties=snapshot.p4_port3_next_orifice_output_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_1_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, + port_2_properties=snapshot.p4_port3_next_orifice_output_line, + port_2_flow=-snapshot.p4_port3_next_orifice_output_to_p4_flow, + ) + + self._write_fixed_chamber_segment( + spec=self.components.p4_port3_next_chamber_spec, + chamber=self.components.p4_port3_next_chamber, + chamber_properties=snapshot.p4_port3_next_chamber, + inlet_line=self.components.p4_port3_next_chamber_inlet_line, + inlet_line_properties=snapshot.p4_port3_next_chamber_inlet_line, + inlet_node_properties=snapshot.p4_port3_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_inlet_line_to_chamber_flow + ), + inlet_orifice=self.components.p4_port3_next_chamber_inlet_orifice, + outlet_line=self.components.p4_port3_next_chamber_outlet_line, + outlet_line_properties=snapshot.p4_port3_next_chamber_outlet_line, + outlet_node_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + outlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_outlet_line_to_chamber_flow + ), + outlet_orifice=self.components.p4_port3_next_chamber_outlet_orifice, + ) + + self._write_resistance_ports( + resistance=self.components.p4_port3_next_orifice_resistance, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_next_resistance_node_to_next_flow, + port_2_properties=snapshot.p4_port3_next_orifice_line_port_1, + port_2_flow=-snapshot.p4_port3_next_resistance_node_to_next_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_next_resistance_node_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_next_resistance_node_to_line_flow, + port_2_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_2_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_next_resistance_orifice, + port_a_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_a_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, + port_b_properties=snapshot.p4_port3_next_resistance_output_line, + port_b_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_next_resistance_output_line, + line_properties=snapshot.p4_port3_next_resistance_output_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_port3_next_resistance_output_line, + port_2_flow=-snapshot.p4_port3_next_resistance_output_to_p4_flow, + ) + + self._write_fixed_chamber_segment( + spec=self.components.p4_port3_next_resistance_chamber_spec, + chamber=self.components.p4_port3_next_resistance_chamber, + chamber_properties=snapshot.p4_port3_next_resistance_chamber, + inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, + inlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_inlet_line + ), + inlet_node_properties=snapshot.p4_port1_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow + ), + inlet_orifice=( + self.components.p4_port3_next_resistance_chamber_inlet_orifice + ), + outlet_line=self.components.p4_port3_next_resistance_chamber_outlet_line, + outlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_outlet_line + ), + outlet_node_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + outlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow + ), + outlet_orifice=( + self.components.p4_port3_next_resistance_chamber_outlet_orifice + ), + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_remote_orifice_line, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_remote_node_to_orifice_line_flow, + port_2_properties=snapshot.p4_port3_remote_orifice_line_port_2, + port_2_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, + ) + + self._write_resistance_ports( + resistance=self.components.p4_port3_remote_orifice_resistance, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_remote_node_to_resistance_flow, + port_2_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + port_2_flow=-snapshot.p4_port3_remote_node_to_resistance_flow, + ) + + self._write_two_state_connection_line( + line=self.components.p4_port3_remote_resistance_node_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_remote_resistance_node_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_2_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_remote_resistance_orifice, + port_a_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_a_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, + port_b_properties=snapshot.p4_port3_remote_resistance_output_line, + port_b_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, + ) + + self._write_connection_line( + line=self.components.p4_port3_remote_resistance_output_line, + line_properties=snapshot.p4_port3_remote_resistance_output_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_port3_remote_resistance_output_line, + port_2_flow=-snapshot.p4_port3_remote_resistance_output_to_p4_flow, + ) + + self._write_orifice_ports( + orifice=self.components.p4_port3_remote_orifice, + port_a_properties=snapshot.p4_port3_remote_orifice_line_port_2, + port_a_flow=snapshot.p4_port3_remote_orifice_to_node_flow, + port_b_properties=snapshot.p4_port3_remote_primary_line, + port_b_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, + ) + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + ( + inlet_derivative, + chamber_derivative, + outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.spec, + chamber=self.components.volume, + chamber_properties=snapshot.chamber, + inlet_line=self.components.inlet_line, + inlet_line_properties=snapshot.inlet_line, + inlet_connected_properties=snapshot.p4_port3_remote_orifice_line_port_1, + inlet_node_to_line_flow=snapshot.inlet_node_to_line_flow, + inlet_line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + outlet_line=self.components.outlet_line, + outlet_line_properties=snapshot.outlet_line, + outlet_connected_properties=snapshot.node_line_port_1, + outlet_node_to_line_flow=snapshot.outlet_node_to_line_flow, + outlet_line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + ) + if self.use_inlet_line_reference_rhs: + inlet_derivative = self._pnl0001_reference_enthalpy_derivative( + line=self.components.inlet_line, + line_properties=snapshot.inlet_line, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.inlet_node_to_line_flow, + port_2_properties=snapshot.chamber, + port_2_flow=-snapshot.inlet_line_to_chamber_flow, + ) + ( + node_line_derivative_1, + node_line_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.node_line, + port_1_properties=snapshot.node_line_port_1, + port_1_flow=snapshot.node_to_pnl0003_flow, + port_2_connected_properties=snapshot.p4_primary_line, + port_2_flow=-snapshot.pnl0003_to_p4_flow, + ) + ( + p4_primary_derivative, + p4_primary_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_primary_line, + line_properties=snapshot.p4_primary_line, + chamber=self.components.p4_primary_chamber, + chamber_properties=snapshot.p4_primary_chamber, + chamber_to_line_flow=snapshot.p4_primary_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_node_to_primary_line_flow, + ) + p4_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_line, + port_1_properties=snapshot.p4_primary_line, + port_1_flow=snapshot.p4_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_remote_primary_line, + port_2_flow=snapshot.p4_port1_remote_node_to_line_flow, + ) + ( + p4_port1_remote_primary_derivative, + p4_port1_remote_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port1_remote_primary_line, + line_properties=snapshot.p4_port1_remote_primary_line, + chamber=self.components.p4_port1_remote_primary_chamber, + chamber_properties=snapshot.p4_port1_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_remote_node_to_primary_line_flow, + ) + p4_port1_remote_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_remote_port1_line, + port_1_properties=snapshot.p4_port1_remote_primary_line, + port_1_flow=snapshot.p4_port1_remote_to_port1_line_flow, + port_2_properties=snapshot.p4_port1_far_primary_line, + port_2_flow=snapshot.p4_port1_far_node_to_line_flow, + ) + ( + p4_port1_far_primary_derivative, + p4_port1_far_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port1_far_primary_line, + line_properties=snapshot.p4_port1_far_primary_line, + chamber=self.components.p4_port1_far_primary_chamber, + chamber_properties=snapshot.p4_port1_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_far_node_to_primary_line_flow, + ) + p4_port1_far_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_far_port1_line, + port_1_properties=snapshot.p4_port1_far_primary_line, + port_1_flow=snapshot.p4_port1_far_to_next_line_flow, + port_2_properties=snapshot.p4_port1_next_primary_line, + port_2_flow=snapshot.p4_port1_next_node_to_line_flow, + ) + ( + p4_port1_next_primary_derivative, + p4_port1_next_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port1_next_primary_line, + line_properties=snapshot.p4_port1_next_primary_line, + chamber=self.components.p4_port1_next_primary_chamber, + chamber_properties=snapshot.p4_port1_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port1_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port1_next_node_to_primary_line_flow, + ) + p4_port1_next_port1_derivative = self._connection_line_derivative( + line=self.components.p4_port1_next_port1_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_to_bridge_line_flow, + port_2_properties=snapshot.p4_bridge_primary_line, + port_2_flow=snapshot.p4_bridge_to_port1_next_line_flow, + ) + ( + p4_bridge_primary_derivative, + p4_bridge_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_bridge_primary_line, + line_properties=snapshot.p4_bridge_primary_line, + chamber=self.components.p4_bridge_primary_chamber, + chamber_properties=snapshot.p4_bridge_primary_chamber, + chamber_to_line_flow=snapshot.p4_bridge_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_bridge_node_to_primary_line_flow, + ) + ( + p4_port1_remote_orifice_derivative_1, + p4_port1_remote_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port1_remote_orifice_line, + port_1_properties=snapshot.p4_port1_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_remote_node_to_orifice_line_flow, + port_2_connected_properties=snapshot.p4_port1_remote_primary_line, + port_2_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, + ) + p4_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_line, + port_1_properties=snapshot.p4_port3_remote_primary_line, + port_1_flow=snapshot.p4_port3_remote_node_to_line_flow, + port_2_properties=snapshot.p4_primary_line, + port_2_flow=snapshot.p4_to_port3_line_flow, + ) + p4_port3_remote_node_connected_h = self._p4_node_port_2_connected_h( + balance=snapshot.p4_port3_remote_balance, + fallback_h=snapshot.p4_port3_remote_primary_line.h, + ) + ( + p4_port3_remote_primary_derivative, + p4_port3_remote_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port3_remote_primary_line, + line_properties=snapshot.p4_port3_remote_primary_line, + chamber=self.components.p4_port3_remote_primary_chamber, + chamber_properties=snapshot.p4_port3_remote_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_remote_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, + node_connected_h=p4_port3_remote_node_connected_h, + ) + if self.use_p4_port3_remote_primary_reference_rhs: + p4_port3_remote_primary_derivative = ( + self._pnl0001_reference_enthalpy_derivative( + line=self.components.p4_port3_remote_primary_line, + line_properties=snapshot.p4_port3_remote_primary_line, + port_1_properties=snapshot.p4_port3_remote_primary_chamber, + port_1_flow=snapshot.p4_port3_remote_chamber_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, + port_2_connected_reference_h=( + self._p4_port3_remote_port_2_reference_h( + snapshot=snapshot + ) + ), + ) + ) + p4_port3_remote_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_remote_port3_line, + port_1_properties=snapshot.p4_port3_far_primary_line, + port_1_flow=snapshot.p4_port3_far_node_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=snapshot.p4_port3_remote_to_port3_line_flow, + ) + ( + p4_port3_far_primary_derivative, + p4_port3_far_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port3_far_primary_line, + line_properties=snapshot.p4_port3_far_primary_line, + chamber=self.components.p4_port3_far_primary_chamber, + chamber_properties=snapshot.p4_port3_far_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_far_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_far_node_to_primary_line_flow, + ) + p4_port3_far_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_far_port3_line, + port_1_properties=snapshot.p4_port3_next_primary_line, + port_1_flow=snapshot.p4_port3_next_node_to_line_flow, + port_2_properties=snapshot.p4_port3_far_primary_line, + port_2_flow=snapshot.p4_port3_far_to_next_line_flow, + ) + ( + p4_port3_next_primary_derivative, + p4_port3_next_chamber_derivative, + ) = self._primary_chamber_line_derivatives( + line=self.components.p4_port3_next_primary_line, + line_properties=snapshot.p4_port3_next_primary_line, + chamber=self.components.p4_port3_next_primary_chamber, + chamber_properties=snapshot.p4_port3_next_primary_chamber, + chamber_to_line_flow=snapshot.p4_port3_next_chamber_to_line_flow, + node_to_line_flow=snapshot.p4_port3_next_node_to_primary_line_flow, + ) + p4_port3_next_port3_derivative = self._connection_line_derivative( + line=self.components.p4_port3_next_port3_line, + port_1_properties=snapshot.p4_bridge_primary_line, + port_1_flow=snapshot.p4_bridge_to_port3_next_line_flow, + port_2_properties=snapshot.p4_port3_next_primary_line, + port_2_flow=snapshot.p4_port3_next_to_bridge_line_flow, + ) + ( + p4_port3_remote_resistance_derivative_1, + p4_port3_remote_resistance_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_remote_resistance_node_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_remote_resistance_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port3_remote_resistance_output_line, + port_2_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, + ) + p4_port3_remote_resistance_output_derivative = self._connection_line_derivative( + line=self.components.p4_port3_remote_resistance_output_line, + port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_port3_far_primary_line, + port_2_flow=-snapshot.p4_port3_remote_resistance_output_to_p4_flow, + ) + ( + p4_port3_remote_orifice_derivative_1, + p4_port3_remote_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_remote_orifice_line, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_remote_node_to_orifice_line_flow, + port_2_connected_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, + port_1_connected_h=self._pn3_node_port_2_connected_h( + balance=snapshot.p4_port3_remote_orifice_node_balance, + fallback_h=snapshot.p4_port3_remote_orifice_line_port_1.h, + ), + ) + ( + p4_port3_next_orifice_derivative_1, + p4_port3_next_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_next_orifice_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port3_next_orifice_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port3_next_orifice_output_line, + port_2_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, + ) + p4_port3_next_orifice_output_derivative = self._connection_line_derivative( + line=self.components.p4_port3_next_orifice_output_line, + port_1_properties=snapshot.p4_port3_next_orifice_line_port_2, + port_1_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, + port_2_properties=snapshot.p4_port3_next_primary_line, + port_2_flow=-snapshot.p4_port3_next_orifice_output_to_p4_flow, + ) + ( + p4_port3_next_chamber_inlet_derivative, + p4_port3_next_fixed_chamber_derivative, + p4_port3_next_chamber_outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.p4_port3_next_chamber_spec, + chamber=self.components.p4_port3_next_chamber, + chamber_properties=snapshot.p4_port3_next_chamber, + inlet_line=self.components.p4_port3_next_chamber_inlet_line, + inlet_line_properties=snapshot.p4_port3_next_chamber_inlet_line, + inlet_connected_properties=snapshot.p4_port3_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_inlet_line_to_chamber_flow + ), + outlet_line=self.components.p4_port3_next_chamber_outlet_line, + outlet_line_properties=snapshot.p4_port3_next_chamber_outlet_line, + outlet_connected_properties=( + snapshot.p4_port3_remote_resistance_node_line_port_1 + ), + outlet_node_to_line_flow=( + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_chamber_outlet_line_to_chamber_flow + ), + ) + ( + p4_port3_next_resistance_derivative_1, + p4_port3_next_resistance_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port3_next_resistance_node_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + port_1_flow=snapshot.p4_port3_next_resistance_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port3_next_resistance_output_line, + port_2_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, + ) + p4_port3_next_resistance_output_derivative = self._connection_line_derivative( + line=self.components.p4_port3_next_resistance_output_line, + port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_2, + port_1_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, + port_2_properties=snapshot.p4_bridge_primary_line, + port_2_flow=-snapshot.p4_port3_next_resistance_output_to_p4_flow, + ) + ( + p4_port3_next_resistance_chamber_inlet_derivative, + p4_port3_next_resistance_fixed_chamber_derivative, + p4_port3_next_resistance_chamber_outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.p4_port3_next_resistance_chamber_spec, + chamber=self.components.p4_port3_next_resistance_chamber, + chamber_properties=snapshot.p4_port3_next_resistance_chamber, + inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, + inlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_inlet_line + ), + inlet_connected_properties=snapshot.p4_port1_next_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow + ), + outlet_line=self.components.p4_port3_next_resistance_chamber_outlet_line, + outlet_line_properties=( + snapshot.p4_port3_next_resistance_chamber_outlet_line + ), + outlet_connected_properties=snapshot.p4_port3_next_resistance_node_line_port_1, + outlet_node_to_line_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow + ), + ) + ( + p4_port1_next_orifice_derivative_1, + p4_port1_next_orifice_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port1_next_orifice_line, + port_1_properties=snapshot.p4_port1_next_orifice_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port1_next_orifice_output_line, + port_2_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + p4_port1_next_orifice_output_derivative = self._connection_line_derivative( + line=self.components.p4_port1_next_orifice_output_line, + port_1_properties=snapshot.p4_port1_next_primary_line, + port_1_flow=snapshot.p4_port1_next_orifice_p4_to_output_line_flow, + port_2_properties=snapshot.p4_port1_next_orifice_line_port_2, + port_2_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, + ) + ( + p4_port1_next_orifice_resistance_derivative_1, + p4_port1_next_orifice_resistance_derivative_2, + ) = self._two_state_connection_line_derivatives( + line=self.components.p4_port1_next_orifice_resistance_line, + port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, + port_1_flow=snapshot.p4_port1_next_orifice_resistance_node_to_line_flow, + port_2_connected_properties=snapshot.p4_port1_far_primary_line, + port_2_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + ) + ( + p4_port1_next_orifice_resistance_chamber_inlet_derivative, + p4_port1_next_orifice_resistance_fixed_chamber_derivative, + p4_port1_next_orifice_resistance_chamber_outlet_derivative, + ) = self._fixed_chamber_segment_derivatives( + spec=self.components.p4_port1_next_orifice_resistance_chamber_spec, + chamber=self.components.p4_port1_next_orifice_resistance_chamber, + chamber_properties=snapshot.p4_port1_next_orifice_resistance_chamber, + inlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line + ), + inlet_connected_properties=snapshot.p4_port1_remote_orifice_line_port_1, + inlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + ), + inlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow + ), + outlet_line=( + self.components.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_line_properties=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line + ), + outlet_connected_properties=( + snapshot.p4_port1_next_orifice_resistance_line_port_1 + ), + outlet_node_to_line_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + outlet_line_to_chamber_flow=( + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow + ), + ) + return [ + *inlet_derivative.as_vector(), + *chamber_derivative.as_vector(), + *outlet_derivative.as_vector(), + *node_line_derivative_1.as_vector(), + *node_line_derivative_2.as_vector(), + *p4_primary_derivative.as_vector(), + *p4_primary_chamber_derivative.as_vector(), + *p4_port1_derivative.as_vector(), + *p4_port1_remote_primary_derivative.as_vector(), + *p4_port1_remote_chamber_derivative.as_vector(), + *p4_port1_remote_port1_derivative.as_vector(), + *p4_port1_far_primary_derivative.as_vector(), + *p4_port1_far_chamber_derivative.as_vector(), + *p4_port1_far_port1_derivative.as_vector(), + *p4_port1_next_primary_derivative.as_vector(), + *p4_port1_next_chamber_derivative.as_vector(), + *p4_port1_next_port1_derivative.as_vector(), + *p4_bridge_primary_derivative.as_vector(), + *p4_bridge_chamber_derivative.as_vector(), + *p4_port1_remote_orifice_derivative_1.as_vector(), + *p4_port1_remote_orifice_derivative_2.as_vector(), + *p4_port3_derivative.as_vector(), + *p4_port3_remote_primary_derivative.as_vector(), + *p4_port3_remote_chamber_derivative.as_vector(), + *p4_port3_remote_port3_derivative.as_vector(), + *p4_port3_far_primary_derivative.as_vector(), + *p4_port3_far_chamber_derivative.as_vector(), + *p4_port3_far_port3_derivative.as_vector(), + *p4_port3_next_primary_derivative.as_vector(), + *p4_port3_next_chamber_derivative.as_vector(), + *p4_port3_next_port3_derivative.as_vector(), + *p4_port3_remote_resistance_derivative_1.as_vector(), + *p4_port3_remote_resistance_derivative_2.as_vector(), + *p4_port3_remote_resistance_output_derivative.as_vector(), + *p4_port3_remote_orifice_derivative_1.as_vector(), + *p4_port3_remote_orifice_derivative_2.as_vector(), + *p4_port3_next_orifice_derivative_1.as_vector(), + *p4_port3_next_orifice_derivative_2.as_vector(), + *p4_port3_next_orifice_output_derivative.as_vector(), + *p4_port3_next_chamber_inlet_derivative.as_vector(), + *p4_port3_next_fixed_chamber_derivative.as_vector(), + *p4_port3_next_chamber_outlet_derivative.as_vector(), + *p4_port3_next_resistance_derivative_1.as_vector(), + *p4_port3_next_resistance_derivative_2.as_vector(), + *p4_port3_next_resistance_output_derivative.as_vector(), + *p4_port3_next_resistance_chamber_inlet_derivative.as_vector(), + *p4_port3_next_resistance_fixed_chamber_derivative.as_vector(), + *p4_port3_next_resistance_chamber_outlet_derivative.as_vector(), + *p4_port1_next_orifice_derivative_1.as_vector(), + *p4_port1_next_orifice_derivative_2.as_vector(), + *p4_port1_next_orifice_output_derivative.as_vector(), + *p4_port1_next_orifice_resistance_derivative_1.as_vector(), + *p4_port1_next_orifice_resistance_derivative_2.as_vector(), + *p4_port1_next_orifice_resistance_chamber_inlet_derivative.as_vector(), + *p4_port1_next_orifice_resistance_fixed_chamber_derivative.as_vector(), + *p4_port1_next_orifice_resistance_chamber_outlet_derivative.as_vector(), + ] + + +@dataclass(frozen=True) +class TestMqlPneumaticBranchComponents: + name: str + upstream_volume: AmesimPneumaticVolume + orifice: AmesimPneumaticOrifice + downstream_volume: AmesimPneumaticVolume + spec: TestMqlPneumaticBranchSpec | None = None + + +@dataclass(frozen=True) +class TestMqlPneumaticBranchState: + name: str + upstream: ThermodynamicProperties + downstream: ThermodynamicProperties + flow: float + upstream_inlet_h: float + downstream_inlet_h: float + + +@dataclass(frozen=True) +class TestMqlPneumaticSnapshot: + branch: TestMqlPneumaticBranchState + + @property + def flow(self) -> float: + return self.branch.flow + + @property + def upstream(self) -> ThermodynamicProperties: + return self.branch.upstream + + @property + def downstream(self) -> ThermodynamicProperties: + return self.branch.downstream + + +class TestMqlPneumaticClosure: + """Minimal test_mql pneumatic closure following the existing Testmodel pattern. + + The canonical branch flow is positive from ``upstream_volume`` through the + orifice port_a/port_b into ``downstream_volume``. Port ``m_flow`` values are + written with Modelica-style signs: positive means flow into that component. + """ + + def __init__( + self, + *, + components: TestMqlPneumaticBranchComponents, + initial_state_vector: Callable[[], list[float]], + apply_state_vector: Callable[[list[float]], None], + ) -> None: + self.components = components + self._initial_state_vector = initial_state_vector + self._apply_state_vector = apply_state_vector + + def initial_state_vector(self) -> list[float]: + return self._initial_state_vector() + + def apply_state_vector(self, values: list[float]) -> None: + self._apply_state_vector(values) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPneumaticSnapshot: + if state_vector is not None: + self._apply_state_vector(state_vector) + upstream = self.components.upstream_volume.properties() + downstream = self.components.downstream_volume.properties() + flow = self._solve_branch_flow(upstream, downstream) + upstream_inlet_h = self.components.upstream_volume.connection_inlet_enthalpy( + port_m_flow=-flow, + connected_h=downstream.h, + internal_h=upstream.h, + ) + downstream_inlet_h = self.components.downstream_volume.connection_inlet_enthalpy( + port_m_flow=flow, + connected_h=upstream.h, + internal_h=downstream.h, + ) + branch = TestMqlPneumaticBranchState( + name=self.components.name, + upstream=upstream, + downstream=downstream, + flow=flow, + upstream_inlet_h=upstream_inlet_h, + downstream_inlet_h=downstream_inlet_h, + ) + self._write_port_states(branch) + return TestMqlPneumaticSnapshot(branch=branch) + + def _solve_branch_flow( + self, + upstream: ThermodynamicProperties, + downstream: ThermodynamicProperties, + ) -> float: + upstream_temperature = upstream.T if upstream.p >= downstream.p else downstream.T + return self.components.orifice.mass_flow( + upstream.p, + downstream.p, + upstream_temperature, + ) + + def _write_port_states(self, branch: TestMqlPneumaticBranchState) -> None: + upstream_volume = self.components.upstream_volume + downstream_volume = self.components.downstream_volume + orifice = self.components.orifice + + upstream_volume.port_a.p = branch.upstream.p + upstream_volume.port_a.m_flow = -branch.flow + upstream_volume.port_a.h_outflow = branch.upstream.h + + orifice.port_a.p = branch.upstream.p + orifice.port_a.m_flow = branch.flow + orifice.port_a.h_outflow = branch.upstream.h + orifice.port_b.p = branch.downstream.p + orifice.port_b.m_flow = -branch.flow + orifice.port_b.h_outflow = branch.downstream.h + + downstream_volume.port_a.p = branch.downstream.p + downstream_volume.port_a.m_flow = branch.flow + downstream_volume.port_a.h_outflow = branch.downstream.h + + def branch_derivatives( + self, + snapshot: TestMqlPneumaticSnapshot, + ) -> tuple[VolumeState, VolumeState]: + flow = snapshot.flow + upstream_derivative = self.components.upstream_volume.derivatives( + inlet_h=snapshot.branch.upstream_inlet_h, + m_flow=-flow, + ) + downstream_derivative = self.components.downstream_volume.derivatives( + inlet_h=snapshot.branch.downstream_inlet_h, + m_flow=flow, + ) + return upstream_derivative, downstream_derivative + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + upstream_derivative, downstream_derivative = self.branch_derivatives(snapshot) + return [ + *upstream_derivative.as_vector(), + *downstream_derivative.as_vector(), + ] + + +class TestMqlPneumaticChamberSegmentClosure: + """Boundary-reduced fixed chamber segment discovered from AMESim topology. + + The two PNL0001 lines remain prescribed boundary interfaces in this first + closure. Their aliases are retained in the spec so line dynamics can be + inserted later without rediscovering the component path. + """ + + def __init__( + self, + *, + components: TestMqlPneumaticChamberSegmentComponents, + inlet_boundary: TestMqlPneumaticBoundaryCondition, + outlet_boundary: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_boundary = inlet_boundary + self.outlet_boundary = outlet_boundary + + def initial_state_vector(self) -> list[float]: + return self.components.volume.get_state_vector() + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 2: + raise ValueError("single-chamber segment state vector requires two values") + self.components.volume.set_state_vector(values) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPneumaticChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + chamber = self.components.volume.properties() + inlet_boundary = self.inlet_boundary.properties(self.components.volume.gas) + outlet_boundary = self.outlet_boundary.properties(self.components.volume.gas) + inlet_temperature = ( + inlet_boundary.T if inlet_boundary.p >= chamber.p else chamber.T + ) + outlet_temperature = ( + chamber.T if chamber.p >= outlet_boundary.p else outlet_boundary.T + ) + inlet_flow = self.components.inlet_orifice.mass_flow( + inlet_boundary.p, + chamber.p, + inlet_temperature, + ) + outlet_flow = self.components.outlet_orifice.mass_flow( + chamber.p, + outlet_boundary.p, + outlet_temperature, + ) + snapshot = TestMqlPneumaticChamberSegmentSnapshot( + chamber=chamber, + inlet_boundary=inlet_boundary, + outlet_boundary=outlet_boundary, + inlet_flow=inlet_flow, + outlet_flow=outlet_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + if port_name == "port_1": + return component.port_a + if port_name == "port_2": + return component.port_b + raise ValueError(f"unsupported pneumatic port: {port_name}") + + def _write_port_states( + self, + snapshot: TestMqlPneumaticChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + chamber = self.components.volume + inlet_orifice = self.components.inlet_orifice + outlet_orifice = self.components.outlet_orifice + + inlet_boundary_port = self._port( + inlet_orifice, + spec.inlet_orifice_boundary_port, + ) + inlet_volume_port = self._port(inlet_orifice, spec.inlet_orifice_volume_port) + inlet_boundary_port.p = snapshot.inlet_boundary.p + inlet_boundary_port.m_flow = snapshot.inlet_flow + inlet_boundary_port.h_outflow = snapshot.inlet_boundary.h + inlet_volume_port.p = snapshot.chamber.p + inlet_volume_port.m_flow = -snapshot.inlet_flow + inlet_volume_port.h_outflow = snapshot.chamber.h + + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + chamber_inlet_port.m_flow = snapshot.inlet_flow + chamber_outlet_port.m_flow = -snapshot.outlet_flow + + outlet_volume_port = self._port( + outlet_orifice, + spec.outlet_orifice_volume_port, + ) + outlet_boundary_port = self._port( + outlet_orifice, + spec.outlet_orifice_boundary_port, + ) + outlet_volume_port.p = snapshot.chamber.p + outlet_volume_port.m_flow = snapshot.outlet_flow + outlet_volume_port.h_outflow = snapshot.chamber.h + outlet_boundary_port.p = snapshot.outlet_boundary.p + outlet_boundary_port.m_flow = -snapshot.outlet_flow + outlet_boundary_port.h_outflow = snapshot.outlet_boundary.h + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + spec = self.components.spec + chamber = self.components.volume + derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_boundary.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_boundary.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_boundary.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_boundary.h + ), + internal_h=snapshot.chamber.h, + ) + return derivative.as_vector() + + +class TestMqlPnl0001ChamberSegmentClosure: + """Fixed chamber segment with the topology-derived inlet PNL0001 state. + + The inlet line has a closed causal boundary here: port-1 pressure and + temperature come from the PN3 node boundary, while port-2 flow comes from + the fixed orifice. The outlet line remains a boundary until its three-line + PN3 node balance is assembled. + """ + + def __init__( + self, + *, + components: TestMqlPnl0001ChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + outlet_boundary: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.outlet_boundary = outlet_boundary + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 4: + raise ValueError("PNL0001/chamber segment state vector requires four values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPnl0001ChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + outlet_boundary = self.outlet_boundary.properties(self.components.volume.gas) + node_to_line_flow = self.components.inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node.p, + port_1_temperature_k=inlet_node.T, + ) + inlet_temperature = line.T if line.p >= chamber.p else chamber.T + line_to_chamber_flow = self.components.inlet_orifice.mass_flow( + line.p, + chamber.p, + inlet_temperature, + ) + outlet_temperature = ( + chamber.T if chamber.p >= outlet_boundary.p else outlet_boundary.T + ) + outlet_flow = self.components.outlet_orifice.mass_flow( + chamber.p, + outlet_boundary.p, + outlet_temperature, + ) + snapshot = TestMqlPnl0001ChamberSegmentSnapshot( + inlet_line=line, + chamber=chamber, + inlet_node=inlet_node, + outlet_boundary=outlet_boundary, + node_to_line_flow=node_to_line_flow, + line_to_chamber_flow=line_to_chamber_flow, + outlet_flow=outlet_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) + + def _write_port_states( + self, + snapshot: TestMqlPnl0001ChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + line = self.components.inlet_line + chamber = self.components.volume + inlet_orifice = self.components.inlet_orifice + outlet_orifice = self.components.outlet_orifice + + line.port_1.p = snapshot.inlet_node.p + line.port_1.m_flow = snapshot.node_to_line_flow + line.port_1.h_outflow = snapshot.inlet_line.h + line.port_2.p = snapshot.inlet_line.p + line.port_2.m_flow = -snapshot.line_to_chamber_flow + + inlet_boundary_port = self._port( + inlet_orifice, + spec.inlet_orifice_boundary_port, + ) + inlet_volume_port = self._port(inlet_orifice, spec.inlet_orifice_volume_port) + inlet_boundary_port.p = snapshot.inlet_line.p + inlet_boundary_port.m_flow = snapshot.line_to_chamber_flow + inlet_boundary_port.h_outflow = snapshot.inlet_line.h + inlet_volume_port.p = snapshot.chamber.p + inlet_volume_port.m_flow = -snapshot.line_to_chamber_flow + inlet_volume_port.h_outflow = snapshot.chamber.h + + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + chamber_inlet_port.m_flow = snapshot.line_to_chamber_flow + chamber_outlet_port.m_flow = -snapshot.outlet_flow + + outlet_volume_port = self._port( + outlet_orifice, + spec.outlet_orifice_volume_port, + ) + outlet_boundary_port = self._port( + outlet_orifice, + spec.outlet_orifice_boundary_port, + ) + outlet_volume_port.p = snapshot.chamber.p + outlet_volume_port.m_flow = snapshot.outlet_flow + outlet_volume_port.h_outflow = snapshot.chamber.h + outlet_boundary_port.p = snapshot.outlet_boundary.p + outlet_boundary_port.m_flow = -snapshot.outlet_flow + outlet_boundary_port.h_outflow = snapshot.outlet_boundary.h + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + line_derivative = self.components.inlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.node_to_line_flow, + connected_h_1=snapshot.inlet_node.h, + port_2_m_flow=-snapshot.line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + chamber = self.components.volume + spec = self.components.spec + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_boundary.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_boundary.h + ), + internal_h=snapshot.chamber.h, + ) + return [*line_derivative.as_vector(), *chamber_derivative.as_vector()] + + +class TestMqlPnl0001PairChamberSegmentClosure: + """Six-state fixed chamber segment with both PNL0001 compliance states. + + Generated C shows that both line instances receive ``t1/p1`` from their + PN3 nodes and send their explicit ``t2/p2`` states to the fixed orifices. + The reduced closure therefore prescribes node pressure/temperature at both + resistive port-1 boundaries while keeping both line storage states native. + """ + + def __init__( + self, + *, + components: TestMqlPnl0001PairChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + outlet_node: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.outlet_node = outlet_node + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + *self.components.outlet_line.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 6: + raise ValueError("PNL0001 pair/chamber state vector requires six values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:4]) + self.components.outlet_line.set_state_vector(values[4:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPnl0001PairChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + inlet_line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + outlet_line = self.components.outlet_line.properties() + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + outlet_node = self.outlet_node.properties(self.components.outlet_line.gas) + inlet_node_to_line_flow = self.components.inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node.p, + port_1_temperature_k=inlet_node.T, + ) + outlet_node_to_line_flow = self.components.outlet_line.resistance_mass_flow( + port_1_pressure_pa=outlet_node.p, + port_1_temperature_k=outlet_node.T, + ) + inlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=inlet_line, + chamber=chamber, + orifice=self.components.inlet_orifice, + ) + outlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=outlet_line, + chamber=chamber, + orifice=self.components.outlet_orifice, + ) + snapshot = TestMqlPnl0001PairChamberSegmentSnapshot( + inlet_line=inlet_line, + chamber=chamber, + outlet_line=outlet_line, + inlet_node=inlet_node, + outlet_node=outlet_node, + inlet_node_to_line_flow=inlet_node_to_line_flow, + inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, + outlet_node_to_line_flow=outlet_node_to_line_flow, + outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_chamber_flow( + *, + line: ThermodynamicProperties, + chamber: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= chamber.p else chamber.T + return orifice.mass_flow(line.p, chamber.p, upstream_temperature) + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) + + def _write_port_states( + self, + snapshot: TestMqlPnl0001PairChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + chamber = self.components.volume + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + + self._write_line_side( + line=self.components.inlet_line, + line_properties=snapshot.inlet_line, + node_properties=snapshot.inlet_node, + node_to_line_flow=snapshot.inlet_node_to_line_flow, + line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + orifice=self.components.inlet_orifice, + orifice_boundary_port=spec.inlet_orifice_boundary_port, + orifice_volume_port=spec.inlet_orifice_volume_port, + chamber_port=chamber_inlet_port, + chamber_properties=snapshot.chamber, + ) + self._write_line_side( + line=self.components.outlet_line, + line_properties=snapshot.outlet_line, + node_properties=snapshot.outlet_node, + node_to_line_flow=snapshot.outlet_node_to_line_flow, + line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + orifice=self.components.outlet_orifice, + orifice_boundary_port=spec.outlet_orifice_boundary_port, + orifice_volume_port=spec.outlet_orifice_volume_port, + chamber_port=chamber_outlet_port, + chamber_properties=snapshot.chamber, + ) + + def _write_line_side( + self, + *, + line: AmesimPnl0001Pipe, + line_properties: ThermodynamicProperties, + node_properties: ThermodynamicProperties, + node_to_line_flow: float, + line_to_chamber_flow: float, + orifice: AmesimPneumaticOrifice, + orifice_boundary_port: str, + orifice_volume_port: str, + chamber_port: PortState, + chamber_properties: ThermodynamicProperties, + ) -> None: + line.port_1.p = node_properties.p + line.port_1.m_flow = node_to_line_flow + line.port_1.h_outflow = line_properties.h + line.port_2.p = line_properties.p + line.port_2.m_flow = -line_to_chamber_flow + + boundary_port = self._port(orifice, orifice_boundary_port) + volume_port = self._port(orifice, orifice_volume_port) + boundary_port.p = line_properties.p + boundary_port.m_flow = line_to_chamber_flow + boundary_port.h_outflow = line_properties.h + volume_port.p = chamber_properties.p + volume_port.m_flow = -line_to_chamber_flow + volume_port.h_outflow = chamber_properties.h + chamber_port.m_flow = line_to_chamber_flow + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + inlet_derivative = self.components.inlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.inlet_node_to_line_flow, + connected_h_1=snapshot.inlet_node.h, + port_2_m_flow=-snapshot.inlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + outlet_derivative = self.components.outlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.outlet_node_to_line_flow, + connected_h_1=snapshot.outlet_node.h, + port_2_m_flow=-snapshot.outlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + chamber = self.components.volume + spec = self.components.spec + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_line.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_line.h + ), + internal_h=snapshot.chamber.h, + ) + return [ + *inlet_derivative.as_vector(), + *chamber_derivative.as_vector(), + *outlet_derivative.as_vector(), + ] + + +class TestMqlPn3NodeChamberSegmentClosure: + """Boundary-reduced chamber segment closed by one real PN3 node. + + ``pneumatic_88`` supplies the PN3 primary pressure/temperature from its + port-1 compliance. The node has no storage, so the sum of the port-1 + PNL00R flow and port-3 PNL0001 flow is written as the opposite port-1 + flow into the PNL0003 state. + """ + + def __init__( + self, + *, + components: TestMqlPn3NodeChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + resistance_boundary: TestMqlPneumaticBoundaryCondition, + p4_boundary: TestMqlPneumaticBoundaryCondition, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.resistance_boundary = resistance_boundary + self.p4_boundary = p4_boundary + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + *self.components.outlet_line.get_state_vector(), + *self.components.node_line.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 10: + raise ValueError("PN3 node/chamber segment state vector requires ten values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:4]) + self.components.outlet_line.set_state_vector(values[4:6]) + self.components.node_line.set_state_vector(values[6:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPn3NodeChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + inlet_line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + outlet_line = self.components.outlet_line.properties() + node_line_port_1 = self.components.node_line.properties_1() + node_line_port_2 = self.components.node_line.properties_2() + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + resistance_boundary = self.resistance_boundary.properties( + self.components.node_resistance.gas + ) + p4_boundary = self.p4_boundary.properties(self.components.node_line.gas) + inlet_node_to_line_flow = self.components.inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node.p, + port_1_temperature_k=inlet_node.T, + ) + outlet_node_to_line_flow = self.components.outlet_line.resistance_mass_flow( + port_1_pressure_pa=node_line_port_1.p, + port_1_temperature_k=node_line_port_1.T, + ) + inlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=inlet_line, + chamber=chamber, + orifice=self.components.inlet_orifice, + ) + outlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=outlet_line, + chamber=chamber, + orifice=self.components.outlet_orifice, + ) + node_to_resistance_flow = self.components.node_resistance.mass_flow( + port_1_pressure_pa=node_line_port_1.p, + port_1_temperature_k=node_line_port_1.T, + port_2_pressure_pa=resistance_boundary.p, + port_2_temperature_k=resistance_boundary.T, + ) + node_balance = self.components.node.balance( + port_2_temperature_k=node_line_port_1.T, + port_2_pressure_pa=node_line_port_1.p, + port_1_enthalpy_flow_w=self._enthalpy_flow_from_node( + flow_kg_s=node_to_resistance_flow, + node_h=node_line_port_1.h, + connected_h=resistance_boundary.h, + ), + port_1_mass_flow_g_s=node_to_resistance_flow * 1.0e3, + port_3_enthalpy_flow_w=self._enthalpy_flow_from_node( + flow_kg_s=outlet_node_to_line_flow, + node_h=node_line_port_1.h, + connected_h=outlet_line.h, + ), + port_3_mass_flow_g_s=outlet_node_to_line_flow * 1.0e3, + ) + node_to_pnl0003_flow = -node_balance.port_2_mass_flow_g_s * 1.0e-3 + pnl0003_center_flow = self.components.node_line.resistance_mass_flow() + pnl0003_to_p4_flow = self._line_to_p4_flow( + line=node_line_port_2, + p4_boundary=p4_boundary, + orifice=self.components.node_orifice, + ) + snapshot = TestMqlPn3NodeChamberSegmentSnapshot( + inlet_line=inlet_line, + chamber=chamber, + outlet_line=outlet_line, + node_line_port_1=node_line_port_1, + node_line_port_2=node_line_port_2, + inlet_node=inlet_node, + resistance_boundary=resistance_boundary, + p4_boundary=p4_boundary, + node_balance=node_balance, + inlet_node_to_line_flow=inlet_node_to_line_flow, + inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, + outlet_node_to_line_flow=outlet_node_to_line_flow, + outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, + node_to_resistance_flow=node_to_resistance_flow, + node_to_pnl0003_flow=node_to_pnl0003_flow, + pnl0003_center_flow=pnl0003_center_flow, + pnl0003_to_p4_flow=pnl0003_to_p4_flow, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_chamber_flow( + *, + line: ThermodynamicProperties, + chamber: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + return TestMqlPnl0001PairChamberSegmentClosure._line_to_chamber_flow( + line=line, + chamber=chamber, + orifice=orifice, + ) + + @staticmethod + def _line_to_p4_flow( + *, + line: ThermodynamicProperties, + p4_boundary: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T + return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) + + @staticmethod + def _enthalpy_flow_from_node( + *, + flow_kg_s: float, + node_h: float, + connected_h: float, + ) -> float: + return flow_kg_s * (node_h if flow_kg_s >= 0.0 else connected_h) + + def _write_port_states( + self, + snapshot: TestMqlPn3NodeChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + chamber = self.components.volume + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=self.components.inlet_line, + line_properties=snapshot.inlet_line, + node_properties=snapshot.inlet_node, + node_to_line_flow=snapshot.inlet_node_to_line_flow, + line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + orifice=self.components.inlet_orifice, + orifice_boundary_port=spec.inlet_orifice_boundary_port, + orifice_volume_port=spec.inlet_orifice_volume_port, + chamber_port=chamber_inlet_port, + chamber_properties=snapshot.chamber, + ) + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=self.components.outlet_line, + line_properties=snapshot.outlet_line, + node_properties=snapshot.node_line_port_1, + node_to_line_flow=snapshot.outlet_node_to_line_flow, + line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + orifice=self.components.outlet_orifice, + orifice_boundary_port=spec.outlet_orifice_boundary_port, + orifice_volume_port=spec.outlet_orifice_volume_port, + chamber_port=chamber_outlet_port, + chamber_properties=snapshot.chamber, + ) + node_line = self.components.node_line + node_line.port_1.p = snapshot.node_line_port_1.p + node_line.port_1.m_flow = snapshot.node_to_pnl0003_flow + node_line.port_1.h_outflow = snapshot.node_line_port_1.h + node_line.port_2.p = snapshot.node_line_port_2.p + node_line.port_2.m_flow = -snapshot.pnl0003_to_p4_flow + node_line.port_2.h_outflow = snapshot.node_line_port_2.h + + node_orifice = self.components.node_orifice + node_orifice.port_a.p = snapshot.node_line_port_2.p + node_orifice.port_a.m_flow = snapshot.pnl0003_to_p4_flow + node_orifice.port_a.h_outflow = snapshot.node_line_port_2.h + node_orifice.port_b.p = snapshot.p4_boundary.p + node_orifice.port_b.m_flow = -snapshot.pnl0003_to_p4_flow + node_orifice.port_b.h_outflow = snapshot.p4_boundary.h + + resistance = self.components.node_resistance + resistance.port_1.p = snapshot.node_line_port_1.p + resistance.port_1.m_flow = snapshot.node_to_resistance_flow + resistance.port_1.h_outflow = snapshot.node_line_port_1.h + resistance.port_2.p = snapshot.resistance_boundary.p + resistance.port_2.m_flow = -snapshot.node_to_resistance_flow + resistance.port_2.h_outflow = snapshot.resistance_boundary.h + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + inlet_derivative = self.components.inlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.inlet_node_to_line_flow, + connected_h_1=snapshot.inlet_node.h, + port_2_m_flow=-snapshot.inlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + outlet_derivative = self.components.outlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.outlet_node_to_line_flow, + connected_h_1=snapshot.node_line_port_1.h, + port_2_m_flow=-snapshot.outlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + chamber = self.components.volume + spec = self.components.spec + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_line.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_line.h + ), + internal_h=snapshot.chamber.h, + ) + node_line_derivative_1, node_line_derivative_2 = ( + self.components.node_line.derivatives_from_connections( + port_1_m_flow=snapshot.node_to_pnl0003_flow, + connected_h_1=snapshot.node_line_port_1.h, + port_2_m_flow=-snapshot.pnl0003_to_p4_flow, + connected_h_2=snapshot.p4_boundary.h, + ) + ) + return [ + *inlet_derivative.as_vector(), + *chamber_derivative.as_vector(), + *outlet_derivative.as_vector(), + *node_line_derivative_1.as_vector(), + *node_line_derivative_2.as_vector(), + ] diff --git a/app/simulation/examples/testmodel/test_mql_computed.py b/app/simulation/examples/testmodel/test_mql_computed.py new file mode 100644 index 0000000..e13bfb9 --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_computed.py @@ -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 + diff --git a/app/simulation/examples/testmodel/test_mql_config.py b/app/simulation/examples/testmodel/test_mql_config.py new file mode 100644 index 0000000..487936c --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_config.py @@ -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)}") diff --git a/app/simulation/examples/testmodel/test_mql_line_parameters.py b/app/simulation/examples/testmodel/test_mql_line_parameters.py new file mode 100644 index 0000000..e79298f --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_line_parameters.py @@ -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".*?", 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".*?", 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".*?", 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".*?", 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}>.*?", + 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".*?", 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".*?", 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}>(.*?)", block, flags=re.DOTALL) + return match.group(1).strip() if match is not None else None diff --git a/app/simulation/examples/testmodel/test_mql_lines.py b/app/simulation/examples/testmodel/test_mql_lines.py new file mode 100644 index 0000000..d437bc5 --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_lines.py @@ -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 diff --git a/app/simulation/examples/testmodel/test_mql_mechanical.py b/app/simulation/examples/testmodel/test_mql_mechanical.py new file mode 100644 index 0000000..1b8481f --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_mechanical.py @@ -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) diff --git a/app/simulation/examples/testmodel/test_mql_nodes.py b/app/simulation/examples/testmodel/test_mql_nodes.py new file mode 100644 index 0000000..1227abc --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_nodes.py @@ -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" + } diff --git a/app/simulation/examples/testmodel/test_mql_pneumatic.py b/app/simulation/examples/testmodel/test_mql_pneumatic.py new file mode 100644 index 0000000..208507c --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_pneumatic.py @@ -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 diff --git a/app/simulation/examples/testmodel/test_mql_pneumatic_lines.py b/app/simulation/examples/testmodel/test_mql_pneumatic_lines.py new file mode 100644 index 0000000..850991b --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_pneumatic_lines.py @@ -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) diff --git a/app/simulation/examples/testmodel/test_mql_pneumatic_topology.py b/app/simulation/examples/testmodel/test_mql_pneumatic_topology.py new file mode 100644 index 0000000..70babcc --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_pneumatic_topology.py @@ -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, + } diff --git a/app/simulation/examples/testmodel/test_mql_topology.py b/app/simulation/examples/testmodel/test_mql_topology.py new file mode 100644 index 0000000..4fac424 --- /dev/null +++ b/app/simulation/examples/testmodel/test_mql_topology.py @@ -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".*?", + "", + 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 diff --git a/app/simulation/registry.py b/app/simulation/registry.py index 2740c3c..63f7421 100644 --- a/app/simulation/registry.py +++ b/app/simulation/registry.py @@ -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_]*") diff --git a/app/simulation/reporting/amesim_results.py b/app/simulation/reporting/amesim_results.py new file mode 100644 index 0000000..449b839 --- /dev/null +++ b/app/simulation/reporting/amesim_results.py @@ -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(" 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, + ) diff --git a/app/simulation/reporting/test_mql_chamber_observations.py b/app/simulation/reporting/test_mql_chamber_observations.py new file mode 100644 index 0000000..fd60209 --- /dev/null +++ b/app/simulation/reporting/test_mql_chamber_observations.py @@ -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}." + ) diff --git a/app/simulation/reporting/test_mql_comparison.py b/app/simulation/reporting/test_mql_comparison.py new file mode 100644 index 0000000..3d65cf2 --- /dev/null +++ b/app/simulation/reporting/test_mql_comparison.py @@ -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 diff --git a/app/simulation/reporting/test_mql_line_observations.py b/app/simulation/reporting/test_mql_line_observations.py new file mode 100644 index 0000000..0a85c8a --- /dev/null +++ b/app/simulation/reporting/test_mql_line_observations.py @@ -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}." + ) diff --git a/app/simulation/reporting/test_mql_mechanical_observations.py b/app/simulation/reporting/test_mql_mechanical_observations.py new file mode 100644 index 0000000..645be29 --- /dev/null +++ b/app/simulation/reporting/test_mql_mechanical_observations.py @@ -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}." + ) diff --git a/app/simulation/reporting/test_mql_observations.py b/app/simulation/reporting/test_mql_observations.py new file mode 100644 index 0000000..fe48070 --- /dev/null +++ b/app/simulation/reporting/test_mql_observations.py @@ -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))) diff --git a/app/simulation/reporting/test_mql_orifice_observations.py b/app/simulation/reporting/test_mql_orifice_observations.py new file mode 100644 index 0000000..defe414 --- /dev/null +++ b/app/simulation/reporting/test_mql_orifice_observations.py @@ -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] diff --git a/app/simulation/reporting/test_mql_output_schema.py b/app/simulation/reporting/test_mql_output_schema.py new file mode 100644 index 0000000..3a5661c --- /dev/null +++ b/app/simulation/reporting/test_mql_output_schema.py @@ -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, + ) diff --git a/app/simulation/reporting/test_mql_output_validation.py b/app/simulation/reporting/test_mql_output_validation.py new file mode 100644 index 0000000..39333e2 --- /dev/null +++ b/app/simulation/reporting/test_mql_output_validation.py @@ -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 diff --git a/app/simulation/reporting/test_mql_variables.py b/app/simulation/reporting/test_mql_variables.py new file mode 100644 index 0000000..c453928 --- /dev/null +++ b/app/simulation/reporting/test_mql_variables.py @@ -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) diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py index bd3216d..77198d0 100644 --- a/app/simulation/solvers/algebraic.py +++ b/app/simulation/solvers/algebraic.py @@ -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 ] diff --git a/app/simulation/solvers/signal.py b/app/simulation/solvers/signal.py new file mode 100644 index 0000000..467639b --- /dev/null +++ b/app/simulation/solvers/signal.py @@ -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.") diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py index d3ae1ea..3944c20 100644 --- a/app/simulation/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -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"]), } diff --git a/docs/README.md b/docs/README.md index 0fe72b1..e811c2d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,8 @@ 用于理解组件库清单、自动发现、启动校验、目录接口和前端读取流程。 3. [组件目录 JSON Schema v1](../schemas/component-catalog-v1.schema.json) `GET /api/components/catalog` 的机器可读结构。 +4. [AMESim 子模型公开组件迁移矩阵](amesim-component-migration-matrix.md) + 用于划分 `test_mql` 子模型族的公开组件、内部模型和暂不支持范围。 建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有 模型。不要从前端兜底数据反推后端物理契约。 diff --git a/docs/amesim-component-migration-matrix.md b/docs/amesim-component-migration-matrix.md new file mode 100644 index 0000000..6c9138b --- /dev/null +++ b/docs/amesim-component-migration-matrix.md @@ -0,0 +1,92 @@ +# AMESim 子模型公开组件迁移矩阵 + +状态:规划中 +适用模型:`AmesimModels/test_mql.ame` / `PythonModels.systems.test_mql` +配套规范:[`component-model-authoring-spec-v1.md`](component-model-authoring-spec-v1.md) + +## 目标 + +本文档把 `test_mql` 中的 AMESim 子模型族拆成可执行的迁移批次。它不是组件注册清单;只有完成公开模型契约、方程实现、最小 XML 编译/仿真测试后,子模型才能加入某个 `library.py` 的 `models` 清单并出现在前端组件库中。 + +当前原则: + +- 不把现有固定拓扑 `TestMqlSystem` 整体注册为公开拖拽组件。 +- 不把机械、信号、事件元件提前伪装成可由当前气动求解器仿真的公开组件。 +- 可先公开当前 `app.simulation` 求解链路能承载的气动元件:储能、阻性、孔口、三通/四通、准稳态管段。 +- 对 AMESim 精确语义尚未复刻的元件,允许先做内部模型或文档化候选,不能在目录中标记为完成。 + +## 当前求解器能力边界 + +`app.simulation` 当前支持: + +- 物理域:`pneumatic`。 +- 端口变量:`p`、`m_flow`、`h_outflow`。 +- 流量符号:`m_flow > 0` 表示流入当前组件。 +- 网络方程:连接压力相等、连接流量和为零、组件压力-流量残差、stream 焓传播。 +- 动态:半显式 ODE,动态组件提供质量和内能状态导数。 +- 暂不支持:机械端口、信号端口仿真、事件系统、一般高指数 DAE、AMESim 气体定义组件对全局 medium 的动态切换。 + +## 子模型族矩阵 + +| AMESim 子模型 | 数量 | AMESim 角色 | 当前公开状态 | 建议目标 | 先决条件 / 限制 | +| --- | ---: | --- | --- | --- | --- | +| `PNCH023` | 4 | 固定容积气室,带换热 | 候选公开 | `amesim_pnch023`,`storage` | 可基于 `ThermodynamicVolumeComponent`;需按 AMESim `cvol/extemp/kth/sth/gi` 复核质量、能量、换热方程。 | +| `PNCH012` | 8 | 变容气室,带换热 | 参数化第一版公开 | `amesim_pnch012`,`storage` | 已将 AMESim `vol1..4/dvol1..4` 外部体积输入映射为 SI 参数,支持固定/预设体积场景;实时机械耦合仍需后续机械域或输入端口协议。 | +| `PNOR001` | 8 | 常系数气动孔口 | 候选公开 | `amesim_pnor001`,`flow` | 可基于 `AlgebraicComponent`;需按 AMESim `cq/area/Cv/Kv/flowset/gi` 复核双向流、零压差正则化和单位换算。 | +| `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入标量信号端口 `res`,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 | +| `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 候选公开 | `amesim_pn3node2`,`junctions` | 当前 `Tee` 是通用三通近似;AMESim port 2 参考温压语义和 stream 混合需单独测试。 | +| `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 候选公开 | `amesim_p4node2`,`junctions` | 需要新增四端 junction 基类/模型;复核 port 2 参考温压和多支路混合。 | +| `PNL00R` | 4 | 管路纯阻性摩擦段 | 候选公开 | `amesim_pnl00r`,`flow` | 可基于准稳态阻性管;需按 AMESim `PNL00R` 参数和摩擦公式复核。 | +| `PNL0001` | 20 | C-R 动态管路 | 第一版公开 | `amesim_pnl0001`,`flow` | 已按公开契约接入两状态管内容积 + port 1 摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 | +| `PNL0002` | 8 | R-C-R 动态管路 | 第一版公开 | `amesim_pnl0002`,`flow` | 已按公开契约接入中心两状态容积 + 两端半长摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 | +| `PNL0003` | 8 | C-R-C 动态管路 | 第一版公开 | `amesim_pnl0003`,`flow` | 已按公开契约接入两端四状态容积 + 中心摩擦流 + mode 2 换热项;大压差动态闭合和 AMESim baseline 误差仍留后续修模。 | +| `PNPL01` | 16 | 零气动流源 | 第一版公开 | `amesim_pnpl01`,`boundary` | 当前实现一端零流边界,只约束端口质量流量为 0;压力源/外部边界语义留后续扩展。 | +| `PNGD00` | 1 | 氦气气体定义 | 暂不公开 | medium 配置,不是画布物理组件 | 应映射为系统/介质设置;不能作为普通可连接组件注册。 | +| `PNRP17` | 8 | 气动活塞与移动体耦合 | 暂不公开 | 内部固定算例;后续跨域组件 | 依赖气动端口和机械端口耦合;需要新增机械域、跨域状态和连接规则。 | +| `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;完整 AMESim 接触/事件语义仍留后续对齐。 | +| `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 9 端一维机械节点、端口位移/速度等值和节点力平衡,并跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML;端口朝向/符号细节留后续 AMESim baseline 对齐。 | +| `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 | +| `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 | +| `FORC` | 2 | 信号转力 | 第一版公开 | `amesim_forc`,`mechanical` | 已接入信号输入 `res` 到机械端口力源,可由 `STEP0/UD00` 驱动质量组件。 | +| `STEP0` | 8 | 阶跃信号源 | 第一版公开 | `amesim_step0`,`signals` | 已接入标量信号输出端口和求解时信号传播;当前是基础阶跃,不含更复杂事件调度语义。 | +| `UD00` | 2 | 分段线性信号源 | 第一版公开 | `amesim_ud00`,`signals` | 已接入标量信号输出端口、8 段 start/end/t 参数、循环模式和 System XML signal connection;当前按已转换 PythonModels 语义处理最后一段外推,AMESim baseline 仍留后续复核。 | +| `DIRECT` | 44 | 直接连接 | 不注册为组件 | System XML `Connection` | 连接不是组件;物理连接必须保持无方向端点语义。 | + +## 建议迁移批次 + +### 批次 1:可进入当前气动求解器的公开组件 + +优先级最高,但仍需先补 AMESim 公式与测试: + +- `amesim_pnor001` +- `amesim_pnl00r` +- `amesim_pnch023` +- `amesim_pn3node2` +- `amesim_p4node2` + +完成定义:每个模型必须有 `MODEL_TYPE/MODEL_VERSION/PORTS/PARAMETERS/RESULT_VARIABLES/DISPLAY/create()`,并补目录、参数边界、残差、零流量/反向流、最小 XML 编译和短时仿真测试。 + +### 批次 2:动态管路 + +- `amesim_pnl0001`:已第一版公开,保留后续 baseline 精修。 +- `amesim_pnl0002`:已第一版公开,保留后续 baseline 精修。 +- `amesim_pnl0003`:已第一版公开,保留后续 baseline 精修。 + +这些模型是 `test_mql` 对齐工作的核心。当前已先按动态组件契约公开,后续仍需要在固定算例和 AMESim baseline 上复核摩擦、换热、多方模式和大压差动态闭合;不能用准稳态 `pipe` 替代这些动态管路并宣称等价。 + +### 批次 3:信号、事件、机械和跨域组件 + +- `STEP0`:已第一版公开。 +- `PNVO001`:已第一版公开,保留固定开度变体。 +- `UD00`:已第一版公开。 +- `MECMAS21`、`F000`、`FORC`、`LSTP00A`、`LMECHN1`:已第一版公开。 +- `PNRP17` + +当前已具备一对一标量信号传播、一维机械端口基础闭合、弹性接触基础件和 9 端机械节点;剩余跨域组件仍需要继续设计气动-机械耦合、XML 协议、前端连线兼容和最小闭合系统测试。 + +## 下一步执行建议 + +1. 先为 AMESim 公开组件建立独立库,例如 `app.simulation.components.amesim`,不要混入 `experimental`。 +2. 先只登记一个完成度最高的气动代数组件,例如 `PNOR001` 或 `PNL00R`。 +3. 每登记一个模型,都同步补充测试和目录校验,确认 `/api/components/catalog`、System XML 编译和最小仿真都通过。 +4. 动态管路 `PNL0001/2/3`、参数化 `PNCH012`、`STEP0`、`UD00`、信号版 `PNVO001` 和机械基础件 `F000/FORC/MECMAS21/LSTP00A/LMECHN1` 已完成第一版公开接入;下一步转向 `PNRP17` 或完整实时耦合。 diff --git a/docs/component-model-authoring-spec-v1.md b/docs/component-model-authoring-spec-v1.md index d81dd72..63d129b 100644 --- a/docs/component-model-authoring-spec-v1.md +++ b/docs/component-model-authoring-spec-v1.md @@ -249,9 +249,13 @@ ParameterDefinition( | quantity | SI 单位 | | --- | --- | +| `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` | @@ -260,7 +264,13 @@ ParameterDefinition( | `specific_enthalpy` | `J/kg` | | `specific_internal_energy` | `J/kg` | | `temperature` | `K` | +| `time` | `s` | +| `translational_damping` | `N/(m/s)` | +| `translational_stiffness` | `N/m` | +| `velocity` | `m/s` | | `volume` | `m3` | +| `volume_flow` | `m3/s` | +| `windage` | `N/(m/s)^2` | 新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在 单个模型中私自拼写新的同义 `quantity`。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25a3e07..898127a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -405,6 +405,19 @@ const scaleUnit = (factor: number) => ({ }); const unitOptions: Record = { + area: [ + { value: "m2", label: "m²", toBase: identityUnit, fromBase: identityUnit }, + { value: "cm2", label: "cm²", ...scaleUnit(1e-4) }, + { value: "mm2", label: "mm²", ...scaleUnit(1e-6) }, + ], + heat_transfer_coefficient: [ + { + value: "W/(m2*K)", + label: "W/(m²·K)", + toBase: identityUnit, + fromBase: identityUnit, + }, + ], pressure: [ { value: "Pa", label: "Pa", toBase: identityUnit, fromBase: identityUnit }, { value: "kPa", label: "kPa", ...scaleUnit(1_000) }, diff --git a/frontend/src/SimulationResultsView.tsx b/frontend/src/SimulationResultsView.tsx index a8997a8..4b074c8 100644 --- a/frontend/src/SimulationResultsView.tsx +++ b/frontend/src/SimulationResultsView.tsx @@ -265,6 +265,14 @@ const SPECIFIC_ENERGY_RESULT_UNITS = [ scaledResultUnit("MJ/kg", "MJ/kg", 1e6), ]; const RESULT_UNIT_OPTIONS: Record = { + area: [ + scaledResultUnit("m2", "m²", 1), + scaledResultUnit("cm2", "cm²", 1e-4), + scaledResultUnit("mm2", "mm²", 1e-6), + ], + heat_transfer_coefficient: [ + scaledResultUnit("W/(m2*K)", "W/(m²·K)", 1), + ], pressure: PRESSURE_RESULT_UNITS, absolute_pressure: PRESSURE_RESULT_UNITS, temperature: [ @@ -308,6 +316,9 @@ const RESULT_UNIT_OPTIONS: Record = { scaledResultUnit("cm", "cm", 1e-2), scaledResultUnit("mm", "mm", 1e-3), ], + velocity: [ + scaledResultUnit("m/s", "m/s", 1), + ], }; export function SimulationResultsView({ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_amesim_component_migration_matrix.py b/tests/test_amesim_component_migration_matrix.py new file mode 100644 index 0000000..76d2c08 --- /dev/null +++ b/tests/test_amesim_component_migration_matrix.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.systems import test_mql + + +MATRIX_PATH = Path(__file__).resolve().parents[1] / "docs" / "amesim-component-migration-matrix.md" + + +class AmesimComponentMigrationMatrixTests(unittest.TestCase): + def test_matrix_covers_every_test_mql_submodel_family(self) -> None: + text = MATRIX_PATH.read_text(encoding="utf-8") + families = set(test_mql.SUBMODEL_COUNTS) | set(test_mql.LINE_SUBMODEL_COUNTS) + + missing = sorted(family for family in families if f"`{family}`" not in text) + + self.assertEqual(missing, []) + + def test_unsupported_domains_are_not_marked_as_public_candidates(self) -> None: + text = MATRIX_PATH.read_text(encoding="utf-8") + unsupported = { + "PNRP17", + } + + for family in unsupported: + with self.subTest(family=family): + row = next(line for line in text.splitlines() if f"`{family}`" in line) + self.assertIn("暂不公开", row) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_mechanical_components.py b/tests/test_amesim_mechanical_components.py new file mode 100644 index 0000000..1589672 --- /dev/null +++ b/tests/test_amesim_mechanical_components.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.components.amesim_mechanical import ( + AmesimElasticEndstop, + AmesimMassFrictionEndstops, + AmesimPistonGeometry, + circular_area, + m_to_mm, + mm_to_m, +) +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class AmesimMechanicalComponentsTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_unit_helpers_and_area(self) -> None: + self.assertAlmostEqual(mm_to_m(200.0), 0.2) + self.assertAlmostEqual(m_to_mm(0.37), 370.0) + self.assertAlmostEqual(circular_area(0.2), 0.031415926535897934) + + def test_piston_geometry_matches_amesim_length_and_volume(self) -> None: + geometry = AmesimPistonGeometry(piston_diameter_m=0.2, rod_diameter_m=0.001) + index = -1 + x4 = self.amesim_results.series("x4@pn_brp2_8")[index] + x5 = self.amesim_results.series("x5@pn_brp2_8")[index] + + self.assertAlmostEqual( + geometry.chamber_length_mm(x4, x5), + self.amesim_results.series("length@pn_brp2_8")[index], + ) + self.assertAlmostEqual( + geometry.chamber_volume_cm3(x4, x5), + self.amesim_results.series("vol1@pn_brp2_8")[index], + delta=1.0e-3, + ) + + def test_piston_geometry_matches_amesim_volume_rate(self) -> None: + geometry = AmesimPistonGeometry(piston_diameter_m=0.2, rod_diameter_m=0.001) + index = 100 + v4 = self.amesim_results.series("v4@pn_brp2_8")[index] + v5 = self.amesim_results.series("v5@pn_brp2_8")[index] + + self.assertAlmostEqual( + geometry.chamber_volume_rate_l_min(v4, v5), + self.amesim_results.series("vvol1@pn_brp2_8")[index], + delta=1.0e-8, + ) + + def test_elastic_endstop_contact_force_matches_amesim_final_sample(self) -> None: + endstop = AmesimElasticEndstop( + contact_stiffness_n_per_m=1.0e11, + contact_damping_n_per_m_per_s=1.0e11, + ) + index = -1 + gap_mm = self.amesim_results.series("gap@elasticendstop_8")[index] + penetration_velocity_m_s = self.amesim_results.series("v5@pn_brp2_8")[index] + + self.assertAlmostEqual( + endstop.contact_force(gap_mm, penetration_velocity_m_s), + self.amesim_results.series("f1@elasticendstop_8")[index], + delta=1.0e-3, + ) + + def test_elastic_endstop_returns_zero_before_contact(self) -> None: + endstop = AmesimElasticEndstop(contact_stiffness_n_per_m=1.0e11) + + self.assertEqual(endstop.static_contact_force(0.1), 0.0) + self.assertEqual(endstop.penetration_m_from_gap_mm(0.1), 0.0) + + def test_mass_endstop_penetration_and_viscous_friction(self) -> None: + mass = AmesimMassFrictionEndstops( + mass_kg=50.0, + lower_limit_m=-1.0, + upper_limit_m=0.8, + lower_stiffness_n_per_m=1.0e9, + upper_stiffness_n_per_m=1.0e9, + viscous_friction_n_per_m_per_s=12.0, + ) + + self.assertEqual(mass.lower_penetration_m(0.0), 0.0) + self.assertEqual(mass.upper_penetration_m(0.0), 0.0) + self.assertAlmostEqual(mass.lower_static_force_magnitude(-1.001), 1.0e6, delta=1.0e-6) + self.assertAlmostEqual(mass.upper_static_force_magnitude(0.801), 1.0e6, delta=1.0e-6) + self.assertAlmostEqual(mass.viscous_friction_force(0.25), -3.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_mechanical_public_components.py b/tests/test_amesim_mechanical_public_components.py new file mode 100644 index 0000000..3f74bec --- /dev/null +++ b/tests/test_amesim_mechanical_public_components.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.mechanical.translational import ( + AmesimF000, + AmesimForc, + AmesimMecmas21, + AmesimLstp00a, + AmesimLmechn1, +) +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimMechanicalPublicComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_f000_constrains_mechanical_port_force_to_zero(self) -> None: + source = AmesimF000("zero_1") + source.port_1.f = 12.5 + + residuals = source.pressure_flow_equation_residuals() + + self.assertEqual(len(residuals), 1) + self.assertEqual(residuals[0].variables, ("zero_1.port_1.f",)) + self.assertAlmostEqual(residuals[0].value, 12.5) + + def test_forc_converts_signal_to_opposing_source_port_force(self) -> None: + converter = AmesimForc("force_1") + converter.res.signal = 20.0 + converter.port_2.f = -20.0 + + residuals = converter.pressure_flow_equation_residuals() + + self.assertAlmostEqual(converter.output_force, 20.0) + self.assertAlmostEqual(residuals[0].value, 0.0) + self.assertEqual(converter.component_result_values(), {"force": 20.0}) + + def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None: + mass = AmesimMecmas21( + "mass_1", + self.medium, + mass=2.0, + fcoul=0.0, + rvisc=0.0, + wind=0.0, + x0=0.1, + v0=0.2, + ) + mass.port_1.f = 10.0 + mass.port_2.f = -2.0 + + self.assertEqual(mass.get_state_vector(), [0.2, 0.1]) + self.assertAlmostEqual(mass.acceleration(), 4.0) + self.assertEqual(mass.state_derivative_from_ports({}), [4.0, 0.2]) + self.assertAlmostEqual(mass.port_1.x, 0.1) + self.assertAlmostEqual(mass.port_2.v, 0.2) + + + + def test_lmechn1_ties_active_port_kinematics_and_balances_force(self) -> None: + node = AmesimLmechn1("node_1", self.medium, v1=2.0, sum=1.0) + node.port_1.x = 0.2 + node.port_1.v = 0.3 + node.port_1.f = 4.0 + node.port_2.x = 0.2 + node.port_2.v = 0.3 + node.port_2.f = 6.0 + node.port_9.x = 0.2 + node.port_9.v = 0.3 + node.port_9.f = -10.0 + + residuals = node.pressure_flow_equation_residuals() + + self.assertEqual(node.active_ports, ("port_1", "port_2", "port_9")) + self.assertAlmostEqual(node.total_force, 0.0) + self.assertEqual(len(residuals), 5) + self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals)) + self.assertEqual(node.component_result_values(), {"tforce": 0.0}) + + def test_lmechn1_registry_rejects_fractional_integer_options(self) -> None: + with self.assertRaisesRegex(ValueError, "v1 must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_lmechn1"].create( + "node_1", + self.medium, + {"v1": 2.5}, + ) + + def test_lstp00a_generates_opposing_contact_forces(self) -> None: + contact = AmesimLstp00a( + "contact_1", + self.medium, + gap0=0.0, + kcont=1000.0, + rcont=10.0, + ) + contact.port_1.x = 0.0 + contact.port_2.x = 0.002 + contact.port_1.v = 0.0 + contact.port_2.v = 0.1 + contact.port_1.f = -3.0 + contact.port_2.f = 3.0 + + self.assertAlmostEqual(contact.gap, -0.002) + self.assertAlmostEqual(contact.penetration, 0.002) + self.assertAlmostEqual(contact.contact_force, 3.0) + self.assertEqual( + contact.component_result_values(), + {"gap": -0.002, "penetration": 0.002, "force": 3.0}, + ) + residuals = contact.pressure_flow_equation_residuals() + self.assertAlmostEqual(residuals[0].value, 0.0) + self.assertAlmostEqual(residuals[1].value, 0.0) + + def test_lstp00a_returns_zero_before_contact(self) -> None: + contact = AmesimLstp00a("contact_1", self.medium, gap0=0.001, kcont=1000.0) + contact.port_1.x = 0.0 + contact.port_2.x = 0.0005 + + self.assertAlmostEqual(contact.gap, 0.0005) + self.assertAlmostEqual(contact.contact_force, 0.0) + + def test_mecmas21_registry_rejects_fractional_integer_options(self) -> None: + with self.assertRaisesRegex(ValueError, "useFriction must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create( + "mass_1", + self.medium, + {"useFriction": 0.5}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_mechanical_xml.py b/tests/test_amesim_mechanical_xml.py new file mode 100644 index 0000000..57237c1 --- /dev/null +++ b/tests/test_amesim_mechanical_xml.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +LSTP00A_DEFAULTS = { + "na": 10.0, + "gap0": 0.0, + "kcont": 1000.0, + "G": 8.57e10, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 0.0, + "Pdis": 1.0e-7, + "stiffmode": 1.0, + "discContactOption": 1.0, +} + + +MECMAS21_DEFAULTS = { + "mass": 2.0, + "fstick": 0.0, + "fcoul": 0.0, + "rvisc": 0.0, + "wind": 0.0, + "dvel": 1.0e-6, + "restdvel": 1.0e-6, + "restcoeff": 0.65, + "astrib": 1.0e-3, + "xmin": -1.0, + "Kbmin": 1.0e9, + "Dbmin": 1.0e4, + "Pdmin": 1.0e-4, + "xmax": 1.0, + "Kbmax": 1.0e9, + "Dbmax": 1.0e4, + "Pdmax": 1.0e-4, + "theta": 0.0, + "useFriction": 1.0, + "stoptype": 4.0, + "discContactOption": 1.0, + "strib": 1.0, + "frictionType": 1.0, + "v0": 0.0, + "x0": 0.0, +} + + +def mechanical_port(name: str, side: str) -> dict[str, str]: + return physical_port(name, "bidirectional", side, domain="mechanical") + + +def zero_force_mass_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-mechanical-zero-force-smoke", + nodes=[ + component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]), + component_node( + "mass_1", + "amesim_mecmas21", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + MECMAS21_DEFAULTS, + ), + component_node("zero_right", "amesim_f000", [mechanical_port("port_1", "left")]), + ], + edges=[ + physical_edge("edge-1", "zero_left", "port_1", "mass_1", "port_1"), + physical_edge("edge-2", "mass_1", "port_2", "zero_right", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.01, "method": "BDF"}, + ) + + +def signal_force_mass_project() -> ReactFlowProjectPayload: + parameters = dict(MECMAS21_DEFAULTS) + parameters["mass"] = 2.0 + return ReactFlowProjectPayload( + name="amesim-mechanical-signal-force-smoke", + nodes=[ + component_node( + "force_signal", + "amesim_ud00", + [signal_port("out", "output", "right")], + { + "tstart": 0.0, + "start1": 10.0, + "end1": 10.0, + "t1": 1.0, + "start2": 10.0, + "end2": 10.0, + "t2": 0.0, + "start3": 10.0, + "end3": 10.0, + "t3": 0.0, + "start4": 10.0, + "end4": 10.0, + "t4": 0.0, + "start5": 10.0, + "end5": 10.0, + "t5": 0.0, + "start6": 10.0, + "end6": 10.0, + "t6": 0.0, + "start7": 10.0, + "end7": 10.0, + "t7": 0.0, + "start8": 10.0, + "end8": 10.0, + "t8": 0.0, + "nstages": 1.0, + "iscyclic": 0.0, + }, + ), + component_node( + "force_1", + "amesim_forc", + [signal_port("res", "input", "left"), mechanical_port("port_2", "right")], + ), + component_node( + "mass_1", + "amesim_mecmas21", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + parameters, + ), + component_node("zero_right", "amesim_f000", [mechanical_port("port_1", "left")]), + ], + edges=[ + signal_edge("signal-1", "force_signal", "out", "force_1", "res"), + physical_edge("edge-1", "force_1", "port_2", "mass_1", "port_1"), + physical_edge("edge-2", "mass_1", "port_2", "zero_right", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.005, "method": "BDF"}, + ) + + +def elastic_contact_project() -> ReactFlowProjectPayload: + left_parameters = dict(MECMAS21_DEFAULTS) + left_parameters["x0"] = 0.0 + right_parameters = dict(MECMAS21_DEFAULTS) + right_parameters["x0"] = 0.001 + return ReactFlowProjectPayload( + name="amesim-mechanical-elastic-contact-smoke", + nodes=[ + component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]), + component_node( + "mass_left", + "amesim_mecmas21", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + left_parameters, + ), + component_node( + "contact_1", + "amesim_lstp00a", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + LSTP00A_DEFAULTS, + ), + component_node( + "mass_right", + "amesim_mecmas21", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + right_parameters, + ), + component_node("zero_right", "amesim_f000", [mechanical_port("port_1", "left")]), + ], + edges=[ + physical_edge("edge-1", "zero_left", "port_1", "mass_left", "port_1"), + physical_edge("edge-2", "mass_left", "port_2", "contact_1", "port_1"), + physical_edge("edge-3", "contact_1", "port_2", "mass_right", "port_1"), + physical_edge("edge-4", "mass_right", "port_2", "zero_right", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.005, "method": "BDF"}, + ) + + +def force_node_mass_project() -> ReactFlowProjectPayload: + node_ports = [mechanical_port(f"port_{index}", "left") for index in range(1, 9)] + node_ports.append(mechanical_port("port_9", "right")) + zero_nodes = [ + component_node(f"zero_{index}", "amesim_f000", [mechanical_port("port_1", "right")]) + for index in range(2, 9) + ] + zero_edges = [ + physical_edge(f"edge-zero-{index}", f"zero_{index}", "port_1", "node_1", f"port_{index}") + for index in range(2, 9) + ] + return ReactFlowProjectPayload( + name="amesim-mechanical-node-smoke", + nodes=[ + component_node( + "step_1", + "amesim_step0", + [signal_port("out", "output", "right")], + {"initial": 10.0, "final": 10.0, "time": 0.0}, + ), + component_node( + "force_1", + "amesim_forc", + [signal_port("res", "input", "left"), mechanical_port("port_2", "right")], + ), + component_node("node_1", "amesim_lmechn1", node_ports, {"v1": 8.0, "sum": 1.0}), + *zero_nodes, + component_node( + "mass_1", + "amesim_mecmas21", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + MECMAS21_DEFAULTS, + ), + component_node("zero_right", "amesim_f000", [mechanical_port("port_1", "left")]), + ], + edges=[ + signal_edge("signal-1", "step_1", "out", "force_1", "res"), + physical_edge("edge-force", "force_1", "port_2", "node_1", "port_1"), + *zero_edges, + physical_edge("edge-mass", "node_1", "port_9", "mass_1", "port_1"), + physical_edge("edge-right", "mass_1", "port_2", "zero_right", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.005, "method": "BDF"}, + ) + + +class AmesimMechanicalXmlTests(unittest.TestCase): + + + def test_force_node_mass_project_compiles_and_simulates(self) -> None: + xml = build_reactflow_system_xml(force_node_mass_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + network = compile_reactflow_network(force_node_mass_project()) + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["step_1.out.signal"], [10.0, 10.0, 10.0]) + self.assertEqual(result["series"]["force_1.res.signal"], [10.0, 10.0, 10.0]) + self.assertAlmostEqual(result["series"]["mass_1.a"][0], 5.0) + self.assertGreater(result["series"]["mass_1.v"][-1], 0.0) + self.assertGreater(result["series"]["mass_1.x"][-1], 0.0) + + def test_elastic_contact_project_compiles_and_simulates(self) -> None: + xml = build_reactflow_system_xml(elastic_contact_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + network = compile_reactflow_network(elastic_contact_project()) + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.01, 0.02]) + self.assertAlmostEqual(result["series"]["contact_1.force"][0], 1.0) + self.assertAlmostEqual(result["series"]["mass_left.a"][0], 0.5) + self.assertAlmostEqual(result["series"]["mass_right.a"][0], -0.5) + self.assertGreater(result["series"]["mass_left.x"][-1], 0.0) + self.assertLess(result["series"]["mass_right.x"][-1], 0.001) + + def test_zero_force_mechanical_project_compiles_and_simulates(self) -> None: + xml = build_reactflow_system_xml(zero_force_mass_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + network = compile_reactflow_network(zero_force_mass_project()) + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.01, 0.02]) + self.assertEqual(result["series"]["mass_1.v"], [0.0, 0.0, 0.0]) + self.assertEqual(result["series"]["mass_1.x"], [0.0, 0.0, 0.0]) + + def test_signal_force_mechanical_project_compiles_and_simulates(self) -> None: + xml = build_reactflow_system_xml(signal_force_mass_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + network = compile_reactflow_network(signal_force_mass_project()) + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["force_signal.out.signal"], [10.0, 10.0, 10.0]) + self.assertEqual(result["series"]["force_1.res.signal"], [10.0, 10.0, 10.0]) + self.assertAlmostEqual(result["series"]["mass_1.a"][0], 5.0) + self.assertGreater(result["series"]["mass_1.v"][-1], 0.0) + self.assertGreater(result["series"]["mass_1.x"][-1], 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnch012_component.py b/tests/test_amesim_pnch012_component.py new file mode 100644 index 0000000..0008310 --- /dev/null +++ b/tests/test_amesim_pnch012_component.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.storage.chambers import AmesimPnch012 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnch012ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_parameterized_volume_contract(self) -> None: + chamber = COMPONENT_MODEL_REGISTRY["amesim_pnch012"].create( + "chamber_1", + self.medium, + {}, + ) + + self.assertIsInstance(chamber, AmesimPnch012) + self.assertEqual(set(chamber.ports), {"port_1", "port_2", "port_3", "port_4"}) + self.assertEqual(len(chamber.get_state_vector()), 2) + self.assertEqual(chamber.parameter_values["cvol0"], 0.015) + self.assertAlmostEqual(chamber.total_volume(), 0.015) + + def test_total_volume_adds_four_external_volume_parameters(self) -> None: + chamber = AmesimPnch012( + "chamber_1", + self.medium, + cvol0=0.015, + vol1=0.001, + vol2=0.002, + vol3=0.003, + vol4=0.004, + dvol1=1.0e-6, + dvol2=2.0e-6, + dvol3=-1.0e-6, + dvol4=0.0, + ) + + self.assertAlmostEqual(chamber.total_volume(), 0.025) + self.assertAlmostEqual(chamber.total_volume_rate(), 2.0e-6) + + def test_initial_state_uses_total_volume_pressure_and_temperature(self) -> None: + chamber = AmesimPnch012( + "chamber_1", + self.medium, + cvol0=0.015, + vol1=0.010, + p0=200000.0, + T0=300.0, + ) + props = chamber.properties() + + self.assertAlmostEqual(props.p, 200000.0, delta=1.0e-8) + self.assertAlmostEqual(props.T, 300.0) + self.assertAlmostEqual(chamber.port_1.p, props.p) + self.assertAlmostEqual(chamber.port_4.p, props.p) + + def test_pressure_flow_residuals_bind_all_ports_to_chamber_state(self) -> None: + chamber = AmesimPnch012("chamber_1", self.medium) + chamber.properties() + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in chamber.pressure_flow_equation_residuals() + } + + self.assertEqual( + set(residuals), + { + "port_1_pressure_state", + "port_2_pressure_state", + "port_3_pressure_state", + "port_4_pressure_state", + }, + ) + self.assertTrue(all(abs(residual.value) < 1.0e-9 for residual in residuals.values())) + + def test_derivative_sums_four_mass_flows_and_boundary_work(self) -> None: + chamber = AmesimPnch012( + "chamber_1", + self.medium, + dvol1=1.0e-6, + kth=2.0, + sth=0.5, + extemp=310.0, + ) + props = chamber.properties() + chamber.port_1.m_flow = 0.2 + chamber.port_2.m_flow = -0.1 + chamber.port_3.m_flow = 0.05 + chamber.port_4.m_flow = 0.0 + + derivative = chamber.state_derivative_from_ports( + { + "port_1": props.h + 1000.0, + "port_2": props.h - 1000.0, + "port_3": props.h + 500.0, + "port_4": props.h, + } + ) + + self.assertAlmostEqual(derivative[0], 0.15) + self.assertLess(derivative[1], 0.2 * (props.h + 1000.0) + 0.05 * (props.h + 500.0)) + + def test_results_include_total_volume_and_volume_rate(self) -> None: + chamber = AmesimPnch012("chamber_1", self.medium, vol1=0.001, dvol1=1.0e-6) + + values = chamber.component_result_values() + + self.assertIn("vol", values) + self.assertIn("dvol", values) + self.assertAlmostEqual(values["vol"], 0.016) + self.assertAlmostEqual(values["dvol"], 1.0e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnch012_xml.py b/tests/test_amesim_pnch012_xml.py new file mode 100644 index 0000000..a6b55d8 --- /dev/null +++ b/tests/test_amesim_pnch012_xml.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def _pnpl_node(index: int) -> object: + return component_node( + f"boundary_{index}", + "amesim_pnpl01", + [physical_port("port_1", "bidirectional", "left")], + {}, + ) + + +def amesim_pnch012_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnch012-smoke", + nodes=[ + component_node( + "chamber_1", + "amesim_pnch012", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + physical_port("port_3", "bidirectional", "left"), + physical_port("port_4", "bidirectional", "right"), + ], + { + "cvol0": 0.015, + "kth": 0.0, + "sth": 0.7, + "extemp": 300.0, + "gi": 1.0, + "p0": 200000.0, + "T0": 300.0, + "vol1": 0.001, + "vol2": 0.0, + "vol3": 0.0, + "vol4": 0.0, + "dvol1": 0.0, + "dvol2": 0.0, + "dvol3": 0.0, + "dvol4": 0.0, + }, + ), + _pnpl_node(1), + _pnpl_node(2), + _pnpl_node(3), + _pnpl_node(4), + ], + edges=[ + physical_edge("edge-1", "chamber_1", "port_1", "boundary_1", "port_1"), + physical_edge("edge-2", "chamber_1", "port_2", "boundary_2", "port_1"), + physical_edge("edge-3", "chamber_1", "port_3", "boundary_3", "port_1"), + physical_edge("edge-4", "chamber_1", "port_4", "boundary_4", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimPnch012XmlTests(unittest.TestCase): + def test_pnch012_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnch012_project()) + + self.assertIn("chamber_1", network.components) + self.assertEqual(network.components["chamber_1"].model_type, "amesim_pnch012") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnch012_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnch012_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("chamber_1.port_1.m_flow", result["series"]) + self.assertIn("chamber_1.vol", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnch023_component.py b/tests/test_amesim_pnch023_component.py new file mode 100644 index 0000000..673083c --- /dev/null +++ b/tests/test_amesim_pnch023_component.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.storage.chambers import AmesimPnch023 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnch023ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_amesim_parameter_contract(self) -> None: + chamber = COMPONENT_MODEL_REGISTRY["amesim_pnch023"].create( + "chamber_1", + self.medium, + {}, + ) + + self.assertIsInstance(chamber, AmesimPnch023) + self.assertEqual(set(chamber.ports), {"port_1", "port_2"}) + self.assertEqual( + set(chamber.parameter_values), + {"cvol", "kth", "sth", "extemp", "gi", "p0", "T0"}, + ) + self.assertEqual(chamber.parameter_values["cvol"], 0.057) + self.assertEqual(chamber.gi, 1) + + def test_rejects_fractional_gas_index(self) -> None: + with self.assertRaisesRegex(ValueError, "gi must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnch023"].create( + "chamber_1", + self.medium, + {"gi": 1.5}, + ) + + def test_initial_state_matches_requested_pressure_temperature(self) -> None: + chamber = AmesimPnch023( + "chamber_1", + self.medium, + cvol=0.057, + p0=15300000.0, + T0=293.15, + ) + + props = chamber.properties() + + self.assertAlmostEqual(props.p, 15300000.0) + self.assertAlmostEqual(props.T, 293.15) + self.assertAlmostEqual(chamber.port_1.p, props.p) + self.assertAlmostEqual(chamber.port_2.p, props.p) + + def test_two_port_derivative_conserves_mass_and_adds_heat_transfer(self) -> None: + chamber = AmesimPnch023( + "chamber_1", + self.medium, + kth=10.0, + sth=2.0, + extemp=310.0, + T0=300.0, + ) + props = chamber.properties() + chamber.port_1.m_flow = 0.2 + chamber.port_2.m_flow = -0.1 + + derivative = chamber.state_derivative_from_ports( + { + "port_1": props.h + 1000.0, + "port_2": props.h - 1000.0, + } + ) + + self.assertAlmostEqual(derivative[0], 0.1) + self.assertAlmostEqual( + derivative[1], + 0.2 * (props.h + 1000.0) - 0.1 * props.h + 10.0 * 2.0 * 10.0, + ) + + def test_pressure_residuals_bind_both_ports_to_state(self) -> None: + chamber = AmesimPnch023("chamber_1", self.medium, p0=200000.0, T0=300.0) + chamber.port_1.p = 200100.0 + chamber.port_2.p = 199900.0 + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in chamber.pressure_flow_equation_residuals() + } + + self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"}) + self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 100.0) + self.assertAlmostEqual(residuals["port_2_pressure_state"].value, -100.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnch023_xml.py b/tests/test_amesim_pnch023_xml.py new file mode 100644 index 0000000..e4220d5 --- /dev/null +++ b/tests/test_amesim_pnch023_xml.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pnch023_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnch023-smoke", + nodes=[ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 101000.0, "T0": 300.0}, + ), + component_node( + "orifice_1", + "orifice", + [ + physical_port("port_a", "inlet", "left"), + physical_port("port_b", "outlet", "right"), + ], + {"K": 1e-8, "opening": 1.0}, + ), + component_node( + "chamber_1", + "amesim_pnch023", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + { + "cvol": 0.057, + "kth": 0.0, + "sth": 0.1, + "extemp": 293.15, + "gi": 1.0, + "p0": 100000.0, + "T0": 293.15, + }, + ), + component_node( + "pipe_1", + "amesim_pnl00r", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + {"diam": 0.02, "le": 1.0, "rr": 1.0e-5, "gi": 1.0}, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"), + physical_edge("edge-2", "orifice_1", "port_b", "chamber_1", "port_1"), + physical_edge("edge-3", "chamber_1", "port_2", "pipe_1", "port_1"), + physical_edge("edge-4", "pipe_1", "port_2", "tank_1", "port_a"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPnch023XmlTests(unittest.TestCase): + def test_pnch023_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnch023_project()) + + self.assertIn("chamber_1", network.components) + self.assertEqual(network.components["chamber_1"].model_type, "amesim_pnch023") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnch023_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnch023_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("chamber_1.p", result["series"]) + self.assertIn("chamber_1.port_1.m_flow", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pneumatic_components.py b/tests/test_amesim_pneumatic_components.py new file mode 100644 index 0000000..fe8f876 --- /dev/null +++ b/tests/test_amesim_pneumatic_components.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import unittest + +from PythonModels.components.amesim_pneumatic import ( + HELIUM_PNEUMATIC_GAS, + AmesimPneumaticGas, + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + AmesimVariablePneumaticVolume, + cm3_to_m3, + compressible_orifice_mass_flow, + diameter_mm_to_area_m2, + kg_to_g, + liters_to_m3, + m3_to_cm3, + mm2_to_m2, +) + + +class AmesimPneumaticComponentsTest(unittest.TestCase): + def test_unit_conversions(self) -> None: + self.assertAlmostEqual(liters_to_m3(15.0), 0.015) + self.assertAlmostEqual(m3_to_cm3(0.015), 15000.0) + self.assertAlmostEqual(cm3_to_m3(15000.0), 0.015) + self.assertAlmostEqual(kg_to_g(1.25), 1250.0) + self.assertAlmostEqual(mm2_to_m2(78.5), 78.5e-6) + self.assertAlmostEqual(diameter_mm_to_area_m2(10.0), 7.853981633974483e-5) + + def test_reference_enthalpy_uses_amesim_operating_temperature_baseline(self) -> None: + gas = AmesimPneumaticGas(cp=5193.0, cv=3116.0) + + self.assertAlmostEqual(gas.specific_reference_enthalpy(298.15), 0.0) + self.assertAlmostEqual( + gas.specific_reference_enthalpy(290.2036), + 5193.0 * (290.2036 - 298.15), + ) + self.assertAlmostEqual( + gas.reference_temperature_from_specific_enthalpy( + gas.specific_reference_enthalpy(287.7322), + ), + 287.7322, + ) + + def test_pressure_reference_enthalpy_includes_real_gas_departure(self) -> None: + gas = AmesimPneumaticGas(cp=5193.0, cv=3116.0) + + ideal_reference_h = gas.specific_reference_enthalpy(287.7322) + pressure_reference_h = gas.pressure_reference_enthalpy(13_839_965.0, 287.7322) + + self.assertAlmostEqual(ideal_reference_h, -54099.6354, delta=0.001) + self.assertAlmostEqual( + pressure_reference_h - ideal_reference_h, + 11936.1, + delta=0.1, + ) + + def test_pressure_transport_enthalpy_restores_absolute_energy_offset(self) -> None: + gas = HELIUM_PNEUMATIC_GAS + pressure = 13_839_965.0 + temperature = 287.7322 + + self.assertAlmostEqual( + gas.pressure_transport_enthalpy(pressure, temperature), + gas.pressure_reference_enthalpy(pressure, temperature) + + gas.cp * 298.15, + ) + + def test_reference_temperature_from_enthalpy_rejects_non_positive_cp(self) -> None: + gas = AmesimPneumaticGas(cp=0.0, cv=3116.0) + + with self.assertRaisesRegex(ValueError, "cp must be positive"): + gas.reference_temperature_from_specific_enthalpy(42.0) + + def test_volume_initial_state_matches_requested_pressure_temperature(self) -> None: + volume = AmesimPneumaticVolume.from_liters( + name="pn_general_chamber", + volume_liters=57.0, + p0=15.3e6, + T0=293.15, + ) + props = volume.properties() + + self.assertAlmostEqual(props.p, 15.3e6, delta=15.3e6 * 1.0e-12) + self.assertAlmostEqual(props.T, 293.15) + self.assertGreater(props.rho, 20.0) + self.assertLess(props.rho, 30.0) + + def test_volume_reports_amesim_style_observables(self) -> None: + volume = AmesimPneumaticVolume.from_liters( + name="pn_general_chamber", + volume_liters=57.0, + p0=15.3e6, + T0=293.15, + ) + + self.assertAlmostEqual(volume.volume_cm3(), 57000.0) + self.assertAlmostEqual( + volume.pressure_gauge_pa(reference_pressure_pa=101_300.0), + 15198700.0, + ) + self.assertGreater(volume.gas_mass_g(), 1300.0) + + def test_volume_balances_two_connections_with_stream_enthalpy(self) -> None: + volume = AmesimPneumaticVolume.from_liters( + name="pn_general_chamber", + volume_liters=57.0, + p0=15.3e6, + T0=293.15, + ) + internal_h = volume.properties().h + + derivative = volume.derivatives_from_two_connections( + port_a_m_flow=0.2, + connected_h_a=internal_h + 1000.0, + port_b_m_flow=-0.1, + connected_h_b=internal_h - 1000.0, + internal_h=internal_h, + ) + + self.assertAlmostEqual(derivative.m, 0.1) + self.assertAlmostEqual( + derivative.U, + 0.2 * (internal_h + 1000.0) - 0.1 * internal_h, + ) + self.assertAlmostEqual(volume.port_a.p, volume.port_b.p) + + def test_volume_applies_amesim_heat_exchange_to_energy_derivative(self) -> None: + volume = AmesimPneumaticVolume.from_liters( + name="heated_chamber", + volume_liters=15.0, + p0=100000.0, + T0=300.0, + heat_transfer_coefficient=1500.0, + heat_transfer_area=0.7, + external_temperature_k=293.15, + ) + props = volume.properties() + + derivative = volume.derivatives_from_two_connections( + port_a_m_flow=0.0, + connected_h_a=props.h, + port_b_m_flow=0.0, + connected_h_b=props.h, + internal_h=props.h, + ) + + self.assertAlmostEqual( + derivative.U, + 1500.0 * 0.7 * (293.15 - 300.0), + ) + + def test_variable_volume_tracks_external_volume(self) -> None: + volume = AmesimVariablePneumaticVolume.from_liters( + name="pn_c1_8", + dead_volume_liters=15.0, + p0=100000.0, + T0=293.15, + ) + + self.assertAlmostEqual(volume.volume_cm3(), 15000.0) + volume.set_external_volume_m3(cm3_to_m3(34242.54636512914)) + self.assertAlmostEqual(volume.volume_cm3(), 49242.54636512914) + + def test_variable_volume_boundary_work_uses_external_volume_rate(self) -> None: + volume = AmesimVariablePneumaticVolume.from_liters( + name="pn_c1_8", + dead_volume_liters=15.0, + p0=100000.0, + T0=293.15, + ) + props = volume.properties() + volume.set_external_volume_m3(0.0, external_volume_rate_m3_s=2.0e-6) + + derivative = volume.derivatives_from_two_connections( + port_a_m_flow=0.0, + connected_h_a=props.h, + port_b_m_flow=0.0, + connected_h_b=props.h, + internal_h=props.h, + ) + + self.assertAlmostEqual(derivative.m, 0.0) + self.assertAlmostEqual(derivative.U, -props.p * 2.0e-6) + + def test_orifice_effective_area_clamps_opening(self) -> None: + orifice = AmesimPneumaticOrifice.from_mm2( + name="pn_variable_orifice", + area_mm2=78.5, + opening=1.4, + ) + self.assertAlmostEqual(orifice.effective_area, 78.5e-6) + + orifice.opening = -0.25 + self.assertAlmostEqual(orifice.effective_area, 0.0) + + def test_orifice_returns_signed_mass_flow(self) -> None: + orifice = AmesimPneumaticOrifice.from_mm2( + name="pn_orifice_18", + area_mm2=78.5, + flow_coefficient=0.9, + ) + + forward = orifice.mass_flow(15.3e6, 1.0e6, 293.15) + reverse = orifice.mass_flow(1.0e6, 15.3e6, 293.15) + + self.assertGreater(forward, 0.0) + self.assertAlmostEqual(reverse, -forward) + self.assertEqual(orifice.mass_flow(1.0e6, 1.0e6, 293.15), 0.0) + + def test_choked_flow_is_independent_of_lower_downstream_pressure(self) -> None: + base = compressible_orifice_mass_flow( + upstream_pressure=15.3e6, + downstream_pressure=1.0e6, + upstream_temperature=293.15, + area=78.5e-6, + flow_coefficient=0.9, + gas=HELIUM_PNEUMATIC_GAS, + ) + lower_back_pressure = compressible_orifice_mass_flow( + upstream_pressure=15.3e6, + downstream_pressure=0.1e6, + upstream_temperature=293.15, + area=78.5e-6, + flow_coefficient=0.9, + gas=HELIUM_PNEUMATIC_GAS, + ) + + self.assertGreater(base, 0.0) + self.assertAlmostEqual(lower_back_pressure, base) + + def test_subcritical_flow_decreases_as_back_pressure_rises(self) -> None: + low_back_pressure = compressible_orifice_mass_flow( + upstream_pressure=1.0e6, + downstream_pressure=0.6e6, + upstream_temperature=293.15, + area=78.5e-6, + flow_coefficient=0.9, + ) + high_back_pressure = compressible_orifice_mass_flow( + upstream_pressure=1.0e6, + downstream_pressure=0.9e6, + upstream_temperature=293.15, + area=78.5e-6, + flow_coefficient=0.9, + ) + + self.assertGreater(low_back_pressure, high_back_pressure) + self.assertGreater(high_back_pressure, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pneumatic_line_components.py b/tests/test_amesim_pneumatic_line_components.py new file mode 100644 index 0000000..4f73603 --- /dev/null +++ b/tests/test_amesim_pneumatic_line_components.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import unittest + +from PythonModels.components.amesim_pneumatic_line import ( + AmesimPnl0002Pipe, + AmesimPnl0003Pipe, + AmesimPnl00rPipe, +) + + +class AmesimPnl0002PipeTests(unittest.TestCase): + def setUp(self) -> None: + self.pipe = AmesimPnl0002Pipe( + name="pneumatic_86", + diameter_mm=20.0, + length_m=2.0, + relative_roughness=0.045 / 20.0, + pctr_0=100_000.0, + Tctr_0=293.15, + ) + + def test_initial_state_uses_center_compliance(self) -> None: + properties = self.pipe.properties() + + self.assertAlmostEqual(self.pipe.volume, 6.283185307179586e-4) + self.assertEqual(len(self.pipe.get_state_vector()), 2) + self.assertAlmostEqual(properties.p, 100_000.0, delta=1.0e-6) + self.assertAlmostEqual(properties.T, 293.15) + self.assertAlmostEqual(self.pipe.port_1.p, properties.p) + self.assertAlmostEqual(self.pipe.port_2.p, properties.p) + + def test_port_flow_enters_center_from_higher_external_pressure(self) -> None: + forward = self.pipe.port_mass_flow( + port_pressure_pa=101_000.0, + port_temperature_k=293.15, + ) + reverse = self.pipe.port_mass_flow( + port_pressure_pa=99_000.0, + port_temperature_k=293.15, + ) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + + def test_connection_derivatives_conserve_two_external_port_flows(self) -> None: + properties = self.pipe.properties() + + derivative = self.pipe.derivatives_from_connections( + port_1_m_flow=0.2, + connected_h_1=properties.h + 1000.0, + port_2_m_flow=-0.1, + connected_h_2=properties.h - 1000.0, + ) + + self.assertAlmostEqual(derivative.m, 0.1) + + +class AmesimPnl0003PipeTests(unittest.TestCase): + def setUp(self) -> None: + self.pipe = AmesimPnl0003Pipe( + name="pneumatic_88", + diameter_mm=20.0, + length_m=0.3, + relative_roughness=0.045 / 20.0, + p1_0=15.3e6, + p2_0=15.3e6, + T1_0=293.15, + T2_0=293.15, + ) + + def test_initial_state_uses_two_half_volume_compliances(self) -> None: + port_1 = self.pipe.properties_1() + port_2 = self.pipe.properties_2() + + self.assertAlmostEqual(self.pipe.volume, 9.424777960769381e-5) + self.assertAlmostEqual(self.pipe.compliance_volume, self.pipe.volume / 2.0) + self.assertEqual(len(self.pipe.get_state_vector()), 4) + self.assertAlmostEqual(port_1.p, 15.3e6, delta=1.0e-5) + self.assertAlmostEqual(port_2.p, 15.3e6, delta=1.0e-5) + self.assertAlmostEqual(port_1.T, 293.15) + self.assertAlmostEqual(port_2.T, 293.15) + + def test_center_resistance_flow_follows_end_pressure_gradient(self) -> None: + state = self.pipe.get_state_vector() + state[0] *= 1.01 + state[1] *= 1.01 + self.pipe.set_state_vector(state) + + forward = self.pipe.resistance_mass_flow() + state[0] /= 1.01 * 1.01 + state[1] /= 1.01 * 1.01 + state[2] *= 1.01 + state[3] *= 1.01 + self.pipe.set_state_vector(state) + reverse = self.pipe.resistance_mass_flow() + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + + def test_connection_derivatives_conserve_internal_center_flow_mass(self) -> None: + state = self.pipe.get_state_vector() + state[0] *= 1.01 + state[1] *= 1.01 + self.pipe.set_state_vector(state) + + d1, d2 = self.pipe.derivatives_from_connections( + port_1_m_flow=0.2, + connected_h_1=self.pipe.properties_1().h + 1000.0, + port_2_m_flow=-0.1, + connected_h_2=self.pipe.properties_2().h - 1000.0, + ) + + self.assertAlmostEqual(d1.m + d2.m, 0.1) + + +class AmesimPnl00rPipeTests(unittest.TestCase): + def setUp(self) -> None: + self.pipe = AmesimPnl00rPipe( + name="pneumatic_100", + diameter_mm=14.0, + length_m=1.0, + relative_roughness=0.045 / 14.0, + ) + + def test_stateless_resistance_flow_follows_pressure_gradient(self) -> None: + forward = self.pipe.mass_flow( + port_1_pressure_pa=15.31e6, + port_1_temperature_k=293.15, + port_2_pressure_pa=15.29e6, + port_2_temperature_k=293.15, + ) + reverse = self.pipe.mass_flow( + port_1_pressure_pa=15.29e6, + port_1_temperature_k=293.15, + port_2_pressure_pa=15.31e6, + port_2_temperature_k=293.15, + ) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01) + + def test_diagnostics_expose_darcy_terms(self) -> None: + diagnostics = self.pipe.diagnostics( + mass_flow_kg_s=1.0e-4, + pressure_pa=15.3e6, + temperature_k=293.15, + ) + + self.assertGreater(diagnostics.reynolds_number, 0.0) + self.assertGreater(diagnostics.gas_velocity_m_s, 0.0) + self.assertGreater(diagnostics.pressure_drop_pa, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pneumatic_node_components.py b/tests/test_amesim_pneumatic_node_components.py new file mode 100644 index 0000000..8f15ad3 --- /dev/null +++ b/tests/test_amesim_pneumatic_node_components.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.junctions.nodes import ( + AmesimP4Node2, + AmesimPn3Node2, +) +from app.simulation.core.medium import IdealGasMedium + + +class AmesimPneumaticNodeComponentTests(unittest.TestCase): + def test_pn3node2_declares_bidirectional_pneumatic_ports(self) -> None: + node = AmesimPn3Node2.create( + name="pn3_1", + medium=IdealGasMedium(), + parameters={}, + ) + + self.assertEqual(set(node.ports), {"port_1", "port_2", "port_3"}) + self.assertTrue( + all( + definition.domain == "pneumatic" + and definition.nominal_role == "bidirectional" + for definition in node.port_definitions + ) + ) + self.assertEqual(node.parameter_values, {}) + + def test_pn3node2_residuals_use_port_2_as_pressure_reference(self) -> None: + node = AmesimPn3Node2("pn3_1") + node.port_1.p = 101000.0 + node.port_2.p = 100000.0 + node.port_3.p = 99000.0 + node.port_1.m_flow = 0.2 + node.port_2.m_flow = -0.3 + node.port_3.m_flow = 0.1 + + residuals = {residual.id.rsplit(":", 1)[1]: residual for residual in node.pressure_flow_equation_residuals()} + + self.assertEqual(set(residuals), {"port_1_pressure_reference", "port_3_pressure_reference", "mass_flow_balance"}) + self.assertEqual(residuals["port_1_pressure_reference"].value, 1000.0) + self.assertEqual(residuals["port_3_pressure_reference"].value, -1000.0) + self.assertAlmostEqual(residuals["mass_flow_balance"].value, 0.0) + + def test_p4node2_residuals_cover_four_ports(self) -> None: + node = AmesimP4Node2("p4_1") + node.port_1.p = 100000.0 + node.port_2.p = 100000.0 + node.port_3.p = 100000.0 + node.port_4.p = 100000.0 + node.port_1.m_flow = 0.1 + node.port_2.m_flow = 0.2 + node.port_3.m_flow = -0.4 + node.port_4.m_flow = 0.1 + + residuals = node.pressure_flow_equation_residuals() + + self.assertEqual(len(residuals), 4) + self.assertTrue(all(residual.value == 0.0 for residual in residuals)) + self.assertEqual( + residuals[-1].variables, + ( + "p4_1.port_1.m_flow", + "p4_1.port_2.m_flow", + "p4_1.port_3.m_flow", + "p4_1.port_4.m_flow", + ), + ) + + def test_node_stream_outflow_uses_incoming_weighted_mix(self) -> None: + node = AmesimP4Node2("p4_1") + node.port_1.m_flow = 0.25 + node.port_2.m_flow = 0.75 + node.port_3.m_flow = -0.5 + node.port_4.m_flow = -0.5 + + node.update_stream_outflows( + { + "port_1": 100.0, + "port_2": 300.0, + "port_3": 900.0, + "port_4": 1200.0, + } + ) + + self.assertEqual(node.port_1.h_outflow, 250.0) + self.assertEqual(node.port_2.h_outflow, 250.0) + self.assertEqual(node.port_3.h_outflow, 250.0) + self.assertEqual(node.port_4.h_outflow, 250.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pneumatic_node_xml.py b/tests/test_amesim_pneumatic_node_xml.py new file mode 100644 index 0000000..b016c27 --- /dev/null +++ b/tests/test_amesim_pneumatic_node_xml.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pn3node_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pn3node-smoke", + nodes=[ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 500000.0, "T0": 300.0}, + ), + component_node( + "orifice_1", + "orifice", + [ + physical_port("port_a", "inlet", "left"), + physical_port("port_b", "outlet", "right"), + ], + {"K": 1e-5, "opening": 1.0}, + ), + component_node( + "node_1", + "amesim_pn3node2", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + physical_port("port_3", "bidirectional", "right"), + ], + ), + component_node( + "pipe_1", + "pipe", + [ + physical_port("port_a", "inlet", "left"), + physical_port("port_b", "outlet", "right"), + ], + { + "length": 1.0, + "diameter": 0.02, + "lambda_darcy": 0.02, + "p0": 100000.0, + "T0": 300.0, + }, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + component_node( + "pipe_2", + "pipe", + [ + physical_port("port_a", "inlet", "left"), + physical_port("port_b", "outlet", "right"), + ], + { + "length": 1.0, + "diameter": 0.02, + "lambda_darcy": 0.02, + "p0": 100000.0, + "T0": 300.0, + }, + ), + component_node( + "tank_2", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"), + physical_edge("edge-2", "orifice_1", "port_b", "node_1", "port_2"), + physical_edge("edge-3", "node_1", "port_1", "pipe_1", "port_a"), + physical_edge("edge-4", "pipe_1", "port_b", "tank_1", "port_a"), + physical_edge("edge-5", "node_1", "port_3", "pipe_2", "port_a"), + physical_edge("edge-6", "pipe_2", "port_b", "tank_2", "port_a"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPneumaticNodeXmlTests(unittest.TestCase): + def test_pn3node_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pn3node_project()) + + self.assertIn("node_1", network.components) + self.assertEqual(network.components["node_1"].model_type, "amesim_pn3node2") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pn3node_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pn3node_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("node_1.port_2.p", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0001_component.py b/tests/test_amesim_pnl0001_component.py new file mode 100644 index 0000000..e08eb75 --- /dev/null +++ b/tests/test_amesim_pnl0001_component.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.pipes import AmesimPnl0001 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnl0001ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_amesim_parameter_contract(self) -> None: + pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( + "pnl_1", + self.medium, + {}, + ) + + self.assertIsInstance(pipe, AmesimPnl0001) + self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) + self.assertEqual( + set(pipe.parameter_values), + { + "diam", + "le", + "rr", + "k", + "kth", + "extemp", + "gi", + "mode", + "p0", + "T0", + }, + ) + self.assertEqual(len(pipe.get_state_vector()), 2) + self.assertAlmostEqual(pipe.volume, 7.853981633974483e-5) + + def test_rejects_fractional_integer_parameters(self) -> None: + with self.assertRaisesRegex(ValueError, "gi must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( + "pnl_1", + self.medium, + {"gi": 1.5}, + ) + with self.assertRaisesRegex(ValueError, "mode must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( + "pnl_1", + self.medium, + {"mode": 1.5}, + ) + + def test_initial_state_uses_pipe_volume_pressure_and_temperature(self) -> None: + pipe = AmesimPnl0001( + "pnl_1", + self.medium, + diam=0.014, + le=1.0, + rr=0.045 / 14.0, + p0=15.3e6, + T0=293.15, + ) + props = pipe.properties() + + self.assertAlmostEqual(pipe.volume, 1.539380400258999e-4) + self.assertAlmostEqual(props.p, 15.3e6, delta=1.0e-5) + self.assertAlmostEqual(props.T, 293.15) + self.assertAlmostEqual(pipe.port_2.p, props.p) + + def test_resistance_flow_follows_pressure_gradient(self) -> None: + pipe = AmesimPnl0001( + "pnl_1", + self.medium, + diam=0.014, + le=1.0, + rr=0.045 / 14.0, + p0=15.3e6, + T0=293.15, + ) + props = pipe.properties() + + forward = pipe.mass_flow(15.31e6, props.p, props.T) + reverse = pipe.mass_flow(15.29e6, props.p, props.T) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.02) + + def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None: + pipe = AmesimPnl0001("pnl_1", self.medium) + pipe.port_1.p = 101000.0 + pipe.port_2.m_flow = -1.0e-4 + props = pipe.properties() + pipe.port_1.m_flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p, props.T) + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in pipe.pressure_flow_equation_residuals() + } + + self.assertEqual( + set(residuals), + {"port_2_pressure_state", "port_1_pressure_flow_relation"}, + ) + self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0) + self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0) + + def test_connection_derivative_preserves_two_port_accumulation(self) -> None: + pipe = AmesimPnl0001("pnl_1", self.medium, kth=2.0, extemp=310.0) + props = pipe.properties() + pipe.port_1.m_flow = 0.2 + pipe.port_2.m_flow = -0.1 + + derivative = pipe.state_derivative_from_ports( + { + "port_1": props.h + 1000.0, + "port_2": props.h - 1000.0, + } + ) + + self.assertAlmostEqual(derivative[0], 0.1) + self.assertGreater(derivative[1], 0.0) + + def test_results_include_thermodynamic_and_pipe_diagnostics(self) -> None: + pipe = AmesimPnl0001("pnl_1", self.medium) + pipe.port_1.p = 101000.0 + + values = pipe.component_result_values() + + self.assertEqual( + set(values), + {"m", "U", "p", "T", "rho", "u", "h", "re", "cm", "v", "ff"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0001_pipe.py b/tests/test_amesim_pnl0001_pipe.py new file mode 100644 index 0000000..0818eb6 --- /dev/null +++ b/tests/test_amesim_pnl0001_pipe.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import unittest + +from PythonModels.components.amesim_pneumatic_line import AmesimPnl0001Pipe + + +class AmesimPnl0001PipeTests(unittest.TestCase): + def setUp(self) -> None: + self.pipe = AmesimPnl0001Pipe( + name="pneumatic_96", + diameter_mm=14.0, + length_m=1.0, + relative_roughness=0.045 / 14.0, + p0=15.3e6, + T0=293.15, + ) + + def test_initial_state_uses_real_pipe_volume_and_two_states(self) -> None: + properties = self.pipe.properties() + + self.assertAlmostEqual(self.pipe.volume, 1.539380400258999e-4) + self.assertEqual(len(self.pipe.get_state_vector()), 2) + self.assertAlmostEqual(properties.p, 15.3e6, delta=1.0e-5) + self.assertAlmostEqual(properties.T, 293.15) + self.assertAlmostEqual(self.pipe.gas_mass_g(), 3.716965219188, places=10) + + def test_resistance_flow_follows_pressure_gradient(self) -> None: + forward = self.pipe.resistance_mass_flow( + port_1_pressure_pa=15.31e6, + port_1_temperature_k=293.15, + ) + reverse = self.pipe.resistance_mass_flow( + port_1_pressure_pa=15.29e6, + port_1_temperature_k=293.15, + ) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01) + + def test_pn2pipefr_candidate_flow_follows_pressure_gradient(self) -> None: + forward = self.pipe.pn2pipefr_mass_flow( + port_1_pressure_pa=15.31e6, + port_1_temperature_k=293.15, + port_2_pressure_pa=15.3e6, + port_2_temperature_k=293.15, + ) + reverse = self.pipe.pn2pipefr_mass_flow( + port_1_pressure_pa=15.29e6, + port_1_temperature_k=293.15, + port_2_pressure_pa=15.3e6, + port_2_temperature_k=293.15, + ) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01) + + def test_calibrated_linear_conductance_overrides_darcy_flow(self) -> None: + pipe = AmesimPnl0001Pipe( + name="linear", + diameter_mm=20.0, + length_m=1.0, + relative_roughness=0.045 / 20.0, + calibrated_linear_conductance=5.5636e-6, + p0=200000.0, + T0=293.15, + ) + flow = pipe.resistance_mass_flow( + port_1_pressure_pa=180000.0, + port_1_temperature_k=293.15, + ) + + self.assertAlmostEqual( + flow, + 5.5636e-6 * (180000.0 - 200000.0) / (293.15 ** 0.5), + ) + + def test_diagnostics_reproduce_laminar_friction_contract(self) -> None: + diagnostics = self.pipe.diagnostics(mass_flow_kg_s=8.90603914774626e-6) + + self.assertGreater(diagnostics.reynolds_number, 40.0) + self.assertLess(diagnostics.reynolds_number, 45.0) + self.assertAlmostEqual( + diagnostics.friction_factor, + 64.0 / diagnostics.reynolds_number, + ) + self.assertGreater(diagnostics.gas_velocity_m_s, 0.0) + self.assertGreater(diagnostics.pressure_drop_pa, 0.0) + + def test_darcy_pressure_drop_for_state_uses_supplied_density_state(self) -> None: + diagnostics = self.pipe.diagnostics(mass_flow_kg_s=8.90603914774626e-6) + pressure_drop = self.pipe.darcy_pressure_drop_for_state( + mass_flow_kg_s=8.90603914774626e-6, + pressure_pa=self.pipe.properties().p, + temperature_k=self.pipe.properties().T, + ) + + self.assertAlmostEqual(pressure_drop, diagnostics.pressure_drop_pa) + + def test_connection_derivative_preserves_mass_and_stream_direction(self) -> None: + internal = self.pipe.properties() + derivative = self.pipe.derivatives_from_connections( + port_1_m_flow=0.2, + connected_h_1=internal.h + 1000.0, + port_2_m_flow=-0.1, + connected_h_2=internal.h - 1000.0, + ) + + self.assertAlmostEqual(derivative.m, 0.1) + self.assertAlmostEqual( + derivative.U, + 0.2 * (internal.h + 1000.0) / self.pipe.gas.gamma + - 0.1 * internal.u, + ) + + def test_transport_enthalpy_derivative_preserves_pn2vol_energy_basis(self) -> None: + internal = self.pipe.properties() + derivative = self.pipe.derivatives_from_transport_enthalpy_connections( + port_1_m_flow=0.2, + connected_h_1=internal.h + 1000.0, + port_2_m_flow=-0.1, + connected_h_2=internal.h - 1000.0, + ) + + self.assertAlmostEqual(derivative.m, 0.1) + self.assertAlmostEqual( + derivative.U, + 0.2 * (internal.h + 1000.0) - 0.1 * internal.h, + ) + temperature_derivative = ( + derivative.U - internal.u * derivative.m + ) / (self.pipe.state.m * self.pipe.gas.cv) + expected_temperature_derivative = ( + 0.2 * (internal.h + 1000.0 - internal.u) + - 0.1 * (internal.h - internal.u) + ) / (self.pipe.state.m * self.pipe.gas.cv) + self.assertAlmostEqual(temperature_derivative, expected_temperature_derivative) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0001_xml.py b/tests/test_amesim_pnl0001_xml.py new file mode 100644 index 0000000..4b1d654 --- /dev/null +++ b/tests/test_amesim_pnl0001_xml.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pnl0001_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnl0001-smoke", + nodes=[ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 500000.0, "T0": 300.0}, + ), + component_node( + "pnl_1", + "amesim_pnl0001", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + { + "diam": 0.01, + "le": 1.0, + "rr": 1.0e-5, + "k": 1.35, + "kth": 0.0, + "extemp": 300.0, + "gi": 1.0, + "mode": 2.0, + "p0": 300000.0, + "T0": 300.0, + }, + ), + component_node( + "resistance_1", + "amesim_pnl00r", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + {"diam": 0.01, "le": 0.5, "rr": 1.0e-5, "gi": 1.0}, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "pnl_1", "port_1"), + physical_edge("edge-2", "pnl_1", "port_2", "resistance_1", "port_1"), + physical_edge("edge-3", "resistance_1", "port_2", "tank_1", "port_a"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPnl0001XmlTests(unittest.TestCase): + def test_pnl0001_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnl0001_project()) + + self.assertIn("pnl_1", network.components) + self.assertEqual(network.components["pnl_1"].model_type, "amesim_pnl0001") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnl0001_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnl0001_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnl_1.port_1.m_flow", result["series"]) + self.assertIn("pnl_1.re", result["series"]) + self.assertIn("pnl_1.m", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0002_pnl0003_component.py b/tests/test_amesim_pnl0002_pnl0003_component.py new file mode 100644 index 0000000..c7be92d --- /dev/null +++ b/tests/test_amesim_pnl0002_pnl0003_component.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.pipes import AmesimPnl0002, AmesimPnl0003 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnl0002ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_contract(self) -> None: + pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0002"].create("pnl_2", self.medium, {}) + + self.assertIsInstance(pipe, AmesimPnl0002) + self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) + self.assertEqual(len(pipe.get_state_vector()), 2) + self.assertAlmostEqual(pipe.resistance_length, pipe.le / 2.0) + + def test_center_compliance_initial_state(self) -> None: + pipe = AmesimPnl0002( + "pnl_2", + self.medium, + diam=0.02, + le=2.0, + rr=0.045 / 20.0, + p0=100000.0, + T0=293.15, + ) + props = pipe.properties() + + self.assertAlmostEqual(pipe.volume, 6.283185307179586e-4) + self.assertAlmostEqual(props.p, 100000.0, delta=1.0e-6) + self.assertAlmostEqual(props.T, 293.15) + + def test_port_flows_enter_center_from_higher_external_pressure(self) -> None: + pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15) + center = pipe.properties() + + forward = pipe.port_mass_flow(101000.0, center.p, center.T) + reverse = pipe.port_mass_flow(99000.0, center.p, center.T) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + + def test_pressure_flow_residuals_use_two_port_resistances(self) -> None: + pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15) + center = pipe.properties() + pipe.port_1.p = 101000.0 + pipe.port_2.p = 99000.0 + pipe.port_1.m_flow = pipe.port_mass_flow(pipe.port_1.p, center.p, center.T) + pipe.port_2.m_flow = pipe.port_mass_flow(pipe.port_2.p, center.p, center.T) + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in pipe.pressure_flow_equation_residuals() + } + + self.assertEqual( + set(residuals), + {"port_1_pressure_flow_relation", "port_2_pressure_flow_relation"}, + ) + self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0) + self.assertAlmostEqual(residuals["port_2_pressure_flow_relation"].value, 0.0) + + def test_connection_derivative_accumulates_two_external_flows(self) -> None: + pipe = AmesimPnl0002("pnl_2", self.medium) + props = pipe.properties() + pipe.port_1.m_flow = 0.2 + pipe.port_2.m_flow = -0.1 + + derivative = pipe.state_derivative_from_ports( + {"port_1": props.h + 1000.0, "port_2": props.h - 1000.0} + ) + + self.assertAlmostEqual(derivative[0], 0.1) + + +class AmesimPnl0003ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_contract(self) -> None: + pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0003"].create("pnl_3", self.medium, {}) + + self.assertIsInstance(pipe, AmesimPnl0003) + self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) + self.assertEqual(len(pipe.get_state_vector()), 4) + + def test_initial_state_uses_two_half_volume_compliances(self) -> None: + pipe = AmesimPnl0003( + "pnl_3", + self.medium, + diam=0.02, + le=0.3, + rr=0.045 / 20.0, + p1_0=15.3e6, + T1_0=293.15, + p2_0=15.3e6, + T2_0=293.15, + ) + port_1 = pipe.properties_1() + port_2 = pipe.properties_2() + + self.assertAlmostEqual(pipe.volume, 9.424777960769381e-5) + self.assertAlmostEqual(pipe.compliance_volume, pipe.volume / 2.0) + self.assertAlmostEqual(port_1.p, 15.3e6, delta=1.0e-5) + self.assertAlmostEqual(port_2.p, 15.3e6, delta=1.0e-5) + + def test_center_resistance_flow_follows_end_pressure_gradient(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) + forward = pipe.resistance_mass_flow() + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=100000.0, p2_0=101000.0) + reverse = pipe.resistance_mass_flow() + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + + def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium) + pipe.properties_1() + pipe.properties_2() + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in pipe.pressure_flow_equation_residuals() + } + + self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"}) + self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 0.0) + self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0) + + def test_connection_derivative_conserves_external_and_center_flows(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) + port_1 = pipe.properties_1() + port_2 = pipe.properties_2() + pipe.port_1.m_flow = 0.2 + pipe.port_2.m_flow = -0.1 + + derivative = pipe.state_derivative_from_ports( + {"port_1": port_1.h + 1000.0, "port_2": port_2.h - 1000.0} + ) + + self.assertAlmostEqual(derivative[0] + derivative[2], 0.1) + + def test_results_include_two_thermodynamic_sides_and_center_flow(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) + + values = pipe.component_result_values() + + self.assertIn("m1", values) + self.assertIn("m2", values) + self.assertIn("dmctr", values) + self.assertIn("re", values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0002_pnl0003_xml.py b/tests/test_amesim_pnl0002_pnl0003_xml.py new file mode 100644 index 0000000..32ba243 --- /dev/null +++ b/tests/test_amesim_pnl0002_pnl0003_xml.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def _storage_nodes(*, source_pressure: float = 500000.0, sink_pressure: float = 100000.0) -> list: + return [ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": source_pressure, "T0": 300.0}, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": sink_pressure, "T0": 300.0}, + ), + ] + + +def _dynamic_pipe_params() -> dict[str, float]: + return { + "diam": 0.01, + "le": 1.0, + "rr": 1.0e-5, + "k": 1.35, + "kth": 0.0, + "extemp": 300.0, + "gi": 1.0, + "mode": 2.0, + "p0": 300000.0, + "T0": 300.0, + } + + +def _resistance_node(name: str) -> object: + return component_node( + name, + "amesim_pnl00r", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + {"diam": 0.01, "le": 0.5, "rr": 1.0e-5, "gi": 1.0}, + ) + + +def amesim_pnl0002_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnl0002-smoke", + nodes=[ + _storage_nodes()[0], + component_node( + "pnl_2", + "amesim_pnl0002", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + _dynamic_pipe_params(), + ), + _storage_nodes()[1], + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "pnl_2", "port_1"), + physical_edge("edge-2", "pnl_2", "port_2", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +def amesim_pnl0003_project() -> ReactFlowProjectPayload: + params = { + key: value + for key, value in _dynamic_pipe_params().items() + if key not in {"p0", "T0"} + } + params.update({"p1_0": 300000.0, "T1_0": 300.0, "p2_0": 300000.0, "T2_0": 300.0}) + return ReactFlowProjectPayload( + name="amesim-pnl0003-smoke", + nodes=[ + _storage_nodes(source_pressure=300000.0, sink_pressure=300000.0)[0], + _resistance_node("resistance_1"), + component_node( + "pnl_3", + "amesim_pnl0003", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + params, + ), + _resistance_node("resistance_2"), + _storage_nodes(source_pressure=300000.0, sink_pressure=300000.0)[1], + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "resistance_1", "port_1"), + physical_edge("edge-2", "resistance_1", "port_2", "pnl_3", "port_1"), + physical_edge("edge-3", "pnl_3", "port_2", "resistance_2", "port_1"), + physical_edge("edge-4", "resistance_2", "port_2", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimPnl0002Pnl0003XmlTests(unittest.TestCase): + def test_pnl0002_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnl0002_project()) + + self.assertIn("pnl_2", network.components) + self.assertEqual(network.components["pnl_2"].model_type, "amesim_pnl0002") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnl0002_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnl0002_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnl_2.port_1.m_flow", result["series"]) + self.assertIn("pnl_2.m", result["series"]) + + def test_pnl0003_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnl0003_project()) + + self.assertIn("pnl_3", network.components) + self.assertEqual(network.components["pnl_3"].model_type, "amesim_pnl0003") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnl0003_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnl0003_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnl_3.port_1.m_flow", result["series"]) + self.assertIn("pnl_3.dmctr", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl00r_component.py b/tests/test_amesim_pnl00r_component.py new file mode 100644 index 0000000..620f297 --- /dev/null +++ b/tests/test_amesim_pnl00r_component.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.pipes import AmesimPnl00r +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnl00rComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_amesim_parameter_contract(self) -> None: + pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create( + "pnl_1", + self.medium, + {}, + ) + + self.assertIsInstance(pipe, AmesimPnl00r) + self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) + self.assertEqual(set(pipe.parameter_values), {"diam", "le", "rr", "gi"}) + self.assertEqual(pipe.parameter_values["diam"], 0.01) + self.assertEqual(pipe.gi, 1) + + def test_rejects_fractional_gas_index(self) -> None: + with self.assertRaisesRegex(ValueError, "gi must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create( + "pnl_1", + self.medium, + {"gi": 1.5}, + ) + + def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None: + pipe = AmesimPnl00r("pnl_1", self.medium) + pipe.port_1.p = 100000.0 + pipe.port_2.p = 100000.0 + pipe.port_1.m_flow = 0.0 + pipe.port_2.m_flow = 0.0 + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in pipe.pressure_flow_equation_residuals() + } + + self.assertEqual(pipe.mass_flow(100000.0, 100000.0), 0.0) + self.assertEqual(residuals["mass_flow_balance"].value, 0.0) + self.assertEqual(residuals["pressure_flow_relation"].value, 0.0) + self.assertEqual(set(pipe.component_result_values()), {"re", "cm", "v", "ff"}) + + def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None: + pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0) + + forward = pipe.mass_flow(501000.0, 500000.0) + reverse = pipe.mass_flow(500000.0, 501000.0) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02) + + def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None: + pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5) + + self.assertAlmostEqual(pipe.friction_factor(100.0), 64.0 / 100.0) + self.assertGreater(pipe.friction_factor(100000.0), 0.0) + self.assertLess(pipe.friction_factor(100000.0), 0.1) + + def test_darcy_pressure_drop_uses_flow_sign(self) -> None: + pipe = AmesimPnl00r("pnl_1", self.medium) + density = self.medium.density(500000.0, 300.0) + + forward = pipe.darcy_pressure_drop(0.01, density=density, temperature=300.0) + reverse = pipe.darcy_pressure_drop(-0.01, density=density, temperature=300.0) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(forward, -reverse) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl00r_xml.py b/tests/test_amesim_pnl00r_xml.py new file mode 100644 index 0000000..6c4cae3 --- /dev/null +++ b/tests/test_amesim_pnl00r_xml.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pnl00r_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnl00r-smoke", + nodes=[ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 500000.0, "T0": 300.0}, + ), + component_node( + "pnl_1", + "amesim_pnl00r", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + {"diam": 0.01, "le": 1.0, "rr": 1.0e-5, "gi": 1.0}, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "pnl_1", "port_1"), + physical_edge("edge-2", "pnl_1", "port_2", "tank_1", "port_a"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPnl00rXmlTests(unittest.TestCase): + def test_pnl00r_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnl00r_project()) + + self.assertIn("pnl_1", network.components) + self.assertEqual(network.components["pnl_1"].model_type, "amesim_pnl00r") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnl00r_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnl00r_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnl_1.port_1.m_flow", result["series"]) + self.assertIn("pnl_1.re", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnor001_component.py b/tests/test_amesim_pnor001_component.py new file mode 100644 index 0000000..275058f --- /dev/null +++ b/tests/test_amesim_pnor001_component.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.orifices import AmesimPnor001 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnor001ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_amesim_parameter_contract(self) -> None: + orifice = COMPONENT_MODEL_REGISTRY["amesim_pnor001"].create( + "pnor_1", + self.medium, + {}, + ) + + self.assertIsInstance(orifice, AmesimPnor001) + self.assertEqual(set(orifice.ports), {"port_1", "port_2"}) + self.assertEqual( + set(orifice.parameter_values), + {"cq", "area", "Cv", "Kv", "gi", "flowset"}, + ) + self.assertEqual(orifice.parameter_values["area"], 5.0e-6) + self.assertEqual(orifice.flowset, 1) + + def test_rejects_fractional_integer_parameters(self) -> None: + with self.assertRaisesRegex(ValueError, "flowset must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnor001"].create( + "pnor_1", + self.medium, + {"flowset": 1.5}, + ) + + def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None: + orifice = AmesimPnor001("pnor_1", self.medium) + orifice.port_1.p = 100000.0 + orifice.port_2.p = 100000.0 + orifice.port_1.m_flow = 0.0 + orifice.port_2.m_flow = 0.0 + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in orifice.pressure_flow_equation_residuals() + } + + self.assertEqual(orifice.mass_flow(100000.0, 100000.0), 0.0) + self.assertEqual(residuals["mass_flow_balance"].value, 0.0) + self.assertEqual(residuals["pressure_flow_relation"].value, 0.0) + self.assertEqual(set(orifice.component_result_values()), {"cm", "gasvel"}) + + def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None: + orifice = AmesimPnor001("pnor_1", self.medium) + + forward = orifice.mass_flow(500000.0, 100000.0) + reverse = orifice.mass_flow(100000.0, 500000.0) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(forward, -reverse) + + def test_cv_and_kv_modes_produce_positive_equivalent_area(self) -> None: + cv_orifice = AmesimPnor001( + "pnor_cv", + self.medium, + flowset=2.0, + Cv=0.5, + ) + kv_orifice = AmesimPnor001( + "pnor_kv", + self.medium, + flowset=3.0, + Kv=0.4, + ) + + self.assertGreater(cv_orifice.effective_area, 0.0) + self.assertGreater(kv_orifice.effective_area, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnor001_xml.py b/tests/test_amesim_pnor001_xml.py new file mode 100644 index 0000000..5a7004b --- /dev/null +++ b/tests/test_amesim_pnor001_xml.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pnor001_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnor001-smoke", + nodes=[ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 500000.0, "T0": 300.0}, + ), + component_node( + "pnor_1", + "amesim_pnor001", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + { + "cq": 0.72, + "area": 5.0e-6, + "Cv": 0.5, + "Kv": 0.4, + "gi": 1.0, + "flowset": 1.0, + }, + ), + component_node( + "pipe_1", + "pipe", + [ + physical_port("port_a", "inlet", "left"), + physical_port("port_b", "outlet", "right"), + ], + { + "length": 1.0, + "diameter": 0.02, + "lambda_darcy": 0.02, + "p0": 100000.0, + "T0": 300.0, + }, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "pnor_1", "port_1"), + physical_edge("edge-2", "pnor_1", "port_2", "pipe_1", "port_a"), + physical_edge("edge-3", "pipe_1", "port_b", "tank_1", "port_a"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPnor001XmlTests(unittest.TestCase): + def test_pnor001_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnor001_project()) + + self.assertIn("pnor_1", network.components) + self.assertEqual(network.components["pnor_1"].model_type, "amesim_pnor001") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnor001_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnor001_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnor_1.port_1.m_flow", result["series"]) + self.assertIn("pnor_1.gasvel", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnpl01_component.py b/tests/test_amesim_pnpl01_component.py new file mode 100644 index 0000000..4207865 --- /dev/null +++ b/tests/test_amesim_pnpl01_component.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnpl01ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_zero_flow_boundary_contract(self) -> None: + source = COMPONENT_MODEL_REGISTRY["amesim_pnpl01"].create( + "plug_1", + self.medium, + {}, + ) + + self.assertIsInstance(source, AmesimPnpl01) + self.assertEqual(set(source.ports), {"port_1"}) + self.assertEqual(source.parameter_values, {}) + + def test_zero_flow_residual_uses_port_mass_flow(self) -> None: + source = AmesimPnpl01("plug_1") + source.port_1.m_flow = 0.125 + + residuals = source.pressure_flow_equation_residuals() + + self.assertEqual(len(residuals), 1) + self.assertEqual(residuals[0].id, "plug_1:zero_mass_flow") + self.assertEqual(residuals[0].variables, ("plug_1.port_1.m_flow",)) + self.assertEqual(residuals[0].value, 0.125) + + def test_stream_outflow_tracks_connected_enthalpy_when_available(self) -> None: + source = AmesimPnpl01("plug_1") + + source.update_stream_outflows({"port_1": 123.0}) + + self.assertEqual(source.port_1.h_outflow, 123.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnpl01_xml.py b/tests/test_amesim_pnpl01_xml.py new file mode 100644 index 0000000..6469239 --- /dev/null +++ b/tests/test_amesim_pnpl01_xml.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pnpl01_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnpl01-smoke", + nodes=[ + component_node( + "chamber_1", + "amesim_pnch023", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + { + "cvol": 0.057, + "kth": 0.0, + "sth": 0.1, + "extemp": 293.15, + "gi": 1.0, + "p0": 100000.0, + "T0": 293.15, + }, + ), + component_node( + "plug_1", + "amesim_pnpl01", + [physical_port("port_1", "bidirectional", "left")], + ), + component_node( + "plug_2", + "amesim_pnpl01", + [physical_port("port_1", "bidirectional", "left")], + ), + ], + edges=[ + physical_edge("edge-1", "chamber_1", "port_1", "plug_1", "port_1"), + physical_edge("edge-2", "chamber_1", "port_2", "plug_2", "port_1"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPnpl01XmlTests(unittest.TestCase): + def test_pnpl01_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnpl01_project()) + + self.assertIn("plug_1", network.components) + self.assertEqual(network.components["plug_1"].model_type, "amesim_pnpl01") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnpl01_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnpl01_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertTrue( + all(abs(value) < 1.0e-12 for value in result["series"]["plug_1.port_1.m_flow"]) + ) + self.assertIn("chamber_1.p", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnvo001_fixed_component.py b/tests/test_amesim_pnvo001_fixed_component.py new file mode 100644 index 0000000..6f5d0c5 --- /dev/null +++ b/tests/test_amesim_pnvo001_fixed_component.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.orifices import AmesimPnvo001FixedOpening +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_fixed_opening_contract(self) -> None: + valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001_fixed"].create( + "valve_1", + self.medium, + {}, + ) + + self.assertIsInstance(valve, AmesimPnvo001FixedOpening) + self.assertEqual(set(valve.ports), {"port_2", "port_3"}) + self.assertEqual( + set(valve.parameter_values), + {"cq", "area0", "Cv", "Kv", "gi", "flowset", "opening"}, + ) + self.assertEqual(valve.opening, 1.0) + + def test_rejects_fractional_flowset(self) -> None: + with self.assertRaisesRegex(ValueError, "flowset must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnvo001_fixed"].create( + "valve_1", + self.medium, + {"flowset": 1.5}, + ) + + def test_opening_scales_effective_area(self) -> None: + valve = AmesimPnvo001FixedOpening( + "valve_1", + self.medium, + area0=2.0e-6, + opening=0.25, + ) + + self.assertAlmostEqual(valve.maximum_area, 2.0e-6) + self.assertAlmostEqual(valve.effective_area, 0.5e-6) + self.assertEqual(valve.component_result_values()["xv"], 0.25) + + def test_zero_opening_blocks_flow(self) -> None: + valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.0) + + self.assertEqual(valve.mass_flow(500000.0, 100000.0), 0.0) + + def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None: + valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5) + + forward = valve.mass_flow(500000.0, 100000.0) + reverse = valve.mass_flow(100000.0, 500000.0) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(forward, -reverse) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnvo001_fixed_xml.py b/tests/test_amesim_pnvo001_fixed_xml.py new file mode 100644 index 0000000..11e8f14 --- /dev/null +++ b/tests/test_amesim_pnvo001_fixed_xml.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pnvo001_fixed_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnvo001-fixed-smoke", + nodes=[ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 150000.0, "T0": 300.0}, + ), + component_node( + "valve_1", + "amesim_pnvo001_fixed", + [ + physical_port("port_2", "bidirectional", "left"), + physical_port("port_3", "bidirectional", "right"), + ], + { + "cq": 0.72, + "area0": 5.0e-6, + "Cv": 0.5, + "Kv": 0.4, + "gi": 1.0, + "flowset": 1.0, + "opening": 0.5, + }, + ), + component_node( + "pipe_1", + "amesim_pnl00r", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + {"diam": 0.02, "le": 1.0, "rr": 1.0e-5, "gi": 1.0}, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "valve_1", "port_2"), + physical_edge("edge-2", "valve_1", "port_3", "pipe_1", "port_1"), + physical_edge("edge-3", "pipe_1", "port_2", "tank_1", "port_a"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPnvo001FixedOpeningXmlTests(unittest.TestCase): + def test_pnvo001_fixed_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnvo001_fixed_project()) + + self.assertIn("valve_1", network.components) + self.assertEqual(network.components["valve_1"].model_type, "amesim_pnvo001_fixed") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnvo001_fixed_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnvo001_fixed_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("valve_1.xv", result["series"]) + self.assertIn("valve_1.port_2.m_flow", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnvo001_signal_xml.py b/tests/test_amesim_pnvo001_signal_xml.py new file mode 100644 index 0000000..451bbbe --- /dev/null +++ b/tests/test_amesim_pnvo001_signal_xml.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def signal_port(name: str, role: str, side: str) -> dict[str, str | None]: + return { + "name": name, + "kind": "signal", + "domain": "signal", + "nominalRole": role, + "positiveFlowDirection": None, + "side": side, + } + + +def signal_edge(edge_id: str, source: str, source_port: str, target: str, target_port: str) -> dict[str, str]: + return { + "id": edge_id, + "source": source, + "sourceHandle": source_port, + "target": target, + "targetHandle": target_port, + } + + +def amesim_pnvo001_signal_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnvo001-signal-smoke", + nodes=[ + component_node( + "step_1", + "amesim_step0", + [signal_port("out", "output", "right")], + {"initial": 1.0, "final": 1.0, "time": 0.0}, + ), + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 300000.0, "T0": 300.0}, + ), + component_node( + "valve_1", + "amesim_pnvo001", + [ + signal_port("res", "input", "left"), + physical_port("port_2", "bidirectional", "left"), + physical_port("port_3", "bidirectional", "right"), + ], + { + "cq": 0.72, + "area0": 5.0e-6, + "Cv": 0.5, + "Kv": 0.4, + "gi": 1.0, + "flowset": 1.0, + "opening0": 0.0, + }, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + signal_edge("signal-1", "step_1", "out", "valve_1", "res"), + physical_edge("edge-1", "cylinder_1", "port_b", "valve_1", "port_2"), + physical_edge("edge-2", "valve_1", "port_3", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimPnvo001SignalXmlTests(unittest.TestCase): + def test_signal_project_compiles_with_signal_connection(self) -> None: + network = compile_reactflow_network(amesim_pnvo001_signal_project()) + + self.assertIn("step_1", network.components) + self.assertIn("valve_1", network.components) + self.assertEqual(network.components["valve_1"].model_type, "amesim_pnvo001") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_signal_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnvo001_signal_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertEqual(result["series"]["step_1.out.signal"], [1.0, 1.0, 1.0]) + self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0]) + self.assertIn("valve_1.xv", result["series"]) + self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_results.py b/tests/test_amesim_results.py new file mode 100644 index 0000000..679c763 --- /dev/null +++ b/tests/test_amesim_results.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import Mock + +from PythonModels.reporting.amesim_results import ( + _resolve_result_members, + load_test_mql_amesim_results, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class AmesimResultsTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_reads_binary_results_shape(self) -> None: + self.assertEqual(self.results.point_count, 1002) + self.assertEqual(self.results.saved_variable_count, 1116) + self.assertEqual(len(self.results.variables), 1278) + self.assertEqual(len(self.results.final_values_by_data_path), 1100) + + def test_time_axis_matches_amesim_simulation(self) -> None: + self.assertAlmostEqual(self.results.times[0], 0.0) + self.assertAlmostEqual(self.results.times[1], 0.01) + self.assertAlmostEqual(self.results.times[-1], 10.0) + + def test_variable_mapping_preserves_data_paths(self) -> None: + self.assertEqual(self.results.saved_variable_indices[:2], (0, 1)) + self.assertEqual(self.results.variables[0].data_path, "temp3@pn_c1_8") + self.assertEqual(self.results.variables[1].data_path, "press3@pn_c1_8") + self.assertIn("vvol1@pn_brp2_8", self.results.series_by_data_path) + self.assertIn("vol1@pn_brp2_8", self.results.series_by_data_path) + + def test_series_values_match_known_amesim_samples(self) -> None: + temp3 = self.results.series("temp3@pn_c1_8") + press3 = self.results.series("press3@pn_c1_8") + self.assertAlmostEqual(temp3[0], 293.15) + self.assertAlmostEqual(press3[0], -1300.0) + self.assertAlmostEqual(temp3[-1], self.results.final_value("temp3@pn_c1_8")) + self.assertAlmostEqual(press3[-1], self.results.final_value("press3@pn_c1_8")) + + def test_window_loader_keeps_one_sample_after_stop(self) -> None: + results = load_test_mql_amesim_results(TEST_MQL_AME, time_stop_s=0.04) + + self.assertEqual(results.point_count, 6) + self.assertAlmostEqual(results.times[-2], 0.04) + self.assertAlmostEqual(results.times[-1], 0.05) + self.assertEqual( + len(results.series("temp@pn_c1_8")), + results.point_count, + ) + self.assertEqual(results.final_values_by_data_path, {}) + + def test_auto_detects_renamed_result_members(self) -> None: + archive = Mock() + archive.getnames.return_value = ( + "renamed_model_.var", + "renamed_model_.results", + "renamed_model_.sim", + ) + + pair = _resolve_result_members( + archive, + var_member=None, + results_member=None, + ) + + self.assertEqual( + pair, + ("renamed_model_.var", "renamed_model_.results"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_signal_components.py b/tests/test_amesim_signal_components.py new file mode 100644 index 0000000..93b3110 --- /dev/null +++ b/tests/test_amesim_signal_components.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.orifices import AmesimPnvo001SignalOpening +from app.simulation.components.amesim.signals.sources import AmesimStep0, AmesimUd00 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY +from app.simulation.solvers.signal import SignalResolver +from app.simulation.systems.network import SimulationNetwork + + +class AmesimSignalComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_step0_output_switches_at_step_time(self) -> None: + step = AmesimStep0("step_1", self.medium, initial=0.2, final=0.8, time=0.5) + + self.assertEqual(step.output_at(0.49), 0.2) + self.assertEqual(step.output_at(0.5), 0.8) + self.assertEqual(step.signal_output_values(0.5), {"out": 0.8}) + + + def test_ud00_output_interpolates_piecewise_signal(self) -> None: + signal = AmesimUd00( + "piecewise_1", + self.medium, + tstart=0.5, + starts=(0.0, 10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ends=(10.0, 20.0, 30.0, 0.0, 0.0, 0.0, 0.0, 0.0), + durations=(1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0), + nstages=3, + ) + + self.assertAlmostEqual(signal.output_at(0.0), 0.0) + self.assertAlmostEqual(signal.output_at(1.0), 5.0) + self.assertAlmostEqual(signal.output_at(2.5), 15.0) + self.assertAlmostEqual(signal.output_at(5.0), 35.0) + self.assertEqual(signal.signal_output_values(2.5), {"out": 15.0}) + + def test_ud00_can_cycle_active_stages(self) -> None: + signal = AmesimUd00( + "piecewise_1", + self.medium, + starts=(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ends=(10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + durations=(1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + nstages=2, + iscyclic=True, + ) + + self.assertAlmostEqual(signal.output_at(0.25), 2.5) + self.assertAlmostEqual(signal.output_at(1.25), 12.5) + self.assertAlmostEqual(signal.output_at(2.25), 2.5) + + def test_ud00_registry_rejects_fractional_stage_controls(self) -> None: + with self.assertRaisesRegex(ValueError, "nstages must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_ud00"].create( + "piecewise_1", + self.medium, + {"nstages": 1.5}, + ) + + def test_pnvo001_signal_opening_reads_res_port(self) -> None: + valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001"].create( + "valve_1", + self.medium, + {"opening0": 0.25}, + ) + + self.assertIsInstance(valve, AmesimPnvo001SignalOpening) + self.assertEqual(set(valve.ports), {"res", "port_2", "port_3"}) + self.assertAlmostEqual(valve.opening, 0.25) + valve.res.signal = 1.5 + self.assertAlmostEqual(valve.opening, 1.0) + valve.res.signal = -0.5 + self.assertAlmostEqual(valve.opening, 0.0) + + def test_signal_resolver_propagates_step_to_valve_input(self) -> None: + network = SimulationNetwork("signal-smoke") + step = AmesimStep0("step_1", self.medium, initial=0.0, final=0.75, time=0.1) + valve = AmesimPnvo001SignalOpening("valve_1", self.medium, opening0=0.0) + network.add_component(step) + network.add_component(valve) + network.connect("step_1", "out", "valve_1", "res", connection_id="signal-1") + + resolver = SignalResolver(network) + resolver.solve(0.2) + + self.assertAlmostEqual(step.out.signal, 0.75) + self.assertAlmostEqual(valve.res.signal, 0.75) + self.assertAlmostEqual(valve.opening, 0.75) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_ud00_xml.py b/tests/test_amesim_ud00_xml.py new file mode 100644 index 0000000..51fa88b --- /dev/null +++ b/tests/test_amesim_ud00_xml.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_ud00_signal_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-ud00-signal-smoke", + nodes=[ + component_node( + "piecewise_1", + "amesim_ud00", + [signal_port("out", "output", "right")], + { + "tstart": 0.0, + "start1": 1.0, + "end1": 1.0, + "t1": 1.0, + "start2": 1.0, + "end2": 1.0, + "t2": 0.0, + "start3": 1.0, + "end3": 1.0, + "t3": 0.0, + "start4": 1.0, + "end4": 1.0, + "t4": 0.0, + "start5": 1.0, + "end5": 1.0, + "t5": 0.0, + "start6": 1.0, + "end6": 1.0, + "t6": 0.0, + "start7": 1.0, + "end7": 1.0, + "t7": 0.0, + "start8": 1.0, + "end8": 1.0, + "t8": 0.0, + "nstages": 1.0, + "iscyclic": 0.0, + }, + ), + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 300000.0, "T0": 300.0}, + ), + component_node( + "valve_1", + "amesim_pnvo001", + [ + signal_port("res", "input", "left"), + physical_port("port_2", "bidirectional", "left"), + physical_port("port_3", "bidirectional", "right"), + ], + { + "cq": 0.72, + "area0": 5.0e-6, + "Cv": 0.5, + "Kv": 0.4, + "gi": 1.0, + "flowset": 1.0, + "opening0": 0.0, + }, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + signal_edge("signal-1", "piecewise_1", "out", "valve_1", "res"), + physical_edge("edge-1", "cylinder_1", "port_b", "valve_1", "port_2"), + physical_edge("edge-2", "valve_1", "port_3", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimUd00XmlTests(unittest.TestCase): + def test_signal_project_compiles_with_signal_connection(self) -> None: + network = compile_reactflow_network(amesim_ud00_signal_project()) + + self.assertIn("piecewise_1", network.components) + self.assertEqual(network.components["piecewise_1"].model_type, "amesim_ud00") + self.assertEqual(network.components["valve_1"].model_type, "amesim_pnvo001") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_signal_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_ud00_signal_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertEqual(result["series"]["piecewise_1.out.signal"], [1.0, 1.0, 1.0]) + self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0]) + self.assertIn("valve_1.xv", result["series"]) + self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_component_catalog.py b/tests/test_component_catalog.py index 0353446..ce85478 100644 --- a/tests/test_component_catalog.py +++ b/tests/test_component_catalog.py @@ -14,10 +14,14 @@ from app.simulation.registry import ( class ComponentCatalogTests(unittest.TestCase): def setUp(self) -> None: self.catalog = get_component_catalog() - self.library = self.catalog["libraries"][0] + self.libraries = { + library["id"]: library for library in self.catalog["libraries"] + } + self.library = self.libraries["experimental"] self.components = { component["modelType"]: component - for component in self.library["components"] + for library in self.catalog["libraries"] + for component in library["components"] } def test_experimental_library_is_exposed_as_temporary(self) -> None: @@ -35,6 +39,122 @@ class ComponentCatalogTests(unittest.TestCase): ["storage", "flow", "junctions"], ) + + def test_amesim_library_exposes_pneumatic_models(self) -> None: + library = self.libraries["amesim"] + components = { + component["modelType"]: component for component in library["components"] + } + + self.assertEqual(library["label"], "AMESim 组件库") + self.assertEqual([category["id"] for category in library["categories"]], ["storage", "flow", "junctions", "boundary", "signals", "mechanical"]) + self.assertEqual(set(components), {"amesim_pnpl01", "amesim_step0", "amesim_ud00", "amesim_f000", "amesim_forc", "amesim_mecmas21", "amesim_lstp00a", "amesim_lmechn1", "amesim_pnch023", "amesim_pnch012", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnvo001", "amesim_pnl00r", "amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003", "amesim_pn3node2", "amesim_p4node2"}) + self.assertEqual( + [port["name"] for port in components["amesim_p4node2"]["ports"]], + ["port_1", "port_2", "port_3", "port_4"], + ) + self.assertEqual(components["amesim_pnpl01"]["category"]["id"], "boundary") + self.assertEqual([port["name"] for port in components["amesim_pnpl01"]["ports"]], ["port_1"]) + + pnch_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnch023"]["parameters"] + } + self.assertEqual(components["amesim_pnch023"]["category"]["id"], "storage") + self.assertEqual(pnch_parameters["cvol"]["quantity"], "volume") + self.assertEqual(pnch_parameters["kth"]["quantity"], "heat_transfer_coefficient") + + pnch012_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnch012"]["parameters"] + } + self.assertEqual(components["amesim_pnch012"]["category"]["id"], "storage") + self.assertEqual([port["name"] for port in components["amesim_pnch012"]["ports"]], ["port_1", "port_2", "port_3", "port_4"]) + self.assertEqual(pnch012_parameters["cvol0"]["quantity"], "volume") + self.assertEqual(pnch012_parameters["dvol1"]["unit"], "m3/s") + + pnor_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnor001"]["parameters"] + } + self.assertEqual(components["amesim_pnor001"]["category"]["id"], "flow") + self.assertEqual(pnor_parameters["area"]["quantity"], "area") + self.assertEqual(pnor_parameters["area"]["unit"], "m2") + pnvo_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnvo001_fixed"]["parameters"] + } + self.assertEqual(components["amesim_pnvo001_fixed"]["category"]["id"], "flow") + self.assertEqual(pnvo_parameters["opening"]["maximum"], 1.0) + self.assertEqual([port["name"] for port in components["amesim_pnvo001_fixed"]["ports"]], ["port_2", "port_3"]) + + self.assertEqual(components["amesim_step0"]["category"]["id"], "signals") + self.assertEqual([port["name"] for port in components["amesim_step0"]["ports"]], ["out"]) + ud00_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_ud00"]["parameters"] + } + self.assertEqual(components["amesim_ud00"]["category"]["id"], "signals") + self.assertEqual([port["name"] for port in components["amesim_ud00"]["ports"]], ["out"]) + self.assertEqual(ud00_parameters["tstart"]["quantity"], "time") + self.assertEqual(ud00_parameters["nstages"]["maximum"], 8.0) + self.assertEqual(components["amesim_f000"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_f000"]["ports"]], ["port_1"]) + self.assertEqual(components["amesim_f000"]["ports"][0]["domain"], "mechanical") + self.assertEqual(components["amesim_forc"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_forc"]["ports"]], ["res", "port_2"]) + self.assertEqual(components["amesim_forc"]["ports"][1]["domain"], "mechanical") + mecmas_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_mecmas21"]["parameters"] + } + self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_mecmas21"]["ports"]], ["port_1", "port_2"]) + self.assertEqual(mecmas_parameters["mass"]["unit"], "kg") + self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m") + lstp_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_lstp00a"]["parameters"] + } + self.assertEqual(components["amesim_lstp00a"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_lstp00a"]["ports"]], ["port_1", "port_2"]) + self.assertEqual(lstp_parameters["gap0"]["unit"], "m") + self.assertEqual(lstp_parameters["kcont"]["unit"], "N/m") + lmechn_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_lmechn1"]["parameters"] + } + self.assertEqual(components["amesim_lmechn1"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_lmechn1"]["ports"]], [f"port_{index}" for index in range(1, 10)]) + self.assertEqual(lmechn_parameters["v1"]["maximum"], 8.0) + self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") + self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"]) + + pnl_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnl00r"]["parameters"] + } + self.assertEqual(components["amesim_pnl00r"]["category"]["id"], "flow") + self.assertEqual(pnl_parameters["diam"]["quantity"], "length") + self.assertEqual(pnl_parameters["le"]["unit"], "m") + + pnl0001_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnl0001"]["parameters"] + } + self.assertEqual(components["amesim_pnl0001"]["category"]["id"], "flow") + self.assertEqual(pnl0001_parameters["kth"]["quantity"], "heat_transfer_coefficient") + self.assertEqual(pnl0001_parameters["p0"]["unit"], "Pa") + + self.assertEqual(components["amesim_pnl0002"]["category"]["id"], "flow") + self.assertEqual(components["amesim_pnl0003"]["category"]["id"], "flow") + pnl0003_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnl0003"]["parameters"] + } + self.assertEqual(pnl0003_parameters["p1_0"]["unit"], "Pa") + self.assertEqual(pnl0003_parameters["T2_0"]["quantity"], "temperature") + def test_every_registered_component_is_in_the_catalog(self) -> None: self.assertEqual( set(self.components), diff --git a/tests/test_component_registry.py b/tests/test_component_registry.py index c5cb666..88909e5 100644 --- a/tests/test_component_registry.py +++ b/tests/test_component_registry.py @@ -3,6 +3,7 @@ from __future__ import annotations import unittest from app.simulation.components.experimental.library import LIBRARY +from app.simulation.components.amesim.library import LIBRARY as AMESIM_LIBRARY from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec from app.simulation.core.medium import IdealGasMedium @@ -23,13 +24,64 @@ class ComponentRegistryTests(unittest.TestCase): self.assertEqual(models, COMPONENT_MODEL_REGISTRY) self.assertEqual( tuple(models), - ("cylinder", "tank", "pipe", "orifice", "tee"), + ( + "cylinder", + "tank", + "pipe", + "orifice", + "tee", + "amesim_pnpl01", + "amesim_step0", + "amesim_ud00", + "amesim_f000", + "amesim_forc", + "amesim_mecmas21", + "amesim_lstp00a", + "amesim_lmechn1", + "amesim_pnch023", + "amesim_pnch012", + "amesim_pnor001", + "amesim_pnvo001_fixed", + "amesim_pnvo001", + "amesim_pnl00r", + "amesim_pnl0001", + "amesim_pnl0002", + "amesim_pnl0003", + "amesim_pn3node2", + "amesim_p4node2", + ), ) self.assertNotIn( "app.simulation.components.experimental.flow.pipe:Pipe", LIBRARY.models, ) + + def test_amesim_library_is_enabled_as_separate_public_boundary(self) -> None: + libraries, models = discover_component_registries() + + self.assertIn("amesim", libraries) + self.assertEqual(libraries["amesim"], AMESIM_LIBRARY) + self.assertEqual(models["amesim_pnpl01"].library.id, "amesim") + self.assertEqual(models["amesim_step0"].library.id, "amesim") + self.assertEqual(models["amesim_ud00"].library.id, "amesim") + self.assertEqual(models["amesim_f000"].library.id, "amesim") + self.assertEqual(models["amesim_forc"].library.id, "amesim") + self.assertEqual(models["amesim_mecmas21"].library.id, "amesim") + self.assertEqual(models["amesim_lstp00a"].library.id, "amesim") + self.assertEqual(models["amesim_lmechn1"].library.id, "amesim") + self.assertEqual(models["amesim_pnch023"].library.id, "amesim") + self.assertEqual(models["amesim_pnch012"].library.id, "amesim") + self.assertEqual(models["amesim_pnor001"].library.id, "amesim") + self.assertEqual(models["amesim_pnvo001_fixed"].library.id, "amesim") + self.assertEqual(models["amesim_pnvo001"].library.id, "amesim") + self.assertEqual(models["amesim_pnl00r"].library.id, "amesim") + self.assertEqual(models["amesim_pnl0001"].library.id, "amesim") + self.assertEqual(models["amesim_pnl0002"].library.id, "amesim") + self.assertEqual(models["amesim_pnl0003"].library.id, "amesim") + self.assertEqual(models["amesim_pn3node2"].library.id, "amesim") + self.assertEqual(models["amesim_p4node2"].library.id, "amesim") + def test_every_public_model_owns_its_catalog_contract(self) -> None: for model_type, spec in COMPONENT_MODEL_REGISTRY.items(): with self.subTest(model_type=model_type): @@ -37,10 +89,10 @@ class ComponentRegistryTests(unittest.TestCase): self.assertIn("MODEL_VERSION", declarations) self.assertIn("DISPLAY", declarations) self.assertIn("create", declarations) - self.assertEqual(spec.display.library_id, LIBRARY.id) + self.assertEqual(spec.display.library_id, spec.library.id) self.assertIn( spec.display.category_id, - LIBRARY.category_by_id, + spec.library.category_by_id, ) def test_display_ports_must_match_physical_ports(self) -> None: diff --git a/tests/test_core_solver.py b/tests/test_core_solver.py new file mode 100644 index 0000000..2fe9d8b --- /dev/null +++ b/tests/test_core_solver.py @@ -0,0 +1,80 @@ +import sys +import types +import unittest +from unittest.mock import patch + +from PythonModels.core.solver import SolveIVPConfig, integrate_ode + + +class IntegrateOdeTests(unittest.TestCase): + def test_scipy_solver_receives_step_size_controls(self) -> None: + calls: list[dict[str, object]] = [] + + def fake_solve_ivp(**kwargs): + calls.append(kwargs) + return object() + + scipy_module = types.ModuleType("scipy") + integrate_module = types.ModuleType("scipy.integrate") + integrate_module.solve_ivp = fake_solve_ivp + scipy_module.integrate = integrate_module + + with patch.dict( + sys.modules, + {"scipy": scipy_module, "scipy.integrate": integrate_module}, + ): + integrate_ode( + rhs=lambda _time, state: state, + initial_state=[1.0], + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0, + max_step=1.0e-4, + first_step=1.0e-8, + ), + t_eval=[0.0, 1.0], + ) + + self.assertEqual(calls[0]["max_step"], 1.0e-4) + self.assertEqual(calls[0]["first_step"], 1.0e-8) + + def test_scipy_solver_omits_unset_first_step(self) -> None: + calls: list[dict[str, object]] = [] + + def fake_solve_ivp(**kwargs): + calls.append(kwargs) + return object() + + scipy_module = types.ModuleType("scipy") + integrate_module = types.ModuleType("scipy.integrate") + integrate_module.solve_ivp = fake_solve_ivp + scipy_module.integrate = integrate_module + + with patch.dict( + sys.modules, + {"scipy": scipy_module, "scipy.integrate": integrate_module}, + ): + integrate_ode( + rhs=lambda _time, state: state, + initial_state=[1.0], + config=SolveIVPConfig(t_start=0.0, t_stop=1.0), + ) + + self.assertEqual(calls[0]["max_step"], 1.0e-3) + self.assertNotIn("first_step", calls[0]) + + def test_scipy_stepwise_solver_can_cancel_before_start(self) -> None: + result = integrate_ode( + rhs=lambda _time, state: state, + initial_state=[1.0], + config=SolveIVPConfig(t_start=0.0, t_stop=1.0), + cancel_check=lambda: True, + ) + + self.assertFalse(result.success) + self.assertEqual(result.status, "cancelled") + self.assertEqual(result.t, [0.0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peng_robinson.py b/tests/test_peng_robinson.py new file mode 100644 index 0000000..080202b --- /dev/null +++ b/tests/test_peng_robinson.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import unittest + +from PythonModels.core.peng_robinson import AIR_PR, HELIUM_PR, NITROGEN_PR, PengRobinsonFluid + + +class PengRobinsonTest(unittest.TestCase): + def test_helium_near_ideal_at_atmospheric_condition(self) -> None: + z = HELIUM_PR.compressibility_factor(101_325.0, 300.0) + density = HELIUM_PR.density(101_325.0, 300.0) + + self.assertAlmostEqual(z, 1.0, delta=1.0e-3) + self.assertAlmostEqual(density, 0.1625, delta=0.002) + + def test_helium_density_pressure_round_trip(self) -> None: + pressure = 15.3e6 + temperature = 293.15 + density = HELIUM_PR.density(pressure, temperature) + + self.assertGreater(HELIUM_PR.compressibility_factor(pressure, temperature), 1.0) + self.assertAlmostEqual( + HELIUM_PR.pressure_from_density(temperature, density), + pressure, + delta=pressure * 1.0e-12, + ) + + def test_helium_residual_enthalpy_is_small_at_atmosphere(self) -> None: + self.assertAlmostEqual( + HELIUM_PR.residual_specific_enthalpy(101_300.0, 298.15), + 17.834, + delta=0.01, + ) + + def test_helium_residual_enthalpy_captures_high_pressure_departure(self) -> None: + self.assertAlmostEqual( + HELIUM_PR.residual_specific_enthalpy(13_839_965.0, 287.7322), + 11953.9, + delta=0.1, + ) + + def test_air_reference_remains_available_for_other_models(self) -> None: + z = AIR_PR.compressibility_factor(101_325.0, 300.0) + density = AIR_PR.density(101_325.0, 300.0) + + self.assertAlmostEqual(z, 1.0, delta=1.0e-3) + self.assertAlmostEqual(density, 1.177, delta=0.01) + + def test_nitrogen_can_return_multiple_roots(self) -> None: + roots = NITROGEN_PR.compressibility_roots(1.0e6, 100.0) + + self.assertEqual(len(roots), 3) + self.assertLess(roots[0], roots[-1]) + self.assertEqual(NITROGEN_PR.compressibility_factor(1.0e6, 100.0, phase="liquid"), roots[0]) + self.assertEqual(NITROGEN_PR.compressibility_factor(1.0e6, 100.0, phase="vapor"), roots[-1]) + + def test_custom_fluid_can_be_defined_explicitly(self) -> None: + fluid = PengRobinsonFluid( + name="custom_helium", + molar_mass=0.004002602, + critical_temperature=5.1953, + critical_pressure=227_460.0, + acentric_factor=-0.385, + ) + + self.assertAlmostEqual(fluid.specific_gas_constant, 2077.3, delta=0.5) + self.assertAlmostEqual(fluid.compressibility_factor(1.0e6, 298.15), HELIUM_PR.compressibility_factor(1.0e6, 298.15)) + + def test_rejects_invalid_states(self) -> None: + with self.assertRaises(ValueError): + HELIUM_PR.compressibility_factor(0.0, 300.0) + with self.assertRaises(ValueError): + HELIUM_PR.density(101_325.0, 0.0) + with self.assertRaises(ValueError): + HELIUM_PR.pressure_from_density(300.0, -1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_run_test_mql_baseline.py b/tests/test_run_test_mql_baseline.py new file mode 100644 index 0000000..0a4ea30 --- /dev/null +++ b/tests/test_run_test_mql_baseline.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from PythonModels.scripts.run_test_mql_baseline import ( + TestMqlBaselineExecutionConfig, + TestMqlBaselinePathConfig, + TestMqlBaselineScriptConfig, + format_test_mql_baseline_summary, + run_test_mql_baseline, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class RunTestMqlBaselineScriptTests(unittest.TestCase): + def test_baseline_script_writes_summary_for_selected_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + run, returned_output_dir = run_test_mql_baseline( + TestMqlBaselineScriptConfig( + paths=TestMqlBaselinePathConfig( + archive_path=TEST_MQL_AME, + output_dir=output_dir, + ), + execution=TestMqlBaselineExecutionConfig( + data_paths=("press@pn_c1_8", "vol1@pn_brp2_8"), + ), + ) + ) + + summary_path = output_dir / "test_mql_baseline_summary.txt" + summary = summary_path.read_text(encoding="utf-8") + + self.assertEqual(returned_output_dir, output_dir) + self.assertEqual(run.sample_count, 1002) + self.assertEqual(run.signal_count, 2) + self.assertEqual(run.comparison.max_abs_error, 0.0) + self.assertIn("Mode: AMESim baseline passthrough", summary) + self.assertIn("Samples: 1002", summary) + self.assertIn("Compared signals: 2", summary) + self.assertIn("Max absolute error: 0.0", summary) + + def test_baseline_script_can_skip_summary_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + run, returned_output_dir = run_test_mql_baseline( + TestMqlBaselineScriptConfig( + paths=TestMqlBaselinePathConfig( + archive_path=TEST_MQL_AME, + output_dir=output_dir, + ), + execution=TestMqlBaselineExecutionConfig( + write_summary=False, + data_paths=("press@pn_c1_8",), + ), + ) + ) + + self.assertEqual(returned_output_dir, output_dir) + self.assertEqual(run.signal_count, 1) + self.assertFalse((output_dir / "test_mql_baseline_summary.txt").exists()) + + def test_format_test_mql_baseline_summary(self) -> None: + run, _ = run_test_mql_baseline( + TestMqlBaselineScriptConfig( + paths=TestMqlBaselinePathConfig(archive_path=TEST_MQL_AME), + execution=TestMqlBaselineExecutionConfig( + write_summary=False, + data_paths=("press@pn_c1_8",), + ), + ) + ) + + summary = format_test_mql_baseline_summary(run) + + self.assertIn("Model: test_mql", summary) + self.assertIn("Output schema signals: 858", summary) + self.assertIn("Compared signals: 1", summary) + self.assertTrue(summary.endswith("\n")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_run_test_mql_full_state_comparison.py b/tests/test_run_test_mql_full_state_comparison.py new file mode 100644 index 0000000..79fb6ac --- /dev/null +++ b/tests/test_run_test_mql_full_state_comparison.py @@ -0,0 +1,1024 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from PythonModels.core.solver import SolveIVPConfig +from PythonModels.systems.test_mql import TestMqlPnl0001LineRhsDiagnostic + +from PythonModels.scripts.run_test_mql_full_state_comparison import ( + TestMqlFullStateComparisonExecutionConfig, + TestMqlFullStateComparisonPathConfig, + TestMqlFullStateComparisonScriptConfig, + TestMqlPnl0001EnergyFlowDiagnostic, + TestMqlP4NodeEnthalpyBreakdownDiagnostic, + TestMqlPnl0003EnergyDiagnostic, + TestMqlPneumatic96InletLineDiagnostic, + TestMqlPnch012EnergyEquationDiagnostic, + TestMqlPnvoUpstreamEnthalpyDiagnostic, + TestMqlPnl0001PressureLossCalibrationDiagnostic, + TestMqlPnvoEventBoundaryDiagnostic, + TestMqlPnvoEventWindowCandidateComparisonDiagnostic, + TestMqlPnvoEventWindowCandidateMetricComparison, + TestMqlPnvoEventWindowDiagnostic, + TestMqlPnvoEventWindowSampleDiagnostic, + TestMqlPnvoFlowParameterDiagnostic, + TestMqlPnvoEventWindowSegmentDiagnostic, + format_test_mql_full_state_comparison_summary, + format_test_mql_pnvo_event_boundary_summary, + format_test_mql_pnvo_event_window_candidate_comparison_summary, + format_test_mql_pnvo_event_window_summary, + run_test_mql_full_state_comparison, + _ideal_pn2vol_reference_dtemp, + _series_sample_bracket, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class RunTestMqlFullStateComparisonScriptTests(unittest.TestCase): + def test_ideal_pn2vol_reference_dtemp_uses_internal_energy_mass_offset( + self, + ) -> None: + from PythonModels.components.amesim_pneumatic import HELIUM_PNEUMATIC_GAS + + gas = HELIUM_PNEUMATIC_GAS + result = _ideal_pn2vol_reference_dtemp( + gas=gas, + mass_kg=0.002, + temperature_k=320.0, + mass_derivative_kg_s=0.1, + enthalpy_flow_w=-100.0, + heat_flow_w=5.0, + ) + + expected = ( + -100.0 + + (gas.cp * 298.15 - gas.cv * 320.0) * 0.1 + + 5.0 + ) / (0.002 * gas.cv) + self.assertAlmostEqual(result, expected) + + def test_series_sample_bracket_distinguishes_exact_and_interpolated_times(self) -> None: + times = (0.04000000000000009, 0.050000000000000114) + + exact_left, exact_right = _series_sample_bracket( + times=times, + time_s=0.04, + ) + interpolated_left, interpolated_right = _series_sample_bracket( + times=times, + time_s=0.041, + ) + + self.assertEqual(exact_left, exact_right) + self.assertAlmostEqual(exact_left, 0.04) + self.assertAlmostEqual(interpolated_left, 0.04) + self.assertAlmostEqual(interpolated_right, 0.05) + + def test_full_state_script_writes_summary_and_comparison_csv(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + run, returned_output_dir = run_test_mql_full_state_comparison( + TestMqlFullStateComparisonScriptConfig( + paths=TestMqlFullStateComparisonPathConfig( + archive_path=TEST_MQL_AME, + output_dir=output_dir, + ), + execution=TestMqlFullStateComparisonExecutionConfig( + data_paths=( + "press@pn_c1_8", + "vol1@pn_brp2_8", + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", + ), + solver=SolveIVPConfig(t_start=0.0, t_stop=0.0), + t_eval=(0.0,), + ), + ) + ) + summary_path = output_dir / "test_mql_full_state_comparison_summary.txt" + csv_path = output_dir / "test_mql_amesim_comparison.csv" + csv_summary_path = output_dir / "test_mql_amesim_comparison_summary.txt" + summary = summary_path.read_text(encoding="utf-8") + csv_header = csv_path.read_text(encoding="utf-8").splitlines()[0] + csv_summary_exists = csv_summary_path.exists() + + self.assertEqual(returned_output_dir, output_dir) + self.assertEqual(run.sample_count, 1) + self.assertEqual(run.signal_count, 5) + self.assertAlmostEqual(run.comparison.max_abs_error, 0.0, delta=1.0e-8) + self.assertEqual( + run.metrics_by_max_abs_error()[0].data_path, + "press@pn_c1_8", + ) + diagnostic = run.signal_diagnostic("press@pn_c1_8") + self.assertEqual(diagnostic.data_path, "press@pn_c1_8") + self.assertAlmostEqual(diagnostic.initial_python_value, -1300.0) + self.assertAlmostEqual(diagnostic.final_python_value, -1300.0) + self.assertAlmostEqual(diagnostic.final_abs_error, 0.0, delta=1.0e-8) + self.assertEqual( + run.diagnostics_by_final_abs_error()[0].data_path, + "press@pn_c1_8", + ) + flow_diagnostic = run.pnl0001_mass_flow_diagnostic() + self.assertEqual(flow_diagnostic.data_path, "dm1@pneumatic_69") + self.assertAlmostEqual(flow_diagnostic.initial_python_dm1_g_s, 0.0) + self.assertAlmostEqual(flow_diagnostic.initial_amesim_dm1_g_s, 0.0) + self.assertAlmostEqual( + flow_diagnostic.initial_python_canonical_kg_s, + -flow_diagnostic.initial_python_dm1_g_s * 1.0e-3, + ) + pnvo_diagnostic = run.pnvo_diagnostic() + self.assertEqual(pnvo_diagnostic.alias, "pn_morifice_1") + self.assertAlmostEqual(pnvo_diagnostic.initial_python_opening, 0.0) + self.assertAlmostEqual(pnvo_diagnostic.initial_amesim_opening, 0.0) + self.assertAlmostEqual(pnvo_diagnostic.initial_python_mass_flow_kg_s, 0.0) + self.assertAlmostEqual(pnvo_diagnostic.initial_amesim_mass_flow_kg_s, 0.0) + chamber_diagnostic = run.chamber_rhs_diagnostic("pn_c1_8") + self.assertEqual(chamber_diagnostic.chamber_alias, "pn_c1_8") + self.assertEqual(chamber_diagnostic.piston_alias, "pn_brp2_8") + self.assertAlmostEqual(chamber_diagnostic.chamber_volume_m3, 0.015) + self.assertAlmostEqual( + chamber_diagnostic.energy_derivative_w, + chamber_diagnostic.port_a_energy_flow_w + + chamber_diagnostic.port_b_energy_flow_w + + chamber_diagnostic.boundary_work_w + + chamber_diagnostic.thermal_energy_flow_w, + ) + self.assertTrue(csv_summary_exists) + self.assertIn("Mode: Python 132 full-state closure comparison", summary) + self.assertIn("Compared signals: 5", summary) + self.assertIn("AMESim first saved interval: 0.010000000000000023", summary) + self.assertIn("Final time aligns with AMESim sample: True", summary) + self.assertIn("Largest absolute error: press@pn_c1_8=", summary) + self.assertIn("Largest final endpoint error: press@pn_c1_8=", summary) + self.assertIn("Metrics by max absolute error:", summary) + self.assertIn("Endpoint diagnostics by final absolute error:", summary) + self.assertIn( + "PNL0001 canonical mass-flow diagnostic: dm1@pneumatic_69", + summary, + ) + self.assertIn("equals -AMESim dm1 * 1e-3", summary) + self.assertIn("PNVO diagnostic: pn_morifice_1", summary) + self.assertIn("final_python_opening=", summary) + self.assertIn("final_python_mass_flow_kg_s=", summary) + self.assertIn("Largest endpoint chamber RHS breakdown: pn_c1_8", summary) + self.assertIn("piston_alias=pn_brp2_8", summary) + self.assertIn("mass_derivative_kg_s=", summary) + self.assertIn("boundary_work_w=", summary) + self.assertIn("thermal_energy_flow_w=", summary) + self.assertIn("final_python=", summary) + self.assertIn("final_amesim=", summary) + self.assertIn("python.press@pn_c1_8", csv_header) + self.assertIn("amesim.vol1@pn_brp2_8", csv_header) + self.assertIn("python.dm1@pneumatic_69", csv_header) + self.assertIn("python.xv@pn_morifice_1", csv_header) + self.assertIn("python.dm2@pn_morifice_1", csv_header) + + def test_default_execution_aligns_first_amesim_saved_sample(self) -> None: + execution = TestMqlFullStateComparisonExecutionConfig() + + self.assertEqual(execution.solver.t_stop, 0.01) + self.assertEqual(execution.solver.max_step, 1.0e-3) + self.assertEqual(execution.t_eval, (0.0, 0.01)) + self.assertIn("xv@pn_morifice_1", execution.data_paths) + self.assertIn("dm2@pn_morifice_1", execution.data_paths) + + def test_formats_pnvo_event_boundary_diagnostic(self) -> None: + data_paths = ( + "press@pn_c1_8", + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", + ) + diagnostic = TestMqlPnvoEventBoundaryDiagnostic( + orifice_alias="pn_morifice_1", + event_time_s=0.04, + integration_stop_time_s=0.039999999999999994, + data_paths=data_paths, + python_values_by_data_path={ + "press@pn_c1_8": -1090.9, + "dm1@pneumatic_69": -0.00949, + "xv@pn_morifice_1": 1.0, + "dm2@pn_morifice_1": 502.945, + }, + amesim_values_by_data_path={ + "press@pn_c1_8": -1146.38, + "dm1@pneumatic_69": -0.00667, + "xv@pn_morifice_1": 1.0, + "dm2@pn_morifice_1": 497.824, + }, + ) + + self.assertEqual(diagnostic.orifice_alias, "pn_morifice_1") + self.assertLess(diagnostic.integration_stop_time_s, diagnostic.event_time_s) + self.assertEqual(diagnostic.abs_error("xv@pn_morifice_1"), 0.0) + self.assertAlmostEqual( + diagnostic.abs_error("dm2@pn_morifice_1"), + 5.121, + ) + summary = format_test_mql_pnvo_event_boundary_summary(diagnostic) + self.assertIn("PNVO event boundary diagnostic (pn_morifice_1)", summary) + self.assertIn("Observation side: right-continuous STEP0 opening", summary) + self.assertTrue(summary.endswith("\n")) + + def test_formats_pnvo_event_window_diagnostic_with_samples(self) -> None: + data_paths = ( + "press@pn_c1_8", + "temp@pn_c1_8", + "vol@pn_c1_8", + "mgas1@pn_c1_8", + "p2@pneumatic_69", + "t2@pneumatic_69", + "mgas@pneumatic_69", + "re@pneumatic_69", + "v@pneumatic_69", + "ff@pneumatic_69", + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", + ) + sample = TestMqlPnvoEventWindowSampleDiagnostic( + time_s=0.05, + data_paths=data_paths, + python_values_by_data_path={ + "press@pn_c1_8": 219127.9, + "temp@pn_c1_8": 372.3, + "vol@pn_c1_8": 15000.0, + "mgas1@pn_c1_8": 5.46, + "p2@pneumatic_69": 93250.0, + "t2@pneumatic_69": 293.15, + "mgas@pneumatic_69": 0.53, + "re@pneumatic_69": 1.41e6, + "v@pneumatic_69": 832.0, + "ff@pneumatic_69": 0.0243, + "dm1@pneumatic_69": 443.2, + "xv@pn_morifice_1": 1.0, + "dm2@pn_morifice_1": 463.7, + }, + amesim_values_by_data_path={ + "press@pn_c1_8": 187060.0, + "temp@pn_c1_8": 296.1, + "vol@pn_c1_8": 15000.0, + "mgas1@pn_c1_8": 5.47, + "p2@pneumatic_69": 1484689.6, + "t2@pneumatic_69": 296.1, + "mgas@pneumatic_69": 0.81, + "re@pneumatic_69": 1.35e6, + "v@pneumatic_69": 519.8, + "ff@pneumatic_69": 0.0241, + "dm1@pneumatic_69": 419.9, + "xv@pn_morifice_1": 1.0, + "dm2@pn_morifice_1": 456.8, + }, + amesim_sample_left_time_s=0.05, + amesim_sample_right_time_s=0.05, + pnl0001_rhs_diagnostics=( + TestMqlPnl0001LineRhsDiagnostic( + line_alias="pneumatic_69", + chamber_alias="pn_c1_8", + line_pressure_pa=1034250.0, + line_temperature_k=293.15, + chamber_pressure_pa=320427.9, + chamber_temperature_k=379.9, + chamber_to_line_flow_kg_s=-0.4432, + node_to_line_flow_kg_s=0.4210, + mass_derivative_kg_s=-0.0222, + port_1_energy_flow_w=-404000.0, + port_2_energy_flow_w=384000.0, + thermal_energy_flow_w=0.0, + energy_derivative_w=-20000.0, + ), + ), + pnl0001_pressure_loss_diagnostics=( + TestMqlPnl0001PressureLossCalibrationDiagnostic( + line_alias="pneumatic_69", + chamber_alias="pn_c1_8", + time_s=0.05, + amesim_cm=0.0159, + amesim_dm1_g_s=419.9, + amesim_mass_flow_magnitude_kg_s=0.4199, + amesim_line_gauge_pressure_pa=1484689.6, + amesim_chamber_gauge_pressure_pa=187060.0, + amesim_line_temperature_k=296.1, + amesim_pressure_drop_pa=1297629.6, + current_darcy_pressure_drop_pa=422633.2, + pressure_drop_multiplier=3.07, + candidate_pn2pipefr_dm1_g_s=424.2, + candidate_pn2pipefr_to_amesim_dm1_ratio=1.01, + amesim_linear_conductance_kg_s_sqrt_k_per_pa=5.56e-6, + python_linear_conductance_kg_s_sqrt_k_per_pa=1.06e-5, + python_to_amesim_linear_conductance_ratio=1.91, + ), + ), + pnvo_flow_parameter_diagnostics=( + TestMqlPnvoFlowParameterDiagnostic( + orifice_alias="pn_morifice_1", + time_s=0.05, + amesim_cm=0.0158, + amesim_dm2_g_s=456.8, + amesim_opening=1.0, + amesim_gas_velocity_m_s=885.9, + python_opening=1.0, + python_flow_coefficient=0.45, + python_effective_area_m2=78.5e-6, + python_line_pressure_pa=15310000.0, + python_boundary_pressure_pa=1586000.0, + python_upstream_pressure_pa=15310000.0, + python_upstream_temperature_k=293.15, + python_mass_flow_kg_s=0.4637, + python_cm=0.0147, + python_to_amesim_cm_ratio=0.93, + ), + ), + pnvo_upstream_enthalpy_diagnostics=( + TestMqlPnvoUpstreamEnthalpyDiagnostic( + orifice_alias="pn_morifice_1", + upstream_line_alias="pneumatic_87", + time_s=0.05, + amesim_orifice_port_2_enthalpy_flow_w=-18851.7, + amesim_orifice_port_2_mass_flow_g_s=456.8, + amesim_orifice_port_3_enthalpy_flow_w=18851.7, + amesim_orifice_port_3_mass_flow_g_s=-456.8, + amesim_upstream_line_center_enthalpy_flow_w=15823.7, + amesim_upstream_line_center_mass_flow_g_s=-454.2, + amesim_upstream_line_port_2_temperature_k=287.7, + amesim_upstream_line_port_2_pressure_pa=13.871e6, + amesim_orifice_port_2_implied_reference_temperature_k=290.2, + amesim_orifice_port_2_implied_temperature_delta_to_line_k=2.5, + amesim_upstream_line_port_2_reference_enthalpy_flow_w=-24714.8, + amesim_upstream_line_port_2_reference_enthalpy_error_w=-5863.1, + amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w=-19240.8, + amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w=-389.1, + python_orifice_to_node_flow_kg_s=0.4637, + python_upstream_line_port_2_temperature_k=282.2, + python_upstream_line_port_2_pressure_pa=13.84e6, + python_current_port_2_enthalpy_flow_w=679490.8, + python_reference_port_2_enthalpy_flow_w=-38478.5, + python_real_gas_reference_port_2_enthalpy_flow_w=-32998.5, + python_current_port_2_enthalpy_error_w=698342.5, + python_reference_port_2_enthalpy_error_w=-19626.8, + python_real_gas_reference_port_2_enthalpy_error_w=-14146.8, + ), + ), + pnl0003_energy_diagnostics=( + TestMqlPnl0003EnergyDiagnostic( + line_alias="pneumatic_87", + time_s=0.05, + amesim_port_1_temperature_k=293.1, + amesim_port_2_temperature_k=287.7, + amesim_port_1_pressure_pa=15.2e6, + amesim_port_2_pressure_pa=13.9e6, + amesim_port_1_mass_flow_g_s=-8.5, + amesim_port_1_enthalpy_flow_w=85.0, + amesim_center_mass_flow_g_s=-454.2, + amesim_center_enthalpy_flow_w=15823.7, + amesim_port_2_mass_flow_g_s=-456.8, + amesim_port_2_enthalpy_flow_w=18851.7, + amesim_port_1_storage_mass_derivative_g_s=-462.7, + amesim_port_1_storage_enthalpy_sum_w=15908.7, + amesim_port_2_storage_mass_derivative_g_s=-2.6, + amesim_port_2_storage_enthalpy_sum_w=3028.0, + python_pn3_node_port_1_mass_flow_g_s=-333.0, + python_pn3_node_port_3_mass_flow_g_s=-119.9, + python_pn3_node_to_line_mass_flow_g_s=452.9, + python_pn3_node_to_line_mass_flow_error_g_s=461.4, + amesim_port_1_mass_estimate_g=1.12, + amesim_port_2_mass_estimate_g=1.05, + python_port_1_mass_g=1.11, + python_port_2_mass_g=1.04, + python_port_1_mass_error_to_amesim_g=-0.01, + python_port_2_mass_error_to_amesim_g=-0.01, + amesim_total_mass_derivative_fd_g_s=-470.0, + amesim_total_mass_derivative_backward_g_s=-540.0, + amesim_total_mass_derivative_forward_g_s=-400.0, + amesim_total_storage_mass_derivative_g_s=-465.3, + amesim_total_storage_mass_derivative_residual_g_s=4.7, + amesim_center_flow_python_sign_equivalent_g_s=454.2, + amesim_center_flow_python_sign_error_g_s=-0.2, + python_center_mass_flow_g_s=454.0, + amesim_port_1_center_mass_derivative_g_s=-454.2, + amesim_port_2_center_mass_derivative_g_s=454.2, + python_port_1_external_mass_flow_g_s=337.0, + python_port_1_external_mass_flow_error_g_s=15.1, + python_port_1_center_mass_derivative_g_s=-454.0, + python_port_1_center_mass_derivative_error_g_s=0.2, + python_port_1_storage_mass_derivative_g_s=-117.0, + python_port_1_storage_mass_derivative_error_g_s=345.7, + python_port_2_external_mass_flow_g_s=-456.4, + python_port_2_external_mass_flow_error_g_s=0.4, + python_port_2_center_mass_derivative_g_s=454.0, + python_port_2_center_mass_derivative_error_g_s=-0.2, + python_port_2_storage_mass_derivative_g_s=-2.4, + python_port_2_storage_mass_derivative_error_g_s=0.2, + python_amesim_state_darcy_center_mass_flow_g_s=453.5, + python_amesim_state_darcy_center_mass_flow_error_g_s=-0.7, + python_amesim_state_pn2pipefr_center_mass_flow_g_s=455.0, + python_amesim_state_pn2pipefr_center_mass_flow_error_g_s=0.8, + amesim_center_real_gas_reference_port_1_estimate_w=15700.0, + amesim_center_real_gas_reference_port_1_error_w=-123.7, + python_center_ideal_reference_port_1_estimate_w=16100.0, + python_center_ideal_reference_port_1_error_w=276.3, + python_center_ideal_reference_port_2_estimate_w=16200.0, + python_center_ideal_reference_port_2_error_w=376.3, + python_center_real_gas_reference_port_1_estimate_w=8300.0, + python_center_real_gas_reference_port_1_error_w=-7523.7, + python_center_real_gas_reference_port_2_estimate_w=8400.0, + python_center_real_gas_reference_port_2_error_w=-7423.7, + python_total_mass_derivative_kg_s=-0.47, + python_port_1_reference_external_enthalpy_flow_w=80.0, + python_port_1_reference_center_enthalpy_flow_w=15800.0, + python_port_1_reference_storage_enthalpy_sum_w=15880.0, + python_port_1_reference_storage_enthalpy_error_w=-28.7, + python_port_2_reference_external_enthalpy_flow_w=19000.0, + python_port_2_reference_center_enthalpy_flow_w=-15800.0, + python_port_2_reference_storage_enthalpy_sum_w=3200.0, + python_port_2_reference_storage_enthalpy_error_w=172.0, + amesim_port_1_external_enthalpy_dtemp_component_k_s=80.0, + amesim_port_1_center_enthalpy_dtemp_component_k_s=14000.0, + amesim_port_1_mass_offset_dtemp_component_k_s=-13200.0, + amesim_port_1_heat_dtemp_component_k_s=0.0, + python_port_1_external_enthalpy_dtemp_component_k_s=75.0, + python_port_1_center_enthalpy_dtemp_component_k_s=13900.0, + python_port_1_mass_offset_dtemp_component_k_s=-13100.0, + python_port_1_heat_dtemp_component_k_s=0.0, + amesim_port_2_external_enthalpy_dtemp_component_k_s=6000.0, + amesim_port_2_center_enthalpy_dtemp_component_k_s=-5000.0, + amesim_port_2_mass_offset_dtemp_component_k_s=-1500.0, + amesim_port_2_heat_dtemp_component_k_s=0.0, + python_port_2_external_enthalpy_dtemp_component_k_s=6100.0, + python_port_2_center_enthalpy_dtemp_component_k_s=-5100.0, + python_port_2_mass_offset_dtemp_component_k_s=-1650.0, + python_port_2_heat_dtemp_component_k_s=0.0, + amesim_port_1_temperature_derivative_fd_k_s=-210.0, + amesim_port_1_temperature_derivative_backward_k_s=-420.0, + amesim_port_1_temperature_derivative_forward_k_s=100.0, + amesim_port_2_temperature_derivative_fd_k_s=-540.0, + amesim_port_2_temperature_derivative_backward_k_s=-600.0, + amesim_port_2_temperature_derivative_forward_k_s=120.0, + amesim_port_1_pn2vol_dtemp_k_s=-220.0, + amesim_port_2_pn2vol_dtemp_k_s=-530.0, + amesim_port_1_pn2vol_dtemp_residual_k_s=-10.0, + amesim_port_2_pn2vol_dtemp_residual_k_s=10.0, + amesim_port_1_pressure_derivative_fd_pa_s=-2100000.0, + amesim_port_2_pressure_derivative_fd_pa_s=-5400000.0, + amesim_port_1_pressure_derivative_mass_component_pa_s=-3100000.0, + amesim_port_1_pressure_derivative_temperature_component_pa_s=900000.0, + amesim_port_1_pressure_derivative_eos_sum_pa_s=-2200000.0, + amesim_port_1_pressure_derivative_eos_residual_pa_s=-100000.0, + amesim_port_2_pressure_derivative_mass_component_pa_s=-6100000.0, + amesim_port_2_pressure_derivative_temperature_component_pa_s=800000.0, + amesim_port_2_pressure_derivative_eos_sum_pa_s=-5300000.0, + amesim_port_2_pressure_derivative_eos_residual_pa_s=100000.0, + python_port_1_pressure_derivative_mass_component_pa_s=-3000000.0, + python_port_1_pressure_derivative_temperature_component_pa_s=950000.0, + python_port_1_pressure_derivative_eos_sum_pa_s=-2050000.0, + python_port_2_pressure_derivative_mass_component_pa_s=-6000000.0, + python_port_2_pressure_derivative_temperature_component_pa_s=850000.0, + python_port_2_pressure_derivative_eos_sum_pa_s=-5150000.0, + python_port_1_temperature_k=293.15, + python_port_2_temperature_k=282.2, + python_port_1_pressure_pa=15.3e6, + python_port_2_pressure_pa=13.8e6, + python_port_1_mass_derivative_kg_s=-0.46, + python_port_2_mass_derivative_kg_s=-0.01, + python_port_1_current_dtemp_k_s=0.0, + python_port_2_current_dtemp_k_s=-1200.0, + python_port_1_real_gas_reference_dtemp_k_s=-100.0, + python_port_2_real_gas_reference_dtemp_k_s=-650.0, + python_port_2_amesim_center_counterfactual_dtemp_k_s=-610.0, + python_port_2_amesim_external_counterfactual_dtemp_k_s=-620.0, + python_port_2_amesim_external_center_counterfactual_dtemp_k_s=-630.0, + python_port_2_temperature_error_to_amesim_k=-5.5, + ), + ), + pneumatic_96_inlet_line_diagnostics=( + TestMqlPneumatic96InletLineDiagnostic( + line_alias="pneumatic_96", + node_alias="pn_node3_8", + orifice_alias="pn_orifice_18", + chamber_alias="pn_general_chamber", + time_s=0.05, + amesim_line_pressure_pa=14256386.8, + amesim_line_temperature_k=289.8, + amesim_line_mass_g=3.51, + amesim_line_mass_derivative_fd_g_s=-6.0, + amesim_line_to_node_mass_flow_g_s=452.2, + amesim_orifice_to_line_mass_flow_g_s=446.2, + amesim_storage_mass_derivative_from_ports_g_s=-6.0, + amesim_storage_mass_derivative_residual_g_s=0.0, + amesim_chamber_pressure_pa=15131788.1, + amesim_chamber_temperature_k=291.9, + amesim_node_pressure_pa=13887929.6, + amesim_node_temperature_k=288.9, + amesim_line_temperature_derivative_fd_k_s=149.5, + amesim_line_pressure_derivative_fd_pa_s=-17835932.3, + amesim_port_1_enthalpy_dtemp_component_k_s=1234.0, + amesim_port_2_enthalpy_dtemp_component_k_s=-724.0, + amesim_mass_offset_dtemp_component_k_s=-351.0, + amesim_reference_temperature_derivative_sum_k_s=159.0, + amesim_reference_temperature_derivative_residual_k_s=9.5, + amesim_pressure_derivative_mass_component_pa_s=-30562435.9, + amesim_pressure_derivative_temperature_component_pa_s=12794597.7, + amesim_pressure_derivative_eos_sum_pa_s=-17767838.2, + amesim_pressure_derivative_eos_residual_pa_s=68094.1, + python_line_pressure_pa=14257000.0, + python_line_temperature_k=290.1, + python_line_mass_g=3.52, + python_line_pressure_error_pa=613.2, + python_line_temperature_error_k=0.3, + python_line_mass_error_g=0.01, + python_line_to_node_mass_flow_g_s=453.0, + python_line_to_node_mass_flow_error_g_s=0.8, + python_orifice_to_line_mass_flow_g_s=447.0, + python_orifice_to_line_mass_flow_error_g_s=0.8, + python_storage_mass_derivative_g_s=-6.0, + python_storage_mass_derivative_error_g_s=0.0, + python_line_to_node_amesim_node_mass_flow_g_s=451.0, + python_line_to_node_amesim_node_mass_flow_error_g_s=-1.2, + python_line_to_node_amesim_line_mass_flow_g_s=452.8, + python_line_to_node_amesim_line_mass_flow_error_g_s=0.6, + python_line_to_node_amesim_node_line_mass_flow_g_s=452.0, + python_line_to_node_amesim_node_line_mass_flow_error_g_s=-0.2, + python_orifice_to_line_amesim_line_mass_flow_g_s=445.8, + python_orifice_to_line_amesim_line_mass_flow_error_g_s=-0.4, + python_orifice_to_line_amesim_chamber_mass_flow_g_s=446.6, + python_orifice_to_line_amesim_chamber_mass_flow_error_g_s=0.4, + python_orifice_to_line_amesim_line_chamber_mass_flow_g_s=446.0, + python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s=-0.2, + python_storage_amesim_boundary_states_mass_derivative_g_s=-6.0, + python_storage_amesim_boundary_states_mass_derivative_error_g_s=0.0, + python_chamber_pressure_pa=15131800.0, + python_chamber_temperature_k=292.0, + python_current_temperature_derivative_k_s=261.9, + python_port_1_current_dtemp_component_k_s=0.0, + python_port_2_current_dtemp_component_k_s=261.9, + python_heat_dtemp_component_k_s=0.0, + python_temperature_derivative_error_k_s=112.4, + python_pressure_derivative_mass_component_pa_s=-17917501.3, + python_pressure_derivative_temperature_component_pa_s=650331.8, + python_pressure_derivative_eos_sum_pa_s=-17267169.5, + python_pressure_derivative_eos_error_pa_s=568762.8, + python_reference_port_1_enthalpy_flow_w=0.0, + python_reference_port_2_enthalpy_flow_w=-710.0, + python_reference_port_1_dtemp_component_k_s=0.0, + python_reference_port_2_dtemp_component_k_s=-65.3, + python_reference_mass_offset_dtemp_component_k_s=-382.2, + python_reference_temperature_derivative_k_s=-447.5, + python_reference_temperature_derivative_error_k_s=-597.0, + python_reference_pressure_derivative_mass_component_pa_s=( + -17917501.3 + ), + python_reference_pressure_derivative_temperature_component_pa_s=( + -22045000.0 + ), + python_reference_pressure_derivative_eos_sum_pa_s=-39962501.3, + python_reference_pressure_derivative_eos_error_pa_s=( + -21955100.0 + ), + python_reference_amesim_storage_mass_offset_dtemp_component_k_s=( + -351.0 + ), + python_reference_amesim_storage_temperature_derivative_k_s=( + -416.3 + ), + python_reference_amesim_storage_temperature_derivative_error_k_s=( + -565.8 + ), + python_reference_amesim_storage_pressure_derivative_mass_component_pa_s=( + -25407051.3 + ), + python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s=( + -20500000.0 + ), + python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s=( + -45907051.3 + ), + python_reference_amesim_storage_pressure_derivative_eos_error_pa_s=( + -27999648.2 + ), + python_active_temperature_derivative_k_s=261.9, + python_active_temperature_derivative_error_k_s=112.4, + python_active_pressure_derivative_eos_sum_pa_s=-17267169.5, + python_active_pressure_derivative_eos_error_pa_s=568762.8, + python_chamber_pressure_error_pa=11.9, + python_chamber_temperature_error_k=0.1, + ), + ), + pnl0001_energy_flow_diagnostics=( + TestMqlPnl0001EnergyFlowDiagnostic( + line_alias="pneumatic_69", + chamber_alias="pn_c1_8", + node_alias="pnnode4_16", + time_s=0.05, + amesim_port_1_enthalpy_flow_w=-4248.6, + amesim_port_1_mass_flow_g_s=419.9, + amesim_node_port_2_enthalpy_flow_w=-18664.2, + amesim_node_port_2_mass_flow_g_s=438.3, + amesim_observed_port_enthalpy_sum_w=-22912.8, + python_port_1_internal_energy_flow_w=-378000.0, + python_port_2_internal_energy_flow_w=398000.0, + python_current_energy_derivative_w=20000.0, + python_direct_enthalpy_port_1_flow_w=-629000.0, + python_direct_enthalpy_port_2_flow_w=663000.0, + python_direct_enthalpy_energy_derivative_w=34000.0, + python_direct_minus_current_energy_derivative_w=14000.0, + python_p4_node_port_2_enthalpy_flow_w=640000.0, + python_p4_node_port_2_mass_flow_g_s=430.0, + python_p4_node_port_2_connected_h_j_kg=1488372.1, + python_p4_node_port_2_connected_temperature_k=286.6, + python_p4_node_port_2_connected_h_delta_to_line_h_j_kg=-34000.0, + python_p4_node_port_2_counterfactual_dtemp_k_s=-5100.0, + reference_temperature_k=298.15, + amesim_port_1_reference_enthalpy_estimate_w=-4467.0, + amesim_port_1_reference_enthalpy_error_w=-218.4, + amesim_candidate_pn2pipefr_dm2i_g_s=-419.9, + amesim_candidate_pn2pipefr_dh2i_w=4467.0, + amesim_candidate_storage_enthalpy_sum_w=-14197.2, + amesim_candidate_pn2pipefr_dtemp_k_s=-5480.0, + amesim_candidate_pn2pipefr_dtemp_residual_k_s=-5420.3, + python_reference_port_1_enthalpy_flow_w=-10760.0, + python_reference_node_port_2_enthalpy_flow_w=-18500.0, + python_reference_observed_port_enthalpy_sum_w=-29260.0, + python_reference_port_1_to_amesim_error_w=-6511.4, + python_reference_node_port_2_to_amesim_error_w=164.2, + python_reference_sum_to_amesim_error_w=-6347.2, + python_current_line_temperature_derivative_k_s=0.0, + amesim_line_temperature_derivative_fd_k_s=277.3, + amesim_pn2vol2_dtemp_node_plus_dh1_k_s=-9000.0, + amesim_pn2vol2_dtemp_node_minus_dh1_k_s=-5600.0, + python_reference_pn2vol2_dtemp_node_minus_dh1_k_s=-11200.0, + ), + ), + p4_node_enthalpy_breakdown_diagnostics=( + TestMqlP4NodeEnthalpyBreakdownDiagnostic( + node_alias="pnnode4_16", + time_s=0.05, + amesim_port_1_enthalpy_flow_w=-1000.0, + amesim_port_1_mass_flow_g_s=-30.0, + amesim_port_3_enthalpy_flow_w=-2000.0, + amesim_port_3_mass_flow_g_s=-40.0, + amesim_port_4_enthalpy_flow_w=-15664.2, + amesim_port_4_mass_flow_g_s=508.3, + amesim_port_2_enthalpy_flow_w=-18664.2, + amesim_port_2_mass_flow_g_s=438.3, + python_port_1_enthalpy_flow_w=-900.0, + python_port_1_mass_flow_g_s=-29.0, + python_port_3_enthalpy_flow_w=-2100.0, + python_port_3_mass_flow_g_s=-41.0, + python_port_4_enthalpy_flow_w=643000.0, + python_port_4_mass_flow_g_s=500.0, + python_port_2_enthalpy_flow_w=640000.0, + python_port_2_mass_flow_g_s=430.0, + python_reference_port_1_enthalpy_flow_w=-800.0, + python_reference_port_3_enthalpy_flow_w=-1700.0, + python_reference_port_4_enthalpy_flow_w=-16000.0, + python_reference_port_2_enthalpy_flow_w=-18500.0, + python_real_gas_reference_port_1_enthalpy_flow_w=-790.0, + python_real_gas_reference_port_3_enthalpy_flow_w=-1685.0, + python_real_gas_reference_port_4_enthalpy_flow_w=-15750.0, + python_real_gas_reference_port_2_enthalpy_flow_w=-18225.0, + python_port_2_enthalpy_error_w=658664.2, + python_reference_port_2_enthalpy_error_w=164.2, + python_real_gas_reference_port_2_enthalpy_error_w=439.2, + ), + ), + pnch012_energy_equation_diagnostics=( + TestMqlPnch012EnergyEquationDiagnostic( + chamber_alias="pn_c1_8", + piston_alias="pn_brp2_8", + line_alias="pneumatic_69", + time_s=0.05, + amesim_port_1_enthalpy_flow_w=-4248.6, + amesim_port_1_mass_flow_g_s=419.9, + amesim_chamber_mass_derivative_fd_g_s=370.8, + amesim_chamber_mass_derivative_residual_g_s=49.1, + amesim_chamber_temperature_derivative_fd_k_s=5134.6, + amesim_chamber_volume_rate_fd_m3_s=0.001004, + amesim_piston_volume_rate_m3_s=2.5e-8, + amesim_heat_flow_w=-0.0, + amesim_boundary_work_fd_volume_w=-289.5, + amesim_pn2vol_reference_dtemp_fd_volume_k_s=8090.0, + amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s=2955.4, + amesim_pn2vol_reference_dtemp_piston_volume_k_s=8107.0, + amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s=2972.4, + python_port_a_mass_flow_kg_s=-0.42, + python_current_energy_derivative_w=-28000.0, + python_current_chamber_temperature_derivative_k_s=-7800.0, + reference_temperature_k=298.15, + ), + ), + ) + diagnostic = TestMqlPnvoEventWindowDiagnostic( + orifice_alias="pn_morifice_1", + event_time_s=0.04, + final_time_s=0.05, + data_paths=data_paths, + segment_diagnostics=( + TestMqlPnvoEventWindowSegmentDiagnostic( + t_start=0.048, + t_stop=0.05, + method="BDF", + rtol=1.0e-5, + atol=1.0e-8, + max_step=1.0e-5, + rhs_evaluations=8154, + success=True, + message="success", + ), + ), + sample_diagnostics=(sample,), + python_values_by_data_path=sample.python_values_by_data_path, + amesim_values_by_data_path=sample.amesim_values_by_data_path, + ) + + summary = format_test_mql_pnvo_event_window_summary(diagnostic) + + self.assertAlmostEqual(diagnostic.abs_error("dm2@pn_morifice_1"), 6.9) + self.assertAlmostEqual(sample.abs_error("press@pn_c1_8"), 32067.9) + self.assertIn("PNVO event window diagnostic (pn_morifice_1)", summary) + self.assertIn("Sample comparisons:", summary) + self.assertFalse(sample.amesim_values_are_interpolated) + self.assertIn("t=0.05 (amesim=exact@0.05)", summary) + self.assertIn("rhs=8154", summary) + self.assertIn("rhs@pneumatic_69", summary) + self.assertIn("dm_dt=-0.0222", summary) + self.assertIn("pressure_loss@pneumatic_69", summary) + self.assertIn("cm=0.0159", summary) + self.assertIn("dp_multiplier=3.07", summary) + self.assertIn("candidate_pn2pipefr_dm1=424.2", summary) + self.assertIn("candidate_pn2pipefr_ratio=1.01", summary) + self.assertIn("amesim_linear_k=5.56e-06", summary) + self.assertIn("python_linear_k=1.06e-05", summary) + self.assertIn("linear_k_ratio=1.91", summary) + self.assertIn("flow_parameter@pn_morifice_1", summary) + self.assertIn("amesim_cm=0.0158", summary) + self.assertIn("python_cm=0.0147", summary) + self.assertIn("cm_ratio=0.93", summary) + self.assertIn("amesim_gasvel=885.9", summary) + self.assertIn("pnvo_upstream_enthalpy@pn_morifice_1", summary) + self.assertIn("amesim_dh2=-18851.7", summary) + self.assertIn("amesim_line_dhctr=15823.7", summary) + self.assertIn("amesim_implied_t2=290.2", summary) + self.assertIn("amesim_implied_t2_delta=2.5", summary) + self.assertIn("amesim_ref_dh2=-24714.8", summary) + self.assertIn("amesim_real_ref_dh2=-19240.8", summary) + self.assertIn("amesim_real_ref_dh2_error=-389.1", summary) + self.assertIn("python_current_dh2=679490.8", summary) + self.assertIn("python_ref_dh2=-38478.5", summary) + self.assertIn("python_real_ref_dh2=-32998.5", summary) + self.assertIn("inlet_line@pneumatic_96", summary) + self.assertIn("node=pn_node3_8", summary) + self.assertIn("orifice=pn_orifice_18", summary) + self.assertIn("amesim_line_to_node_dm1=452.2", summary) + self.assertIn("amesim_orifice_to_line_dm2=446.2", summary) + self.assertIn("python_line_to_node_dm=453.0", summary) + self.assertIn("python_line_to_node_dm_error=0.8", summary) + self.assertIn("python_storage_dm_error=0.0", summary) + self.assertIn("python_line_to_node_amesim_node_line_dm=452.0", summary) + self.assertIn("python_orifice_to_line_amesim_line_chamber_dm=446.0", summary) + self.assertIn("python_storage_amesim_boundary_states_dm_error=0.0", summary) + self.assertIn("amesim_fd_dT=149.5", summary) + self.assertIn("amesim_dT_m_offset=-351.0", summary) + self.assertIn("python_current_dT=261.9", summary) + self.assertIn("python_dT_error=112.4", summary) + self.assertIn("python_dP_error=568762.8", summary) + self.assertIn("python_ref_dT=-447.5", summary) + self.assertIn("python_ref_dT_error=-597.0", summary) + self.assertIn("python_ref_dP_error=-21955100.0", summary) + self.assertIn("python_ref_amesim_storage_dT=-416.3", summary) + self.assertIn("python_ref_amesim_storage_dP_error=-27999648.2", summary) + self.assertIn("python_active_dT=261.9", summary) + self.assertIn("python_active_dP_error=568762.8", summary) + self.assertIn("pnl0003_energy@pneumatic_87", summary) + self.assertIn("amesim_sdm1=-462.7", summary) + self.assertIn("amesim_sdh2=3028.0", summary) + self.assertIn("python_node_dm1=-333.0", summary) + self.assertIn("python_node_dm3=-119.9", summary) + self.assertIn("python_node_to_line_dm=452.9", summary) + self.assertIn("python_node_to_line_dm_error=461.4", summary) + self.assertIn("amesim_m2=1.05", summary) + self.assertIn("python_m2=1.04", summary) + self.assertIn("python_m2_error=-0.01", summary) + self.assertIn("amesim_fd_dmgas=-470.0", summary) + self.assertIn("amesim_back_dmgas=-540.0", summary) + self.assertIn("amesim_fwd_dmgas=-400.0", summary) + self.assertIn("amesim_storage_dmgas=-465.3", summary) + self.assertIn("amesim_storage_dmgas_residual=4.7", summary) + self.assertIn("amesim_dmctr_py_sign=454.2", summary) + self.assertIn("python_center_dm=454.0", summary) + self.assertIn("amesim_sdm1_center=-454.2", summary) + self.assertIn("amesim_sdm2_center=454.2", summary) + self.assertIn("python_sdm1_ext=337.0", summary) + self.assertIn("python_sdm1_ext_error=15.1", summary) + self.assertIn("python_sdm1_center=-454.0", summary) + self.assertIn("python_sdm1_center_error=0.2", summary) + self.assertIn("python_sdm1=-117.0", summary) + self.assertIn("python_sdm1_error=345.7", summary) + self.assertIn("python_sdm2_ext=-456.4", summary) + self.assertIn("python_sdm2_ext_error=0.4", summary) + self.assertIn("python_sdm2_center=454.0", summary) + self.assertIn("python_sdm2_center_error=-0.2", summary) + self.assertIn("python_sdm2=-2.4", summary) + self.assertIn("python_sdm2_error=0.2", summary) + self.assertIn("python_amesim_state_darcy_center_dm=453.5", summary) + self.assertIn("python_amesim_state_darcy_center_dm_error=-0.7", summary) + self.assertIn("python_amesim_state_pn2pipefr_center_dm=455.0", summary) + self.assertIn("python_amesim_state_pn2pipefr_center_dm_error=0.8", summary) + self.assertIn("amesim_center_real_p1_error=-123.7", summary) + self.assertIn("python_center_ideal_p2=16200.0", summary) + self.assertIn("python_center_dm_error=-0.2", summary) + self.assertIn("python_dmgas_dt=-0.47", summary) + self.assertIn("python_ref_sdh1=15880.0", summary) + self.assertIn("python_ref_sdh2_error=172.0", summary) + self.assertIn("amesim_dT1_ext_h=80.0", summary) + self.assertIn("amesim_dT1_ctr_h=14000.0", summary) + self.assertIn("amesim_dT1_m_offset=-13200.0", summary) + self.assertIn("python_dT1_ext_h=75.0", summary) + self.assertIn("python_dT1_ctr_h=13900.0", summary) + self.assertIn("python_dT1_m_offset=-13100.0", summary) + self.assertIn("amesim_dT2_ext_h=6000.0", summary) + self.assertIn("amesim_dT2_ctr_h=-5000.0", summary) + self.assertIn("amesim_dT2_m_offset=-1500.0", summary) + self.assertIn("python_dT2_ext_h=6100.0", summary) + self.assertIn("python_dT2_ctr_h=-5100.0", summary) + self.assertIn("python_dT2_m_offset=-1650.0", summary) + self.assertIn("amesim_back_dT2=-600.0", summary) + self.assertIn("amesim_fwd_dT2=120.0", summary) + self.assertIn("amesim_pn2vol_dT2=-530.0", summary) + self.assertIn("amesim_fd_dP1=-2100000.0", summary) + self.assertIn("amesim_dP1_m=-3100000.0", summary) + self.assertIn("amesim_dP1_T=900000.0", summary) + self.assertIn("amesim_dP1_residual=-100000.0", summary) + self.assertIn("amesim_dP2_sum=-5300000.0", summary) + self.assertIn("python_dP1_sum=-2050000.0", summary) + self.assertIn("python_dP2_T=850000.0", summary) + self.assertIn("python_real_ref_dT2=-650.0", summary) + self.assertIn("python_amesim_center_dT2=-610.0", summary) + self.assertIn("python_amesim_ext_dT2=-620.0", summary) + self.assertIn("python_amesim_ext_ctr_dT2=-630.0", summary) + self.assertIn("python_t2_error=-5.5", summary) + self.assertIn("energy_flow@pneumatic_69", summary) + self.assertIn("amesim_dh1=-4248.6", summary) + self.assertIn("amesim_node_dh2=-18664.2", summary) + self.assertIn("direct_h_dU_dt=34000.0", summary) + self.assertIn("python_p4_node_dh2=640000.0", summary) + self.assertIn("python_p4_node_dm2=430.0", summary) + self.assertIn("python_p4_node_h2=1488372.1", summary) + self.assertIn("python_p4_node_t2=286.6", summary) + self.assertIn("python_p4_node_h2_delta=-34000.0", summary) + self.assertIn("python_p4_node_counterfactual_dT=-5100.0", summary) + self.assertIn("ref_dh1_est=-4467.0", summary) + self.assertIn("ref_dh1_error=-218.4", summary) + self.assertIn("candidate_dm2i=-419.9", summary) + self.assertIn("candidate_dh2i=4467.0", summary) + self.assertIn("candidate_storage_sdh=-14197.2", summary) + self.assertIn("candidate_pn2pipefr_dT=-5480.0", summary) + self.assertIn("python_ref_dh1=-10760.0", summary) + self.assertIn("python_ref_node_dh2=-18500.0", summary) + self.assertIn("python_ref_sdh=-29260.0", summary) + self.assertIn("python_ref_sdh_error=-6347.2", summary) + self.assertIn("python_current_dT=0.0", summary) + self.assertIn("amesim_fd_dT=277.3", summary) + self.assertIn("amesim_pn2vol2_dT_node_plus_dh1=-9000.0", summary) + self.assertIn("python_ref_pn2vol2_dT_node_minus_dh1=-11200.0", summary) + self.assertIn("p4_node_enthalpy@pnnode4_16", summary) + self.assertIn("python_real_ref_p2_dh_error=439.2", summary) + self.assertIn("amesim_p4_dh=-15664.2", summary) + self.assertIn("python_p4_dh=643000.0", summary) + self.assertIn("python_ref_p2_dh=-18500.0", summary) + self.assertIn("python_ref_p2_dh_error=164.2", summary) + self.assertIn("pnch012_energy@pn_c1_8", summary) + self.assertIn("amesim_fd_dmgas=370.8", summary) + self.assertIn("amesim_dmgas_residual=49.1", summary) + self.assertIn("amesim_fd_dvol=0.001004", summary) + self.assertIn("amesim_pn2vol_ref_dT_fdvol=8090.0", summary) + self.assertIn("python_current_dT=-7800.0", summary) + self.assertIn("Final comparison:", summary) + self.assertTrue(summary.endswith("\n")) + + def test_formats_pnvo_event_window_candidate_comparison(self) -> None: + data_paths = ("press@pn_c1_8", "dm2@pn_morifice_1") + default_diagnostic = TestMqlPnvoEventWindowDiagnostic( + orifice_alias="pn_morifice_1", + event_time_s=0.04, + final_time_s=0.05, + data_paths=data_paths, + segment_diagnostics=(), + sample_diagnostics=(), + python_values_by_data_path={ + "press@pn_c1_8": 1.0, + "dm2@pn_morifice_1": 10.0, + }, + amesim_values_by_data_path={ + "press@pn_c1_8": 4.0, + "dm2@pn_morifice_1": 8.0, + }, + ) + candidate_diagnostic = TestMqlPnvoEventWindowDiagnostic( + orifice_alias="pn_morifice_1", + event_time_s=0.04, + final_time_s=0.05, + data_paths=data_paths, + segment_diagnostics=(), + sample_diagnostics=(), + python_values_by_data_path={ + "press@pn_c1_8": 3.0, + "dm2@pn_morifice_1": 13.0, + }, + amesim_values_by_data_path={ + "press@pn_c1_8": 4.0, + "dm2@pn_morifice_1": 8.0, + }, + ) + metric = TestMqlPnvoEventWindowCandidateMetricComparison( + time_s=0.0401, + metric_name="p96:p2_abs_error_pa", + default_abs_error=32000.0, + candidate_abs_error=3600.0, + ) + diagnostic = TestMqlPnvoEventWindowCandidateComparisonDiagnostic( + orifice_alias="pn_morifice_1", + event_time_s=0.04, + final_time_s=0.05, + default_diagnostic=default_diagnostic, + candidate_diagnostic=candidate_diagnostic, + metric_comparisons=(metric,), + ) + + summary = format_test_mql_pnvo_event_window_candidate_comparison_summary( + diagnostic + ) + + self.assertEqual(metric.abs_error_delta, -28400.0) + self.assertIn("PNVO event window candidate comparison", summary) + self.assertIn("negative delta means the candidate reduced error", summary) + self.assertIn("t=0.0401 - p96:p2_abs_error_pa", summary) + self.assertIn("default_abs_error=32000.0", summary) + self.assertIn("candidate_abs_error=3600.0", summary) + self.assertIn("delta=-28400.0", summary) + self.assertIn("Final comparison:", summary) + self.assertIn("press@pn_c1_8", summary) + self.assertIn("delta=-2.0", summary) + self.assertIn("dm2@pn_morifice_1", summary) + self.assertIn("delta=3.0", summary) + self.assertTrue(summary.endswith("\n")) + + def test_full_state_script_can_skip_artifact_files(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + run, returned_output_dir = run_test_mql_full_state_comparison( + TestMqlFullStateComparisonScriptConfig( + paths=TestMqlFullStateComparisonPathConfig( + archive_path=TEST_MQL_AME, + output_dir=output_dir, + ), + execution=TestMqlFullStateComparisonExecutionConfig( + write_summary=False, + write_comparison_csv=False, + data_paths=("press@pn_c1_8",), + solver=SolveIVPConfig(t_start=0.0, t_stop=0.0), + t_eval=(0.0,), + ), + ) + ) + + self.assertEqual(returned_output_dir, output_dir) + self.assertEqual(run.signal_count, 1) + self.assertFalse((output_dir / "test_mql_full_state_comparison_summary.txt").exists()) + self.assertFalse((output_dir / "test_mql_amesim_comparison.csv").exists()) + + def test_format_test_mql_full_state_comparison_summary(self) -> None: + run, _ = run_test_mql_full_state_comparison( + TestMqlFullStateComparisonScriptConfig( + paths=TestMqlFullStateComparisonPathConfig(archive_path=TEST_MQL_AME), + execution=TestMqlFullStateComparisonExecutionConfig( + write_summary=False, + write_comparison_csv=False, + data_paths=("press@pn_c1_8",), + solver=SolveIVPConfig(t_start=0.0, t_stop=0.0), + t_eval=(0.0,), + ), + ) + ) + + summary = format_test_mql_full_state_comparison_summary(run) + + self.assertIn("Model: test_mql", summary) + self.assertIn("Output schema signals: 858", summary) + self.assertIn("Compared signals: 1", summary) + self.assertIn("Largest absolute error: press@pn_c1_8=", summary) + self.assertIn("Largest final endpoint error: press@pn_c1_8=", summary) + self.assertIn("Metrics by max absolute error:", summary) + self.assertIn("Endpoint diagnostics by final absolute error:", summary) + self.assertIn("Largest endpoint chamber RHS breakdown: pn_c1_8", summary) + self.assertTrue(summary.endswith("\n")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_baseline.py b/tests/test_test_mql_baseline.py new file mode 100644 index 0000000..cb82017 --- /dev/null +++ b/tests/test_test_mql_baseline.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.systems.test_mql_baseline import run_test_mql_baseline_passthrough + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlBaselinePassthroughTests(unittest.TestCase): + def test_full_baseline_passthrough_satisfies_output_contract_with_zero_error(self) -> None: + run = run_test_mql_baseline_passthrough(TEST_MQL_AME) + + self.assertEqual(run.sample_count, 1002) + self.assertEqual(run.signal_count, 858) + self.assertEqual(run.output_schema.signal_count, 858) + self.assertEqual(run.observation_catalog.binding_count, 114) + self.assertEqual(len(run.comparison.metrics), 858) + self.assertEqual(run.comparison.max_abs_error, 0.0) + self.assertEqual(run.comparison.max_rel_error, 0.0) + self.assertEqual(run.output.data_paths, run.output_schema.data_paths()) + self.assertAlmostEqual(run.output.series("press@pn_c1_8")[-1], 4310310.457796034) + + def test_selected_baseline_passthrough_preserves_requested_order(self) -> None: + data_paths = ( + "vol1@pn_brp2_8", + "press@pn_c1_8", + "dm1@pneumatic_69", + ) + + run = run_test_mql_baseline_passthrough(TEST_MQL_AME, data_paths=data_paths) + + self.assertEqual(run.signal_count, 3) + self.assertEqual(run.output.data_paths, data_paths) + self.assertEqual(tuple(metric.data_path for metric in run.comparison.metrics), data_paths) + self.assertEqual(run.comparison.max_abs_error, 0.0) + + def test_baseline_passthrough_rejects_unobserved_data_path(self) -> None: + with self.assertRaises(KeyError): + run_test_mql_baseline_passthrough( + TEST_MQL_AME, + data_paths=("not_a_signal@not_an_owner",), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_branch_simulation.py b/tests/test_test_mql_branch_simulation.py new file mode 100644 index 0000000..9869c22 --- /dev/null +++ b/tests/test_test_mql_branch_simulation.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import unittest + +from PythonModels.core.solver import SolveIVPConfig +from PythonModels.systems.test_mql import TestMqlSystem + + +class TestMqlPneumaticBranchSimulationTests(unittest.TestCase): + def test_simulate_pneumatic_branch_returns_ode_solution(self) -> None: + system = TestMqlSystem() + + solution = system.simulate_pneumatic_branch( + name="sample_branch", + upstream_volume_alias="pn_general_chamber", + orifice_alias="pn_orifice_18", + downstream_volume_alias="pn_c1_8", + config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), + t_eval=[0.0, 5.0e-6, 1.0e-5], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 3) + self.assertEqual(len(solution.y), 4) + self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3]) + + def test_evaluate_pneumatic_branch_solution_reports_branch_series(self) -> None: + system = TestMqlSystem() + solution = system.simulate_pneumatic_branch( + name="sample_branch", + upstream_volume_alias="pn_general_chamber", + orifice_alias="pn_orifice_18", + downstream_volume_alias="pn_c1_8", + config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), + t_eval=[0.0, 5.0e-6, 1.0e-5], + ) + + series = system.evaluate_pneumatic_branch_solution( + solution, + name="sample_branch", + upstream_volume_alias="pn_general_chamber", + orifice_alias="pn_orifice_18", + downstream_volume_alias="pn_c1_8", + ) + + self.assertEqual(series["time"], [0.0, 5.0e-6, 1.0e-5]) + self.assertIn("sample_branch.flow", series) + self.assertIn("pn_general_chamber.p", series) + self.assertIn("pn_c1_8.p", series) + self.assertEqual(len(series["sample_branch.flow"]), 3) + self.assertGreater(series["sample_branch.flow"][0], 0.0) + self.assertGreater(series["pn_general_chamber.p"][0], series["pn_general_chamber.p"][-1]) + self.assertLess(series["pn_c1_8.p"][0], series["pn_c1_8.p"][-1]) + self.assertAlmostEqual( + series["pn_general_chamber.m_flow"][0], + -series["sample_branch.flow"][0], + ) + self.assertAlmostEqual( + series["pn_c1_8.m_flow"][0], + series["sample_branch.flow"][0], + ) + + def test_pneumatic_branch_spec_drives_closure_and_simulation(self) -> None: + system = TestMqlSystem() + spec = system.pneumatic_branch_spec( + name="sample_branch", + upstream_volume_alias="pn_general_chamber", + orifice_alias="pn_orifice_18", + downstream_volume_alias="pn_c1_8", + source="manual-test", + ) + + closure = system.pneumatic_branch_closure_from_spec(spec) + solution = system.simulate_pneumatic_branch_from_spec( + spec, + config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), + t_eval=[0.0, 1.0e-5], + ) + series = system.evaluate_pneumatic_branch_solution_from_spec(solution, spec) + + self.assertIs(closure.components.spec, spec) + self.assertEqual(spec.source, "manual-test") + self.assertEqual(len(solution.t), 2) + self.assertIn("sample_branch.flow", series) + self.assertGreater(series["sample_branch.flow"][0], 0.0) + self.assertGreater(series["pn_general_chamber.p"][0], series["pn_general_chamber.p"][-1]) + self.assertLess(series["pn_c1_8.p"][0], series["pn_c1_8.p"][-1]) + + def test_discover_pneumatic_branch_topology_reports_blocked_candidates(self) -> None: + system = TestMqlSystem() + + discovery = system.discover_pneumatic_branch_topology() + + self.assertEqual(discovery.branch_specs, ()) + self.assertEqual(len(discovery.blocked_candidates), 48) + candidates = { + candidate.connection_alias: candidate + for candidate in discovery.blocked_candidates + } + chamber_candidate = candidates["pneumatic_69"] + self.assertEqual(chamber_candidate.line_submodel, "PNL0001") + self.assertEqual(chamber_candidate.source_component, "pnnode4_16") + self.assertEqual(chamber_candidate.target_component, "pn_c1_8") + self.assertFalse(chamber_candidate.source_is_typed_pneumatic) + self.assertTrue(chamber_candidate.target_is_typed_pneumatic) + self.assertIn("node/line", chamber_candidate.reason) + + orifice_candidate = candidates["pneumatic_96"] + self.assertEqual(orifice_candidate.line_submodel, "PNL0001") + self.assertEqual(orifice_candidate.source_component, "pn_node3_8") + self.assertEqual(orifice_candidate.target_component, "pn_orifice_18") + self.assertFalse(orifice_candidate.source_is_typed_pneumatic) + self.assertTrue(orifice_candidate.target_is_typed_pneumatic) + self.assertIn("node/line", orifice_candidate.reason) + + def test_simulate_pneumatic_branch_rejects_invalid_component_aliases(self) -> None: + system = TestMqlSystem() + + with self.assertRaises(TypeError): + system.simulate_pneumatic_branch( + name="invalid_branch", + upstream_volume_alias="pn_brp2_8", + orifice_alias="pn_orifice_18", + downstream_volume_alias="pn_c1_8", + config=SolveIVPConfig(t_start=0.0, t_stop=0.0), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_chamber_observations.py b/tests/test_test_mql_chamber_observations.py new file mode 100644 index 0000000..7a2cc92 --- /dev/null +++ b/tests/test_test_mql_chamber_observations.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_chamber_observations import ( + build_test_mql_chamber_observation_catalog, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlChamberObservationCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.catalog = build_test_mql_chamber_observation_catalog(cls.amesim_results) + + def test_builds_expected_chamber_observation_bindings(self) -> None: + self.assertEqual(len(self.catalog.bindings), 12) + self.assertEqual(self.catalog.fixed_count, 4) + self.assertEqual(self.catalog.variable_count, 8) + + fixed = self.catalog.by_alias("pn_general_chamber") + variable = self.catalog.by_alias("pn_c1_8") + + self.assertEqual(fixed.submodel, "PNCH023") + self.assertFalse(fixed.is_variable) + self.assertEqual(fixed.pressure_path, "press@pn_general_chamber") + self.assertEqual(fixed.temperature_path, "temp@pn_general_chamber") + self.assertEqual(fixed.gas_mass_path, "mgas@pn_general_chamber") + self.assertEqual(fixed.pressure_duplicate_paths, ("press2@pn_general_chamber",)) + self.assertEqual(fixed.temperature_duplicate_paths, ("temp2@pn_general_chamber",)) + self.assertIsNone(fixed.volume_path) + self.assertEqual(variable.submodel, "PNCH012") + self.assertTrue(variable.is_variable) + self.assertEqual(variable.pressure_path, "press@pn_c1_8") + self.assertEqual(variable.temperature_path, "temp@pn_c1_8") + self.assertEqual(variable.gas_mass_path, "mgas1@pn_c1_8") + self.assertEqual( + variable.pressure_duplicate_paths, + ("press3@pn_c1_8", "press2@pn_c1_8", "press4@pn_c1_8"), + ) + self.assertEqual( + variable.temperature_duplicate_paths, + ("temp3@pn_c1_8", "temp2@pn_c1_8", "temp4@pn_c1_8"), + ) + self.assertEqual(variable.volume_path, "vol@pn_c1_8") + + def test_fixed_chamber_observation_matches_amesim_samples(self) -> None: + observation = self.catalog.by_alias("pn_general_chamber").observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.time, self.amesim_results.times[-1]) + self.assertAlmostEqual(observation.pressure_pa, 4310273.332655074) + self.assertAlmostEqual(observation.temperature_k, 179.646447269399) + self.assertAlmostEqual(observation.gas_mass_g, 666.3851161956942) + self.assertIsNone(observation.volume_cm3) + + def test_variable_chamber_observation_matches_amesim_samples(self) -> None: + observation = self.catalog.by_alias("pn_c1_8").observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.pressure_pa, 4310310.457796034) + self.assertAlmostEqual(observation.temperature_k, 293.1143259012449) + self.assertAlmostEqual(observation.gas_mass_g, 353.6887889918552) + self.assertAlmostEqual(observation.volume_cm3, 49242.54636512914) + + def test_initial_pressure_conventions_are_preserved(self) -> None: + fixed = self.catalog.by_alias("pn_general_chamber").observation_at( + self.amesim_results, + 0, + ) + variable = self.catalog.by_alias("pn_c1_8").observation_at( + self.amesim_results, + 0, + ) + + self.assertAlmostEqual(fixed.pressure_pa, 15198699.999999998) + self.assertAlmostEqual(variable.pressure_pa, -1299.99999999999) + self.assertAlmostEqual(fixed.temperature_k, 293.15) + self.assertAlmostEqual(variable.temperature_k, 293.15) + self.assertAlmostEqual(variable.volume_cm3, 15000.0) + + def test_duplicate_pressure_and_temperature_paths_match_primary_series(self) -> None: + for binding in self.catalog.bindings: + pressure = self.amesim_results.series(binding.pressure_path) + temperature = self.amesim_results.series(binding.temperature_path) + with self.subTest(alias=binding.alias): + for duplicate_path in binding.pressure_duplicate_paths: + duplicate = self.amesim_results.series(duplicate_path) + self.assertLess( + max(abs(a - b) for a, b in zip(pressure, duplicate)), + 1.0e-12, + ) + for duplicate_path in binding.temperature_duplicate_paths: + duplicate = self.amesim_results.series(duplicate_path) + self.assertLess( + max(abs(a - b) for a, b in zip(temperature, duplicate)), + 1.0e-12, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_chamber_segment.py b/tests/test_test_mql_chamber_segment.py new file mode 100644 index 0000000..e722624 --- /dev/null +++ b/tests/test_test_mql_chamber_segment.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import unittest + +from PythonModels.core.solver import SolveIVPConfig +from PythonModels.systems.test_mql import TestMqlSystem + + +class TestMqlPneumaticChamberSegmentTests(unittest.TestCase): + def setUp(self) -> None: + self.system = TestMqlSystem() + self.discovery = self.system.discover_pneumatic_branch_topology() + self.spec = self.discovery.chamber_segment_specs[0] + + def test_discovers_four_fixed_chamber_segments_from_real_topology(self) -> None: + self.assertEqual(len(self.discovery.chamber_segment_specs), 4) + self.assertEqual(self.spec.volume_alias, "pn_general_chamber") + self.assertEqual(self.spec.inlet_node_alias, "pn_node3_8") + self.assertEqual(self.spec.inlet_line_alias, "pneumatic_96") + self.assertEqual(self.spec.inlet_orifice_alias, "pn_orifice_18") + self.assertEqual(self.spec.volume_inlet_port, "port_2") + self.assertEqual(self.spec.outlet_orifice_alias, "pn_orifice_19") + self.assertEqual(self.spec.outlet_line_alias, "pneumatic_97") + self.assertEqual(self.spec.outlet_node_alias, "pn_node3_9") + self.assertEqual(self.spec.source, "amesim-cir-topology") + + def test_segment_closure_writes_real_amesim_port_directions(self) -> None: + closure = self.system.pneumatic_chamber_segment_closure_from_spec( + self.spec, + inlet_pressure_pa=16.0e6, + outlet_pressure_pa=1.0e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertGreater(snapshot.inlet_flow, 0.0) + self.assertGreater(snapshot.outlet_flow, 0.0) + self.assertAlmostEqual( + closure.components.volume.port_b.m_flow, + snapshot.inlet_flow, + ) + self.assertAlmostEqual( + closure.components.volume.port_a.m_flow, + -snapshot.outlet_flow, + ) + self.assertAlmostEqual( + closure.components.inlet_orifice.port_b.m_flow, + snapshot.inlet_flow, + ) + self.assertAlmostEqual( + closure.components.outlet_orifice.port_a.m_flow, + -snapshot.outlet_flow, + ) + self.assertEqual(len(rhs), 2) + self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow) + + def test_segment_closure_handles_reverse_boundary_pressures(self) -> None: + closure = self.system.pneumatic_chamber_segment_closure_from_spec( + self.spec, + inlet_pressure_pa=1.0e6, + outlet_pressure_pa=16.0e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertLess(snapshot.inlet_flow, 0.0) + self.assertLess(snapshot.outlet_flow, 0.0) + self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow) + + def test_simulates_topology_derived_segment_with_prescribed_boundaries(self) -> None: + solution = self.system.simulate_pneumatic_chamber_segment_from_spec( + self.spec, + inlet_pressure_pa=16.0e6, + outlet_pressure_pa=14.0e6, + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0e-5, + max_step=1.0e-6, + ), + t_eval=[0.0, 5.0e-6, 1.0e-5], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 3) + self.assertEqual(len(solution.y), 2) + self.assertEqual([len(row) for row in solution.y], [3, 3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_closure.py b/tests/test_test_mql_closure.py new file mode 100644 index 0000000..dc698fe --- /dev/null +++ b/tests/test_test_mql_closure.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import unittest + +from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, +) +from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBranchComponents, + TestMqlPneumaticClosure, +) + + +class TestMqlPneumaticClosureTests(unittest.TestCase): + def _make_closure( + self, + *, + upstream_pressure: float = 15.3e6, + downstream_pressure: float = 1.0e5, + ) -> TestMqlPneumaticClosure: + upstream = AmesimPneumaticVolume.from_liters( + name="source_chamber", + volume_liters=57.0, + p0=upstream_pressure, + T0=293.15, + ) + downstream = AmesimPneumaticVolume.from_liters( + name="target_chamber", + volume_liters=15.0, + p0=downstream_pressure, + T0=293.15, + ) + orifice = AmesimPneumaticOrifice.from_mm2( + name="branch_orifice", + area_mm2=78.5, + flow_coefficient=0.9, + ) + + def initial_state_vector() -> list[float]: + return [*upstream.get_state_vector(), *downstream.get_state_vector()] + + def apply_state_vector(values: list[float]) -> None: + if len(values) != 4: + raise ValueError("two-volume closure state vector requires four values") + upstream.set_state_vector(values[:2]) + downstream.set_state_vector(values[2:]) + + return TestMqlPneumaticClosure( + components=TestMqlPneumaticBranchComponents( + name="source_to_target", + upstream_volume=upstream, + orifice=orifice, + downstream_volume=downstream, + ), + initial_state_vector=initial_state_vector, + apply_state_vector=apply_state_vector, + ) + + def test_snapshot_writes_modelica_style_port_flow_signs(self) -> None: + closure = self._make_closure() + + snapshot = closure.snapshot() + + self.assertGreater(snapshot.flow, 0.0) + self.assertAlmostEqual( + closure.components.upstream_volume.port_a.m_flow, + -snapshot.flow, + ) + self.assertAlmostEqual( + closure.components.orifice.port_a.m_flow, + snapshot.flow, + ) + self.assertAlmostEqual( + closure.components.orifice.port_b.m_flow, + -snapshot.flow, + ) + self.assertAlmostEqual( + closure.components.downstream_volume.port_a.m_flow, + snapshot.flow, + ) + self.assertAlmostEqual( + closure.components.orifice.port_a.p, + snapshot.upstream.p, + ) + self.assertAlmostEqual( + closure.components.orifice.port_b.p, + snapshot.downstream.p, + ) + + def test_rhs_uses_canonical_flow_for_volume_mass_balance(self) -> None: + closure = self._make_closure() + state = closure.initial_state_vector() + + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + + self.assertEqual(len(rhs), 4) + self.assertAlmostEqual(rhs[0], -snapshot.flow) + self.assertAlmostEqual(rhs[2], snapshot.flow) + self.assertAlmostEqual(rhs[0] + rhs[2], 0.0) + self.assertAlmostEqual(rhs[1], -snapshot.flow * snapshot.upstream.h) + self.assertAlmostEqual(rhs[3], snapshot.flow * snapshot.upstream.h) + + def test_reverse_pressure_reverses_canonical_flow_and_port_signs(self) -> None: + closure = self._make_closure( + upstream_pressure=1.0e5, + downstream_pressure=15.3e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertLess(snapshot.flow, 0.0) + self.assertGreater(closure.components.upstream_volume.port_a.m_flow, 0.0) + self.assertLess(closure.components.downstream_volume.port_a.m_flow, 0.0) + self.assertAlmostEqual(rhs[0], -snapshot.flow) + self.assertAlmostEqual(rhs[2], snapshot.flow) + self.assertAlmostEqual(rhs[0] + rhs[2], 0.0) + self.assertAlmostEqual(rhs[1], -snapshot.flow * snapshot.downstream.h) + self.assertAlmostEqual(rhs[3], snapshot.flow * snapshot.downstream.h) + + def test_snapshot_can_apply_explicit_state_vector(self) -> None: + closure = self._make_closure() + state = closure.initial_state_vector() + state[0] *= 0.99 + + snapshot = closure.snapshot(state) + + self.assertAlmostEqual( + closure.components.upstream_volume.state.m, + state[0], + ) + self.assertGreater(snapshot.upstream.p, snapshot.downstream.p) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_comparison.py b/tests/test_test_mql_comparison.py new file mode 100644 index 0000000..65fcf54 --- /dev/null +++ b/tests/test_test_mql_comparison.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_comparison import ( + DEFAULT_TEST_MQL_ALIGNMENT_PATHS, + TestMqlComparisonError, + compare_test_mql_series, + interpolate_series_value, + write_test_mql_amesim_baseline_csv, + write_test_mql_comparison_csv, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlComparisonTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_exact_amesim_series_compare_with_zero_error(self) -> None: + data_paths = ("temp3@pn_c1_8", "press3@pn_c1_8") + python_series = { + data_path: self.amesim_results.series(data_path) + for data_path in data_paths + } + + comparison = compare_test_mql_series( + python_times=self.amesim_results.times, + python_series_by_data_path=python_series, + amesim_results=self.amesim_results, + data_paths=data_paths, + ) + + self.assertEqual(len(comparison.metrics), 2) + self.assertEqual(comparison.max_abs_error, 0.0) + self.assertEqual(comparison.metric("temp3@pn_c1_8").sample_count, 1002) + + def test_coarse_python_times_are_interpolated_against_amesim(self) -> None: + data_path = "temp3@pn_c1_8" + python_times = self.amesim_results.times[::100] + python_series = { + data_path: tuple( + interpolate_series_value( + self.amesim_results.times, + self.amesim_results.series(data_path), + time_value, + ) + for time_value in python_times + ) + } + + comparison = compare_test_mql_series( + python_times=python_times, + python_series_by_data_path=python_series, + amesim_results=self.amesim_results, + data_paths=(data_path,), + ) + + self.assertEqual(comparison.metric(data_path).sample_count, len(python_times)) + self.assertEqual(comparison.max_abs_error, 0.0) + + def test_offset_series_reports_abs_and_relative_error(self) -> None: + data_path = "temp3@pn_c1_8" + python_series = { + data_path: tuple(value + 1.0 for value in self.amesim_results.series(data_path)) + } + + comparison = compare_test_mql_series( + python_times=self.amesim_results.times, + python_series_by_data_path=python_series, + amesim_results=self.amesim_results, + data_paths=(data_path,), + ) + metric = comparison.metric(data_path) + + self.assertAlmostEqual(metric.max_abs_error, 1.0) + self.assertAlmostEqual(metric.mean_abs_error, 1.0) + self.assertGreater(metric.max_rel_error, 0.0) + + def test_missing_data_path_is_rejected(self) -> None: + with self.assertRaises(TestMqlComparisonError): + compare_test_mql_series( + python_times=self.amesim_results.times, + python_series_by_data_path={"missing@component": (1.0,) * 1002}, + amesim_results=self.amesim_results, + ) + + def test_writes_baseline_and_comparison_csv_files(self) -> None: + data_paths = DEFAULT_TEST_MQL_ALIGNMENT_PATHS[:2] + python_series = { + data_path: self.amesim_results.series(data_path) + for data_path in data_paths + } + + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + baseline_path = write_test_mql_amesim_baseline_csv( + output_dir, + self.amesim_results, + data_paths=data_paths, + ) + comparison_path, summary_path, comparison = write_test_mql_comparison_csv( + output_dir=output_dir, + python_times=self.amesim_results.times, + python_series_by_data_path=python_series, + amesim_results=self.amesim_results, + data_paths=data_paths, + ) + + self.assertTrue(baseline_path.exists()) + self.assertTrue(comparison_path.exists()) + self.assertTrue(summary_path.exists()) + self.assertIn("time_s,temp3@pn_c1_8,press3@pn_c1_8", baseline_path.read_text(encoding="utf-8")) + self.assertEqual(comparison.max_abs_error, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_computed.py b/tests/test_test_mql_computed.py new file mode 100644 index 0000000..96b5c18 --- /dev/null +++ b/tests/test_test_mql_computed.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.systems.test_mql_computed import ( + run_test_mql_computed_geometry, + run_test_mql_computed_line_relations, + run_test_mql_computed_mechanical_relations, + run_test_mql_computed_piston_geometry, + run_test_mql_computed_pneumatic_relations, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlComputedPistonGeometryTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.computed_run = run_test_mql_computed_piston_geometry(TEST_MQL_AME) + + def test_computed_piston_geometry_outputs_schema_valid_series(self) -> None: + self.assertEqual(self.computed_run.sample_count, 1002) + self.assertEqual(len(self.computed_run.mechanical_assembly.pistons), 8) + self.assertEqual(self.computed_run.signal_count, 24) + self.assertEqual(len(self.computed_run.comparison.metrics), 24) + self.assertEqual(self.computed_run.output_schema.signal_count, 858) + + def test_computed_piston_geometry_output_paths_are_expected(self) -> None: + paths = self.computed_run.output.data_paths + + self.assertIn("length@pn_brp2_8", paths) + self.assertIn("vol1@pn_brp2_8", paths) + self.assertIn("vvol1@pn_brp2_8", paths) + self.assertIn("length@pn_brp2_15", paths) + self.assertEqual(len([path for path in paths if path.startswith("length@")]), 8) + self.assertEqual(len([path for path in paths if path.startswith("vol1@")]), 8) + self.assertEqual(len([path for path in paths if path.startswith("vvol1@")]), 8) + + def test_computed_piston_geometry_matches_amesim_with_tight_error_bounds(self) -> None: + for data_path in self.computed_run.output.data_paths: + metric = self.computed_run.comparison.metric(data_path) + with self.subTest(data_path=data_path): + if data_path.startswith("length@"): + self.assertLessEqual(metric.max_abs_error, 3.0e-13) + elif data_path.startswith("vol1@"): + self.assertLessEqual(metric.max_abs_error, 8.0e-12) + elif data_path.startswith("vvol1@"): + self.assertLessEqual(metric.max_abs_error, 3.0e-11) + else: + self.fail(f"Unexpected computed piston geometry path: {data_path}") + + def test_computed_piston_geometry_final_samples_match_expected_values(self) -> None: + self.assertAlmostEqual( + self.computed_run.output.series("length@pn_brp2_8")[-1], + 1090.0013536465842, + ) + self.assertAlmostEqual( + self.computed_run.output.series("vol1@pn_brp2_8")[-1], + 34242.54636512914, + delta=1.0e-9, + ) + self.assertAlmostEqual( + self.computed_run.output.series("vvol1@pn_brp2_8")[-1], + 8.360141002531034e-07, + delta=1.0e-11, + ) + + +class TestMqlComputedGeometryTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.computed_run = run_test_mql_computed_geometry(TEST_MQL_AME) + + def test_computed_geometry_outputs_schema_valid_series(self) -> None: + self.assertEqual(self.computed_run.sample_count, 1002) + self.assertEqual(len(self.computed_run.mechanical_assembly.pistons), 8) + self.assertEqual(len(self.computed_run.pneumatic_assembly.variable_chambers), 8) + self.assertEqual(self.computed_run.signal_count, 32) + self.assertEqual(len(self.computed_run.comparison.metrics), 32) + self.assertEqual(self.computed_run.output_schema.signal_count, 858) + + def test_computed_geometry_includes_variable_chamber_volume_paths(self) -> None: + paths = self.computed_run.output.data_paths + + self.assertIn("vol@pn_c1_8", paths) + self.assertIn("vol@pn_c1_15", paths) + self.assertEqual(len([path for path in paths if path.startswith("vol@pn_c1")]), 8) + self.assertEqual(len([path for path in paths if path.startswith("length@")]), 8) + self.assertEqual(len([path for path in paths if path.startswith("vol1@")]), 8) + self.assertEqual(len([path for path in paths if path.startswith("vvol1@")]), 8) + + def test_computed_variable_chamber_volumes_match_amesim_with_tight_error_bounds(self) -> None: + for data_path in self.computed_run.output.data_paths: + metric = self.computed_run.comparison.metric(data_path) + with self.subTest(data_path=data_path): + if data_path.startswith("vol@pn_c1"): + self.assertLessEqual(metric.max_abs_error, 8.0e-12) + + def test_computed_variable_chamber_volume_final_samples_match_expected_values(self) -> None: + self.assertAlmostEqual( + self.computed_run.output.series("vol@pn_c1_8")[-1], + 49242.54636512914, + delta=1.0e-9, + ) + self.assertAlmostEqual( + self.computed_run.output.series("vol@pn_c1_15")[-1], + 49242.546365149814, + delta=1.0e-9, + ) + + +class TestMqlComputedLineRelationsTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.computed_run = run_test_mql_computed_line_relations(TEST_MQL_AME) + + def test_computed_line_relations_outputs_schema_valid_series(self) -> None: + self.assertEqual(self.computed_run.sample_count, 1002) + self.assertEqual(len(self.computed_run.observation_catalog.lines.by_submodel("PNL00R")), 4) + self.assertEqual(self.computed_run.signal_count, 8) + self.assertEqual(len(self.computed_run.comparison.metrics), 8) + self.assertEqual(self.computed_run.output_schema.signal_count, 858) + + def test_computed_line_relations_include_expected_paths(self) -> None: + paths = self.computed_run.output.data_paths + + self.assertIn("dm2@pneumatic_100", paths) + self.assertIn("dh2@pneumatic_100", paths) + self.assertEqual(len([path for path in paths if path.startswith("dm2@")]), 4) + self.assertEqual(len([path for path in paths if path.startswith("dh2@")]), 4) + + def test_computed_line_relations_match_amesim_exactly(self) -> None: + for data_path in self.computed_run.output.data_paths: + metric = self.computed_run.comparison.metric(data_path) + with self.subTest(data_path=data_path): + self.assertEqual(metric.max_abs_error, 0.0) + + def test_computed_line_relations_final_samples_match_expected_values(self) -> None: + self.assertAlmostEqual( + self.computed_run.output.series("dm2@pneumatic_100")[-1], + 1.4837698580885316e-30, + ) + self.assertAlmostEqual( + self.computed_run.output.series("dh2@pneumatic_100")[-1], + -1.2638893078048237e-28, + ) + + +class TestMqlComputedPneumaticRelationsTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.computed_run = run_test_mql_computed_pneumatic_relations(TEST_MQL_AME) + + def test_computed_pneumatic_relations_outputs_schema_valid_series(self) -> None: + self.assertEqual(self.computed_run.sample_count, 1002) + self.assertEqual(self.computed_run.observation_catalog.chambers.fixed_count, 4) + self.assertEqual(self.computed_run.observation_catalog.chambers.variable_count, 8) + self.assertEqual(self.computed_run.observation_catalog.orifices.fixed_count, 8) + self.assertEqual(self.computed_run.observation_catalog.orifices.variable_count, 8) + self.assertEqual(self.computed_run.signal_count, 88) + self.assertEqual(len(self.computed_run.comparison.metrics), 88) + self.assertEqual(self.computed_run.output_schema.signal_count, 858) + + def test_computed_pneumatic_relations_include_expected_paths(self) -> None: + paths = self.computed_run.output.data_paths + + self.assertIn("press2@pn_c1_8", paths) + self.assertIn("temp2@pn_c1_8", paths) + self.assertIn("dm2@pn_orifice_18", paths) + self.assertIn("dh2@pn_orifice_18", paths) + self.assertEqual(len([path for path in paths if path.startswith("press")]), 28) + self.assertEqual(len([path for path in paths if path.startswith("temp")]), 28) + self.assertEqual(len([path for path in paths if path.startswith("dm")]), 16) + self.assertEqual(len([path for path in paths if path.startswith("dh")]), 16) + + def test_computed_pneumatic_relations_match_amesim_exactly(self) -> None: + for data_path in self.computed_run.output.data_paths: + metric = self.computed_run.comparison.metric(data_path) + with self.subTest(data_path=data_path): + self.assertEqual(metric.max_abs_error, 0.0) + + def test_computed_pneumatic_relations_final_samples_match_expected_values(self) -> None: + self.assertAlmostEqual( + self.computed_run.output.series("press2@pn_c1_8")[-1], + 4310310.457796034, + ) + self.assertAlmostEqual( + self.computed_run.output.series("temp2@pn_c1_8")[-1], + 293.1143259012449, + ) + self.assertAlmostEqual( + self.computed_run.output.series("dm2@pn_orifice_18")[-1], + -0.009097746783809915, + ) + self.assertAlmostEqual( + self.computed_run.output.series("dh2@pn_orifice_18")[-1], + 1.1281175640493923, + ) + + +class TestMqlComputedMechanicalRelationsTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.computed_run = run_test_mql_computed_mechanical_relations(TEST_MQL_AME) + + def test_computed_mechanical_relations_outputs_schema_valid_series(self) -> None: + self.assertEqual(self.computed_run.sample_count, 1002) + self.assertEqual(len(self.computed_run.mechanical_assembly.masses), 10) + self.assertEqual(self.computed_run.signal_count, 76) + self.assertEqual(len(self.computed_run.comparison.metrics), 76) + self.assertEqual(self.computed_run.output_schema.signal_count, 858) + + def test_computed_mechanical_relations_include_expected_paths(self) -> None: + paths = self.computed_run.output.data_paths + + self.assertIn("x1dup@mass_friction_endstops_10", paths) + self.assertIn("v1dup@mass_friction_endstops_10", paths) + self.assertIn("acc1dup@mass_friction_endstops_10", paths) + self.assertIn("Fmin@mass_friction_endstops_10", paths) + self.assertIn("Fvisc@mass_friction_endstops_10", paths) + self.assertIn("Ffric@mass_friction_endstops_10", paths) + self.assertIn("fzero@zeroforcesource_17", paths) + self.assertEqual(len([path for path in paths if path.startswith("x1dup@")]), 10) + self.assertEqual(len([path for path in paths if path.startswith("v1dup@")]), 10) + self.assertEqual(len([path for path in paths if path.startswith("acc1dup@")]), 10) + self.assertEqual(len([path for path in paths if path.startswith("Fmin@")]), 10) + self.assertEqual(len([path for path in paths if path.startswith("Fvisc@")]), 10) + self.assertEqual(len([path for path in paths if path.startswith("Ffric@")]), 10) + self.assertEqual(len([path for path in paths if path.startswith("fzero@")]), 16) + + def test_computed_mechanical_relations_match_amesim_exactly(self) -> None: + for data_path in self.computed_run.output.data_paths: + metric = self.computed_run.comparison.metric(data_path) + with self.subTest(data_path=data_path): + self.assertEqual(metric.max_abs_error, 0.0) + + def test_computed_mechanical_relations_final_samples_match_expected_values(self) -> None: + self.assertAlmostEqual( + self.computed_run.output.series("x1dup@mass_friction_endstops_10")[-1], + -0.3700013536465843, + ) + self.assertAlmostEqual( + self.computed_run.output.series("v1dup@mass_friction_endstops_10")[-1], + -4.435303434246023e-10, + ) + self.assertAlmostEqual( + self.computed_run.output.series("acc1dup@mass_friction_endstops_10")[-1], + 3.5662378650158644e-07, + ) + self.assertEqual( + self.computed_run.output.series("Fmin@mass_friction_endstops_10")[-1], + 0.0, + ) + self.assertEqual( + self.computed_run.output.series("Fvisc@mass_friction_endstops_10")[-1], + 0.0, + ) + self.assertEqual( + self.computed_run.output.series("Ffric@mass_friction_endstops_10")[-1], + 0.0, + ) + self.assertEqual( + self.computed_run.output.series("fzero@zeroforcesource_17")[-1], + 0.0, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_config.py b/tests/test_test_mql_config.py new file mode 100644 index 0000000..d16ab58 --- /dev/null +++ b/tests/test_test_mql_config.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import unittest + +from PythonModels.systems.test_mql_config import TestMqlConfig, resolve_numeric_expression + + +class TestMqlConfigTest(unittest.TestCase): + def test_resolves_global_parameters(self) -> None: + config = TestMqlConfig.from_amesim_specs() + + self.assertEqual(config.global_parameters["D1"], 20.0) + self.assertEqual(config.global_parameters["D2"], 20.0) + self.assertEqual(config.global_parameters["D3"], 14.0) + self.assertEqual(config.global_parameters["P0"], 153.0) + self.assertEqual(config.global_parameters["Pdq"], 1.0) + self.assertEqual(config.global_parameters["V"], 15.0) + self.assertEqual(config.global_parameters["cf"], 0.45) + self.assertEqual(config.fluid.name, "helium") + self.assertAlmostEqual(config.fluid.specific_gas_constant, 2077.3, delta=0.5) + + def test_resolves_amesim_parameter_expressions(self) -> None: + variables = {"D2": 20.0, "P0": 153.0, "cf": 0.45} + + self.assertAlmostEqual(resolve_numeric_expression("3.14*10^2/4", variables), 78.5) + self.assertAlmostEqual(resolve_numeric_expression("0.045/D2", variables), 0.00225) + self.assertAlmostEqual(resolve_numeric_expression("cf", variables), 0.45) + self.assertAlmostEqual( + resolve_numeric_expression("(P0-1.01300000000000e+00)/1.00000000000000e-05", variables), + 15198700.0, + ) + self.assertIsNone(resolve_numeric_expression("predefined nitrogen (N2)", variables)) + + def test_groups_components_by_submodel(self) -> None: + config = TestMqlConfig.from_amesim_specs() + + self.assertEqual(len(config.components_by_submodel("PNRP17")), 8) + self.assertEqual(len(config.components_by_submodel("PNCH012")), 8) + self.assertEqual(len(config.components_by_submodel("PNOR001")), 8) + self.assertEqual(len(config.components_by_submodel("PNVO001")), 8) + self.assertEqual(len(config.components_by_submodel("PNGD00")), 1) + + def test_resolves_representative_component_parameters(self) -> None: + config = TestMqlConfig.from_amesim_specs() + + piston = config.component("pn_brp2_8") + self.assertEqual(piston.submodel, "PNRP17") + self.assertEqual(piston.parameter_value("dp"), 200.0) + self.assertEqual(piston.parameter_value("dr"), 1.0) + + fixed_orifice = config.component("pn_orifice_18") + self.assertEqual(fixed_orifice.submodel, "PNOR001") + self.assertAlmostEqual(fixed_orifice.parameter_value("area"), 78.5) + + variable_orifice = config.component("pn_morifice_11") + self.assertEqual(variable_orifice.submodel, "PNVO001") + self.assertAlmostEqual(variable_orifice.parameter_value("cq"), 0.45) + self.assertAlmostEqual(variable_orifice.parameter_value("area0"), 78.5) + + chamber = config.component("pn_c1_8") + self.assertEqual(chamber.submodel, "PNCH012") + self.assertEqual(chamber.parameter_value("cvol0"), 15.0) + + gas = config.component("pn_gas_data") + self.assertEqual(gas.submodel, "PNGD00") + self.assertAlmostEqual(gas.parameter_value("rGas"), 287.2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_example_runner.py b/tests/test_test_mql_example_runner.py new file mode 100644 index 0000000..20f4648 --- /dev/null +++ b/tests/test_test_mql_example_runner.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from app.main import ReactFlowProjectPayload, run_reactflow_test_mql +from app.simulation.examples.test_mql.run import prepare_test_mql_run, run_test_mql +from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR +from PythonModels.systems.test_mql import TestMqlRunConfig + + +class TestMqlExampleRunnerTests(unittest.TestCase): + def test_default_run_paths_use_app_simulation_runs_dir(self) -> None: + prepared = prepare_test_mql_run() + + self.assertEqual(prepared.repo_root, PROJECT_ROOT) + self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR) + + def test_zero_duration_run_writes_summary(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output_dir = Path(directory) / "test_mql" + result = run_test_mql( + run_config=TestMqlRunConfig(t_start=0.0, t_stop=0.0), + output_dir=output_dir, + ) + + self.assertEqual(result.result.t, [0.0]) + self.assertEqual(result.summary_path, output_dir / "test_mql_model_summary.txt") + self.assertIn( + "Model: test_mql", + result.summary_path.read_text(encoding="utf-8"), + ) + + def test_reactflow_helper_runs_fixed_topology_test_mql(self) -> None: + payload = ReactFlowProjectPayload( + name="test-mql-fixed", + simulation={ + "t_start": 0.0, + "t_stop": 0.2, + "step": 0.1, + "max_step": 0.001, + "method": "BDF", + }, + ) + + result = run_reactflow_test_mql(payload) + + self.assertTrue(result["success"]) + self.assertEqual(result["model"]["name"], "test_mql") + self.assertEqual(result["model"]["componentCount"], 117) + self.assertEqual(result["series"]["time"], [0.0, 0.1, 0.2]) + self.assertIn("summary", result["artifacts"]) + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_line_observations.py b/tests/test_test_mql_line_observations.py new file mode 100644 index 0000000..97504ac --- /dev/null +++ b/tests/test_test_mql_line_observations.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_line_observations import ( + G_PER_S_TO_KG_PER_S, + build_test_mql_line_observation_catalog, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlLineObservationCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.catalog = build_test_mql_line_observation_catalog(cls.amesim_results) + + def test_builds_expected_line_observation_counts(self) -> None: + self.assertEqual(self.catalog.line_count, 40) + self.assertEqual(len(self.catalog.by_submodel("PNL0001")), 20) + self.assertEqual(len(self.catalog.by_submodel("PNL0002")), 8) + self.assertEqual(len(self.catalog.by_submodel("PNL0003")), 8) + self.assertEqual(len(self.catalog.by_submodel("PNL00R")), 4) + + def test_pnl0001_binding_exposes_single_flow_and_compliance_observables(self) -> None: + binding = self.catalog.by_alias("pneumatic_69") + + self.assertEqual(binding.submodel, "PNL0001") + self.assertEqual(binding.pattern, "C-R") + self.assertEqual(binding.mass_flow_paths, ("dm1@pneumatic_69",)) + self.assertEqual(binding.enthalpy_flow_paths, ("dh1@pneumatic_69",)) + self.assertEqual(binding.pressure_paths, ("p2@pneumatic_69",)) + self.assertEqual(binding.temperature_paths, ("t2@pneumatic_69",)) + self.assertEqual(binding.gas_mass_path, "mgas@pneumatic_69") + self.assertEqual(binding.reynolds_path, "re@pneumatic_69") + self.assertEqual(binding.mass_flow_parameter_path, "cm@pneumatic_69") + self.assertEqual(binding.gas_velocity_path, "v@pneumatic_69") + self.assertEqual(binding.friction_factor_path, "ff@pneumatic_69") + + def test_pnl0002_and_pnl0003_keep_distinct_flow_topologies(self) -> None: + rcr = self.catalog.by_alias("pneumatic_80") + crc = self.catalog.by_alias("pneumatic_75") + + self.assertEqual(rcr.pattern, "R-C-R") + self.assertEqual(rcr.mass_flow_paths, ("dm1@pneumatic_80", "dm2@pneumatic_80")) + self.assertEqual(rcr.enthalpy_flow_paths, ("dh1@pneumatic_80", "dh2@pneumatic_80")) + self.assertEqual(rcr.pressure_paths, ("pctr@pneumatic_80",)) + self.assertEqual(rcr.temperature_paths, ("tctr@pneumatic_80",)) + self.assertEqual(crc.pattern, "C-R-C") + self.assertEqual(crc.mass_flow_paths, ("dmctr@pneumatic_75",)) + self.assertEqual(crc.enthalpy_flow_paths, ("dhctr@pneumatic_75",)) + self.assertEqual(crc.pressure_paths, ("p1@pneumatic_75", "p2@pneumatic_75")) + self.assertEqual(crc.temperature_paths, ("t1@pneumatic_75", "t2@pneumatic_75")) + + def test_mass_flow_series_are_converted_to_si_units(self) -> None: + binding = self.catalog.by_alias("pneumatic_69") + mass_flow = binding.mass_flow_kg_s_series(self.amesim_results) + + self.assertAlmostEqual( + mass_flow[-1], + self.amesim_results.series("dm1@pneumatic_69")[-1] * G_PER_S_TO_KG_PER_S, + ) + self.assertAlmostEqual(mass_flow[-1], -9.527778353091475e-6) + self.assertAlmostEqual(max(mass_flow), 0.4490761730524304) + + def test_observation_exposes_line_state_and_diagnostics(self) -> None: + observation = self.catalog.by_alias("pneumatic_75").observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.time, self.amesim_results.times[-1]) + self.assertAlmostEqual( + observation.mass_flows_kg_s["dmctr@pneumatic_75"], + 9.486502325625855e-6, + ) + self.assertAlmostEqual( + observation.enthalpy_flows_w["dhctr@pneumatic_75"], + -0.7322143070637952, + ) + self.assertAlmostEqual(observation.pressures_pa["p1@pneumatic_75"], 4310289.672688478) + self.assertAlmostEqual(observation.pressures_pa["p2@pneumatic_75"], 4310295.447776705) + self.assertAlmostEqual(observation.temperatures_k["t1@pneumatic_75"], 281.37837485614546) + self.assertAlmostEqual(observation.temperatures_k["t2@pneumatic_75"], 282.9136549991407) + self.assertAlmostEqual(observation.gas_mass_g, 0.7031236652270819) + self.assertAlmostEqual(observation.reynolds_number, 31.539799199000434) + self.assertAlmostEqual(observation.mass_flow_parameter, 6.351754064211156e-07) + self.assertAlmostEqual(observation.gas_velocity_m_s, 0.0040585650473562935) + self.assertAlmostEqual(observation.friction_factor, 2.0291822277051246) + + def test_resistance_only_lines_preserve_reversed_duplicate_signs(self) -> None: + for binding in self.catalog.by_submodel("PNL00R"): + dm1 = self.amesim_results.series(f"dm1@{binding.alias}") + dm2 = self.amesim_results.series(f"dm2@{binding.alias}") + dh1 = self.amesim_results.series(f"dh1@{binding.alias}") + dh2 = self.amesim_results.series(f"dh2@{binding.alias}") + with self.subTest(alias=binding.alias): + self.assertIsNone(binding.gas_mass_path) + self.assertEqual(binding.pressure_paths, ()) + self.assertEqual(binding.temperature_paths, ()) + self.assertLess(max(abs(a + b) for a, b in zip(dm1, dm2)), 1.0e-36) + self.assertLess(max(abs(a + b) for a, b in zip(dh1, dh2)), 1.0e-34) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_lines.py b/tests/test_test_mql_lines.py new file mode 100644 index 0000000..069d0de --- /dev/null +++ b/tests/test_test_mql_lines.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog +from PythonModels.systems.test_mql_lines import build_test_mql_line_assembly + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlLineAssemblyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.variable_catalog = build_test_mql_variable_catalog(cls.amesim_results) + cls.assembly = build_test_mql_line_assembly( + cls.amesim_results, + cls.variable_catalog, + ) + + def test_assembles_expected_line_counts(self) -> None: + self.assertEqual(self.assembly.line_count, 40) + self.assertEqual( + self.assembly.counts_by_submodel(), + {"PNL0001": 20, "PNL0003": 8, "PNL0002": 8, "PNL00R": 4}, + ) + self.assertIn("pneumatic_69", self.assembly.aliases()) + self.assertIn("pneumatic_100", self.assembly.aliases()) + + def test_preserves_connection_topology(self) -> None: + line = self.assembly.by_alias("pneumatic_69") + + self.assertEqual(line.index, 37) + self.assertEqual(line.submodel, "PNL0001") + self.assertEqual(line.pattern, "C-R") + self.assertEqual(line.source_component, "pnnode4_16") + self.assertEqual(line.source_port, "port_2") + self.assertEqual(line.target_component, "pn_c1_8") + self.assertEqual(line.target_port, "port_1") + self.assertTrue(line.has_compliance) + self.assertTrue(line.has_resistance) + + def test_line_data_paths_are_attached_from_amesim_results(self) -> None: + line = self.assembly.by_alias("pneumatic_69") + + self.assertIn("dh1@pneumatic_69", line.data_paths) + self.assertIn("dm1@pneumatic_69", line.data_paths) + self.assertIn("dh1", line.signal_names) + self.assertIn("dm1", line.signal_names) + + def test_resistance_only_line_keeps_r_pattern(self) -> None: + line = self.assembly.by_alias("pneumatic_100") + + self.assertEqual(line.submodel, "PNL00R") + self.assertEqual(line.pattern, "R") + self.assertFalse(line.has_compliance) + self.assertTrue(line.has_resistance) + self.assertIn("dm1@pneumatic_100", line.data_paths) + + def test_rcr_and_crc_patterns_are_distinct(self) -> None: + self.assertEqual(self.assembly.by_alias("pneumatic_80").pattern, "R-C-R") + self.assertEqual(self.assembly.by_alias("pneumatic_75").pattern, "C-R-C") + self.assertEqual(len(self.assembly.by_submodel("PNL0002")), 8) + self.assertEqual(len(self.assembly.by_submodel("PNL0003")), 8) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_mechanical.py b/tests/test_test_mql_mechanical.py new file mode 100644 index 0000000..3d74536 --- /dev/null +++ b/tests/test_test_mql_mechanical.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.core.solver import SolveIVPConfig +from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog +from PythonModels.systems.test_mql import TestMqlSystem +from PythonModels.systems.test_mql_mechanical import ( + TestMqlMechanicalMassClosure, + build_test_mql_mechanical_assembly, + circular_area, + mm_to_m, + n_per_mm_per_s_to_n_per_m_per_s, + n_per_mm_to_n_per_m, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlMechanicalAssemblyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.variable_catalog = build_test_mql_variable_catalog(cls.amesim_results) + cls.assembly = build_test_mql_mechanical_assembly( + amesim_results=cls.amesim_results, + variable_catalog=cls.variable_catalog, + ) + + def test_assembles_expected_mechanical_component_counts(self) -> None: + self.assertEqual(len(self.assembly.pistons), 8) + self.assertEqual(len(self.assembly.masses), 10) + self.assertEqual(len(self.assembly.elastic_endstops), 8) + self.assertEqual(len(self.assembly.mechanical_nodes), 2) + self.assertEqual(len(self.assembly.piecewise_signals), 2) + self.assertEqual(len(self.assembly.force_connectors), 2) + self.assertEqual(len(self.assembly.zero_force_sources), 16) + self.assertEqual(self.assembly.component_count, 48) + + def test_piecewise_force_sources_match_amesim_samples(self) -> None: + front_signal = self.assembly.piecewise_signals["piecewiselinear_1"] + rear_signal = self.assembly.piecewise_signals["piecewiselinear"] + + self.assertAlmostEqual(front_signal.output_at(0.0), 1.0e12) + self.assertAlmostEqual(front_signal.output_at(0.8000000000000009), 0.0) + self.assertAlmostEqual(rear_signal.output_at(0.0), 1.0e17) + self.assertAlmostEqual(rear_signal.output_at(0.8000000000000009), 49000.0) + self.assertEqual( + self.assembly.force_connectors["forcecon_2"].target_mass_alias, + "mass_friction_endstops_18", + ) + self.assertEqual( + self.assembly.force_connectors["forcecon_1"].target_mass_alias, + "mass_friction_endstops_19", + ) + self.assertAlmostEqual( + self.assembly.force_connectors["forcecon_2"].force_at( + 0.0, + self.assembly.piecewise_signals, + ), + self.amesim_results.series("force@forcecon_2")[0], + ) + self.assertAlmostEqual( + self.assembly.force_connectors["forcecon_1"].force_at( + 0.8000000000000009, + self.assembly.piecewise_signals, + ), + self.amesim_results.series("force@forcecon_1")[80], + ) + + def test_mechanical_mass_closure_exposes_all_mecmas21_states(self) -> None: + closure = TestMqlMechanicalMassClosure(self.assembly) + state = closure.initial_state_vector() + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + + self.assertEqual(closure.mass_aliases, tuple(self.assembly.masses)) + self.assertEqual(len(closure.mass_aliases), 10) + self.assertEqual(len(state), 20) + self.assertEqual(snapshot.state_count, 20) + self.assertEqual(len(rhs), 20) + self.assertEqual(snapshot.states[0].alias, "mass_friction_endstops_10") + self.assertAlmostEqual( + snapshot.states[0].velocity_m_s, + self.amesim_results.series("v1@mass_friction_endstops_10")[0], + ) + self.assertAlmostEqual( + snapshot.states[0].displacement_m, + self.amesim_results.series("x1@mass_friction_endstops_10")[0], + ) + for index, mass_state in enumerate(snapshot.states): + self.assertAlmostEqual(rhs[2 * index + 1], mass_state.velocity_m_s) + + def test_mechanical_mass_snapshot_exposes_lmechan1_node_kinematics(self) -> None: + closure = TestMqlMechanicalMassClosure(self.assembly) + state = closure.initial_state_vector() + state[16] = 0.2 + state[17] = 0.37 + state[18] = -0.3 + state[19] = -0.72 + + snapshot = closure.snapshot(state) + front = snapshot.node_kinematics_by_alias[ + "dynamic_mechanical_node_alternative_2" + ] + rear = snapshot.node_kinematics_by_alias[ + "dynamic_mechanical_node_alternative_3" + ] + + self.assertEqual(set(front.velocities_m_s), set(range(1, 9))) + self.assertEqual(set(front.displacements_m), set(range(1, 9))) + self.assertEqual(set(rear.velocities_m_s), set(range(1, 9))) + self.assertEqual(set(rear.displacements_m), set(range(1, 9))) + self.assertAlmostEqual(front.velocities_m_s[1], -0.2) + self.assertAlmostEqual(front.displacements_m[8], -0.37) + self.assertAlmostEqual(rear.velocities_m_s[1], -0.3) + self.assertAlmostEqual(rear.displacements_m[8], -0.72) + + def test_mechanical_mass_snapshot_exposes_piston_kinematics(self) -> None: + closure = TestMqlMechanicalMassClosure(self.assembly) + state = closure.initial_state_vector() + state[0] = 0.4 + state[1] = 0.37 + state[18] = -0.3 + state[19] = -0.72 + + snapshot = closure.snapshot(state) + piston = snapshot.piston_kinematics_by_alias["pn_brp2_8"] + + self.assertEqual(set(snapshot.piston_kinematics_by_alias), set(self.assembly.pistons)) + self.assertAlmostEqual(piston.port_2_velocity_m_s, 0.4) + self.assertAlmostEqual(piston.port_2_displacement_m, 0.37) + self.assertAlmostEqual(piston.port_3_velocity_m_s, -0.3) + self.assertAlmostEqual(piston.port_3_displacement_m, -0.72) + self.assertAlmostEqual( + self.assembly.pistons["pn_brp2_8"].geometry().chamber_length_mm( + piston.port_3_displacement_m, + piston.port_2_displacement_m, + ), + 1090.0, + ) + + def test_system_simulates_mechanical_mass_state_closure(self) -> None: + system = TestMqlSystem() + + solution = system.simulate_mechanical_masses( + config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), + t_eval=[0.0, 1.0e-5], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(system.mechanical_state_vector()), 20) + self.assertEqual(len(solution.t), 2) + self.assertEqual(len(solution.y), 20) + self.assertEqual([len(row) for row in solution.y], [2] * 20) + + def test_piston_geometry_is_converted_to_si_units(self) -> None: + piston = self.assembly.pistons["pn_brp2_8"] + + self.assertAlmostEqual(piston.piston_diameter_m, 0.2) + self.assertAlmostEqual(piston.rod_diameter_m, 0.001) + self.assertAlmostEqual(piston.zero_displacement_m, 0.0) + self.assertAlmostEqual(piston.piston_area_m2, circular_area(0.2)) + self.assertAlmostEqual(piston.rod_area_m2, circular_area(0.001)) + self.assertAlmostEqual(piston.annulus_area_m2, piston.piston_area_m2 - piston.rod_area_m2) + self.assertIn("vvol1@pn_brp2_8", piston.data_paths) + self.assertIn("f2@pn_brp2_8", piston.data_paths) + + def test_mass_endstop_parameters_are_converted_to_si_units(self) -> None: + mass = self.assembly.masses["mass_friction_endstops_10"] + + self.assertEqual(mass.mass_kg, 50.0) + self.assertEqual(mass.xmin_m, -1.0) + self.assertEqual(mass.xmax_m, 0.8) + self.assertEqual(mass.min_stiffness_n_per_m, 1.0e9) + self.assertEqual(mass.max_stiffness_n_per_m, 1.0e9) + self.assertEqual(mass.min_damping_n_per_m_per_s, 1.0e4) + self.assertEqual(mass.max_damping_n_per_m_per_s, 1.0e4) + self.assertAlmostEqual(mass.min_penetration_m, 1.0e-4) + self.assertAlmostEqual(mass.max_penetration_m, 1.0e-4) + self.assertEqual(mass.stiction_force_n, 0.0) + self.assertEqual(mass.coulomb_friction_n, 0.0) + self.assertEqual(mass.viscous_friction_n_per_m_per_s, 0.0) + self.assertEqual(mass.windage_n_per_m2_per_s2, 0.0) + self.assertEqual(mass.stick_velocity_threshold_m_s, 1.0e-6) + self.assertEqual(mass.reset_velocity_threshold_m_s, 1.0e-6) + self.assertEqual(mass.rest_coeff, 0.65) + self.assertEqual(mass.stribeck_constant_m_s, 1.0e-3) + self.assertTrue(mass.use_friction) + self.assertEqual(mass.stop_type, 4) + self.assertIn("v1@mass_friction_endstops_10", mass.data_paths) + self.assertIn("x1@mass_friction_endstops_10", mass.data_paths) + + def test_large_load_masses_keep_distinct_limits(self) -> None: + front = self.assembly.masses["mass_friction_endstops_18"] + rear = self.assembly.masses["mass_friction_endstops_19"] + + self.assertEqual(front.mass_kg, 170000.0) + self.assertEqual(front.xmin_m, 0.0) + self.assertEqual(front.xmax_m, 0.37) + self.assertEqual(rear.mass_kg, 90000.0) + self.assertEqual(rear.xmin_m, -0.72) + self.assertEqual(rear.xmax_m, 0.0) + + def test_elastic_endstop_parameters_are_converted_to_si_units(self) -> None: + endstop = self.assembly.elastic_endstops["elasticendstop_8"] + + self.assertEqual(endstop.gap_m, 0.0) + self.assertEqual(endstop.contact_stiffness_n_per_m, 1.0e11) + self.assertEqual(endstop.contact_damping_n_per_m_per_s, 1.0e11) + self.assertAlmostEqual(endstop.spring_diameter_m, 0.02) + self.assertAlmostEqual(endstop.wire_diameter_m, 0.002) + + def test_mechanical_nodes_keep_port_count(self) -> None: + node = self.assembly.mechanical_nodes["dynamic_mechanical_node_alternative_2"] + + self.assertEqual(node.port_count, 8) + self.assertEqual(node.sum_mode, 1) + self.assertIn("dynamic_mechanical_node_alternative_2", self.assembly.aliases) + + + def test_all_piston_geometry_observables_match_amesim_final_sample(self) -> None: + for alias, piston in self.assembly.pistons.items(): + with self.subTest(alias=alias): + geometry = piston.geometry() + x4 = self.amesim_results.series(f"x4@{alias}")[-1] + x5 = self.amesim_results.series(f"x5@{alias}")[-1] + v4 = self.amesim_results.series(f"v4@{alias}")[-1] + v5 = self.amesim_results.series(f"v5@{alias}")[-1] + + self.assertAlmostEqual( + geometry.chamber_length_mm(x4, x5), + self.amesim_results.series(f"length@{alias}")[-1], + ) + self.assertAlmostEqual( + geometry.chamber_volume_cm3(x4, x5), + self.amesim_results.series(f"vol1@{alias}")[-1], + delta=1.0e-6, + ) + self.assertAlmostEqual( + geometry.chamber_volume_rate_l_min(v4, v5), + self.amesim_results.series(f"vvol1@{alias}")[-1], + delta=1.0e-8, + ) + + def test_all_elastic_endstop_forces_match_amesim_final_sample(self) -> None: + for endstop_alias, endstop_spec in self.assembly.elastic_endstops.items(): + piston_alias = endstop_alias.replace("elasticendstop", "pn_brp2") + with self.subTest(alias=endstop_alias): + endstop = endstop_spec.endstop() + gap_mm = self.amesim_results.series(f"gap@{endstop_alias}")[-1] + penetration_velocity_m_s = self.amesim_results.series(f"v5@{piston_alias}")[-1] + + self.assertAlmostEqual( + endstop.contact_force(gap_mm, penetration_velocity_m_s), + self.amesim_results.series(f"f1@{endstop_alias}")[-1], + delta=1.0e-3, + ) + + def test_mass_endstop_second_port_observables_are_opposite_sign(self) -> None: + for alias in self.assembly.masses: + with self.subTest(alias=alias): + for signal in ("x1", "v1", "acc1"): + primary = self.amesim_results.series(f"{signal}@{alias}") + second_port = self.amesim_results.series(f"{signal}dup@{alias}") + for index in (0, len(primary) // 2, -1): + self.assertAlmostEqual(primary[index], -second_port[index]) + + def test_mass_endstop_force_observables_match_inactive_amesim_contacts(self) -> None: + for alias, mass_spec in self.assembly.masses.items(): + endstop = mass_spec.endstop() + with self.subTest(alias=alias): + x1 = self.amesim_results.series(f"x1@{alias}")[-1] + v1 = self.amesim_results.series(f"v1@{alias}")[-1] + + self.assertAlmostEqual( + endstop.viscous_friction_force(v1), + self.amesim_results.series(f"Fvisc@{alias}")[-1], + ) + self.assertAlmostEqual(self.amesim_results.series(f"Ffric@{alias}")[-1], 0.0) + self.assertAlmostEqual(self.amesim_results.series(f"Fmin@{alias}")[-1], 0.0) + self.assertAlmostEqual(self.amesim_results.series(f"Fmax@{alias}")[-1], 0.0) + self.assertLessEqual(endstop.lower_penetration_m(x1), 1.0e-12) + self.assertLessEqual(endstop.upper_penetration_m(x1), 1.0e-12) + + def test_unit_helpers(self) -> None: + self.assertAlmostEqual(mm_to_m(20.0), 0.02) + self.assertAlmostEqual(n_per_mm_to_n_per_m(1.0e6), 1.0e9) + self.assertAlmostEqual(n_per_mm_per_s_to_n_per_m_per_s(10.0), 1.0e4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_mechanical_observations.py b/tests/test_test_mql_mechanical_observations.py new file mode 100644 index 0000000..9cb83f1 --- /dev/null +++ b/tests/test_test_mql_mechanical_observations.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_mechanical_observations import ( + build_test_mql_mechanical_observation_catalog, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlMechanicalObservationCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.catalog = build_test_mql_mechanical_observation_catalog(cls.amesim_results) + + def test_builds_expected_mechanical_observation_counts(self) -> None: + self.assertEqual(len(self.catalog.pistons), 8) + self.assertEqual(len(self.catalog.masses), 10) + self.assertEqual(len(self.catalog.elastic_endstops), 8) + self.assertEqual(len(self.catalog.zero_force_sources), 16) + self.assertEqual(len(self.catalog.force_connectors), 2) + self.assertEqual(len(self.catalog.mechanical_nodes), 2) + self.assertEqual(self.catalog.binding_count, 46) + + def test_piston_binding_exposes_amesim_data_paths(self) -> None: + binding = self.catalog.pistons["pn_brp2_8"] + + self.assertEqual(binding.volume_path, "vol1@pn_brp2_8") + self.assertEqual(binding.volume_rate_path, "vvol1@pn_brp2_8") + self.assertEqual(binding.length_path, "length@pn_brp2_8") + self.assertEqual(binding.force_port_2_path, "f2@pn_brp2_8") + self.assertEqual(binding.force_port_3_path, "f3@pn_brp2_8") + self.assertEqual(binding.displacement_port_2_path, "x5@pn_brp2_8") + self.assertEqual(binding.velocity_port_2_path, "v5@pn_brp2_8") + self.assertEqual(binding.displacement_port_3_path, "x4@pn_brp2_8") + self.assertEqual(binding.velocity_port_3_path, "v4@pn_brp2_8") + + def test_piston_observation_matches_amesim_final_sample(self) -> None: + observation = self.catalog.pistons["pn_brp2_8"].observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.time, self.amesim_results.times[-1]) + self.assertAlmostEqual(observation.chamber_volume_cm3, 34242.54636512914) + self.assertAlmostEqual(observation.chamber_volume_rate_l_min, 8.360141002531034e-07) + self.assertAlmostEqual(observation.chamber_length_mm, 1090.0013536465842) + self.assertAlmostEqual(observation.displacement_port_2_m, 0.3700013536465843) + self.assertAlmostEqual(observation.velocity_port_2_m_s, 4.435303434246023e-10) + self.assertAlmostEqual(observation.displacement_port_3_m, -0.72) + self.assertAlmostEqual(observation.velocity_port_3_m_s, 0.0) + + def test_mass_binding_exposes_primary_and_reversed_duplicate_paths(self) -> None: + binding = self.catalog.masses["mass_friction_endstops_10"] + + self.assertEqual(binding.displacement_path, "x1@mass_friction_endstops_10") + self.assertEqual(binding.velocity_path, "v1@mass_friction_endstops_10") + self.assertEqual(binding.acceleration_path, "acc1@mass_friction_endstops_10") + self.assertEqual(binding.displacement_duplicate_path, "x1dup@mass_friction_endstops_10") + self.assertEqual(binding.velocity_duplicate_path, "v1dup@mass_friction_endstops_10") + self.assertEqual(binding.acceleration_duplicate_path, "acc1dup@mass_friction_endstops_10") + self.assertEqual(binding.lower_contact_force_path, "Fmin@mass_friction_endstops_10") + self.assertEqual(binding.upper_contact_force_path, "Fmax@mass_friction_endstops_10") + self.assertEqual(binding.viscous_friction_force_path, "Fvisc@mass_friction_endstops_10") + self.assertEqual(binding.dry_friction_force_path, "Ffric@mass_friction_endstops_10") + self.assertEqual(binding.stick_flag_path, "stick@mass_friction_endstops_10") + + def test_mass_observation_matches_amesim_final_sample(self) -> None: + observation = self.catalog.masses["mass_friction_endstops_10"].observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.displacement_m, 0.3700013536465843) + self.assertAlmostEqual(observation.velocity_m_s, 4.435303434246023e-10) + self.assertAlmostEqual(observation.acceleration_m_s2, -3.5662378650158644e-07) + self.assertAlmostEqual(observation.lower_contact_force_n, 0.0) + self.assertAlmostEqual(observation.upper_contact_force_n, 0.0) + self.assertAlmostEqual(observation.viscous_friction_force_n, 0.0) + self.assertAlmostEqual(observation.dry_friction_force_n, 0.0) + self.assertAlmostEqual(observation.stick_flag, 0.0) + + def test_mass_duplicate_series_are_sign_reversed(self) -> None: + for binding in self.catalog.masses.values(): + with self.subTest(alias=binding.alias): + for primary_path, duplicate_path in ( + (binding.displacement_path, binding.displacement_duplicate_path), + (binding.velocity_path, binding.velocity_duplicate_path), + (binding.acceleration_path, binding.acceleration_duplicate_path), + ): + primary = self.amesim_results.series(primary_path) + duplicate = self.amesim_results.series(duplicate_path) + self.assertLess( + max(abs(a + b) for a, b in zip(primary, duplicate)), + 1.0e-12, + ) + + def test_elastic_endstop_observation_matches_amesim_final_sample(self) -> None: + observation = self.catalog.elastic_endstops["elasticendstop_8"].observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.force_n, 135409.0113969468) + self.assertAlmostEqual(observation.duplicate_force_n, 135409.0113969468) + self.assertAlmostEqual(observation.gap_mm, -0.0013536465842123313) + self.assertAlmostEqual(observation.stiffness_n_m, 100000000000.0) + + def test_elastic_endstop_duplicate_force_matches_primary_series(self) -> None: + for binding in self.catalog.elastic_endstops.values(): + force = self.amesim_results.series(binding.force_path) + duplicate = self.amesim_results.series(binding.duplicate_force_path) + with self.subTest(alias=binding.alias): + self.assertLess( + max(abs(a - b) for a, b in zip(force, duplicate)), + 1.0e-9, + ) + + def test_zero_force_source_observations_are_zero(self) -> None: + for binding in self.catalog.zero_force_sources.values(): + force = self.amesim_results.series(binding.force_path) + with self.subTest(alias=binding.alias): + self.assertEqual(binding.force_path, f"fzero@{binding.alias}") + self.assertEqual(min(force), 0.0) + self.assertEqual(max(force), 0.0) + self.assertEqual(binding.observation_at(self.amesim_results, -1).force_n, 0.0) + + def test_force_connector_observations_match_amesim_samples(self) -> None: + force_1 = self.catalog.force_connectors["forcecon_1"].observation_at( + self.amesim_results, + -1, + ) + force_2 = self.catalog.force_connectors["forcecon_2"].observation_at( + self.amesim_results, + -1, + ) + + self.assertEqual(self.catalog.force_connectors["forcecon_1"].force_path, "force@forcecon_1") + self.assertEqual(self.catalog.force_connectors["forcecon_2"].force_path, "force@forcecon_2") + self.assertAlmostEqual(force_1.force_n, 49000.0) + self.assertAlmostEqual(force_2.force_n, 0.0) + self.assertAlmostEqual( + self.amesim_results.series("force@forcecon_1")[0], + 1.0e17, + ) + self.assertAlmostEqual( + self.amesim_results.series("force@forcecon_2")[0], + 1.0e12, + ) + + def test_mechanical_node_bindings_expose_all_ports(self) -> None: + node = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_2"] + + self.assertEqual(sorted(node.velocity_paths_by_port), list(range(1, 9))) + self.assertEqual(sorted(node.displacement_paths_by_port), list(range(1, 9))) + self.assertEqual(node.velocity_paths_by_port[1], "p1__vt@dynamic_mechanical_node_alternative_2") + self.assertEqual(node.displacement_paths_by_port[8], "p8__xt@dynamic_mechanical_node_alternative_2") + self.assertEqual(node.total_force_path, "tforce@dynamic_mechanical_node_alternative_2") + + def test_mechanical_node_observations_match_amesim_final_samples(self) -> None: + front = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_2"].observation_at( + self.amesim_results, + -1, + ) + rear = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_3"].observation_at( + self.amesim_results, + -1, + ) + + self.assertEqual(set(front.velocities_m_s), set(range(1, 9))) + self.assertEqual(set(front.displacements_m), set(range(1, 9))) + self.assertAlmostEqual(front.velocities_m_s[1], -0.0) + self.assertAlmostEqual(front.displacements_m[1], -0.3700000000000001) + self.assertAlmostEqual(front.displacements_m[8], -0.3700000000000001) + self.assertAlmostEqual(front.total_force_n, 1083272.2763054767) + self.assertAlmostEqual(rear.velocities_m_s[1], 0.0) + self.assertAlmostEqual(rear.displacements_m[1], -0.72) + self.assertAlmostEqual(rear.displacements_m[8], -0.72) + self.assertAlmostEqual(rear.total_force_n, 1083272.2762592242) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_nodes.py b/tests/test_test_mql_nodes.py new file mode 100644 index 0000000..f1e20b3 --- /dev/null +++ b/tests/test_test_mql_nodes.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.systems.test_mql import TestMqlSystem +from PythonModels.systems.test_mql_nodes import ( + build_test_mql_node3_assembly, + build_test_mql_node4_assembly, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlPneumaticNode3Tests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.nodes = build_test_mql_node3_assembly() + cls.results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_builds_all_eight_pn3node2_instances(self) -> None: + self.assertEqual(len(self.nodes), 8) + self.assertIn("pn_node3_9", self.nodes) + system = TestMqlSystem() + self.assertEqual(system.typed_node3_count, 8) + self.assertEqual(system.node3_assembly["pn_node3_9"].alias, "pn_node3_9") + + def test_port_two_flow_is_exact_sum_of_ports_one_and_three(self) -> None: + balance = self.nodes["pn_node3_9"].balance( + port_2_temperature_k=281.0, + port_2_pressure_pa=4.4e6, + port_1_enthalpy_flow_w=2.0, + port_1_mass_flow_g_s=3.0, + port_3_enthalpy_flow_w=-0.5, + port_3_mass_flow_g_s=-1.0, + port_1_volume_derivative_l_min=4.0, + port_1_volume_cm3=5.0, + port_3_volume_derivative_l_min=-1.0, + port_3_volume_cm3=6.0, + ) + + self.assertEqual(balance.temperature_k, 281.0) + self.assertEqual(balance.pressure_pa, 4.4e6) + self.assertEqual(balance.port_2_enthalpy_flow_w, 1.5) + self.assertEqual(balance.port_2_mass_flow_g_s, 2.0) + self.assertEqual(balance.port_2_volume_derivative_l_min, 3.0) + self.assertEqual(balance.port_2_volume_cm3, 11.0) + + def test_pn_node3_9_baseline_matches_adjacent_line_flow_sum(self) -> None: + index = 500 + balance = self.nodes["pn_node3_9"].balance( + port_2_temperature_k=self.results.series("t1@pneumatic_88")[index], + port_2_pressure_pa=( + self.results.series("p1@pneumatic_88")[index] + 101_300.0 + ), + port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_100")[index], + port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_100")[index], + port_3_enthalpy_flow_w=self.results.series("dh1@pneumatic_97")[index], + port_3_mass_flow_g_s=self.results.series("dm1@pneumatic_97")[index], + ) + + self.assertAlmostEqual( + balance.port_2_mass_flow_g_s, + self.results.series("dm2@pn_node3_9")[index], + ) + self.assertAlmostEqual( + balance.port_2_enthalpy_flow_w, + self.results.series("dh2@pn_node3_9")[index], + ) + self.assertEqual(self.results.series("vol2@pn_node3_9")[index], 0.0) + self.assertEqual(self.results.series("dvol2@pn_node3_9")[index], 0.0) + + def test_rejects_nonphysical_primary_pressure_or_temperature(self) -> None: + node = self.nodes["pn_node3_9"] + with self.assertRaises(ValueError): + node.balance( + port_2_temperature_k=0.0, + port_2_pressure_pa=1.0e5, + port_1_enthalpy_flow_w=0.0, + port_1_mass_flow_g_s=0.0, + port_3_enthalpy_flow_w=0.0, + port_3_mass_flow_g_s=0.0, + ) + + +class TestMqlPneumaticNode4Tests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.nodes = build_test_mql_node4_assembly() + cls.results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_builds_all_eight_p4node2_instances(self) -> None: + self.assertEqual(len(self.nodes), 8) + self.assertIn("pnnode4_17", self.nodes) + system = TestMqlSystem() + self.assertEqual(system.typed_node4_count, 8) + self.assertEqual(system.node4_assembly["pnnode4_17"].alias, "pnnode4_17") + + def test_pnnode4_17_neighborhood_maps_adjacent_p4_nodes(self) -> None: + system = TestMqlSystem() + neighborhood = system.p4_node_neighborhood("pnnode4_17") + + self.assertEqual(neighborhood.node_alias, "pnnode4_17") + self.assertEqual(neighborhood.primary.line_alias, "pneumatic_68") + self.assertEqual(neighborhood.primary.chamber_alias, "pn_c1_9") + self.assertEqual(neighborhood.port_1.line_alias, "pneumatic_86") + self.assertEqual(neighborhood.port_1.remote_node_alias, "pnnode4_18") + self.assertEqual(neighborhood.port_1.remote_port, "port_3") + self.assertEqual(neighborhood.port_3.line_alias, "pneumatic_83") + self.assertEqual(neighborhood.port_3.remote_node_alias, "pnnode4_16") + self.assertEqual(neighborhood.port_3.remote_port, "port_1") + self.assertEqual(neighborhood.port_4.orifice_alias, "pn_morifice_9") + self.assertEqual(neighborhood.port_4.direct_line_alias, "pneumatic_90") + + def test_adjacent_p4_node_primary_pressures_match_their_pnl0001_states(self) -> None: + index = 500 + + self.assertAlmostEqual( + self.results.series("press1@pnnode4_18")[index], + self.results.series("p2@pneumatic_66")[index], + ) + self.assertAlmostEqual( + self.results.series("press1@pnnode4_16")[index], + self.results.series("p2@pneumatic_69")[index], + ) + + def test_adjacent_p4_node_baselines_match_adjacent_flow_sums(self) -> None: + index = 500 + pnnode4_18 = self.nodes["pnnode4_18"].balance( + port_2_temperature_k=self.results.series("temp1@pnnode4_18")[index], + port_2_pressure_pa=( + self.results.series("press1@pnnode4_18")[index] + 101_300.0 + ), + port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_80")[index], + port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_80")[index], + port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_86")[index], + port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_86")[index], + port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_10")[index], + port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_10")[index], + ) + pnnode4_16 = self.nodes["pnnode4_16"].balance( + port_2_temperature_k=self.results.series("temp1@pnnode4_16")[index], + port_2_pressure_pa=( + self.results.series("press1@pnnode4_16")[index] + 101_300.0 + ), + port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_83")[index], + port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_83")[index], + port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_95")[index], + port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_95")[index], + port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_1")[index], + port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_1")[index], + ) + + self.assertAlmostEqual( + pnnode4_18.port_2_mass_flow_g_s, + self.results.series("dm2@pnnode4_18")[index], + ) + self.assertAlmostEqual( + pnnode4_18.port_2_enthalpy_flow_w, + self.results.series("dh2@pnnode4_18")[index], + ) + self.assertAlmostEqual( + pnnode4_16.port_2_mass_flow_g_s, + self.results.series("dm2@pnnode4_16")[index], + ) + self.assertAlmostEqual( + pnnode4_16.port_2_enthalpy_flow_w, + self.results.series("dh2@pnnode4_16")[index], + ) + + def test_port_two_flow_is_exact_sum_of_ports_one_three_and_four(self) -> None: + balance = self.nodes["pnnode4_17"].balance( + port_2_temperature_k=281.0, + port_2_pressure_pa=4.4e6, + port_1_enthalpy_flow_w=2.0, + port_1_mass_flow_g_s=3.0, + port_3_enthalpy_flow_w=-0.5, + port_3_mass_flow_g_s=-1.0, + port_4_enthalpy_flow_w=4.0, + port_4_mass_flow_g_s=5.0, + port_1_volume_derivative_l_min=4.0, + port_1_volume_cm3=5.0, + port_3_volume_derivative_l_min=-1.0, + port_3_volume_cm3=6.0, + port_4_volume_derivative_l_min=2.0, + port_4_volume_cm3=7.0, + ) + + self.assertEqual(balance.temperature_k, 281.0) + self.assertEqual(balance.pressure_pa, 4.4e6) + self.assertEqual(balance.port_2_enthalpy_flow_w, 5.5) + self.assertEqual(balance.port_2_mass_flow_g_s, 7.0) + self.assertEqual(balance.port_2_volume_derivative_l_min, 5.0) + self.assertEqual(balance.port_2_volume_cm3, 18.0) + + def test_pnnode4_17_baseline_matches_adjacent_flow_sum(self) -> None: + index = 500 + balance = self.nodes["pnnode4_17"].balance( + port_2_temperature_k=self.results.series("temp1@pnnode4_17")[index], + port_2_pressure_pa=( + self.results.series("press1@pnnode4_17")[index] + 101_300.0 + ), + port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_86")[index], + port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_86")[index], + port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_83")[index], + port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_83")[index], + port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_9")[index], + port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_9")[index], + ) + + self.assertAlmostEqual( + balance.port_2_mass_flow_g_s, + self.results.series("dm2@pnnode4_17")[index], + ) + self.assertAlmostEqual( + balance.port_2_enthalpy_flow_w, + self.results.series("dh2@pnnode4_17")[index], + ) + self.assertEqual(self.results.series("vol2@pnnode4_17")[index], 0.0) + self.assertEqual(self.results.series("dvol2@pnnode4_17")[index], 0.0) + self.assertAlmostEqual( + self.results.series("press1@pnnode4_17")[index], + self.results.series("p2@pneumatic_68")[index], + ) + + def test_rejects_nonphysical_primary_pressure_or_temperature(self) -> None: + node = self.nodes["pnnode4_17"] + with self.assertRaises(ValueError): + node.balance( + port_2_temperature_k=0.0, + port_2_pressure_pa=1.0e5, + port_1_enthalpy_flow_w=0.0, + port_1_mass_flow_g_s=0.0, + port_3_enthalpy_flow_w=0.0, + port_3_mass_flow_g_s=0.0, + port_4_enthalpy_flow_w=0.0, + port_4_mass_flow_g_s=0.0, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_observations.py b/tests/test_test_mql_observations.py new file mode 100644 index 0000000..47b3dd5 --- /dev/null +++ b/tests/test_test_mql_observations.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_comparison import compare_test_mql_series +from PythonModels.reporting.test_mql_observations import build_test_mql_observation_catalog + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlObservationCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.catalog = build_test_mql_observation_catalog(cls.amesim_results) + + def test_aggregates_observation_catalog_counts(self) -> None: + self.assertEqual(len(self.catalog.chambers.bindings), 12) + self.assertEqual(len(self.catalog.orifices.bindings), 16) + self.assertEqual(self.catalog.lines.line_count, 40) + self.assertEqual(self.catalog.mechanical.binding_count, 46) + self.assertEqual(self.catalog.binding_count, 114) + + def test_reports_observed_data_paths_by_domain(self) -> None: + paths_by_domain = self.catalog.data_paths_by_domain() + + self.assertEqual(set(paths_by_domain), {"chambers", "orifices", "lines", "mechanical"}) + self.assertEqual(len(paths_by_domain["chambers"]), 100) + self.assertEqual(len(paths_by_domain["orifices"]), 104) + self.assertEqual(len(paths_by_domain["lines"]), 388) + self.assertEqual(len(paths_by_domain["mechanical"]), 266) + self.assertEqual(len(self.catalog.data_paths()), 858) + self.assertIn("press@pn_general_chamber", paths_by_domain["chambers"]) + self.assertIn("dm1@pn_orifice_18", paths_by_domain["orifices"]) + self.assertIn("pctr@pneumatic_80", paths_by_domain["lines"]) + self.assertIn("tforce@dynamic_mechanical_node_alternative_2", paths_by_domain["mechanical"]) + + def test_all_observed_data_paths_exist_in_saved_amesim_results(self) -> None: + for data_path in self.catalog.data_paths(): + with self.subTest(data_path=data_path): + self.assertIn(data_path, self.amesim_results.series_by_data_path) + + def test_baseline_series_can_be_selected_for_comparison(self) -> None: + selected_paths = ( + "press@pn_general_chamber", + "dm1@pn_orifice_18", + "dm1@pneumatic_69", + "vol1@pn_brp2_8", + "tforce@dynamic_mechanical_node_alternative_2", + ) + baseline = self.catalog.baseline_series_by_data_path( + self.amesim_results, + selected_paths, + ) + + self.assertEqual(tuple(baseline), selected_paths) + self.assertEqual(len(baseline["press@pn_general_chamber"]), len(self.amesim_results.times)) + self.assertAlmostEqual(baseline["vol1@pn_brp2_8"][-1], 34242.54636512914) + + def test_baseline_series_rejects_unobserved_data_path(self) -> None: + with self.assertRaises(KeyError): + self.catalog.baseline_series_by_data_path( + self.amesim_results, + ("not_a_real_signal@not_a_real_owner",), + ) + + def test_observation_baseline_round_trips_through_comparison_with_zero_error(self) -> None: + selected_paths = ( + "press@pn_c1_8", + "dm2@pn_morifice_11", + "dmctr@pneumatic_75", + "x1@mass_friction_endstops_10", + ) + baseline = self.catalog.baseline_series_by_data_path( + self.amesim_results, + selected_paths, + ) + comparison = compare_test_mql_series( + python_times=self.amesim_results.times, + python_series_by_data_path=baseline, + amesim_results=self.amesim_results, + data_paths=selected_paths, + ) + + self.assertEqual(len(comparison.metrics), len(selected_paths)) + self.assertEqual(comparison.max_abs_error, 0.0) + self.assertEqual(comparison.max_rel_error, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_orifice_observations.py b/tests/test_test_mql_orifice_observations.py new file mode 100644 index 0000000..bb03c10 --- /dev/null +++ b/tests/test_test_mql_orifice_observations.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_orifice_observations import ( + G_PER_S_TO_KG_PER_S, + build_test_mql_orifice_observation_catalog, +) +from PythonModels.systems.test_mql_pneumatic import ( + TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlOrificeObservationCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.catalog = build_test_mql_orifice_observation_catalog(cls.amesim_results) + + def test_builds_expected_orifice_observation_bindings(self) -> None: + self.assertEqual(len(self.catalog.bindings), 16) + self.assertEqual(self.catalog.fixed_count, 8) + self.assertEqual(self.catalog.variable_count, 8) + + fixed = self.catalog.by_alias("pn_orifice_18") + variable = self.catalog.by_alias("pn_morifice_11") + + self.assertEqual(fixed.submodel, "PNOR001") + self.assertEqual(fixed.primary_mass_flow_path, "dm1@pn_orifice_18") + self.assertEqual(fixed.reversed_mass_flow_path, "dm2@pn_orifice_18") + self.assertEqual(fixed.primary_enthalpy_flow_path, "dh1@pn_orifice_18") + self.assertEqual(fixed.reversed_enthalpy_flow_path, "dh2@pn_orifice_18") + self.assertIsNone(fixed.opening_path) + self.assertEqual(variable.submodel, "PNVO001") + self.assertEqual(variable.primary_mass_flow_path, "dm2@pn_morifice_11") + self.assertEqual(variable.reversed_mass_flow_path, "dm3@pn_morifice_11") + self.assertEqual(variable.opening_path, "xv@pn_morifice_11") + + def test_fixed_orifice_opening_defaults_to_full_area(self) -> None: + binding = self.catalog.by_alias("pn_orifice_18") + opening = binding.opening_series(self.amesim_results) + effective_area = binding.effective_area_series(self.amesim_results) + + self.assertEqual(opening[0], 1.0) + self.assertEqual(opening[-1], 1.0) + self.assertAlmostEqual(effective_area[0], 78.5e-6) + self.assertAlmostEqual(effective_area[-1], 78.5e-6) + + def test_variable_orifice_opening_controls_effective_area(self) -> None: + binding = self.catalog.by_alias("pn_morifice_11") + first = binding.observation_at(self.amesim_results, 0) + last = binding.observation_at(self.amesim_results, -1) + + self.assertAlmostEqual(binding.nominal_area_m2, 78.5e-6) + self.assertAlmostEqual( + binding.flow_coefficient, + 0.45 * TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, + ) + self.assertEqual(first.opening, 0.0) + self.assertEqual(first.effective_area_m2, 0.0) + self.assertEqual(last.opening, 1.0) + self.assertAlmostEqual(last.effective_area_m2, 78.5e-6) + + def test_mass_flow_series_is_converted_to_si_units(self) -> None: + binding = self.catalog.by_alias("pn_orifice_18") + mass_flow = binding.mass_flow_kg_s_series(self.amesim_results) + + self.assertAlmostEqual( + mass_flow[-1], + self.amesim_results.series("dm1@pn_orifice_18")[-1] * G_PER_S_TO_KG_PER_S, + ) + self.assertAlmostEqual(mass_flow[-1], 9.097746783809915e-6) + self.assertAlmostEqual(min(mass_flow), -0.4462054699031737) + + def test_reversed_duplicate_mass_and_enthalpy_flow_signs_match_amesim(self) -> None: + for binding in self.catalog.bindings: + primary_mass = self.amesim_results.series(binding.primary_mass_flow_path) + reversed_mass = self.amesim_results.series(binding.reversed_mass_flow_path) + primary_enthalpy = self.amesim_results.series(binding.primary_enthalpy_flow_path) + reversed_enthalpy = self.amesim_results.series(binding.reversed_enthalpy_flow_path) + with self.subTest(alias=binding.alias): + self.assertEqual(len(primary_mass), len(reversed_mass)) + self.assertEqual(len(primary_enthalpy), len(reversed_enthalpy)) + self.assertLess( + max(abs(a + b) for a, b in zip(primary_mass, reversed_mass)), + 1.0e-12, + ) + self.assertLess( + max(abs(a + b) for a, b in zip(primary_enthalpy, reversed_enthalpy)), + 1.0e-9, + ) + + def test_observation_exposes_amesim_cm_and_gas_velocity(self) -> None: + fixed = self.catalog.by_alias("pn_orifice_18").observation_at(self.amesim_results, -1) + variable = self.catalog.by_alias("pn_morifice_11").observation_at(self.amesim_results, -1) + + self.assertAlmostEqual(fixed.mass_flow_parameter, 4.831077004227076e-07) + self.assertAlmostEqual(fixed.gas_velocity_m_s, 0.016760630907703977) + self.assertAlmostEqual(variable.mass_flow_parameter, 9.302013872202105e-07) + self.assertAlmostEqual(variable.gas_velocity_m_s, -0.03277314382613749) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_output_schema.py b/tests/test_test_mql_output_schema.py new file mode 100644 index 0000000..6c1b8d9 --- /dev/null +++ b/tests/test_test_mql_output_schema.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_observations import build_test_mql_observation_catalog +from PythonModels.reporting.test_mql_output_schema import build_test_mql_output_schema + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlOutputSchemaTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.observation_catalog = build_test_mql_observation_catalog(cls.amesim_results) + cls.schema = build_test_mql_output_schema( + cls.amesim_results, + observation_catalog=cls.observation_catalog, + ) + + def test_schema_contains_observed_output_data_paths(self) -> None: + self.assertEqual(self.schema.signal_count, 858) + self.assertEqual(self.schema.data_paths(), self.observation_catalog.data_paths()) + self.assertEqual(len(set(self.schema.data_paths())), self.schema.signal_count) + + def test_counts_by_domain_owner_kind_and_submodel(self) -> None: + self.assertEqual( + self.schema.counts_by_domain(), + {"chambers": 100, "lines": 388, "mechanical": 266, "orifices": 104}, + ) + self.assertEqual( + self.schema.counts_by_owner_kind(), + {"component": 470, "connection": 388}, + ) + self.assertEqual(self.schema.counts_by_submodel()["PNCH012"], 80) + self.assertEqual(self.schema.counts_by_submodel()["PNCH023"], 20) + self.assertEqual(self.schema.counts_by_submodel()["PNL0001"], 180) + self.assertEqual(self.schema.counts_by_submodel()["MECMAS21"], 110) + self.assertEqual(self.schema.counts_by_submodel()["LMECHN1"], 34) + + def test_counts_by_units_preserve_amesim_units(self) -> None: + counts = self.schema.counts_by_units() + + self.assertEqual(counts["m/s"], 108) + self.assertEqual(counts[None], 98) + self.assertEqual(counts["N"], 92) + self.assertEqual(counts["J/s"], 84) + self.assertEqual(counts["g/s"], 84) + self.assertEqual(counts["Pa"], 84) + self.assertEqual(counts["K"], 84) + self.assertEqual(counts["cm**3"], 16) + self.assertEqual(counts["L/min"], 8) + + def test_typical_signal_metadata_is_available_by_data_path(self) -> None: + chamber = self.schema.by_data_path("press@pn_general_chamber") + line = self.schema.by_data_path("dm1@pneumatic_69") + mechanical = self.schema.by_data_path("tforce@dynamic_mechanical_node_alternative_2") + + self.assertEqual(chamber.domain, "chambers") + self.assertEqual(chamber.owner_alias, "pn_general_chamber") + self.assertEqual(chamber.owner_kind, "component") + self.assertEqual(chamber.submodel, "PNCH023") + self.assertEqual(chamber.signal_name, "press") + self.assertEqual(chamber.units, "Pa") + self.assertTrue(chamber.saved) + self.assertEqual(line.domain, "lines") + self.assertEqual(line.owner_kind, "connection") + self.assertEqual(line.submodel, "PNL0001") + self.assertEqual(line.units, "g/s") + self.assertEqual(mechanical.domain, "mechanical") + self.assertEqual(mechanical.submodel, "LMECHN1") + self.assertEqual(mechanical.units, "N") + + def test_data_paths_can_be_selected_by_domain(self) -> None: + self.assertEqual(len(self.schema.data_paths_by_domain("chambers")), 100) + self.assertEqual(len(self.schema.data_paths_by_domain("orifices")), 104) + self.assertEqual(len(self.schema.data_paths_by_domain("lines")), 388) + self.assertEqual(len(self.schema.data_paths_by_domain("mechanical")), 266) + self.assertEqual(self.schema.data_paths_by_domain("missing"), ()) + + def test_unknown_data_path_raises_key_error(self) -> None: + with self.assertRaises(KeyError): + self.schema.by_data_path("not_a_signal@not_an_owner") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_output_validation.py b/tests/test_test_mql_output_validation.py new file mode 100644 index 0000000..e280371 --- /dev/null +++ b/tests/test_test_mql_output_validation.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_observations import build_test_mql_observation_catalog +from PythonModels.reporting.test_mql_output_schema import build_test_mql_output_schema +from PythonModels.reporting.test_mql_output_validation import ( + TestMqlOutputValidationError, + compare_validated_test_mql_output, + validate_test_mql_output, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlOutputValidationTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.observation_catalog = build_test_mql_observation_catalog(cls.amesim_results) + cls.schema = build_test_mql_output_schema( + cls.amesim_results, + observation_catalog=cls.observation_catalog, + ) + + def test_validates_selected_python_output_series(self) -> None: + times = [0.0, 0.1, 0.2] + series = { + "press@pn_c1_8": [1.0, 2.0, 3.0], + "vol1@pn_brp2_8": [4, 5, 6], + } + + validated = validate_test_mql_output( + times=times, + series_by_data_path=series, + schema=self.schema, + ) + + self.assertEqual(validated.times, (0.0, 0.1, 0.2)) + self.assertEqual(validated.data_paths, ("press@pn_c1_8", "vol1@pn_brp2_8")) + self.assertEqual(validated.series("press@pn_c1_8"), (1.0, 2.0, 3.0)) + self.assertEqual(validated.series("vol1@pn_brp2_8"), (4.0, 5.0, 6.0)) + + def test_can_validate_explicit_data_path_order(self) -> None: + validated = validate_test_mql_output( + times=(0.0, 0.1), + series_by_data_path={ + "press@pn_c1_8": (10.0, 11.0), + "vol1@pn_brp2_8": (20.0, 21.0), + }, + schema=self.schema, + data_paths=("vol1@pn_brp2_8", "press@pn_c1_8"), + ) + + self.assertEqual(validated.data_paths, ("vol1@pn_brp2_8", "press@pn_c1_8")) + + def test_rejects_output_paths_outside_schema_by_default(self) -> None: + with self.assertRaisesRegex(TestMqlOutputValidationError, "outside test_mql schema"): + validate_test_mql_output( + times=(0.0,), + series_by_data_path={"not_a_signal@not_an_owner": (1.0,)}, + schema=self.schema, + ) + + def test_can_ignore_extra_output_paths_when_requested(self) -> None: + validated = validate_test_mql_output( + times=(0.0,), + series_by_data_path={ + "press@pn_c1_8": (1.0,), + "not_a_signal@not_an_owner": (2.0,), + }, + schema=self.schema, + allow_extra_paths=True, + ) + + self.assertEqual(validated.data_paths, ("press@pn_c1_8",)) + + def test_rejects_missing_selected_data_path(self) -> None: + with self.assertRaisesRegex(TestMqlOutputValidationError, "missing selected"): + validate_test_mql_output( + times=(0.0,), + series_by_data_path={"press@pn_c1_8": (1.0,)}, + schema=self.schema, + data_paths=("press@pn_c1_8", "vol1@pn_brp2_8"), + ) + + def test_rejects_incomplete_full_schema_output(self) -> None: + with self.assertRaisesRegex(TestMqlOutputValidationError, "missing required"): + validate_test_mql_output( + times=(0.0,), + series_by_data_path={"press@pn_c1_8": (1.0,)}, + schema=self.schema, + require_all_schema_paths=True, + ) + + def test_rejects_length_mismatch(self) -> None: + with self.assertRaisesRegex(TestMqlOutputValidationError, "length mismatch"): + validate_test_mql_output( + times=(0.0, 0.1), + series_by_data_path={"press@pn_c1_8": (1.0,)}, + schema=self.schema, + ) + + def test_rejects_non_finite_values(self) -> None: + with self.assertRaisesRegex(TestMqlOutputValidationError, "non-finite"): + validate_test_mql_output( + times=(0.0,), + series_by_data_path={"press@pn_c1_8": (float("nan"),)}, + schema=self.schema, + ) + + def test_rejects_non_monotonic_time_axis(self) -> None: + with self.assertRaisesRegex(TestMqlOutputValidationError, "monotonically increasing"): + validate_test_mql_output( + times=(0.0, 0.2, 0.1), + series_by_data_path={"press@pn_c1_8": (1.0, 2.0, 3.0)}, + schema=self.schema, + ) + + def test_compares_validated_amesim_baseline_with_zero_error(self) -> None: + selected_paths = ( + "press@pn_c1_8", + "dm2@pn_morifice_11", + "x1@mass_friction_endstops_10", + ) + baseline = self.observation_catalog.baseline_series_by_data_path( + self.amesim_results, + selected_paths, + ) + + comparison = compare_validated_test_mql_output( + times=self.amesim_results.times, + series_by_data_path=baseline, + schema=self.schema, + amesim_results=self.amesim_results, + data_paths=selected_paths, + ) + + self.assertEqual(len(comparison.metrics), len(selected_paths)) + self.assertEqual(comparison.max_abs_error, 0.0) + self.assertEqual(comparison.max_rel_error, 0.0) + + def test_compares_validated_output_and_reports_perturbation_error(self) -> None: + selected_paths = ("press@pn_c1_8",) + baseline = self.observation_catalog.baseline_series_by_data_path( + self.amesim_results, + selected_paths, + ) + perturbed = dict(baseline) + values = list(perturbed["press@pn_c1_8"]) + values[-1] += 10.0 + perturbed["press@pn_c1_8"] = tuple(values) + + comparison = compare_validated_test_mql_output( + times=self.amesim_results.times, + series_by_data_path=perturbed, + schema=self.schema, + amesim_results=self.amesim_results, + data_paths=selected_paths, + ) + + metric = comparison.metric("press@pn_c1_8") + self.assertAlmostEqual(metric.final_abs_error, 10.0) + self.assertAlmostEqual(metric.max_abs_error, 10.0) + + def test_compare_validated_output_reuses_schema_validation(self) -> None: + with self.assertRaisesRegex(TestMqlOutputValidationError, "outside test_mql schema"): + compare_validated_test_mql_output( + times=(0.0,), + series_by_data_path={"not_a_signal@not_an_owner": (1.0,)}, + schema=self.schema, + amesim_results=self.amesim_results, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_pneumatic.py b/tests/test_test_mql_pneumatic.py new file mode 100644 index 0000000..b840098 --- /dev/null +++ b/tests/test_test_mql_pneumatic.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.components.amesim_pneumatic import HELIUM_PNEUMATIC_GAS, cm3_to_m3 +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.systems.test_mql_pneumatic import ( + TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, + absolute_pressure_from_amesim_bar_parameter, + build_test_mql_pneumatic_assembly, + pressure_from_amesim_bar_parameter, + pressure_to_amesim_gauge_pa, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlPneumaticAssemblyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_assembles_expected_amesim_pneumatic_component_counts(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + + self.assertEqual(len(assembly.fixed_chambers), 4) + self.assertEqual(len(assembly.variable_chambers), 8) + self.assertEqual(len(assembly.fixed_orifices), 8) + self.assertEqual(len(assembly.variable_orifices), 8) + self.assertEqual(assembly.component_count, 28) + self.assertIn("pn_general_chamber", assembly.aliases) + self.assertIn("pn_c1_8", assembly.aliases) + self.assertIn("pn_orifice_18", assembly.aliases) + self.assertIn("pn_morifice_11", assembly.aliases) + + def test_uses_amesim_pressure_conventions(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + + self.assertAlmostEqual(absolute_pressure_from_amesim_bar_parameter(153.0), 15300000.0) + self.assertAlmostEqual(pressure_from_amesim_bar_parameter(153.0), 15198700.0) + self.assertAlmostEqual(pressure_to_amesim_gauge_pa(100000.0), -1300.0) + self.assertAlmostEqual(assembly.fixed_initial_absolute_pressure_pa, 15300000.0) + self.assertAlmostEqual(assembly.fixed_initial_gauge_pressure_pa, 15198700.0) + self.assertAlmostEqual(assembly.variable_initial_absolute_pressure_pa, 100000.0) + self.assertAlmostEqual(assembly.variable_initial_gauge_pressure_pa, -1300.0) + self.assertAlmostEqual(assembly.initial_pressure_pa, 15198700.0) + + def test_chamber_parameters_are_resolved_in_si_units(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + fixed_chamber = assembly.fixed_chambers["pn_general_chamber"] + variable_chamber = assembly.variable_chambers["pn_c1_8"] + + self.assertAlmostEqual(fixed_chamber.volume, 0.057) + self.assertAlmostEqual(variable_chamber.dead_volume, 0.015) + self.assertAlmostEqual(variable_chamber.volume, 0.015) + self.assertAlmostEqual(variable_chamber.heat_transfer_coefficient, 1500.0) + self.assertAlmostEqual(variable_chamber.heat_transfer_area, 0.7) + self.assertAlmostEqual(variable_chamber.external_temperature, 293.15) + self.assertIs(fixed_chamber.gas, HELIUM_PNEUMATIC_GAS) + self.assertIs(variable_chamber.gas, HELIUM_PNEUMATIC_GAS) + self.assertAlmostEqual(fixed_chamber.properties().T, 293.15) + self.assertAlmostEqual(variable_chamber.properties().T, 293.15) + + def test_orifice_parameters_are_resolved_in_si_units(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + fixed_orifice = assembly.fixed_orifices["pn_orifice_18"] + variable_orifice = assembly.variable_orifices["pn_morifice_11"] + + self.assertAlmostEqual(fixed_orifice.area, 78.5e-6) + self.assertAlmostEqual(fixed_orifice.flow_coefficient, 0.9) + self.assertAlmostEqual(variable_orifice.area, 78.5e-6) + self.assertAlmostEqual( + variable_orifice.flow_coefficient, + 0.45 * TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, + ) + self.assertAlmostEqual(variable_orifice.opening, 0.0) + self.assertEqual(assembly.variable_orifice_control_count, 8) + control = assembly.variable_orifice_controls["pn_morifice_11"] + self.assertEqual(control.step.alias, "step_8") + self.assertAlmostEqual(control.opening_at(0.0), 0.0) + self.assertAlmostEqual(control.opening_at(0.039), 0.0) + self.assertAlmostEqual(control.opening_at(0.04), 1.0) + assembly.set_variable_orifice_openings(0.04) + self.assertAlmostEqual(variable_orifice.opening, 1.0) + + def test_chamber_initial_observables_match_amesim_results(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + fixed_chamber = assembly.fixed_chambers["pn_general_chamber"] + variable_chamber = assembly.variable_chambers["pn_c1_8"] + + self.assertAlmostEqual( + fixed_chamber.pressure_gauge_pa(), + self.amesim_results.series("press@pn_general_chamber")[0], + delta=1.0e-8, + ) + self.assertAlmostEqual( + variable_chamber.pressure_gauge_pa(), + self.amesim_results.series("press@pn_c1_8")[0], + delta=1.0e-8, + ) + self.assertAlmostEqual( + variable_chamber.volume_cm3(), + self.amesim_results.series("vol@pn_c1_8")[0], + ) + self.assertAlmostEqual( + variable_chamber.gas_mass_g(), + self.amesim_results.series("mgas1@pn_c1_8")[0], + delta=1.0e-4, + ) + + def test_variable_chamber_volume_matches_piston_volume_observable(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + for chamber_alias, chamber in assembly.variable_chambers.items(): + piston_alias = chamber_alias.replace("pn_c1", "pn_brp2") + with self.subTest(alias=chamber_alias): + piston_volume_cm3 = self.amesim_results.series(f"vol1@{piston_alias}")[-1] + chamber.set_external_volume_m3(cm3_to_m3(piston_volume_cm3)) + self.assertAlmostEqual( + chamber.volume_cm3(), + self.amesim_results.series(f"vol@{chamber_alias}")[-1], + delta=1.0e-6, + ) + + def test_assembled_orifice_can_compute_helium_mass_flow(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + fixed_orifice = assembly.fixed_orifices["pn_orifice_18"] + + flow = fixed_orifice.mass_flow(15.0e6, 10.0e6, 293.15) + + self.assertGreater(flow, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_pnl0001.py b/tests/test_test_mql_pnl0001.py new file mode 100644 index 0000000..d8a2057 --- /dev/null +++ b/tests/test_test_mql_pnl0001.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.systems.test_mql_line_parameters import load_test_mql_pnl0001_specs +from PythonModels.systems.test_mql_pneumatic_lines import ( + TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE, + build_test_mql_pnl0001_assembly, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlPnl0001Tests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.specs = load_test_mql_pnl0001_specs(TEST_MQL_AME) + cls.assembly = build_test_mql_pnl0001_assembly(TEST_MQL_AME) + cls.results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_loads_all_real_pnl0001_parameters_from_cir(self) -> None: + self.assertEqual(len(self.specs), 20) + spec = self.assembly.spec("pneumatic_96") + + self.assertEqual(spec.source_component, "pn_node3_8") + self.assertEqual(spec.target_component, "pn_orifice_18") + self.assertEqual(spec.diameter_mm, 14.0) + self.assertEqual(spec.length_m, 1.0) + self.assertAlmostEqual(spec.relative_roughness, 0.045 / 14.0) + self.assertEqual(spec.polytropic_constant, 1.35) + self.assertEqual(spec.heat_transfer_coefficient, 0.0) + self.assertEqual(spec.external_temperature_k, 293.15) + self.assertEqual(spec.gas_type_index, 1) + self.assertEqual(spec.mode, 2) + self.assertAlmostEqual(spec.initial_gauge_pressure_pa, 15_198_700.0) + self.assertAlmostEqual(spec.initial_absolute_pressure_pa, 15_300_000.0) + + def test_builds_twenty_two_state_physical_line_components(self) -> None: + self.assertEqual(len(self.assembly.lines), 20) + self.assertTrue( + all(len(line.get_state_vector()) == 2 for line in self.assembly.lines.values()) + ) + + def test_calibrates_primary_d20_chamber_lines_only(self) -> None: + calibrated_aliases = { + alias + for alias, line in self.assembly.lines.items() + if line.calibrated_linear_conductance is not None + } + + self.assertEqual( + calibrated_aliases, + { + "pneumatic_65", + "pneumatic_66", + "pneumatic_68", + "pneumatic_69", + "pneumatic_71", + "pneumatic_72", + "pneumatic_73", + "pneumatic_74", + }, + ) + for alias in calibrated_aliases: + self.assertAlmostEqual( + self.assembly.lines[alias].calibrated_linear_conductance, + TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE, + ) + self.assertIsNone( + self.assembly.lines["pneumatic_70"].calibrated_linear_conductance + ) + self.assertIsNone( + self.assembly.lines["pneumatic_96"].calibrated_linear_conductance + ) + + def test_pneumatic_96_initial_observables_match_amesim_baseline(self) -> None: + pipe = self.assembly.lines["pneumatic_96"] + + self.assertAlmostEqual( + pipe.properties().p - 101_300.0, + self.results.series("p2@pneumatic_96")[0], + delta=1.0e-5, + ) + self.assertAlmostEqual( + pipe.properties().T, + self.results.series("t2@pneumatic_96")[0], + ) + self.assertAlmostEqual( + pipe.gas_mass_g(), + self.results.series("mgas@pneumatic_96")[0], + delta=0.005, + ) + + def test_baseline_mass_balance_identifies_port_two_input_sign(self) -> None: + mass = self.results.series("mgas@pneumatic_96") + dm1 = self.results.series("dm1@pneumatic_96") + dm2_peer = self.results.series("dm2@pn_orifice_18") + index = 500 + finite_difference = (mass[index] - mass[index - 1]) / ( + self.results.times[index] - self.results.times[index - 1] + ) + + self.assertAlmostEqual( + finite_difference, + dm2_peer[index] - dm1[index], + delta=3.0e-5, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_pnl0001_segment.py b/tests/test_test_mql_pnl0001_segment.py new file mode 100644 index 0000000..48376f3 --- /dev/null +++ b/tests/test_test_mql_pnl0001_segment.py @@ -0,0 +1,1564 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.core.solver import SolveIVPConfig +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_output_schema import build_test_mql_output_schema +from PythonModels.reporting.test_mql_output_validation import compare_validated_test_mql_output +from PythonModels.systems.test_mql import TestMqlSystem + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlPnl0001ChamberSegmentTests(unittest.TestCase): + def setUp(self) -> None: + self.system = TestMqlSystem() + self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + + def test_system_assembles_all_twenty_physical_pnl0001_lines(self) -> None: + self.assertEqual(self.system.typed_pnl0001_line_count, 20) + self.assertIn("pneumatic_96", self.system.pnl0001_assembly.lines) + + def test_closure_inserts_topology_derived_inlet_line(self) -> None: + closure = self.system.pnl0001_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=16.0e6, + outlet_pressure_pa=15.3e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") + self.assertEqual(len(closure.initial_state_vector()), 4) + self.assertEqual(len(rhs), 4) + self.assertGreater(snapshot.node_to_line_flow, 0.0) + self.assertAlmostEqual(snapshot.line_to_chamber_flow, 0.0, delta=1.0e-7) + self.assertAlmostEqual(snapshot.outlet_flow, 0.0, delta=1.0e-7) + self.assertAlmostEqual(rhs[0] + rhs[2], snapshot.node_to_line_flow) + + def test_internal_line_orifice_flow_cancels_from_total_mass_balance(self) -> None: + closure = self.system.pnl0001_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.3e6, + outlet_pressure_pa=14.0e6, + ) + state = closure.initial_state_vector() + state[0] *= 1.01 + state[1] *= 1.01 + + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + + self.assertGreater(snapshot.line_to_chamber_flow, 0.0) + self.assertAlmostEqual( + rhs[0] + rhs[2], + snapshot.node_to_line_flow - snapshot.outlet_flow, + delta=1.0e-12, + ) + self.assertAlmostEqual( + closure.components.inlet_line.port_2.m_flow, + -snapshot.line_to_chamber_flow, + ) + + def test_simulates_four_state_line_chamber_segment(self) -> None: + solution = self.system.simulate_pnl0001_chamber_segment_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + outlet_pressure_pa=15.29e6, + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0e-7, + max_step=1.0e-8, + ), + t_eval=[0.0, 5.0e-8, 1.0e-7], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 3) + self.assertEqual(len(solution.y), 4) + self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3]) + + +class TestMqlPnl0001PairChamberSegmentTests(unittest.TestCase): + def setUp(self) -> None: + self.system = TestMqlSystem() + self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + + def test_pair_closure_uses_both_topology_derived_lines(self) -> None: + closure = self.system.pnl0001_pair_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=16.0e6, + outlet_node_pressure_pa=14.0e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") + self.assertEqual(closure.components.outlet_line.name, "pneumatic_97") + self.assertEqual(len(closure.initial_state_vector()), 6) + self.assertEqual(len(rhs), 6) + self.assertGreater(snapshot.inlet_node_to_line_flow, 0.0) + self.assertLess(snapshot.outlet_node_to_line_flow, 0.0) + self.assertAlmostEqual( + rhs[0] + rhs[2] + rhs[4], + snapshot.inlet_node_to_line_flow + snapshot.outlet_node_to_line_flow, + delta=1.0e-12, + ) + + def test_both_internal_orifice_flows_cancel_from_total_mass(self) -> None: + closure = self.system.pnl0001_pair_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.3e6, + outlet_node_pressure_pa=15.3e6, + ) + state = closure.initial_state_vector() + state[0] *= 1.01 + state[1] *= 1.01 + state[4] *= 0.99 + state[5] *= 0.99 + + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + + self.assertGreater(snapshot.inlet_line_to_chamber_flow, 0.0) + self.assertLess(snapshot.outlet_line_to_chamber_flow, 0.0) + self.assertAlmostEqual( + rhs[0] + rhs[2] + rhs[4], + snapshot.inlet_node_to_line_flow + snapshot.outlet_node_to_line_flow, + delta=1.0e-12, + ) + self.assertAlmostEqual( + closure.components.outlet_line.port_2.m_flow, + -snapshot.outlet_line_to_chamber_flow, + ) + + def test_simulates_six_state_pair_line_chamber_segment(self) -> None: + solution = self.system.simulate_pnl0001_pair_chamber_segment_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + outlet_node_pressure_pa=15.29e6, + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0e-7, + max_step=1.0e-8, + ), + t_eval=[0.0, 5.0e-8, 1.0e-7], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 3) + self.assertEqual(len(solution.y), 6) + self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3, 3, 3]) + + +class TestMqlPn3NodeChamberSegmentTests(unittest.TestCase): + def setUp(self) -> None: + self.system = TestMqlSystem() + self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + + def test_closure_replaces_outlet_boundary_with_real_pn3_node(self) -> None: + closure = self.system.pn3_node_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") + self.assertEqual(closure.components.outlet_line.name, "pneumatic_97") + self.assertEqual(closure.components.node_line.name, "pneumatic_88") + self.assertEqual(closure.components.node_resistance.name, "pneumatic_100") + self.assertEqual(closure.components.node_orifice.name, "pn_morifice_9") + self.assertEqual(closure.components.node.alias, "pn_node3_9") + self.assertEqual(len(closure.initial_state_vector()), 10) + self.assertEqual(len(rhs), 10) + self.assertAlmostEqual( + snapshot.node_balance.pressure_pa, + snapshot.node_line_port_1.p, + ) + self.assertAlmostEqual( + snapshot.node_balance.temperature_k, + snapshot.node_line_port_1.T, + ) + + def test_pn3_node_flow_cancels_between_adjacent_lines(self) -> None: + closure = self.system.pn3_node_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.3e6, + resistance_boundary_pressure_pa=15.0e6, + p4_boundary_pressure_pa=15.0e6, + ) + state = closure.initial_state_vector() + state[4] *= 0.99 + state[5] *= 0.99 + self.assertAlmostEqual(closure.components.node_orifice.opening, 0.0) + closure.components.node_orifice.opening = 1.0 + + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + + self.assertGreater(snapshot.outlet_node_to_line_flow, 0.0) + self.assertGreater(snapshot.node_to_resistance_flow, 0.0) + self.assertAlmostEqual( + snapshot.node_balance.port_2_mass_flow_g_s, + (snapshot.outlet_node_to_line_flow + snapshot.node_to_resistance_flow) + * 1.0e3, + ) + self.assertAlmostEqual( + snapshot.node_to_pnl0003_flow, + -(snapshot.outlet_node_to_line_flow + snapshot.node_to_resistance_flow), + ) + self.assertGreater(snapshot.pnl0003_to_p4_flow, 0.0) + self.assertAlmostEqual( + closure.components.node_line.port_1.m_flow, + snapshot.node_to_pnl0003_flow, + ) + self.assertAlmostEqual( + closure.components.node_line.port_2.m_flow, + -snapshot.pnl0003_to_p4_flow, + ) + mass_derivative_sum = rhs[0] + rhs[2] + rhs[4] + rhs[6] + rhs[8] + self.assertAlmostEqual( + mass_derivative_sum, + ( + snapshot.inlet_node_to_line_flow + - snapshot.pnl0003_to_p4_flow + - snapshot.node_to_resistance_flow + ), + delta=1.0e-12, + ) + + def test_simulates_ten_state_pn3_node_chamber_segment(self) -> None: + solution = self.system.simulate_pn3_node_chamber_segment_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + p4_boundary_pressure_pa=15.29e6, + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0e-8, + max_step=1.0e-9, + ), + t_eval=[0.0, 5.0e-9, 1.0e-8], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 3) + self.assertEqual(len(solution.y), 10) + self.assertEqual([len(row) for row in solution.y], [3] * 10) + + +class TestMqlPn3P4NodeChamberSegmentTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.output_schema = build_test_mql_output_schema(cls.amesim_results) + + def setUp(self) -> None: + self.system = TestMqlSystem() + self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + + def test_closure_replaces_p4_pressure_boundary_with_real_node_and_lines(self) -> None: + closure = self.system.pn3_p4_node_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") + self.assertEqual(closure.components.volume.name, "pn_general_chamber") + self.assertEqual(closure.components.outlet_line.name, "pneumatic_97") + self.assertEqual(closure.components.inlet_orifice.name, "pn_orifice_18") + self.assertEqual(closure.components.outlet_orifice.name, "pn_orifice_19") + self.assertEqual(closure.components.node_orifice.name, "pn_morifice_9") + self.assertEqual(closure.components.node_resistance.name, "pneumatic_100") + self.assertEqual(closure.components.node.alias, "pn_node3_9") + self.assertEqual(closure.components.p4_node.alias, "pnnode4_17") + self.assertEqual(closure.components.p4_primary_line.name, "pneumatic_68") + self.assertEqual(closure.components.p4_primary_chamber.name, "pn_c1_9") + self.assertEqual(closure.components.p4_port1_line.name, "pneumatic_86") + self.assertEqual(closure.components.p4_port1_remote_node.alias, "pnnode4_18") + self.assertEqual( + closure.components.p4_port1_remote_primary_line.name, + "pneumatic_66", + ) + self.assertEqual( + closure.components.p4_port1_remote_primary_chamber.name, + "pn_c1_10", + ) + self.assertEqual( + closure.components.p4_port1_remote_port1_line.name, + "pneumatic_80", + ) + self.assertEqual(closure.components.p4_port1_far_node.alias, "pnnode4_19") + self.assertEqual( + closure.components.p4_port1_far_primary_line.name, + "pneumatic_65", + ) + self.assertEqual( + closure.components.p4_port1_far_primary_chamber.name, + "pn_c1_11", + ) + self.assertEqual(closure.components.p4_port1_far_port1_line.name, "pneumatic_85") + self.assertEqual(closure.components.p4_port1_next_node.alias, "pnnode4_23") + self.assertEqual( + closure.components.p4_port1_next_primary_line.name, + "pneumatic_71", + ) + self.assertEqual( + closure.components.p4_port1_next_primary_chamber.name, + "pn_c1_12", + ) + self.assertEqual(closure.components.p4_port1_next_port1_line.name, "pneumatic_81") + self.assertEqual(closure.components.p4_bridge_node.alias, "pnnode4_20") + self.assertEqual(closure.components.p4_bridge_primary_line.name, "pneumatic_72") + self.assertEqual(closure.components.p4_bridge_primary_chamber.name, "pn_c1_13") + self.assertEqual( + closure.components.p4_port1_remote_orifice.name, + "pn_morifice_10", + ) + self.assertEqual( + closure.components.p4_port1_remote_orifice_line.name, + "pneumatic_75", + ) + self.assertEqual( + closure.components.p4_port1_remote_orifice_node.alias, + "pn_node3_10", + ) + self.assertEqual(closure.components.p4_port3_line.name, "pneumatic_83") + self.assertEqual(closure.components.p4_port3_remote_node.alias, "pnnode4_16") + self.assertEqual( + closure.components.p4_port3_remote_primary_line.name, + "pneumatic_69", + ) + self.assertEqual( + closure.components.p4_port3_remote_primary_chamber.name, + "pn_c1_8", + ) + self.assertEqual( + closure.components.p4_port3_remote_port3_line.name, + "pneumatic_95", + ) + self.assertEqual(closure.components.p4_port3_far_node.alias, "pnnode4_22") + self.assertEqual( + closure.components.p4_port3_far_primary_line.name, + "pneumatic_74", + ) + self.assertEqual( + closure.components.p4_port3_far_primary_chamber.name, + "pn_c1_15", + ) + self.assertEqual(closure.components.p4_port3_far_port3_line.name, "pneumatic_82") + self.assertEqual(closure.components.p4_port3_next_node.alias, "pnnode4_21") + self.assertEqual( + closure.components.p4_port3_next_primary_line.name, + "pneumatic_73", + ) + self.assertEqual( + closure.components.p4_port3_next_primary_chamber.name, + "pn_c1_14", + ) + self.assertEqual(closure.components.p4_port3_next_port3_line.name, "pneumatic_84") + self.assertEqual( + closure.components.p4_port3_next_orifice.name, + "pn_morifice_13", + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_line.name, + "pneumatic_91", + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_node.alias, + "pn_node3_14", + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_output_line.name, + "pneumatic_93", + ) + self.assertEqual( + closure.components.p4_port3_next_chamber_inlet_line.name, + "pneumatic_103", + ) + self.assertEqual( + closure.components.p4_port3_next_chamber.name, + "pn_general_chamber_5", + ) + self.assertEqual( + closure.components.p4_port3_next_chamber_outlet_line.name, + "pneumatic_104", + ) + self.assertEqual( + closure.components.p4_port3_next_chamber_inlet_orifice.name, + "pn_orifice_24", + ) + self.assertEqual( + closure.components.p4_port3_next_chamber_outlet_orifice.name, + "pn_orifice_25", + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice.name, + "pn_morifice_1", + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice_line.name, + "pneumatic_87", + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice_node.alias, + "pn_node3_8", + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice_resistance.name, + "pneumatic_105", + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_node.alias, + "pn_node3_15", + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_node_line.name, + "pneumatic_92", + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_orifice.name, + "pn_morifice_14", + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_output_line.name, + "pneumatic_94", + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_resistance.name, + "pneumatic_106", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_node.alias, + "pn_node3_13", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_node_line.name, + "pneumatic_78", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_orifice.name, + "pn_morifice_12", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_output_line.name, + "pneumatic_70", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_inlet_line.name, + "pneumatic_101", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber.name, + "pn_general_chamber_4", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_outlet_line.name, + "pneumatic_102", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_inlet_orifice.name, + "pn_orifice_22", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_outlet_orifice.name, + "pn_orifice_23", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice.name, + "pn_morifice_11", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_line.name, + "pneumatic_77", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_node.alias, + "pn_node3_12", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_output_line.name, + "pneumatic_79", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance.name, + "pneumatic_107", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_node.alias, + "pn_node3_11", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_line.name, + "pneumatic_76", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_orifice.name, + "pn_morifice_15", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_inlet_line.name, + "pneumatic_98", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber.name, + "pn_general_chamber_2", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_outlet_line.name, + "pneumatic_99", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice.name, + "pn_orifice_20", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice.name, + "pn_orifice_21", + ) + self.assertEqual(len(closure.initial_state_vector()), 112) + self.assertEqual(len(rhs), 112) + self.assertAlmostEqual( + snapshot.p4_balance.pressure_pa, + snapshot.p4_primary_line.p, + ) + self.assertAlmostEqual( + snapshot.p4_balance.temperature_k, + snapshot.p4_primary_line.T, + ) + + def test_full_state_closure_combines_pneumatic_and_mechanical_states(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + + state = closure.initial_state_vector() + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + + self.assertEqual(closure.pneumatic_state_count, 112) + self.assertEqual(closure.mechanical_state_count, 20) + self.assertEqual(closure.state_count, 132) + self.assertEqual(len(state), 132) + self.assertEqual(snapshot.state_count, 132) + self.assertEqual(snapshot.mechanical.state_count, 20) + self.assertEqual(len(rhs), 132) + self.assertAlmostEqual( + snapshot.pneumatic.p4_balance.pressure_pa, + snapshot.pneumatic.p4_primary_line.p, + ) + self.assertEqual( + snapshot.mechanical.states[0].alias, + "mass_friction_endstops_10", + ) + self.assertAlmostEqual( + snapshot.mechanical_node_total_force_by_alias[ + "dynamic_mechanical_node_alternative_2" + ], + 0.0, + ) + self.assertAlmostEqual( + snapshot.mechanical_node_total_force_by_alias[ + "dynamic_mechanical_node_alternative_3" + ], + -326.7174678324367, + ) + self.assertAlmostEqual(rhs[112], -0.8167936695810917) + + def test_full_state_closure_feeds_piston_kinematics_to_variable_chambers(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + state = closure.initial_state_vector() + state[113] = 0.05 + + snapshot = closure.snapshot(state) + chamber = closure.pneumatic_closure.components.p4_port3_remote_primary_chamber + piston = closure.mechanical_closure.assembly.pistons["pn_brp2_8"] + expected_external_volume = piston.geometry().chamber_volume_m3(0.0, 0.05) + + self.assertAlmostEqual( + chamber.volume, + chamber.dead_volume + expected_external_volume, + ) + self.assertLess( + snapshot.pneumatic.p4_port3_remote_primary_chamber.p, + self.system.pneumatic_assembly.variable_initial_absolute_pressure_pa, + ) + + base_rhs = closure.rhs(closure.initial_state_vector()) + moving_state = closure.initial_state_vector() + moving_state[112] = 0.1 + moving_snapshot = closure.snapshot(moving_state) + rhs = closure.rhs(moving_state) + expected_volume_rate = piston.geometry().chamber_volume_rate_m3_s(0.0, 0.1) + + self.assertAlmostEqual(chamber.volume_rate_m3_s(), expected_volume_rate) + self.assertAlmostEqual( + rhs[47] - base_rhs[47], + -moving_snapshot.pneumatic.p4_port3_remote_primary_chamber.p + * expected_volume_rate, + ) + + def test_full_state_closure_applies_elastic_endstop_force_to_small_masses(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + state = closure.initial_state_vector() + state[113] = 1.0e-3 + + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + endstop = closure.mechanical_closure.assembly.elastic_endstops[ + "elasticendstop_8" + ].endstop() + expected_contact_force = endstop.contact_force(-1.0, 0.0) + mass = closure.mechanical_closure.assembly.masses[ + "mass_friction_endstops_10" + ].mass_kg + expected_piston_force = closure._piston_force_by_mass_alias( + snapshot.pneumatic + )["mass_friction_endstops_10"] + + self.assertAlmostEqual( + rhs[112], + (expected_piston_force - expected_contact_force) / mass, + ) + self.assertAlmostEqual( + snapshot.mechanical_node_total_force_by_alias[ + "dynamic_mechanical_node_alternative_2" + ], + expected_contact_force, + ) + + def test_full_state_closure_constrains_large_masses_at_active_limits(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertAlmostEqual(rhs[128], 0.0) + self.assertAlmostEqual(rhs[129], 0.0) + self.assertAlmostEqual(rhs[130], 0.0) + self.assertAlmostEqual(rhs[131], 0.0) + + def test_full_state_closure_releases_large_mass_when_node_force_points_inward(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + state = closure.initial_state_vector() + state[113] = 1.0e-3 + + rhs = closure.rhs_at(0.8000000000000009, state) + endstop = closure.mechanical_closure.assembly.elastic_endstops[ + "elasticendstop_8" + ].endstop() + expected_contact_force = endstop.contact_force(-1.0, 0.0) + support_mass = closure.mechanical_closure.assembly.masses[ + "mass_friction_endstops_18" + ].mass_kg + + self.assertAlmostEqual(rhs[128], expected_contact_force / support_mass) + self.assertAlmostEqual(rhs[129], 0.0) + + def test_full_state_closure_diagnoses_variable_chamber_rhs_terms(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + state = closure.initial_state_vector() + + diagnostic = closure.variable_chamber_rhs_diagnostic( + chamber_alias="pn_c1_8", + state_vector=state, + ) + rhs = closure.rhs(state) + + self.assertEqual(diagnostic.piston_alias, "pn_brp2_8") + self.assertAlmostEqual(diagnostic.chamber_volume_m3, 0.015) + self.assertAlmostEqual(diagnostic.chamber_volume_rate_m3_s, 0.0) + self.assertAlmostEqual(diagnostic.mass_derivative_kg_s, rhs[46]) + self.assertAlmostEqual(diagnostic.energy_derivative_w, rhs[47]) + self.assertAlmostEqual( + diagnostic.energy_derivative_w, + diagnostic.port_a_energy_flow_w + + diagnostic.port_b_energy_flow_w + + diagnostic.boundary_work_w + + diagnostic.thermal_energy_flow_w, + ) + + def test_full_state_closure_diagnoses_pnl0001_line_rhs_terms(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + state = closure.initial_state_vector() + + diagnostic = closure.pnl0001_line_rhs_diagnostic( + line_alias="pneumatic_69", + state_vector=state, + time_s=0.04, + ) + rhs = closure.rhs_at(0.04, state) + + self.assertEqual(diagnostic.chamber_alias, "pn_c1_8") + self.assertGreater(diagnostic.node_to_line_flow_kg_s, 0.0) + self.assertAlmostEqual( + diagnostic.mass_derivative_kg_s, + diagnostic.chamber_to_line_flow_kg_s + + diagnostic.node_to_line_flow_kg_s, + ) + self.assertAlmostEqual(diagnostic.mass_derivative_kg_s, rhs[44]) + self.assertAlmostEqual(diagnostic.energy_derivative_w, rhs[45]) + self.assertAlmostEqual( + diagnostic.energy_derivative_w, + diagnostic.port_1_energy_flow_w + + diagnostic.port_2_energy_flow_w + + diagnostic.thermal_energy_flow_w, + ) + + def test_full_state_closure_can_enable_pneumatic_96_reference_rhs_candidate( + self, + ) -> None: + default_closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + candidate_closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + use_pneumatic_96_reference_rhs=True, + ) + state = default_closure.initial_state_vector() + + default_rhs = default_closure.rhs_at(0.04, state) + candidate_rhs = candidate_closure.rhs_at(0.04, state) + snapshot = candidate_closure.snapshot_at(0.04, state).pneumatic + line = candidate_closure.pneumatic_closure.components.inlet_line + expected = candidate_closure.pneumatic_closure._pnl0001_reference_enthalpy_derivative( + line=line, + line_properties=snapshot.inlet_line, + port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=snapshot.inlet_node_to_line_flow, + port_2_properties=snapshot.chamber, + port_2_flow=-snapshot.inlet_line_to_chamber_flow, + ) + + self.assertAlmostEqual(candidate_rhs[0], default_rhs[0]) + self.assertAlmostEqual(candidate_rhs[0], expected.m) + self.assertAlmostEqual(candidate_rhs[1], expected.U) + self.assertNotAlmostEqual(candidate_rhs[1], default_rhs[1]) + + def test_full_state_closure_can_enable_pneumatic_69_reference_rhs_candidate( + self, + ) -> None: + default_closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + candidate_closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + use_pneumatic_69_reference_rhs=True, + ) + state = default_closure.initial_state_vector() + + default_rhs = default_closure.rhs_at(0.04, state) + candidate_rhs = candidate_closure.rhs_at(0.04, state) + snapshot = candidate_closure.snapshot_at(0.04, state).pneumatic + line = candidate_closure.pneumatic_closure.components.p4_port3_remote_primary_line + expected = candidate_closure.pneumatic_closure._pnl0001_reference_enthalpy_derivative( + line=line, + line_properties=snapshot.p4_port3_remote_primary_line, + port_1_properties=snapshot.p4_port3_remote_primary_chamber, + port_1_flow=snapshot.p4_port3_remote_chamber_to_line_flow, + port_2_properties=snapshot.p4_port3_remote_primary_line, + port_2_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, + port_2_connected_reference_h=( + candidate_closure.pneumatic_closure._p4_port3_remote_port_2_reference_h( + snapshot=snapshot + ) + ), + ) + + self.assertAlmostEqual(candidate_rhs[0], default_rhs[0]) + self.assertAlmostEqual(candidate_rhs[1], default_rhs[1]) + self.assertAlmostEqual(candidate_rhs[44], default_rhs[44]) + self.assertAlmostEqual(candidate_rhs[44], expected.m) + self.assertAlmostEqual(candidate_rhs[45], expected.U) + self.assertNotAlmostEqual(candidate_rhs[45], default_rhs[45]) + + def test_full_state_closure_applies_step_controls_to_variable_orifices(self) -> None: + closure = self.system.full_state_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + state = closure.initial_state_vector() + + closed_snapshot = closure.snapshot_at(0.0, state).pneumatic + open_snapshot = closure.snapshot_at(0.04, state).pneumatic + + self.assertAlmostEqual( + closed_snapshot.p4_port3_remote_node_to_primary_line_flow, + 0.0, + ) + self.assertGreater( + open_snapshot.p4_port3_remote_node_to_primary_line_flow, + 0.0, + ) + node_inlet_h = ( + open_snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w + / open_snapshot.p4_port3_remote_node_to_primary_line_flow + ) + self.assertAlmostEqual( + node_inlet_h, + open_snapshot.p4_port3_remote_orifice_line_port_2.h, + ) + rhs = closure.rhs_at(0.04, state) + line = closure.pneumatic_closure.components.p4_port3_remote_primary_line + expected_line_derivative = ( + line.derivatives_from_transport_enthalpy_connections( + port_1_m_flow=( + open_snapshot.p4_port3_remote_chamber_to_line_flow + ), + connected_h_1=open_snapshot.p4_port3_remote_primary_chamber.h, + port_2_m_flow=( + open_snapshot.p4_port3_remote_node_to_primary_line_flow + ), + connected_h_2=node_inlet_h, + ) + ) + self.assertAlmostEqual(rhs[45], expected_line_derivative.U) + self.assertAlmostEqual( + self.system.pneumatic_assembly.variable_orifices[ + "pn_morifice_1" + ].opening, + 1.0, + ) + remote_orifice_line = ( + closure.pneumatic_closure.components.p4_port3_remote_orifice_line + ) + custom_port_1_h = open_snapshot.p4_port3_remote_orifice_line_port_1.h + 1234.0 + custom_d1, _ = closure.pneumatic_closure._two_state_connection_line_derivatives( + line=remote_orifice_line, + port_1_properties=open_snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=0.01, + port_2_connected_properties=open_snapshot.p4_port3_remote_primary_line, + port_2_flow=-open_snapshot.p4_port3_remote_orifice_to_node_flow, + port_1_connected_h=custom_port_1_h, + ) + expected_custom_d1, _ = remote_orifice_line.derivatives_from_connections( + port_1_m_flow=0.01, + connected_h_1=custom_port_1_h, + port_2_m_flow=-open_snapshot.p4_port3_remote_orifice_to_node_flow, + connected_h_2=open_snapshot.p4_port3_remote_primary_line.h, + ) + default_d1, _ = closure.pneumatic_closure._two_state_connection_line_derivatives( + line=remote_orifice_line, + port_1_properties=open_snapshot.p4_port3_remote_orifice_line_port_1, + port_1_flow=0.01, + port_2_connected_properties=open_snapshot.p4_port3_remote_primary_line, + port_2_flow=-open_snapshot.p4_port3_remote_orifice_to_node_flow, + ) + self.assertAlmostEqual(custom_d1.U, expected_custom_d1.U) + self.assertNotAlmostEqual(custom_d1.U, default_d1.U) + closure.snapshot_at(0.0, state) + self.assertAlmostEqual( + self.system.pneumatic_assembly.variable_orifices[ + "pn_morifice_1" + ].opening, + 0.0, + ) + + def test_simulates_full_state_key_data_paths_for_amesim_comparison(self) -> None: + data_paths = ( + "press@pn_c1_8", + "vol@pn_c1_8", + "mgas1@pn_c1_8", + "vol1@pn_brp2_8", + "vvol1@pn_brp2_8", + "x1@mass_friction_endstops_10", + "v1@mass_friction_endstops_10", + "acc1@mass_friction_endstops_10", + "x1@mass_friction_endstops_18", + "v1@mass_friction_endstops_18", + "acc1@mass_friction_endstops_18", + "p2@pneumatic_69", + "t2@pneumatic_69", + "mgas@pneumatic_69", + "re@pneumatic_69", + "v@pneumatic_69", + "ff@pneumatic_69", + "dm1@pneumatic_69", + "xv@pn_morifice_1", + "dm2@pn_morifice_1", + ) + + result = self.system.simulate_full_state_series_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + config=SolveIVPConfig(t_start=0.0, t_stop=0.0), + data_paths=data_paths, + ) + comparison = compare_validated_test_mql_output( + times=result.t, + series_by_data_path={data_path: result.series[data_path] for data_path in data_paths}, + schema=self.output_schema, + amesim_results=self.amesim_results, + data_paths=data_paths, + ) + + self.assertEqual(result.series["time"], [0.0]) + self.assertEqual(len(result.y), 132) + self.assertEqual(len(comparison.metrics), len(data_paths)) + self.assertAlmostEqual(result.series["press@pn_c1_8"][0], -1300.0) + self.assertAlmostEqual(result.series["vol@pn_c1_8"][0], 15000.0) + self.assertAlmostEqual( + result.series["mgas1@pn_c1_8"][0], + self.amesim_results.series("mgas1@pn_c1_8")[0], + delta=1.0e-4, + ) + self.assertAlmostEqual(result.series["vol1@pn_brp2_8"][0], 0.0) + self.assertAlmostEqual(result.series["vvol1@pn_brp2_8"][0], 0.0) + self.assertAlmostEqual(result.series["x1@mass_friction_endstops_10"][0], 0.0) + self.assertAlmostEqual(result.series["v1@mass_friction_endstops_10"][0], 0.0) + self.assertAlmostEqual( + result.series["acc1@mass_friction_endstops_10"][0], + -0.8167936695810917, + ) + self.assertAlmostEqual(result.series["acc1@mass_friction_endstops_18"][0], 0.0) + self.assertAlmostEqual(result.series["p2@pneumatic_69"][0], -1300.0) + self.assertAlmostEqual(result.series["t2@pneumatic_69"][0], 293.15) + self.assertAlmostEqual( + result.series["mgas@pneumatic_69"][0], + self.amesim_results.series("mgas@pneumatic_69")[0], + delta=1.0e-5, + ) + self.assertAlmostEqual(result.series["re@pneumatic_69"][0], 0.0) + self.assertAlmostEqual(result.series["v@pneumatic_69"][0], 0.0) + self.assertGreater(result.series["ff@pneumatic_69"][0], 0.0) + self.assertAlmostEqual(result.series["dm1@pneumatic_69"][0], 0.0) + self.assertAlmostEqual(result.series["xv@pn_morifice_1"][0], 0.0) + self.assertAlmostEqual(result.series["dm2@pn_morifice_1"][0], 0.0) + + def test_simulates_full_132_state_closure(self) -> None: + solution = self.system.simulate_full_state_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), + t_eval=[0.0, 1.0e-5], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 2) + self.assertEqual(len(solution.y), 132) + self.assertEqual([len(row) for row in solution.y], [2] * 132) + + def test_p4_closure_uses_structured_neighborhood(self) -> None: + closure = self.system.pn3_p4_node_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + neighborhood = self.system.p4_node_neighborhood(closure.components.p4_node.alias) + + self.assertEqual( + closure.components.p4_primary_line.name, + neighborhood.primary.line_alias, + ) + self.assertEqual( + closure.components.p4_primary_chamber.name, + neighborhood.primary.chamber_alias, + ) + self.assertEqual( + closure.components.p4_port1_line.name, + neighborhood.port_1.line_alias, + ) + self.assertEqual( + closure.components.p4_port1_remote_node.alias, + neighborhood.port_1.remote_node_alias, + ) + port1_remote_neighborhood = self.system.p4_node_neighborhood( + neighborhood.port_1.remote_node_alias + ) + self.assertEqual( + closure.components.p4_port1_remote_port1_line.name, + port1_remote_neighborhood.port_1.line_alias, + ) + self.assertEqual( + closure.components.p4_port1_far_node.alias, + port1_remote_neighborhood.port_1.remote_node_alias, + ) + self.assertEqual( + closure.components.p4_port1_far_primary_line.name, + self.system._p4_primary_connection_for_node( + port1_remote_neighborhood.port_1.remote_node_alias + ).line_alias, + ) + port1_far_port1 = self.system._pnl0002_connection_for_node_port( + closure.components.p4_port1_far_node.alias, + "port_1", + ) + self.assertEqual( + closure.components.p4_port1_far_port1_line.name, + port1_far_port1.line_alias, + ) + self.assertEqual( + closure.components.p4_port1_next_node.alias, + port1_far_port1.remote_node_alias, + ) + self.assertEqual( + closure.components.p4_port1_next_primary_line.name, + self.system._p4_primary_connection_for_node( + port1_far_port1.remote_node_alias + ).line_alias, + ) + port1_next_port1 = self.system._pnl0002_connection_for_node_port( + closure.components.p4_port1_next_node.alias, + "port_1", + ) + self.assertEqual( + closure.components.p4_port1_next_port1_line.name, + port1_next_port1.line_alias, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_output_line.name, + self.system._pnl0001_line_for_node_port( + closure.components.p4_port1_next_node.alias, + "port_4", + ).name, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_node.alias, + "pn_node3_12", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_line.name, + self.system._pnl0003_line_for_node_port("pn_node3_12", "port_2").name, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice.name, + "pn_morifice_11", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance.name, + self.system._pnl00r_line_for_node_port("pn_node3_12", "port_3").name, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_node.alias, + "pn_node3_11", + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_line.name, + self.system._pnl0003_line_for_node_port("pn_node3_11", "port_2").name, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_orifice.name, + "pn_morifice_15", + ) + resistance_chamber_segment = ( + self.system.discover_pneumatic_branch_topology().chamber_segment_specs[1] + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_spec, + resistance_chamber_segment, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_inlet_line.name, + resistance_chamber_segment.inlet_line_alias, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_outlet_line.name, + resistance_chamber_segment.outlet_line_alias, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice.name, + resistance_chamber_segment.inlet_orifice_alias, + ) + self.assertEqual( + closure.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice.name, + resistance_chamber_segment.outlet_orifice_alias, + ) + self.assertEqual( + closure.components.p4_bridge_node.alias, + port1_next_port1.remote_node_alias, + ) + self.assertEqual( + closure.components.p4_bridge_primary_line.name, + self.system._p4_primary_connection_for_node( + closure.components.p4_bridge_node.alias + ).line_alias, + ) + self.assertEqual( + closure.components.p4_port1_remote_orifice.name, + port1_remote_neighborhood.port_4.orifice_alias, + ) + self.assertEqual( + closure.components.p4_port1_remote_orifice_line.name, + self.system._pnl0003_line_for_node_port("pn_node3_10", "port_2").name, + ) + self.assertEqual( + closure.components.p4_port1_remote_orifice_node.alias, + "pn_node3_10", + ) + self.assertEqual( + closure.components.node_resistance.name, + self.system._pnl00r_line_for_node_port("pn_node3_10", "port_3").name, + ) + self.assertEqual( + closure.components.node.alias, + "pn_node3_9", + ) + self.assertEqual( + closure.components.p4_port3_line.name, + neighborhood.port_3.line_alias, + ) + self.assertEqual( + closure.components.p4_port3_remote_node.alias, + neighborhood.port_3.remote_node_alias, + ) + port3_remote_neighborhood = self.system.p4_node_neighborhood( + neighborhood.port_3.remote_node_alias + ) + self.assertEqual( + closure.components.p4_port3_remote_port3_line.name, + port3_remote_neighborhood.port_3.line_alias, + ) + self.assertEqual( + closure.components.p4_port3_far_node.alias, + port3_remote_neighborhood.port_3.remote_node_alias, + ) + self.assertEqual( + closure.components.p4_port3_far_primary_line.name, + self.system._p4_primary_connection_for_node( + port3_remote_neighborhood.port_3.remote_node_alias + ).line_alias, + ) + port3_far_port3 = self.system._pnl0002_connection_for_node_port( + closure.components.p4_port3_far_node.alias, + "port_3", + ) + self.assertEqual( + closure.components.p4_port3_far_port3_line.name, + port3_far_port3.line_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_node.alias, + port3_far_port3.remote_node_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_primary_line.name, + self.system._p4_primary_connection_for_node( + port3_far_port3.remote_node_alias + ).line_alias, + ) + port3_next_port3 = self.system._pnl0002_connection_for_node_port( + closure.components.p4_port3_next_node.alias, + "port_3", + ) + self.assertEqual( + closure.components.p4_port3_next_port3_line.name, + port3_next_port3.line_alias, + ) + self.assertEqual( + closure.components.p4_bridge_node.alias, + port3_next_port3.remote_node_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_output_line.name, + self.system._pnl0001_line_for_node_port( + closure.components.p4_port3_next_node.alias, + "port_4", + ).name, + ) + self.assertEqual( + closure.components.p4_port3_next_orifice.name, + "pn_morifice_13", + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_line.name, + self.system._pnl0003_line_for_node_port("pn_node3_14", "port_2").name, + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_node.alias, + "pn_node3_14", + ) + chamber_segment = ( + self.system.discover_pneumatic_branch_topology().chamber_segment_specs[3] + ) + self.assertEqual( + closure.components.p4_port3_next_chamber_spec, + chamber_segment, + ) + self.assertEqual( + closure.components.p4_port3_next_chamber_inlet_line.name, + chamber_segment.inlet_line_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_chamber_outlet_line.name, + chamber_segment.outlet_line_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_orifice_resistance.name, + self.system._pnl00r_line_for_node_port("pn_node3_14", "port_3").name, + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_node.alias, + "pn_node3_13", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_node_line.name, + self.system._pnl0003_line_for_node_port("pn_node3_13", "port_2").name, + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_orifice.name, + "pn_morifice_12", + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_output_line.name, + self.system._pnl0001_line_for_node_port( + closure.components.p4_bridge_node.alias, + "port_4", + ).name, + ) + resistance_chamber_segment = ( + self.system.discover_pneumatic_branch_topology().chamber_segment_specs[2] + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_spec, + resistance_chamber_segment, + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_inlet_line.name, + resistance_chamber_segment.inlet_line_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_outlet_line.name, + resistance_chamber_segment.outlet_line_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_inlet_orifice.name, + resistance_chamber_segment.inlet_orifice_alias, + ) + self.assertEqual( + closure.components.p4_port3_next_resistance_chamber_outlet_orifice.name, + resistance_chamber_segment.outlet_orifice_alias, + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice.name, + port3_remote_neighborhood.port_4.orifice_alias, + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice_line.name, + self.system._pnl0003_line_for_node_port("pn_node3_8", "port_2").name, + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice_node.alias, + "pn_node3_8", + ) + self.assertEqual( + closure.components.p4_port3_remote_orifice_resistance.name, + self.system._pnl00r_line_for_node_port("pn_node3_8", "port_3").name, + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_node.alias, + "pn_node3_15", + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_node_line.name, + self.system._pnl0003_line_for_node_port("pn_node3_15", "port_2").name, + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_orifice.name, + "pn_morifice_14", + ) + self.assertEqual( + closure.components.p4_port3_remote_resistance_output_line.name, + self.system._pnl0001_line_for_node_port( + closure.components.p4_port3_far_node.alias, + "port_4", + ).name, + ) + self.assertEqual( + closure.components.node_orifice.name, + neighborhood.port_4.orifice_alias, + ) + + def test_p4_node_flow_cancels_between_pnvo_pnl0002_and_primary_line(self) -> None: + closure = self.system.pn3_p4_node_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + + self.assertAlmostEqual(closure.components.node_orifice.opening, 0.0) + closure.components.node_orifice.opening = 1.0 + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertGreater(snapshot.pnl0003_to_p4_flow, 0.0) + self.assertAlmostEqual( + snapshot.p4_balance.port_2_mass_flow_g_s, + ( + -snapshot.p4_to_port1_line_flow + - snapshot.p4_to_port3_line_flow + + snapshot.pnl0003_to_p4_flow + ) + * 1.0e3, + ) + self.assertAlmostEqual( + snapshot.p4_node_to_primary_line_flow, + snapshot.p4_balance.port_2_mass_flow_g_s * 1.0e-3, + ) + self.assertAlmostEqual( + snapshot.p4_node_to_primary_line_flow + + snapshot.p4_to_port1_line_flow + + snapshot.p4_to_port3_line_flow + - snapshot.pnl0003_to_p4_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port1_remote_node_to_primary_line_flow + + snapshot.p4_port1_remote_node_to_line_flow + + snapshot.p4_port1_remote_to_port1_line_flow + - snapshot.p4_port1_remote_orifice_to_node_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port1_far_node_to_primary_line_flow + + snapshot.p4_port1_far_node_to_line_flow + + snapshot.p4_port1_far_to_next_line_flow + - snapshot.p4_port1_next_orifice_resistance_to_p4_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port1_next_node_to_primary_line_flow + + snapshot.p4_port1_next_node_to_line_flow + + snapshot.p4_port1_next_to_bridge_line_flow + + snapshot.p4_port1_next_orifice_p4_to_output_line_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_bridge_node_to_primary_line_flow + + snapshot.p4_bridge_to_port1_next_line_flow + + snapshot.p4_bridge_to_port3_next_line_flow + - snapshot.p4_port3_next_resistance_output_to_p4_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port1_remote_node_to_orifice_line_flow + + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + - snapshot.node_to_resistance_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.node_to_pnl0003_flow + + snapshot.node_to_resistance_flow + + snapshot.outlet_node_to_line_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_remote_node_to_orifice_line_flow + + snapshot.inlet_node_to_line_flow + + snapshot.p4_port3_remote_node_to_resistance_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_remote_node_to_primary_line_flow + + snapshot.p4_port3_remote_node_to_line_flow + + snapshot.p4_port3_remote_to_port3_line_flow + - snapshot.p4_port3_remote_orifice_to_node_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_remote_resistance_node_to_line_flow + - snapshot.p4_port3_remote_node_to_resistance_flow + + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_remote_resistance_output_to_p4_flow, + snapshot.p4_port3_remote_resistance_to_output_line_flow, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_far_node_to_primary_line_flow + + snapshot.p4_port3_far_node_to_line_flow + + snapshot.p4_port3_far_to_next_line_flow + - snapshot.p4_port3_remote_resistance_output_to_p4_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_next_node_to_primary_line_flow + + snapshot.p4_port3_next_node_to_line_flow + + snapshot.p4_port3_next_to_bridge_line_flow + - snapshot.p4_port3_next_orifice_output_to_p4_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_next_orifice_node_to_line_flow + + snapshot.p4_port3_next_chamber_inlet_node_to_line_flow + - snapshot.p4_port3_next_resistance_node_to_next_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_next_orifice_output_to_p4_flow, + snapshot.p4_port3_next_orifice_to_output_line_flow, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_next_resistance_node_to_line_flow + + snapshot.p4_port3_next_resistance_node_to_next_flow + + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port3_next_resistance_output_to_p4_flow, + snapshot.p4_port3_next_resistance_to_output_line_flow, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port1_next_orifice_node_to_line_flow + + snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow + - snapshot.p4_port1_next_orifice_resistance_node_to_next_flow, + 0.0, + delta=1.0e-12, + ) + self.assertAlmostEqual( + snapshot.p4_port1_next_orifice_resistance_node_to_line_flow + + snapshot.p4_port1_next_orifice_resistance_node_to_next_flow + + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow, + 0.0, + delta=1.0e-12, + ) + next_chamber_mass_sum = rhs[78] + rhs[80] + rhs[82] + self.assertAlmostEqual( + next_chamber_mass_sum, + ( + snapshot.p4_port3_next_chamber_inlet_node_to_line_flow + + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow + ), + delta=1.0e-12, + ) + resistance_chamber_mass_sum = rhs[90] + rhs[92] + rhs[94] + self.assertAlmostEqual( + resistance_chamber_mass_sum, + ( + snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow + + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow + ), + delta=1.0e-12, + ) + p4_port1_next_orifice_line_mass_sum = rhs[96] + rhs[98] + self.assertAlmostEqual( + p4_port1_next_orifice_line_mass_sum, + ( + snapshot.p4_port1_next_orifice_node_to_line_flow + - snapshot.p4_port1_next_orifice_to_output_line_flow + ), + delta=1.0e-12, + ) + self.assertAlmostEqual( + rhs[100], + ( + snapshot.p4_port1_next_orifice_p4_to_output_line_flow + + snapshot.p4_port1_next_orifice_to_output_line_flow + ), + delta=1.0e-12, + ) + p4_port1_next_orifice_resistance_line_mass_sum = rhs[102] + rhs[104] + self.assertAlmostEqual( + p4_port1_next_orifice_resistance_line_mass_sum, + ( + snapshot.p4_port1_next_orifice_resistance_node_to_line_flow + - snapshot.p4_port1_next_orifice_resistance_to_p4_flow + ), + delta=1.0e-12, + ) + p4_port1_next_orifice_resistance_chamber_mass_sum = ( + rhs[106] + rhs[108] + rhs[110] + ) + self.assertAlmostEqual( + p4_port1_next_orifice_resistance_chamber_mass_sum, + ( + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow + + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow + ), + delta=1.0e-12, + ) + mass_derivative_sum = sum(rhs[index] for index in range(0, 112, 2)) + self.assertAlmostEqual( + mass_derivative_sum, + 0.0, + delta=1.0e-12, + ) + + def test_simulates_one_hundred_twelve_state_pn3_p4_node_chamber_segment(self) -> None: + solution = self.system.simulate_pn3_p4_node_chamber_segment_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0e-10, + max_step=1.0e-11, + ), + t_eval=[0.0, 5.0e-11, 1.0e-10], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 3) + self.assertEqual(len(solution.y), 112) + self.assertEqual([len(row) for row in solution.y], [3] * 112) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_pnl0002.py b/tests/test_test_mql_pnl0002.py new file mode 100644 index 0000000..4139cb3 --- /dev/null +++ b/tests/test_test_mql_pnl0002.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.systems.test_mql import TestMqlSystem +from PythonModels.systems.test_mql_line_parameters import load_test_mql_pnl0002_specs +from PythonModels.systems.test_mql_pneumatic_lines import build_test_mql_pnl0002_assembly + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlPnl0002Tests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.pnl0002_specs = load_test_mql_pnl0002_specs(TEST_MQL_AME) + cls.pnl0002_assembly = build_test_mql_pnl0002_assembly(TEST_MQL_AME) + cls.results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_loads_all_real_pnl0002_parameters_from_cir(self) -> None: + self.assertEqual(len(self.pnl0002_specs), 8) + spec = self.pnl0002_assembly.spec("pneumatic_86") + + self.assertEqual(spec.source_component, "pnnode4_17") + self.assertEqual(spec.source_port, "port_1") + self.assertEqual(spec.target_component, "pnnode4_18") + self.assertEqual(spec.target_port, "port_3") + self.assertEqual(spec.diameter_mm, 20.0) + self.assertEqual(spec.length_m, 2.0) + self.assertAlmostEqual(spec.relative_roughness, 0.045 / 20.0) + self.assertEqual(spec.gas_type_index, 1) + self.assertEqual(spec.mode, 2) + self.assertAlmostEqual(spec.initial_center_temperature_k, 293.15) + self.assertAlmostEqual(spec.initial_center_gauge_pressure_pa, -1300.0) + self.assertAlmostEqual(spec.initial_center_absolute_pressure_pa, 100_000.0) + + def test_builds_pnl0002_center_compliance_line_components(self) -> None: + self.assertEqual(len(self.pnl0002_assembly.lines), 8) + self.assertTrue( + all(len(line.get_state_vector()) == 2 for line in self.pnl0002_assembly.lines.values()) + ) + + def test_pneumatic_86_initial_observables_match_amesim_baseline(self) -> None: + pipe = self.pnl0002_assembly.lines["pneumatic_86"] + properties = pipe.properties() + + self.assertAlmostEqual( + properties.p - 101_300.0, + self.results.series("pctr@pneumatic_86")[0], + delta=1.0e-6, + ) + self.assertAlmostEqual( + properties.T, + self.results.series("tctr@pneumatic_86")[0], + delta=1.0e-12, + ) + self.assertAlmostEqual( + pipe.gas_mass_g(), + self.results.series("mgas@pneumatic_86")[0], + delta=0.001, + ) + + def test_amesim_mass_balance_uses_opposite_saved_port_flow_signs(self) -> None: + index = 500 + times = self.results.times + mass = self.results.series("mgas@pneumatic_86") + finite_difference_g_s = ( + mass[index + 1] - mass[index - 1] + ) / (times[index + 1] - times[index - 1]) + saved_port_flow_g_s = ( + self.results.series("dm1@pneumatic_86")[index] + + self.results.series("dm2@pneumatic_86")[index] + ) + + self.assertAlmostEqual( + finite_difference_g_s, + -saved_port_flow_g_s, + delta=1.0e-5, + ) + + def test_system_exposes_real_pnl0002_assembly(self) -> None: + system = TestMqlSystem() + + self.assertEqual(system.typed_pnl0002_line_count, 8) + self.assertIn("pneumatic_86", system.pnl0002_assembly.lines) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_pnl0003_pnl00r.py b/tests/test_test_mql_pnl0003_pnl00r.py new file mode 100644 index 0000000..a47bb19 --- /dev/null +++ b/tests/test_test_mql_pnl0003_pnl00r.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.systems.test_mql import TestMqlSystem +from PythonModels.systems.test_mql_line_parameters import ( + load_test_mql_pnl0003_specs, + load_test_mql_pnl00r_specs, +) +from PythonModels.systems.test_mql_pneumatic_lines import ( + build_test_mql_pnl0003_assembly, + build_test_mql_pnl00r_assembly, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlPnl0003AndPnl00rTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.pnl0003_specs = load_test_mql_pnl0003_specs(TEST_MQL_AME) + cls.pnl00r_specs = load_test_mql_pnl00r_specs(TEST_MQL_AME) + cls.pnl0003_assembly = build_test_mql_pnl0003_assembly(TEST_MQL_AME) + cls.pnl00r_assembly = build_test_mql_pnl00r_assembly(TEST_MQL_AME) + cls.results = load_test_mql_amesim_results(TEST_MQL_AME) + + def test_loads_all_real_pnl0003_parameters_from_cir(self) -> None: + self.assertEqual(len(self.pnl0003_specs), 8) + spec = self.pnl0003_assembly.spec("pneumatic_88") + + self.assertEqual(spec.source_component, "pn_node3_9") + self.assertEqual(spec.target_component, "pn_morifice_9") + self.assertEqual(spec.diameter_mm, 20.0) + self.assertEqual(spec.length_m, 0.3) + self.assertAlmostEqual(spec.relative_roughness, 0.045 / 20.0) + self.assertEqual(spec.gas_type_index, 1) + self.assertEqual(spec.mode, 2) + self.assertAlmostEqual(spec.initial_gauge_pressure_1_pa, 15_198_700.0) + self.assertAlmostEqual(spec.initial_gauge_pressure_2_pa, 15_198_700.0) + self.assertAlmostEqual(spec.initial_absolute_pressure_1_pa, 15_300_000.0) + self.assertAlmostEqual(spec.initial_absolute_pressure_2_pa, 15_300_000.0) + + def test_loads_all_real_pnl00r_parameters_from_cir(self) -> None: + self.assertEqual(len(self.pnl00r_specs), 4) + spec = self.pnl00r_assembly.spec("pneumatic_100") + + self.assertEqual(spec.source_component, "pn_node3_9") + self.assertEqual(spec.target_component, "pn_node3_10") + self.assertEqual(spec.diameter_mm, 14.0) + self.assertEqual(spec.length_m, 1.0) + self.assertAlmostEqual(spec.relative_roughness, 0.045 / 14.0) + self.assertEqual(spec.gas_type_index, 1) + + def test_builds_pnl0003_and_pnl00r_physical_line_components(self) -> None: + self.assertEqual(len(self.pnl0003_assembly.lines), 8) + self.assertEqual(len(self.pnl00r_assembly.lines), 4) + self.assertTrue( + all(len(line.get_state_vector()) == 4 for line in self.pnl0003_assembly.lines.values()) + ) + + def test_pneumatic_88_initial_observables_match_amesim_baseline(self) -> None: + pipe = self.pnl0003_assembly.lines["pneumatic_88"] + + self.assertAlmostEqual( + pipe.properties_1().p - 101_300.0, + self.results.series("p1@pneumatic_88")[0], + delta=1.0e-5, + ) + self.assertAlmostEqual( + pipe.properties_2().p - 101_300.0, + self.results.series("p2@pneumatic_88")[0], + delta=1.0e-5, + ) + self.assertAlmostEqual( + pipe.gas_mass_g(), + self.results.series("mgas@pneumatic_88")[0], + delta=0.005, + ) + + def test_system_exposes_real_pnl0003_and_pnl00r_assemblies(self) -> None: + system = TestMqlSystem() + + self.assertEqual(system.typed_pnl0003_line_count, 8) + self.assertEqual(system.typed_pnl00r_line_count, 4) + self.assertIn("pneumatic_88", system.pnl0003_assembly.lines) + self.assertIn("pneumatic_100", system.pnl00r_assembly.lines) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_structural.py b/tests/test_test_mql_structural.py new file mode 100644 index 0000000..f85a504 --- /dev/null +++ b/tests/test_test_mql_structural.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import unittest + +from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + AmesimVariablePneumaticVolume, +) +from PythonModels.systems.test_mql import GLOBAL_PARAMETERS, SUBMODEL_COUNTS, TestMqlSystem + + +class TestMqlStructuralTest(unittest.TestCase): + def test_snapshot_matches_amesim_archive_metadata(self) -> None: + snapshot = TestMqlSystem().snapshot() + + self.assertEqual(snapshot.model_name, "test_mql") + self.assertEqual(snapshot.component_count, 117) + self.assertEqual(snapshot.connection_count, 84) + self.assertEqual(snapshot.continuous_state_count, 132) + self.assertEqual(snapshot.discrete_state_count, 24) + self.assertEqual(snapshot.global_parameters["P0"], "153") + self.assertEqual(snapshot.global_parameters["cf"], "0.45") + + def test_network_preserves_component_and_connection_counts(self) -> None: + system = TestMqlSystem() + + self.assertEqual(len(system.network.components), 117) + self.assertEqual(len(system.network.connections), 84) + self.assertIn("pn_brp2_8", system.network.components) + self.assertIn("pn_gas_data", system.network.components) + + def test_network_uses_typed_pneumatic_components_where_available(self) -> None: + system = TestMqlSystem() + + self.assertEqual(system.typed_pneumatic_component_count, 28) + self.assertIsInstance( + system.network.components["pn_general_chamber"], + AmesimPneumaticVolume, + ) + self.assertIsInstance( + system.network.components["pn_c1_8"], + AmesimVariablePneumaticVolume, + ) + self.assertIsInstance( + system.network.components["pn_orifice_18"], + AmesimPneumaticOrifice, + ) + self.assertIsInstance( + system.network.components["pn_morifice_11"], + AmesimPneumaticOrifice, + ) + self.assertEqual(len(system.pneumatic_state_vector()), 24) + + def test_can_build_pneumatic_branch_closure_from_typed_components(self) -> None: + system = TestMqlSystem() + + closure = system.pneumatic_branch_closure( + name="typed_branch", + upstream_volume_alias="pn_general_chamber", + orifice_alias="pn_orifice_18", + downstream_volume_alias="pn_c1_8", + ) + snapshot = closure.snapshot() + + self.assertGreater(snapshot.flow, 0.0) + self.assertIs( + closure.components.upstream_volume, + system.network.components["pn_general_chamber"], + ) + self.assertIs( + closure.components.orifice, + system.network.components["pn_orifice_18"], + ) + self.assertIs( + closure.components.downstream_volume, + system.network.components["pn_c1_8"], + ) + self.assertAlmostEqual( + closure.components.upstream_volume.port_a.m_flow, + -snapshot.flow, + ) + self.assertAlmostEqual( + closure.components.downstream_volume.port_a.m_flow, + snapshot.flow, + ) + + def test_pneumatic_branch_closure_rejects_structural_components(self) -> None: + system = TestMqlSystem() + + with self.assertRaises(TypeError): + system.pneumatic_branch_closure( + name="invalid_branch", + upstream_volume_alias="pn_brp2_8", + orifice_alias="pn_orifice_18", + downstream_volume_alias="pn_c1_8", + ) + + def test_submodel_counts_keep_key_amesim_families(self) -> None: + self.assertEqual(SUBMODEL_COUNTS["PNRP17"], 8) + self.assertEqual(SUBMODEL_COUNTS["PNCH012"], 8) + self.assertEqual(SUBMODEL_COUNTS["PNOR001"], 8) + self.assertEqual(SUBMODEL_COUNTS["PNVO001"], 8) + self.assertEqual(SUBMODEL_COUNTS["PNGD00"], 1) + self.assertEqual(GLOBAL_PARAMETERS["V"], "15") + + def test_structural_simulation_shape(self) -> None: + result = TestMqlSystem().simulate() + + self.assertEqual(len(result.t), 101) + self.assertEqual(len(result.y), 132) + self.assertEqual(result.series["component_count"][0], 117.0) + self.assertEqual(result.series["connection_count"][0], 84.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_topology.py b/tests/test_test_mql_topology.py new file mode 100644 index 0000000..fe3947d --- /dev/null +++ b/tests/test_test_mql_topology.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlCirTopologyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.topology = load_test_mql_cir_topology(TEST_MQL_AME) + + def test_recovers_direct_component_contacts_omitted_from_line_specs(self) -> None: + self.assertEqual(len(self.topology.component_contacts), 54) + contacts = self.topology.contacts_for("pn_general_chamber") + + self.assertEqual(len(contacts), 2) + self.assertEqual( + { + ( + contact.port_for("pn_general_chamber"), + *contact.other_endpoint("pn_general_chamber"), + ) + for contact in contacts + }, + { + ("port_1", "pn_orifice_19", "port_2"), + ("port_2", "pn_orifice_18", "port_1"), + }, + ) + + def test_component_contact_lookup_rejects_unrelated_alias(self) -> None: + contact = self.topology.contacts_for("pn_general_chamber")[0] + + with self.assertRaises(KeyError): + contact.other_endpoint("not-an-endpoint") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_mql_variables.py b/tests/test_test_mql_variables.py new file mode 100644 index 0000000..fc80290 --- /dev/null +++ b/tests/test_test_mql_variables.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_variables import ( + build_test_mql_variable_catalog, + split_data_path, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlVariableCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.catalog = build_test_mql_variable_catalog(cls.amesim_results) + + def test_catalog_covers_all_amesim_data_paths(self) -> None: + self.assertEqual(self.catalog.data_path_count, 1100) + self.assertEqual(self.catalog.saved_data_path_count, 1100) + self.assertEqual(self.catalog.counts_by_owner_kind(), {"component": 712, "connection": 388}) + + def test_component_variable_mapping_preserves_alias_and_submodel(self) -> None: + variable = self.catalog.by_data_path("temp3@pn_c1_8") + + self.assertEqual(variable.index, 0) + self.assertEqual(variable.signal_name, "temp3") + self.assertEqual(variable.owner_alias, "pn_c1_8") + self.assertEqual(variable.owner_kind, "component") + self.assertEqual(variable.submodel, "PNCH012") + self.assertEqual(variable.units, "K") + self.assertTrue(variable.saved) + + def test_connection_variable_mapping_preserves_line_submodel(self) -> None: + variable = self.catalog.by_data_path("dm1@pneumatic_69") + + self.assertEqual(variable.signal_name, "dm1") + self.assertEqual(variable.owner_alias, "pneumatic_69") + self.assertEqual(variable.owner_kind, "connection") + self.assertEqual(variable.submodel, "PNL0001") + self.assertEqual(variable.units, "g/s") + + def test_counts_by_submodel_include_components_and_connections(self) -> None: + counts = self.catalog.counts_by_submodel() + + self.assertEqual(counts["PNL0001"], 180) + self.assertEqual(counts["PNCH012"], 80) + self.assertEqual(counts["PNRP17"], 72) + self.assertEqual(counts["PNVO001"], 56) + self.assertEqual(counts["PNOR001"], 48) + self.assertEqual(counts["PNCH023"], 20) + + def test_owner_lookup_returns_data_paths_for_component(self) -> None: + paths = self.catalog.data_paths_for_owner("pn_c1_8") + + self.assertIn("temp3@pn_c1_8", paths) + self.assertIn("press3@pn_c1_8", paths) + self.assertEqual(len(paths), 10) + + def test_split_data_path_rejects_invalid_values(self) -> None: + self.assertEqual(split_data_path("press3@pn_c1_8"), ("press3", "pn_c1_8")) + with self.assertRaises(ValueError): + split_data_path("press3") + + +if __name__ == "__main__": + unittest.main()