Compare commits
10
Commits
e87df789b2
...
503394f3ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503394f3ed | ||
|
|
410ef535e8 | ||
|
|
e7177ab03e | ||
|
|
3e4f466114 | ||
|
|
127ec36a55 | ||
|
|
bbc88a6bbb | ||
|
|
f7f1078911 | ||
|
|
f1256a121d | ||
|
|
104d41d294 | ||
|
|
c52266ba11 |
No files matched your search
@@ -1,680 +0,0 @@
|
||||
# 组件模型建模规范 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/<library_id>/<category_id>/<model_module>.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 能映射到正确模型。
|
||||
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
|
||||
- 针对性测试、完整回归和必要的前端构建通过。
|
||||
- 文档记录了模型假设、适用范围和已知限制。
|
||||
@@ -1,6 +1,6 @@
|
||||
# test_mql
|
||||
|
||||
本目录记录 AMESim 模型 `test_mql.ame` 向 `PythonModels` 迁移时使用的源模型信息、结果对齐约定和当前进度。
|
||||
本目录记录 AMESim 模型 `test_mql.ame` 向 `app.simulation` 迁移时使用的源模型信息、结果对齐约定和当前进度。
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -13,9 +13,9 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
|
||||
## 模型与源数据
|
||||
|
||||
- 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`
|
||||
- Python 系统类:`app.simulation.examples.test_mql.system.TestMqlSystem`
|
||||
- 结构运行入口:`app.simulation.examples.test_mql.run`
|
||||
- 132 状态比较入口:`app.simulation.examples.test_mql.run_full_state_comparison`
|
||||
- AMESim 组件数:117
|
||||
- LINE 连接数:84
|
||||
- 连续状态数:132
|
||||
@@ -47,16 +47,16 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
|
||||
|
||||
主要实现位置:
|
||||
|
||||
- `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`
|
||||
- `app/simulation/examples/test_mql/system.py`
|
||||
- `app/simulation/examples/test_mql/closure.py`
|
||||
- `app/simulation/examples/test_mql/pneumatic.py`
|
||||
- `app/simulation/examples/test_mql/mechanical.py`
|
||||
- `app/simulation/examples/test_mql/lines.py`
|
||||
- `app/simulation/examples/test_mql/primitives/`
|
||||
- `app/simulation/examples/test_mql/structural_network.py`
|
||||
- `app/simulation/reporting/amesim_results.py`
|
||||
- `app/simulation/reporting/test_mql_comparison.py`
|
||||
- `app/simulation/examples/test_mql/run_full_state_comparison.py`
|
||||
|
||||
## 当前对比结果
|
||||
|
||||
@@ -109,13 +109,13 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
|
||||
运行默认短时域 comparison:
|
||||
|
||||
```bash
|
||||
python3 -m PythonModels.scripts.run_test_mql_full_state_comparison
|
||||
python3 -m app.simulation.examples.test_mql.run_full_state_comparison
|
||||
```
|
||||
|
||||
运行 PNVO 事件边界诊断:
|
||||
|
||||
```bash
|
||||
python3 -m PythonModels.scripts.run_test_mql_full_state_comparison --pnvo-event-boundary
|
||||
python3 -m app.simulation.examples.test_mql.run_full_state_comparison --pnvo-event-boundary
|
||||
```
|
||||
|
||||
运行相关测试:
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,371 +0,0 @@
|
||||
# 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.<branch_name>.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 原始结果,参数大概率还要继续调。
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Python port scaffold for the Modelica-based pressurization system."""
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
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%
|
||||
@@ -1,202 +0,0 @@
|
||||
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
|
||||
|
@@ -1,2 +0,0 @@
|
||||
"""Component implementations for the Python system model."""
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
@@ -1,28 +0,0 @@
|
||||
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)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
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)
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
@@ -1,172 +0,0 @@
|
||||
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)
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Core abstractions for the Python system model."""
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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."""
|
||||
@@ -1,96 +0,0 @@
|
||||
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)
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
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)
|
||||
@@ -1,21 +0,0 @@
|
||||
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])
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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",
|
||||
]
|
||||
@@ -1,393 +0,0 @@
|
||||
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'<line x1="{x:.2f}" y1="{top}" x2="{x:.2f}" y2="{top + plot_height}" '
|
||||
'stroke="#d9e2ec" stroke-width="1" />'
|
||||
)
|
||||
x_tick_markup.append(
|
||||
f'<text x="{x:.2f}" y="{height - 30}" text-anchor="middle" '
|
||||
'font-size="14" fill="#102a43">'
|
||||
f"{time_value:.1f}</text>"
|
||||
)
|
||||
|
||||
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'<line x1="{left}" y1="{y:.2f}" x2="{left + plot_width}" y2="{y:.2f}" '
|
||||
'stroke="#d9e2ec" stroke-width="1" />'
|
||||
)
|
||||
y_tick_markup.append(
|
||||
f'<text x="{left - 12}" y="{y + 5:.2f}" text-anchor="end" '
|
||||
'font-size="14" fill="#102a43">'
|
||||
f"{temp_value:.1f}</text>"
|
||||
)
|
||||
|
||||
svg_content = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||
<rect width="{width}" height="{height}" fill="#f7fafc" rx="18" ry="18" />
|
||||
<text x="{width / 2:.0f}" y="32" text-anchor="middle" font-size="24" fill="#102a43">Python Testmodel Tank Temperature</text>
|
||||
<text x="{width / 2:.0f}" y="{height - 8}" text-anchor="middle" font-size="16" fill="#486581">Time (s)</text>
|
||||
<text x="26" y="{height / 2:.0f}" text-anchor="middle" font-size="16" fill="#486581" transform="rotate(-90 26 {height / 2:.0f})">Temperature (K)</text>
|
||||
<rect x="{left}" y="{top}" width="{plot_width}" height="{plot_height}" fill="#ffffff" stroke="#bcccdc" stroke-width="1.5" />
|
||||
{''.join(x_tick_markup)}
|
||||
{''.join(y_tick_markup)}
|
||||
<polyline fill="none" stroke="#d64545" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" points="{points}" />
|
||||
</svg>
|
||||
"""
|
||||
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,
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
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
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
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
|
||||
@@ -1,74 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.systems.test_mql import TestMqlRunConfig, TestMqlSystem
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPathConfig:
|
||||
output_dir: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlExecutionConfig:
|
||||
write_summary: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlScriptConfig:
|
||||
run: TestMqlRunConfig = field(default_factory=TestMqlRunConfig)
|
||||
paths: TestMqlPathConfig = field(default_factory=TestMqlPathConfig)
|
||||
execution: TestMqlExecutionConfig = field(default_factory=TestMqlExecutionConfig)
|
||||
|
||||
|
||||
def _default_output_dir() -> Path:
|
||||
pythonmodels_root = Path(__file__).resolve().parents[1]
|
||||
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
|
||||
return pythonmodels_root / "runs" / timestamp
|
||||
|
||||
|
||||
def format_test_mql_summary(system: TestMqlSystem) -> str:
|
||||
snapshot = system.snapshot()
|
||||
lines = [
|
||||
"Model: test_mql",
|
||||
f"Source archive: {system.archive_path}",
|
||||
f"Components: {snapshot.component_count}",
|
||||
f"Connections: {snapshot.connection_count}",
|
||||
f"Continuous states in AMESim modelinfo: {snapshot.continuous_state_count}",
|
||||
f"Discrete states in AMESim modelinfo: {snapshot.discrete_state_count}",
|
||||
"Global parameters:",
|
||||
]
|
||||
for name, value in sorted(snapshot.global_parameters.items()):
|
||||
lines.append(f" - {name}: {value}")
|
||||
lines.append("Component submodels:")
|
||||
for name, count in sorted(snapshot.submodel_counts.items()):
|
||||
lines.append(f" - {name}: {count}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run_test_mql(config: TestMqlScriptConfig | None = None):
|
||||
config = config or TestMqlScriptConfig()
|
||||
system = TestMqlSystem()
|
||||
result = system.simulate(config.run)
|
||||
output_dir = config.paths.output_dir or _default_output_dir()
|
||||
if config.execution.write_summary:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
(output_dir / "test_mql_model_summary.txt").write_text(
|
||||
format_test_mql_summary(system),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return system, result, output_dir
|
||||
|
||||
|
||||
def main() -> None:
|
||||
system, result, output_dir = run_test_mql()
|
||||
print(format_test_mql_summary(system), end="")
|
||||
print(f"Samples: {len(result.t)}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,219 +0,0 @@
|
||||
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()
|
||||
@@ -1,2 +0,0 @@
|
||||
"""System assembly modules."""
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
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
|
||||
@@ -1,668 +0,0 @@
|
||||
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,
|
||||
]
|
||||
@@ -8,16 +8,16 @@ 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/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。
|
||||
- `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` 组件网络。
|
||||
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
|
||||
- `POST /api/system-xml/simulate`:按 XML 中的组件、连接、参数和仿真设置运行当前支持的气动、标量信号及一维机械网络 MVP,并返回组件及端口时间序列。
|
||||
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
|
||||
|
||||
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
|
||||
|
||||
当前网络层可以从组件和连接生成压力-流量残差,使用 SciPy 完成非线性代数闭合和时间积分,并按实际流向传播 stream 焓。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点。它不是完整 DAE 求解器,也不等价于严格 Modelica.Fluid 实现。
|
||||
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
|
||||
|
||||
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
|
||||
|
||||
@@ -35,3 +35,4 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境
|
||||
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
|
||||
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
|
||||
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
|
||||
|
||||
+116
-7
@@ -28,6 +28,7 @@ from app.system_xml import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.simulation.components.amesim.gases import AmesimGasRegistry
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
@@ -135,6 +136,7 @@ class ReactFlowSimulationConfig(BaseModel):
|
||||
|
||||
class ReactFlowProjectPayload(BaseModel):
|
||||
name: str = "untitled"
|
||||
mediumReferenceVersion: Literal[1] | None = None
|
||||
nodes: list[ReactFlowNodePayload] = Field(default_factory=list)
|
||||
edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
|
||||
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
|
||||
@@ -639,6 +641,11 @@ def run_system_xml_simulation(
|
||||
t_start=project.simulation.t_start,
|
||||
t_stop=project.simulation.t_stop,
|
||||
method=project.simulation.method,
|
||||
# The pressure-flow closure is solved to a scaled 1e-7
|
||||
# residual. Asking the outer adaptive integrator for 1e-6
|
||||
# relative accuracy makes its finite-difference Jacobian chase
|
||||
# algebraic solver noise after discontinuous signal events.
|
||||
rtol=1.0e-5,
|
||||
max_step=project.simulation.max_step,
|
||||
),
|
||||
sample_step=project.simulation.step,
|
||||
@@ -891,12 +898,15 @@ def _compile_xml_document_or_422(
|
||||
|
||||
|
||||
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
||||
from app.simulation.registry import get_component_model_spec
|
||||
|
||||
system = ET.Element(
|
||||
"System",
|
||||
{
|
||||
"name": project.name,
|
||||
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
|
||||
"unitSystem": SYSTEM_XML_UNIT_SYSTEM,
|
||||
"mediumReferenceVersion": "1",
|
||||
},
|
||||
)
|
||||
ET.SubElement(
|
||||
@@ -953,7 +963,20 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
||||
port_definition.positiveFlowDirection or "intoComponent"
|
||||
)
|
||||
ET.SubElement(component_node, "Port", port_attributes)
|
||||
for name, value in node.data.parameters.items():
|
||||
parameter_values = dict(node.data.parameters)
|
||||
try:
|
||||
component_spec = get_component_model_spec(node.data.modelType)
|
||||
except ValueError:
|
||||
component_spec = None
|
||||
if (
|
||||
component_spec is not None
|
||||
and component_spec.display.role == "amesimGasMediumDefinition"
|
||||
):
|
||||
for parameter in component_spec.parameters:
|
||||
if parameter.editor == "amesimGasPropertyModel":
|
||||
parameter_values.setdefault(parameter.name, parameter.default)
|
||||
|
||||
for name, value in parameter_values.items():
|
||||
ET.SubElement(
|
||||
component_node,
|
||||
"Parameter",
|
||||
@@ -1086,14 +1109,34 @@ def validate_compatible_ports(
|
||||
)
|
||||
|
||||
|
||||
def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork":
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
def compile_reactflow_network(
|
||||
project: ReactFlowProjectPayload,
|
||||
*,
|
||||
amesim_gas_registry: "AmesimGasRegistry | None" = None,
|
||||
) -> "SimulationNetwork":
|
||||
from app.simulation.components.amesim.gases import (
|
||||
AMESIM_BUILTIN_AIR_GAS_INDEX,
|
||||
default_amesim_gas_registry,
|
||||
)
|
||||
from app.simulation.components.amesim.media.properties import (
|
||||
AmesimGasMediumDefinitionComponent,
|
||||
)
|
||||
from app.simulation.registry import get_component_model_spec
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
medium = IdealGasMedium()
|
||||
network = SimulationNetwork(name=project.name)
|
||||
if amesim_gas_registry is None:
|
||||
amesim_gas_registry = default_amesim_gas_registry()
|
||||
else:
|
||||
amesim_gas_registry = amesim_gas_registry.copy()
|
||||
|
||||
network = SimulationNetwork(name=project.name)
|
||||
resolved_nodes = []
|
||||
specs_by_component_id = {}
|
||||
gas_indices_by_component_id = {}
|
||||
|
||||
# Phase 1: normalize every node and consume compile-time medium definitions.
|
||||
# A definition node represents one complete property-method instance and is
|
||||
# intentionally not added to the equation network.
|
||||
for node in project.nodes:
|
||||
spec = get_component_model_spec(node.data.modelType)
|
||||
parameter_values = {
|
||||
@@ -1107,7 +1150,73 @@ def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNe
|
||||
+ ", ".join(sorted(unknown_parameters))
|
||||
+ "."
|
||||
)
|
||||
component = spec.create(node.id, medium, parameter_values)
|
||||
if issubclass(
|
||||
spec.component_class,
|
||||
AmesimGasMediumDefinitionComponent,
|
||||
):
|
||||
definition_component = spec.create(
|
||||
node.id,
|
||||
amesim_gas_registry.resolve(AMESIM_BUILTIN_AIR_GAS_INDEX),
|
||||
parameter_values,
|
||||
)
|
||||
assert isinstance(
|
||||
definition_component,
|
||||
AmesimGasMediumDefinitionComponent,
|
||||
)
|
||||
validate_component_port_interface(
|
||||
node,
|
||||
definition_component.port_definitions,
|
||||
)
|
||||
try:
|
||||
amesim_gas_registry.register(
|
||||
definition_component.gas_definition()
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"AMESim medium definition component '{node.id}' is invalid: "
|
||||
f"{exc}"
|
||||
) from exc
|
||||
continue
|
||||
|
||||
resolved_nodes.append((node, spec, parameter_values))
|
||||
specs_by_component_id[node.id] = spec
|
||||
gas_indices_by_component_id[node.id] = (
|
||||
parameter_values["gi"]
|
||||
if spec.library.id == "amesim" and "gi" in spec.parameter_by_name
|
||||
else None
|
||||
)
|
||||
|
||||
# Phase 2: resolve one gas property model for each pneumatic circuit before
|
||||
# any dynamic component initializes its thermodynamic state.
|
||||
pneumatic_connections = []
|
||||
for edge in project.edges:
|
||||
source_spec = specs_by_component_id.get(edge.source)
|
||||
target_spec = specs_by_component_id.get(edge.target)
|
||||
if source_spec is None or target_spec is None:
|
||||
continue
|
||||
source_ports = {port.name: port for port in source_spec.ports}
|
||||
target_ports = {port.name: port for port in target_spec.ports}
|
||||
source_port = source_ports.get(edge.sourceHandle or "")
|
||||
target_port = target_ports.get(edge.targetHandle or "")
|
||||
if (
|
||||
source_port is not None
|
||||
and target_port is not None
|
||||
and source_port.domain == "pneumatic"
|
||||
and target_port.domain == "pneumatic"
|
||||
):
|
||||
pneumatic_connections.append((edge.source, edge.target))
|
||||
|
||||
media_by_component_id = amesim_gas_registry.resolve_network_media(
|
||||
gas_indices_by_component_id,
|
||||
pneumatic_connections,
|
||||
)
|
||||
|
||||
for node, spec, parameter_values in resolved_nodes:
|
||||
component = spec.create(
|
||||
node.id,
|
||||
media_by_component_id[node.id],
|
||||
parameter_values,
|
||||
)
|
||||
validate_component_port_interface(node, component.port_definitions)
|
||||
network.add_component(component)
|
||||
|
||||
@@ -1260,7 +1369,7 @@ 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
|
||||
from app.simulation.examples.test_mql.system import TestMqlRunConfig
|
||||
|
||||
run_config = TestMqlRunConfig(
|
||||
t_start=project.simulation.t_start,
|
||||
|
||||
+37
-14
@@ -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、专用闭合逻辑和基线运行入口。
|
||||
- `examples/test_mql/`: AMESim `test_mql` 的系统装配、校准原语、诊断和运行入口。
|
||||
- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。
|
||||
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
|
||||
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
|
||||
|
||||
@@ -27,7 +29,8 @@
|
||||
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
|
||||
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
|
||||
|
||||
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
|
||||
临时组件库的声明入口是 `components/experimental/library.py`,AMESim 第一版
|
||||
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
|
||||
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
||||
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
||||
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
|
||||
@@ -35,17 +38,25 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
||||
|
||||
当前关键文件:
|
||||
|
||||
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
|
||||
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
|
||||
- `core/medium.py`: 气体介质协议 `GasMedium` 与通用理想气体实现 `IdealGasMedium`
|
||||
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
|
||||
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
|
||||
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
|
||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
||||
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
||||
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
||||
- `examples/test_mql/system.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射
|
||||
- `examples/test_mql/closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回
|
||||
- `examples/test_mql/primitives/`: 固定算例专用的 Peng-Robinson 氦气、管路和机械校准原语
|
||||
- `examples/test_mql/structural_network.py`: 固定算例专用的结构网络;不替代带端口契约校验的通用网络
|
||||
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
||||
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
|
||||
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
|
||||
- `reporting/amesim_results.py`: AMESim 结果读取入口
|
||||
- `examples/test_mql/run_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
|
||||
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
|
||||
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
|
||||
|
||||
## 当前阶段进度
|
||||
|
||||
@@ -272,14 +283,26 @@ print(result.used_modelica_reference)
|
||||
|
||||
当前四个主变量的最大误差为:
|
||||
|
||||
- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%`
|
||||
- `mytank.p`: `max_abs_error = 134.960858 Pa`, `max_rel_error = 0.006798%`
|
||||
- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%`
|
||||
- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%`
|
||||
- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%`
|
||||
|
||||
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
|
||||
|
||||
## 当前架构判断
|
||||
## AMESim test_mql 当前进度
|
||||
|
||||
`test_mql` 从 `AmesimModels/test_mql.ame` 迁移,并与旧 `testmodel` 保持独立。
|
||||
固定算例实现统一位于 `examples/test_mql/`,结果读取和比较能力位于
|
||||
`reporting/`;公开拖拽组件由 `components/amesim/library.py` 单独登记。
|
||||
|
||||
当前已形成 112 个气动状态和 20 个机械状态的总闭包、AMESim 原生结果读取、
|
||||
`Data_Path` 输出校验及短时域 comparison。这里不再复制易过期的数值进度;
|
||||
最新对比结果、运行命令、限制和下一步校准路径以
|
||||
[`AmesimModels/test_mql/README.md`](../../AmesimModels/test_mql/README.md)
|
||||
为唯一说明。当前仍不能宣称 Python 时域仿真与 AMESim 全局一致。
|
||||
|
||||
## Testmodel 当前架构判断
|
||||
|
||||
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
|
||||
|
||||
@@ -293,7 +316,7 @@ print(result.used_modelica_reference)
|
||||
|
||||
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
|
||||
|
||||
## 已知限制
|
||||
## Testmodel 已知限制
|
||||
|
||||
当前最主要的限制可以直接理解成下面几条:
|
||||
|
||||
@@ -319,7 +342,7 @@ print(result.used_modelica_reference)
|
||||
- 作为最终工程结论的唯一依据
|
||||
- 直接扩展到更复杂拓扑而不补通用连接器语义
|
||||
|
||||
## 文件级现状
|
||||
## Testmodel 文件级现状
|
||||
|
||||
按代码现状逐项看:
|
||||
|
||||
@@ -342,7 +365,7 @@ print(result.used_modelica_reference)
|
||||
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
||||
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
||||
|
||||
## 当前主技术债
|
||||
## Testmodel 当前主技术债
|
||||
|
||||
目前最主要的技术债,可以直接理解成下面 4 件事:
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class AmesimPnpl01(AlgebraicComponent):
|
||||
label="PNPL01 零气动流边界",
|
||||
library_id="amesim",
|
||||
category_id="boundary",
|
||||
symbol="generic",
|
||||
symbol="amesim_pnpl01",
|
||||
ports=(PortDisplaySpec("port_1", "left", order=10),),
|
||||
order=10,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,10 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from math import isclose, sqrt
|
||||
|
||||
from app.simulation.components.amesim.gases import (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
normalize_amesim_gas_index,
|
||||
)
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
@@ -10,7 +14,7 @@ from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
ResultVariableDefinition,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.medium import GasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
|
||||
@@ -29,6 +33,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
ParameterDefinition(
|
||||
"cq",
|
||||
0.72,
|
||||
@@ -37,6 +42,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
unit="",
|
||||
minimum=1.0e-10,
|
||||
maximum=1.0,
|
||||
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"area",
|
||||
@@ -46,6 +52,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
maximum=1.0,
|
||||
description="选择 Cq/面积方式时用于流量计算的有效孔口面积。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Cv",
|
||||
@@ -54,6 +61,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
description="选择 Cv 方式时使用的英制流量系数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Kv",
|
||||
@@ -62,15 +70,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
description="选择 Kv 方式时使用的公制流量系数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"flowset",
|
||||
@@ -80,6 +80,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=3.0,
|
||||
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
@@ -104,7 +105,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
label="PNOR001 常系数气动孔口",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
symbol="amesim_pnor001",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -115,7 +116,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
cq: float = 0.72,
|
||||
area: float = 5.0e-6,
|
||||
@@ -140,7 +141,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
self.area = float(area)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.flowset = self._integer_parameter("flowset", flowset)
|
||||
if self.flowset not in {1, 2, 3}:
|
||||
raise ValueError("PNOR001 flowset must be 1, 2, or 3.")
|
||||
@@ -163,7 +164,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnor001:
|
||||
return cls(
|
||||
@@ -206,7 +207,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
def _upstream_temperature(self, port_name: str) -> float:
|
||||
port = self.get_port(port_name)
|
||||
if port.h_outflow > 0.0:
|
||||
return max(port.h_outflow / self.medium.cp_ref, 1.0)
|
||||
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
|
||||
return self.medium.T_ref
|
||||
|
||||
def mass_flow(self, p_1: float, p_2: float) -> float:
|
||||
@@ -321,6 +322,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
ParameterDefinition(
|
||||
"cq",
|
||||
0.72,
|
||||
@@ -329,6 +331,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
unit="",
|
||||
minimum=1.0e-10,
|
||||
maximum=1.0,
|
||||
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"area0",
|
||||
@@ -338,6 +341,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
maximum=1.0,
|
||||
description="阀门完全开启时的最大有效孔口面积。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Cv",
|
||||
@@ -346,6 +350,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
description="选择 Cv 方式时使用的最大英制流量系数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Kv",
|
||||
@@ -354,15 +359,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
description="选择 Kv 方式时使用的最大公制流量系数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"flowset",
|
||||
@@ -372,6 +369,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=3.0,
|
||||
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"opening",
|
||||
@@ -381,6 +379,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
maximum=1.0,
|
||||
description="固定的归一化阀门开度;0 表示关闭,1 表示完全开启。",
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
@@ -413,7 +412,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
label="PNVO001 固定开度气动孔口",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
symbol="amesim_pnvo001_fixed",
|
||||
ports=(
|
||||
PortDisplaySpec("port_2", "left", order=10),
|
||||
PortDisplaySpec("port_3", "right", order=20),
|
||||
@@ -424,7 +423,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
cq: float = 0.72,
|
||||
area0: float = 5.0e-6,
|
||||
@@ -451,7 +450,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
self.area0 = float(area0)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.flowset = self._integer_parameter("flowset", flowset)
|
||||
if self.flowset not in {1, 2, 3}:
|
||||
raise ValueError("PNVO001 fixed-opening flowset must be 1, 2, or 3.")
|
||||
@@ -475,7 +474,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnvo001FixedOpening:
|
||||
return cls(
|
||||
@@ -509,7 +508,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
def _upstream_temperature(self, port_name: str) -> float:
|
||||
port = self.get_port(port_name)
|
||||
if port.h_outflow > 0.0:
|
||||
return max(port.h_outflow / self.medium.cp_ref, 1.0)
|
||||
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
|
||||
return self.medium.T_ref
|
||||
|
||||
def mass_flow(self, p_2: float, p_3: float) -> float:
|
||||
@@ -621,11 +620,11 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
ParameterDefinition("cq", 0.72, label="流量系数 Cq", quantity="dimensionless", unit="", minimum=1.0e-10, maximum=1.0),
|
||||
ParameterDefinition("area0", 5.0e-6, label="最大孔口面积", quantity="area", unit="m2", minimum=0.0, maximum=1.0),
|
||||
ParameterDefinition("Cv", 0.5, label="最大流量系数 Cv", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("Kv", 0.4, label="最大流量系数 Kv", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("gi", 1.0, label="气体类型索引", quantity="dimensionless", unit="", minimum=1.0, maximum=99.0),
|
||||
ParameterDefinition("flowset", 1.0, label="流量系数设置", quantity="dimensionless", unit="", minimum=1.0, maximum=3.0),
|
||||
ParameterDefinition("opening0", 1.0, label="初始开度", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
||||
)
|
||||
@@ -634,7 +633,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
label="PNVO001 信号开度气动孔口",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
symbol="amesim_pnvo001",
|
||||
ports=(
|
||||
PortDisplaySpec("res", "left", order=5),
|
||||
PortDisplaySpec("port_2", "left", order=10),
|
||||
@@ -646,7 +645,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
cq: float = 0.72,
|
||||
area0: float = 5.0e-6,
|
||||
@@ -673,7 +672,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
self.area0 = float(area0)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.flowset = self._integer_parameter("flowset", flowset)
|
||||
if self.flowset not in {1, 2, 3}:
|
||||
raise ValueError("PNVO001 signal-opening flowset must be 1, 2, or 3.")
|
||||
@@ -691,7 +690,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimPnvo001SignalOpening":
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
|
||||
@@ -3,6 +3,10 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from math import isclose, log10, pi, sqrt
|
||||
|
||||
from app.simulation.components.amesim.gases import (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
normalize_amesim_gas_index,
|
||||
)
|
||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent, ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
@@ -11,7 +15,7 @@ from app.simulation.core.metadata import (
|
||||
ResultVariableDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
@@ -31,6 +35,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
ParameterDefinition(
|
||||
"diam",
|
||||
0.01,
|
||||
@@ -39,6 +44,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="管路的有效内径,用于计算流通面积和摩擦压降。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"le",
|
||||
@@ -48,6 +54,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="参与摩擦压降计算的管路有效长度。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"rr",
|
||||
@@ -57,15 +64,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
maximum=0.1,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
description="管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。",
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
@@ -106,7 +105,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
label="PNL00R 气动管路阻力",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="pipe",
|
||||
symbol="amesim_pnl00r",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -117,7 +116,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
diam: float = 0.01,
|
||||
le: float = 1.0,
|
||||
@@ -130,7 +129,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
self.diam = float(diam)
|
||||
self.le = float(le)
|
||||
self.rr = float(rr)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.area = pi * self.diam * self.diam / 4.0
|
||||
|
||||
initial_h = medium.specific_enthalpy(medium.T_ref)
|
||||
@@ -151,7 +150,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnl00r:
|
||||
return cls(
|
||||
@@ -166,22 +165,11 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
def _port_temperature(self, port_name: str) -> float:
|
||||
port = self.get_port(port_name)
|
||||
if port.h_outflow > 0.0:
|
||||
return max(port.h_outflow / self.medium.cp_ref, 1.0)
|
||||
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
|
||||
return self.medium.T_ref
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_viscosity(temperature_k: float) -> float:
|
||||
if temperature_k <= 0.0:
|
||||
raise ValueError("temperature_k must be positive")
|
||||
reference_temperature = 293.15
|
||||
reference_viscosity = 1.82e-5
|
||||
sutherland_constant = 110.4
|
||||
return (
|
||||
reference_viscosity
|
||||
* (temperature_k / reference_temperature) ** 1.5
|
||||
* (reference_temperature + sutherland_constant)
|
||||
/ (temperature_k + sutherland_constant)
|
||||
)
|
||||
def _dynamic_viscosity(self, temperature_k: float) -> float:
|
||||
return self.medium.dynamic_viscosity(temperature_k)
|
||||
|
||||
def reynolds_number(self, mass_flow: float, temperature: float) -> float:
|
||||
viscosity = self._dynamic_viscosity(temperature)
|
||||
@@ -322,6 +310,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
ParameterDefinition(
|
||||
"diam",
|
||||
0.01,
|
||||
@@ -330,6 +319,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="管路的有效内径,用于计算流通面积、储气容积和摩擦压降。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"le",
|
||||
@@ -339,6 +329,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="管路的有效长度,用于计算储气容积、换热面积和摩擦压降。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"rr",
|
||||
@@ -348,6 +339,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
maximum=0.1,
|
||||
description="管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"k",
|
||||
@@ -358,6 +350,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
maximum=2.0,
|
||||
description="AMESim 管路热力学配置使用的多方指数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"kth",
|
||||
@@ -366,6 +359,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
quantity="heat_transfer_coefficient",
|
||||
unit="W/(m2*K)",
|
||||
minimum=0.0,
|
||||
description="管内气体与外部环境之间的对流换热系数。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"extemp",
|
||||
@@ -375,15 +369,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
description="管路外部环境的绝对温度,用于计算换热功率。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"mode",
|
||||
@@ -393,6 +379,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=2.0,
|
||||
description="热模型选择:1 为绝热,2 为按换热系数计算环境换热。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
@@ -402,6 +389,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="仿真开始时管内气体的绝对压力。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
@@ -411,6 +399,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="仿真开始时管内气体的绝对温度。",
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (
|
||||
@@ -451,7 +440,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
label="PNL0001 C-R 动态管路",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="pipe",
|
||||
symbol="amesim_pnl0001",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -462,7 +451,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
diam: float = 0.01,
|
||||
le: float = 1.0,
|
||||
@@ -497,14 +486,14 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
self.k = float(k)
|
||||
self.kth = float(kth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.mode = self._integer_parameter("mode", mode)
|
||||
self.p0 = float(p0)
|
||||
self.T0 = float(T0)
|
||||
self.area = pi * self.diam * self.diam / 4.0
|
||||
self.volume = self.area * self.le
|
||||
self.exchange_area = pi * self.diam * self.le
|
||||
m0 = self.p0 * self.volume / (medium.R_gas * self.T0)
|
||||
m0 = medium.density(self.p0, self.T0) * self.volume
|
||||
U0 = m0 * medium.specific_internal_energy(self.T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
initial_h = medium.specific_enthalpy(self.T0)
|
||||
@@ -527,7 +516,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimPnl0001":
|
||||
return cls(
|
||||
@@ -566,9 +555,8 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
return 0.0
|
||||
return self.kth * self.exchange_area * (self.extemp - temperature)
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_viscosity(temperature_k: float) -> float:
|
||||
return AmesimPnl00r._dynamic_viscosity(temperature_k)
|
||||
def _dynamic_viscosity(self, temperature_k: float) -> float:
|
||||
return self.medium.dynamic_viscosity(temperature_k)
|
||||
|
||||
def reynolds_number(self, mass_flow: float, temperature: float) -> float:
|
||||
viscosity = self._dynamic_viscosity(temperature)
|
||||
@@ -732,7 +720,7 @@ class AmesimPnl0002(AmesimPnl0001):
|
||||
label="PNL0002 R-C-R 动态管路",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="pipe",
|
||||
symbol="amesim_pnl0002",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -745,7 +733,7 @@ class AmesimPnl0002(AmesimPnl0001):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimPnl0002":
|
||||
return cls(
|
||||
@@ -932,7 +920,7 @@ class AmesimPnl0003(DynamicComponent):
|
||||
label="PNL0003 C-R-C 动态管路",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="pipe",
|
||||
symbol="amesim_pnl0003",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -943,7 +931,7 @@ class AmesimPnl0003(DynamicComponent):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
diam: float = 0.01,
|
||||
le: float = 1.0,
|
||||
@@ -982,7 +970,7 @@ class AmesimPnl0003(DynamicComponent):
|
||||
self.k = float(k)
|
||||
self.kth = float(kth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = AmesimPnl0001._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.mode = AmesimPnl0001._integer_parameter("mode", mode)
|
||||
self.area = pi * self.diam * self.diam / 4.0
|
||||
self.volume = self.area * self.le
|
||||
@@ -1004,13 +992,13 @@ class AmesimPnl0003(DynamicComponent):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimPnl0003":
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
|
||||
def _initial_state(self, pressure: float, temperature: float) -> VolumeState:
|
||||
mass = pressure * self.compliance_volume / (self.medium.R_gas * temperature)
|
||||
mass = self.medium.density(pressure, temperature) * self.compliance_volume
|
||||
return VolumeState(m=mass, U=mass * self.medium.specific_internal_energy(temperature))
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
@@ -1040,9 +1028,8 @@ class AmesimPnl0003(DynamicComponent):
|
||||
def refresh_thermodynamic_ports(self) -> tuple[ThermodynamicProperties, ThermodynamicProperties]:
|
||||
return self.properties_1(), self.properties_2()
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_viscosity(temperature_k: float) -> float:
|
||||
return AmesimPnl00r._dynamic_viscosity(temperature_k)
|
||||
def _dynamic_viscosity(self, temperature_k: float) -> float:
|
||||
return self.medium.dynamic_viscosity(temperature_k)
|
||||
|
||||
def reynolds_number(self, mass_flow: float, temperature: float) -> float:
|
||||
viscosity = self._dynamic_viscosity(temperature)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from math import isclose, isfinite
|
||||
from types import MappingProxyType
|
||||
|
||||
from app.simulation.core.metadata import ParameterDefinition
|
||||
from app.simulation.core.medium import GasMedium
|
||||
|
||||
|
||||
AMESIM_BUILTIN_AIR_GAS_INDEX = 0
|
||||
AMESIM_DEFAULT_GAS_INDEX = AMESIM_BUILTIN_AIR_GAS_INDEX
|
||||
AMESIM_MIN_GAS_INDEX = AMESIM_BUILTIN_AIR_GAS_INDEX
|
||||
AMESIM_MIN_DEFINED_GAS_INDEX = 1
|
||||
AMESIM_MAX_GAS_INDEX = 99
|
||||
|
||||
AMESIM_GAS_INDEX_PARAMETER = ParameterDefinition(
|
||||
"gi",
|
||||
float(AMESIM_DEFAULT_GAS_INDEX),
|
||||
label="介质物性模型(gi)",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=float(AMESIM_MIN_GAS_INDEX),
|
||||
maximum=float(AMESIM_MAX_GAS_INDEX),
|
||||
editor="amesimGasReference",
|
||||
description=(
|
||||
"选择本元件使用的气体介质定义索引;0 表示内置空气,"
|
||||
"1–99 引用画布中的介质定义组件。"
|
||||
),
|
||||
)
|
||||
|
||||
AMESIM_GAS_DEFINITION_INDEX_PARAMETER = ParameterDefinition(
|
||||
"gi",
|
||||
float(AMESIM_MIN_DEFINED_GAS_INDEX),
|
||||
label="介质定义索引(gi)",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=float(AMESIM_MIN_DEFINED_GAS_INDEX),
|
||||
maximum=float(AMESIM_MAX_GAS_INDEX),
|
||||
description=(
|
||||
"介质定义在当前模型中的唯一索引;由画布自动分配,"
|
||||
"0 保留给内置空气。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def normalize_amesim_gas_index(value: float | int) -> int:
|
||||
"""Validate an AMESim gas reference.
|
||||
|
||||
Index 0 is reserved for the built-in ideal-gas air profile. Positive
|
||||
indices refer to medium-definition components placed in the project.
|
||||
"""
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError("AMESim gas type index gi must be a number.")
|
||||
numeric = float(value)
|
||||
if not isfinite(numeric):
|
||||
raise ValueError("AMESim gas type index gi must be finite.")
|
||||
rounded = round(numeric)
|
||||
if not isclose(numeric, rounded, rel_tol=0.0, abs_tol=1.0e-12):
|
||||
raise ValueError("AMESim gas type index gi must be an integer value.")
|
||||
index = int(rounded)
|
||||
if not AMESIM_MIN_GAS_INDEX <= index <= AMESIM_MAX_GAS_INDEX:
|
||||
raise ValueError(
|
||||
"AMESim gas type index gi must be between "
|
||||
f"{AMESIM_MIN_GAS_INDEX} and {AMESIM_MAX_GAS_INDEX}."
|
||||
)
|
||||
return index
|
||||
|
||||
|
||||
def normalize_amesim_defined_gas_index(value: float | int) -> int:
|
||||
"""Validate a positive index owned by a project medium definition."""
|
||||
|
||||
index = normalize_amesim_gas_index(value)
|
||||
if index < AMESIM_MIN_DEFINED_GAS_INDEX:
|
||||
raise ValueError(
|
||||
"AMESim medium definition index gi must be between "
|
||||
f"{AMESIM_MIN_DEFINED_GAS_INDEX} and {AMESIM_MAX_GAS_INDEX}; "
|
||||
"gi=0 is reserved for built-in ideal-gas air."
|
||||
)
|
||||
return index
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimGasDefinition:
|
||||
"""One AMESim PNGD-style gas-definition slot.
|
||||
|
||||
``fluid_type`` and ``eos_type`` are intentionally optional today. They
|
||||
reserve the metadata needed to map a future PNGD00 helium definition while
|
||||
the executable behavior is supplied by ``medium``.
|
||||
"""
|
||||
|
||||
gi: int
|
||||
label: str
|
||||
medium: GasMedium
|
||||
fluid_type: int | None = None
|
||||
eos_type: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
normalized = normalize_amesim_gas_index(self.gi)
|
||||
object.__setattr__(self, "gi", normalized)
|
||||
if not self.label.strip():
|
||||
raise ValueError("AMESim gas definition label must not be empty.")
|
||||
|
||||
|
||||
class AmesimGasRegistry:
|
||||
"""Resolve AMESim component ``gi`` references to thermodynamic media."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
definitions: Iterable[AmesimGasDefinition] = (),
|
||||
*,
|
||||
default_gi: int = AMESIM_DEFAULT_GAS_INDEX,
|
||||
) -> None:
|
||||
from app.simulation.components.amesim.media.mediums import (
|
||||
AmesimIdealAirMedium,
|
||||
)
|
||||
|
||||
self.default_gi = normalize_amesim_gas_index(default_gi)
|
||||
self._definitions: dict[int, AmesimGasDefinition] = {
|
||||
AMESIM_BUILTIN_AIR_GAS_INDEX: AmesimGasDefinition(
|
||||
gi=AMESIM_BUILTIN_AIR_GAS_INDEX,
|
||||
label="空气(理想气体,内置默认)",
|
||||
medium=AmesimIdealAirMedium(),
|
||||
)
|
||||
}
|
||||
for definition in definitions:
|
||||
self.register(definition)
|
||||
|
||||
@property
|
||||
def definitions(self) -> Mapping[int, AmesimGasDefinition]:
|
||||
return MappingProxyType(self._definitions)
|
||||
|
||||
def register(self, definition: AmesimGasDefinition) -> None:
|
||||
if not isinstance(definition, AmesimGasDefinition):
|
||||
raise TypeError("AMESim gas registry entries must use AmesimGasDefinition.")
|
||||
if definition.gi == AMESIM_BUILTIN_AIR_GAS_INDEX:
|
||||
raise ValueError(
|
||||
"AMESim gas type index gi=0 is reserved for built-in "
|
||||
"ideal-gas air and cannot be replaced."
|
||||
)
|
||||
if definition.gi in self._definitions:
|
||||
raise ValueError(
|
||||
f"AMESim gas type index gi={definition.gi} is already defined."
|
||||
)
|
||||
self._definitions[definition.gi] = definition
|
||||
|
||||
def copy(self) -> AmesimGasRegistry:
|
||||
"""Return an independent registry for one project compilation."""
|
||||
|
||||
copied = AmesimGasRegistry(
|
||||
(
|
||||
definition
|
||||
for index, definition in self._definitions.items()
|
||||
if index != AMESIM_BUILTIN_AIR_GAS_INDEX
|
||||
),
|
||||
default_gi=self.default_gi,
|
||||
)
|
||||
copied._definitions[AMESIM_BUILTIN_AIR_GAS_INDEX] = self._definitions[
|
||||
AMESIM_BUILTIN_AIR_GAS_INDEX
|
||||
]
|
||||
return copied
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
gi: float | int,
|
||||
*,
|
||||
component_name: str | None = None,
|
||||
) -> GasMedium:
|
||||
index = normalize_amesim_gas_index(gi)
|
||||
try:
|
||||
return self._definitions[index].medium
|
||||
except KeyError as exc:
|
||||
owner = f" for component '{component_name}'" if component_name else ""
|
||||
available = ", ".join(str(index) for index in sorted(self._definitions))
|
||||
available_message = available or "none"
|
||||
raise ValueError(
|
||||
f"AMESim gas type index gi={index}{owner} is not defined. "
|
||||
"Register a PNGD-style gas definition before using this index. "
|
||||
f"Available indices: {available_message}."
|
||||
) from exc
|
||||
|
||||
@property
|
||||
def default_medium(self) -> GasMedium:
|
||||
return self.resolve(self.default_gi)
|
||||
|
||||
def resolve_network_media(
|
||||
self,
|
||||
component_gas_indices: Mapping[str, float | int | None],
|
||||
pneumatic_connections: Iterable[tuple[str, str]],
|
||||
) -> dict[str, GasMedium]:
|
||||
"""Assign one medium to every connected pneumatic circuit.
|
||||
|
||||
Components without ``gi`` inherit the explicit index used by their
|
||||
circuit. Conflicting indices inside one circuit are rejected instead
|
||||
of silently mixing different gases.
|
||||
"""
|
||||
|
||||
parents = {component_id: component_id for component_id in component_gas_indices}
|
||||
|
||||
def find(component_id: str) -> str:
|
||||
parent = parents[component_id]
|
||||
while parent != parents[parent]:
|
||||
parent = parents[parent]
|
||||
while component_id != parent:
|
||||
next_component = parents[component_id]
|
||||
parents[component_id] = parent
|
||||
component_id = next_component
|
||||
return parent
|
||||
|
||||
def union(left: str, right: str) -> None:
|
||||
left_root = find(left)
|
||||
right_root = find(right)
|
||||
if left_root != right_root:
|
||||
parents[right_root] = left_root
|
||||
|
||||
for source, target in pneumatic_connections:
|
||||
if source in parents and target in parents:
|
||||
union(source, target)
|
||||
|
||||
members_by_root: dict[str, list[str]] = {}
|
||||
for component_id in component_gas_indices:
|
||||
members_by_root.setdefault(find(component_id), []).append(component_id)
|
||||
|
||||
media: dict[str, GasMedium] = {}
|
||||
for members in members_by_root.values():
|
||||
indexed_components: dict[int, list[str]] = {}
|
||||
for component_id in members:
|
||||
raw_index = component_gas_indices[component_id]
|
||||
if raw_index is None:
|
||||
continue
|
||||
index = normalize_amesim_gas_index(raw_index)
|
||||
indexed_components.setdefault(index, []).append(component_id)
|
||||
|
||||
if len(indexed_components) > 1:
|
||||
details = ", ".join(
|
||||
f"gi={index} ({', '.join(sorted(component_ids))})"
|
||||
for index, component_ids in sorted(indexed_components.items())
|
||||
)
|
||||
raise ValueError(
|
||||
"Connected pneumatic circuit contains conflicting AMESim "
|
||||
f"gas definitions: {details}."
|
||||
)
|
||||
|
||||
index = (
|
||||
next(iter(indexed_components))
|
||||
if indexed_components
|
||||
else self.default_gi
|
||||
)
|
||||
indexed_members = indexed_components.get(index, members)
|
||||
medium = self.resolve(
|
||||
index,
|
||||
component_name=", ".join(sorted(indexed_members)),
|
||||
)
|
||||
for component_id in members:
|
||||
media[component_id] = medium
|
||||
return media
|
||||
|
||||
|
||||
def default_amesim_gas_registry() -> AmesimGasRegistry:
|
||||
"""Create a registry containing only built-in gi=0 ideal-gas air."""
|
||||
|
||||
return AmesimGasRegistry()
|
||||
@@ -91,7 +91,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
|
||||
label="PN3NODE2 三端气动节点",
|
||||
library_id="amesim",
|
||||
category_id="junctions",
|
||||
symbol="tee",
|
||||
symbol="amesim_pn3node2",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -128,7 +128,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
||||
label="P4NODE2 四端气动节点",
|
||||
library_id="amesim",
|
||||
category_id="junctions",
|
||||
symbol="generic",
|
||||
symbol="amesim_p4node2",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
|
||||
@@ -9,11 +9,12 @@ from app.simulation.core.catalog import (
|
||||
LIBRARY = ComponentLibrarySpec(
|
||||
id="amesim",
|
||||
label="AMESim 组件库",
|
||||
version="0.1.0",
|
||||
version="0.2.0",
|
||||
source_package="app.simulation.components.amesim",
|
||||
temporary=True,
|
||||
order=200,
|
||||
categories=(
|
||||
ComponentCategorySpec(id="media", label="介质物性", order=5),
|
||||
ComponentCategorySpec(id="storage", label="储能元件", order=10),
|
||||
ComponentCategorySpec(id="flow", label="流动元件", order=20),
|
||||
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
|
||||
@@ -22,6 +23,8 @@ LIBRARY = ComponentLibrarySpec(
|
||||
ComponentCategorySpec(id="mechanical", label="机械元件", order=60),
|
||||
),
|
||||
models=(
|
||||
"app.simulation.components.amesim.media.properties:AmesimIdealAirMediumDefinition",
|
||||
"app.simulation.components.amesim.media.properties:AmesimHeliumMediumDefinition",
|
||||
"app.simulation.components.amesim.boundary.sources:AmesimPnpl01",
|
||||
"app.simulation.components.amesim.signals.sources:AmesimStep0",
|
||||
"app.simulation.components.amesim.signals.sources:AmesimUd00",
|
||||
|
||||
@@ -22,7 +22,7 @@ class AmesimF000(AlgebraicComponent):
|
||||
label="F000 零力源",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="generic",
|
||||
symbol="amesim_f000",
|
||||
ports=(PortDisplaySpec("port_1", "right", order=10),),
|
||||
order=10,
|
||||
)
|
||||
@@ -73,7 +73,7 @@ class AmesimForc(AlgebraicComponent):
|
||||
label="FORC 信号转力",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="signal",
|
||||
symbol="amesim_forc",
|
||||
ports=(
|
||||
PortDisplaySpec("res", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -167,7 +167,7 @@ class AmesimMecmas21(DynamicComponent):
|
||||
label="MECMAS21 一维质量",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="generic",
|
||||
symbol="amesim_mecmas21",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -323,7 +323,7 @@ class AmesimLstp00a(AlgebraicComponent):
|
||||
label="LSTP00A 弹性接触",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="generic",
|
||||
symbol="amesim_lstp00a",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -425,7 +425,7 @@ class AmesimLmechn1(AlgebraicComponent):
|
||||
label="LMECHN1 线性机械节点",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="junction",
|
||||
symbol="amesim_lmechn1",
|
||||
ports=tuple(
|
||||
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)]
|
||||
+ [PortDisplaySpec("port_9", "right", order=90)]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""AMESim medium-property definition components."""
|
||||
|
||||
from app.simulation.components.amesim.media.mediums import (
|
||||
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
|
||||
AMESIM_AIR_PROPERTY_MODELS,
|
||||
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
|
||||
AMESIM_HELIUM_PROPERTY_MODELS,
|
||||
AmesimGasPropertyModelSpec,
|
||||
AmesimHeliumPengRobinsonMedium,
|
||||
AmesimIdealAirMedium,
|
||||
)
|
||||
from app.simulation.components.amesim.media.properties import (
|
||||
AMESIM_AIR_PROPERTY_MODEL_PARAMETER,
|
||||
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER,
|
||||
AmesimGasMediumDefinitionComponent,
|
||||
AmesimHeliumMediumDefinition,
|
||||
AmesimIdealAirMediumDefinition,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL",
|
||||
"AMESIM_AIR_PROPERTY_MODELS",
|
||||
"AMESIM_AIR_PROPERTY_MODEL_PARAMETER",
|
||||
"AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL",
|
||||
"AMESIM_HELIUM_PROPERTY_MODELS",
|
||||
"AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER",
|
||||
"AmesimGasMediumDefinitionComponent",
|
||||
"AmesimGasPropertyModelSpec",
|
||||
"AmesimHeliumMediumDefinition",
|
||||
"AmesimHeliumPengRobinsonMedium",
|
||||
"AmesimIdealAirMedium",
|
||||
"AmesimIdealAirMediumDefinition",
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from app.simulation.core.medium import GasMedium, IdealGasMedium
|
||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimIdealAirMedium(IdealGasMedium):
|
||||
"""AMESim air properties evaluated with the ideal-gas method.
|
||||
|
||||
Substance identity and property method are part of the concrete Python
|
||||
type. A future air correlation or helium Peng-Robinson implementation can
|
||||
therefore coexist as a sibling type without turning ``gi`` into a fluid
|
||||
enumeration.
|
||||
"""
|
||||
|
||||
SUBSTANCE_ID: ClassVar[str] = "air"
|
||||
PROPERTY_METHOD_ID: ClassVar[str] = "ideal_gas"
|
||||
|
||||
name: str = "AMESimAirIdealGas"
|
||||
R_gas: float = 287.0
|
||||
cp_ref: float = 1005.0
|
||||
T_ref: float = 300.0
|
||||
cp_slope: float = 0.0
|
||||
viscosity_ref: float = 1.82e-5
|
||||
viscosity_T_ref: float = 293.15
|
||||
sutherland_constant: float = 110.4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
"""AMESim helium with a Peng-Robinson mechanical equation of state.
|
||||
|
||||
The pressure-density-temperature relation is evaluated by the shared
|
||||
``HELIUM_PR`` fluid. The first public AMESim port keeps the committed
|
||||
constant-heat-capacity caloric model so it can be consumed through the
|
||||
same :class:`GasMedium` contract as ideal-gas air.
|
||||
"""
|
||||
|
||||
SUBSTANCE_ID: ClassVar[str] = "helium"
|
||||
PROPERTY_METHOD_ID: ClassVar[str] = "peng_robinson"
|
||||
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
|
||||
|
||||
name: str = "AMESimHeliumPengRobinson"
|
||||
R_gas: float = HELIUM_PR.specific_gas_constant
|
||||
cp_ref: float = 5193.0
|
||||
T_ref: float = 293.15
|
||||
cp_slope: float = 0.0
|
||||
viscosity_ref: float = 1.96e-5
|
||||
viscosity_T_ref: float = 293.15
|
||||
sutherland_constant: float = 79.4
|
||||
|
||||
@property
|
||||
def cv(self) -> float:
|
||||
return 3116.0
|
||||
|
||||
def cv_at_temperature(self, T: float) -> float:
|
||||
del T
|
||||
return self.cv
|
||||
|
||||
def density(self, p: float, T: float) -> float:
|
||||
return self.fluid.density(p, T)
|
||||
|
||||
def pressure(self, m: float, T: float, V: float) -> float:
|
||||
if V <= 0.0:
|
||||
raise ValueError("Volume must stay positive.")
|
||||
return self.fluid.pressure_from_density(T, m / V)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimGasPropertyModelSpec:
|
||||
"""A selectable calculation method for one AMESim gas substance."""
|
||||
|
||||
value: int
|
||||
label: str
|
||||
method_id: str
|
||||
factory: Callable[[], GasMedium]
|
||||
eos_type: int
|
||||
|
||||
def build_medium(self) -> GasMedium:
|
||||
return self.factory()
|
||||
|
||||
|
||||
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL = 0
|
||||
AMESIM_AIR_PROPERTY_MODELS = (
|
||||
AmesimGasPropertyModelSpec(
|
||||
value=AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
|
||||
label="理想气体",
|
||||
method_id=AmesimIdealAirMedium.PROPERTY_METHOD_ID,
|
||||
factory=AmesimIdealAirMedium,
|
||||
eos_type=1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL = 0
|
||||
AMESIM_HELIUM_PROPERTY_MODELS = (
|
||||
AmesimGasPropertyModelSpec(
|
||||
value=AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
|
||||
label="Peng–Robinson",
|
||||
method_id=AmesimHeliumPengRobinsonMedium.PROPERTY_METHOD_ID,
|
||||
factory=AmesimHeliumPengRobinsonMedium,
|
||||
eos_type=6,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.components.amesim.gases import (
|
||||
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
|
||||
AmesimGasDefinition,
|
||||
normalize_amesim_defined_gas_index,
|
||||
)
|
||||
from app.simulation.components.amesim.media.mediums import (
|
||||
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
|
||||
AMESIM_AIR_PROPERTY_MODELS,
|
||||
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
|
||||
AMESIM_HELIUM_PROPERTY_MODELS,
|
||||
AmesimGasPropertyModelSpec,
|
||||
)
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
||||
from app.simulation.core.medium import GasMedium
|
||||
from app.simulation.core.metadata import ParameterDefinition, ParameterOption
|
||||
|
||||
|
||||
AMESIM_AIR_PROPERTY_MODEL_PARAMETER = ParameterDefinition(
|
||||
"property_model",
|
||||
float(AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL),
|
||||
label="物性计算模型",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=float(min(model.value for model in AMESIM_AIR_PROPERTY_MODELS)),
|
||||
maximum=float(max(model.value for model in AMESIM_AIR_PROPERTY_MODELS)),
|
||||
editor="amesimGasPropertyModel",
|
||||
options=tuple(
|
||||
ParameterOption(value=model.value, label=model.label)
|
||||
for model in AMESIM_AIR_PROPERTY_MODELS
|
||||
),
|
||||
description="选择空气介质的物性计算方法;当前首版提供理想气体模型。",
|
||||
)
|
||||
|
||||
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER = ParameterDefinition(
|
||||
"property_model",
|
||||
float(AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL),
|
||||
label="物性计算模型",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=float(min(model.value for model in AMESIM_HELIUM_PROPERTY_MODELS)),
|
||||
maximum=float(max(model.value for model in AMESIM_HELIUM_PROPERTY_MODELS)),
|
||||
editor="amesimGasPropertyModel",
|
||||
options=tuple(
|
||||
ParameterOption(value=model.value, label=model.label)
|
||||
for model in AMESIM_HELIUM_PROPERTY_MODELS
|
||||
),
|
||||
description=(
|
||||
"选择氦气介质的物性计算方法;当前首版提供 "
|
||||
"Peng–Robinson 状态方程模型。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AmesimGasMediumDefinitionComponent(AlgebraicComponent, ABC):
|
||||
"""Compile-time definition of one project-scoped AMESim gas medium.
|
||||
|
||||
Concrete subclasses declare one substance and its available calculation
|
||||
methods; each instance selects a method through ``property_model``. They
|
||||
deliberately expose no physical ports or equations: the compiler consumes
|
||||
them before it creates the simulation network.
|
||||
"""
|
||||
|
||||
IS_AMESIM_GAS_MEDIUM_DEFINITION = True
|
||||
MEDIUM_LABEL = ""
|
||||
FLUID_TYPE: int | None = None
|
||||
PROPERTY_MODELS: tuple[AmesimGasPropertyModelSpec, ...] = ()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
gi: float,
|
||||
property_model: float = float(AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL),
|
||||
) -> None:
|
||||
super().__init__(name)
|
||||
self.gi = normalize_amesim_defined_gas_index(gi)
|
||||
self.property_model = self._resolve_property_model(property_model).value
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"gi": self.gi,
|
||||
"property_model": self.property_model,
|
||||
}
|
||||
)
|
||||
|
||||
def _resolve_property_model(
|
||||
self,
|
||||
value: float | int,
|
||||
) -> AmesimGasPropertyModelSpec:
|
||||
for model in self.PROPERTY_MODELS:
|
||||
if float(model.value) == float(value):
|
||||
return model
|
||||
available = ", ".join(str(model.value) for model in self.PROPERTY_MODELS)
|
||||
raise ValueError(
|
||||
f"AMESim medium definition '{self.name}' does not support property "
|
||||
f"model {value:g}; available models: {available or 'none'}."
|
||||
)
|
||||
|
||||
def build_medium(self) -> GasMedium:
|
||||
"""Create the executable property model selected by this instance."""
|
||||
|
||||
return self._resolve_property_model(self.property_model).build_medium()
|
||||
|
||||
def gas_definition(self) -> AmesimGasDefinition:
|
||||
model = self._resolve_property_model(self.property_model)
|
||||
return AmesimGasDefinition(
|
||||
gi=self.gi,
|
||||
label=f"{self.MEDIUM_LABEL}({model.label})",
|
||||
medium=self.build_medium(),
|
||||
fluid_type=self.FLUID_TYPE,
|
||||
eos_type=model.eos_type,
|
||||
)
|
||||
|
||||
|
||||
class AmesimIdealAirMediumDefinition(AmesimGasMediumDefinitionComponent):
|
||||
"""Project gas slot using the built-in ideal-gas air property method."""
|
||||
|
||||
MODEL_TYPE = "amesim_ideal_air_medium"
|
||||
MODEL_VERSION = "0.2.0"
|
||||
PORTS = ()
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
|
||||
AMESIM_AIR_PROPERTY_MODEL_PARAMETER,
|
||||
)
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="空气介质定义",
|
||||
library_id="amesim",
|
||||
category_id="media",
|
||||
symbol="amesim_ideal_air_medium",
|
||||
ports=(),
|
||||
order=10,
|
||||
role="amesimGasMediumDefinition",
|
||||
)
|
||||
MEDIUM_LABEL = "空气"
|
||||
FLUID_TYPE = 2
|
||||
PROPERTY_MODELS = AMESIM_AIR_PROPERTY_MODELS
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimIdealAirMediumDefinition:
|
||||
del medium
|
||||
return cls(
|
||||
name=name,
|
||||
gi=parameters["gi"],
|
||||
property_model=parameters["property_model"],
|
||||
)
|
||||
|
||||
|
||||
class AmesimHeliumMediumDefinition(AmesimGasMediumDefinitionComponent):
|
||||
"""Project gas slot using the AMESim helium Peng-Robinson method."""
|
||||
|
||||
MODEL_TYPE = "amesim_helium_medium"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = ()
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
|
||||
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER,
|
||||
)
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="氦气介质定义",
|
||||
library_id="amesim",
|
||||
category_id="media",
|
||||
symbol="amesim_helium_medium",
|
||||
ports=(),
|
||||
order=20,
|
||||
role="amesimGasMediumDefinition",
|
||||
)
|
||||
MEDIUM_LABEL = "氦气"
|
||||
FLUID_TYPE = 12
|
||||
PROPERTY_MODELS = AMESIM_HELIUM_PROPERTY_MODELS
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimHeliumMediumDefinition:
|
||||
del medium
|
||||
return cls(
|
||||
name=name,
|
||||
gi=parameters["gi"],
|
||||
property_model=parameters["property_model"],
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from math import floor
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
@@ -27,7 +28,7 @@ class AmesimStep0(AlgebraicComponent):
|
||||
label="STEP0 阶跃信号",
|
||||
library_id="amesim",
|
||||
category_id="signals",
|
||||
symbol="signal",
|
||||
symbol="amesim_step0",
|
||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||
order=10,
|
||||
)
|
||||
@@ -71,6 +72,15 @@ class AmesimStep0(AlgebraicComponent):
|
||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||
return {"out": self.output_at(time)}
|
||||
|
||||
def signal_event_times(
|
||||
self,
|
||||
start_time: float,
|
||||
stop_time: float,
|
||||
) -> tuple[float, ...]:
|
||||
"""Expose the exact STEP0 switch time as an integration split point."""
|
||||
|
||||
return (self.time,) if start_time < self.time < stop_time else ()
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {"y": self.out.signal}
|
||||
|
||||
@@ -117,7 +127,7 @@ class AmesimUd00(AlgebraicComponent):
|
||||
label="UD00 分段线性信号",
|
||||
library_id="amesim",
|
||||
category_id="signals",
|
||||
symbol="signal",
|
||||
symbol="amesim_ud00",
|
||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||
order=20,
|
||||
)
|
||||
@@ -200,5 +210,58 @@ class AmesimUd00(AlgebraicComponent):
|
||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||
return {"out": self.output_at(time)}
|
||||
|
||||
def signal_event_times(
|
||||
self,
|
||||
start_time: float,
|
||||
stop_time: float,
|
||||
) -> tuple[float, ...]:
|
||||
"""Return UD00 start, stage, and repeated cycle boundaries.
|
||||
|
||||
The final non-cyclic stage is intentionally not given an end event:
|
||||
``output_at`` continues that stage's slope after its configured duration.
|
||||
"""
|
||||
|
||||
if stop_time <= start_time:
|
||||
return ()
|
||||
|
||||
active_durations = self.durations[: self.nstages]
|
||||
stage_offsets = [0.0]
|
||||
elapsed = 0.0
|
||||
for duration in active_durations[:-1]:
|
||||
elapsed += duration
|
||||
stage_offsets.append(elapsed)
|
||||
|
||||
if not self.iscyclic:
|
||||
return tuple(
|
||||
sorted(
|
||||
{
|
||||
event_time
|
||||
for offset in stage_offsets
|
||||
if start_time
|
||||
< (event_time := self.tstart + offset)
|
||||
< stop_time
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
cycle_duration = sum(active_durations)
|
||||
if cycle_duration <= 0.0:
|
||||
return ()
|
||||
|
||||
events: set[float] = set()
|
||||
for offset in stage_offsets:
|
||||
first_boundary = self.tstart + offset
|
||||
cycle_index = max(
|
||||
0,
|
||||
floor((start_time - first_boundary) / cycle_duration) + 1,
|
||||
)
|
||||
event_time = first_boundary + cycle_index * cycle_duration
|
||||
while event_time < stop_time:
|
||||
if event_time > start_time:
|
||||
events.add(event_time)
|
||||
cycle_index += 1
|
||||
event_time = first_boundary + cycle_index * cycle_duration
|
||||
return tuple(sorted(events))
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {"y": self.out.signal}
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from math import isclose
|
||||
|
||||
from app.simulation.components.amesim.gases import (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
normalize_amesim_gas_index,
|
||||
)
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
@@ -11,7 +14,7 @@ from app.simulation.core.metadata import (
|
||||
ResultVariableDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
@@ -32,6 +35,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
ParameterDefinition(
|
||||
"cvol",
|
||||
0.057,
|
||||
@@ -40,6 +44,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
unit="m3",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="气室内部用于储存气体的固定有效容积。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"kth",
|
||||
@@ -48,6 +53,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
quantity="heat_transfer_coefficient",
|
||||
unit="W/(m2*K)",
|
||||
minimum=0.0,
|
||||
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"sth",
|
||||
@@ -56,6 +62,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
quantity="area",
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
description="气室与环境进行热交换的有效表面积。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"extemp",
|
||||
@@ -65,15 +72,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
@@ -83,6 +82,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="仿真开始时气室内气体的绝对压力。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
@@ -92,6 +92,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="仿真开始时气室内气体的绝对温度。",
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
@@ -99,7 +100,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
label="PNCH023 固定容积气室",
|
||||
library_id="amesim",
|
||||
category_id="storage",
|
||||
symbol="tank",
|
||||
symbol="amesim_pnch023",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -110,7 +111,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
cvol: float = 0.057,
|
||||
kth: float = 0.0,
|
||||
@@ -137,10 +138,10 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
self.kth = float(kth)
|
||||
self.sth = float(sth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.p0 = float(p0)
|
||||
self.T0 = float(T0)
|
||||
m0 = self.p0 * self.cvol / (medium.R_gas * self.T0)
|
||||
m0 = medium.density(self.p0, self.T0) * self.cvol
|
||||
U0 = m0 * medium.specific_internal_energy(self.T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
initial_h = medium.specific_enthalpy(self.T0)
|
||||
@@ -151,19 +152,12 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
self.port_2.p = self.p0
|
||||
self.port_2.h_outflow = initial_h
|
||||
|
||||
@staticmethod
|
||||
def _integer_parameter(name: str, value: float) -> int:
|
||||
rounded = round(value)
|
||||
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
|
||||
raise ValueError(f"PNCH023 parameter {name} must be an integer value.")
|
||||
return int(rounded)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnch023:
|
||||
return cls(
|
||||
@@ -270,6 +264,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
ParameterDefinition(
|
||||
"cvol0",
|
||||
0.015,
|
||||
@@ -278,6 +273,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
unit="m3",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="变容气室在所有外部容积为零时仍保留的基础容积。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"kth",
|
||||
@@ -286,6 +282,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
quantity="heat_transfer_coefficient",
|
||||
unit="W/(m2*K)",
|
||||
minimum=0.0,
|
||||
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"sth",
|
||||
@@ -294,6 +291,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
quantity="area",
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
description="气室与环境进行热交换的有效表面积。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"extemp",
|
||||
@@ -303,15 +301,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
@@ -321,6 +311,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="仿真开始时气室内气体的绝对压力。",
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
@@ -330,6 +321,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
description="仿真开始时气室内气体的绝对温度。",
|
||||
),
|
||||
ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"),
|
||||
ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"),
|
||||
@@ -348,7 +340,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
label="PNCH012 变容气室",
|
||||
library_id="amesim",
|
||||
category_id="storage",
|
||||
symbol="tank",
|
||||
symbol="amesim_pnch012",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
@@ -361,7 +353,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
*,
|
||||
cvol0: float = 0.015,
|
||||
kth: float = 0.0,
|
||||
@@ -404,7 +396,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
self.kth = float(kth)
|
||||
self.sth = float(sth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = AmesimPnch023._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.p0 = float(p0)
|
||||
self.T0 = float(T0)
|
||||
self.external_volumes = {
|
||||
@@ -421,7 +413,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
}
|
||||
if self.total_volume() <= 0.0:
|
||||
raise ValueError("PNCH012 total volume must be positive.")
|
||||
m0 = self.p0 * self.total_volume() / (medium.R_gas * self.T0)
|
||||
m0 = medium.density(self.p0, self.T0) * self.total_volume()
|
||||
U0 = m0 * medium.specific_internal_energy(self.T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
initial_h = medium.specific_enthalpy(self.T0)
|
||||
@@ -436,7 +428,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimPnch012":
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.simulation.core.metadata import (
|
||||
from app.simulation.core.ports import PortDefinition, PortState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.medium import GasMedium
|
||||
|
||||
|
||||
class Component(ABC):
|
||||
@@ -185,7 +185,7 @@ class Component(ABC):
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Component:
|
||||
"""Create a catalog model from normalized SI parameters."""
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Literal
|
||||
|
||||
|
||||
PortDisplaySide = Literal["left", "right"]
|
||||
ComponentCatalogRole = Literal["amesimGasMediumDefinition"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -42,6 +43,7 @@ class ComponentDisplaySpec:
|
||||
symbol: str
|
||||
ports: tuple[PortDisplaySpec, ...]
|
||||
order: int = 0
|
||||
role: ComponentCatalogRole | None = None
|
||||
|
||||
@property
|
||||
def port_by_name(self) -> dict[str, PortDisplaySpec]:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -12,6 +13,53 @@ class ThermodynamicProperties:
|
||||
h: float
|
||||
|
||||
|
||||
class GasMedium(Protocol):
|
||||
"""Thermodynamic contract required by pneumatic components.
|
||||
|
||||
``IdealGasMedium`` is the default implementation. Keeping the component
|
||||
boundary structural allows a later helium/Peng-Robinson implementation to
|
||||
be registered without changing every AMESim component constructor.
|
||||
"""
|
||||
|
||||
name: str
|
||||
R_gas: float
|
||||
cp_ref: float
|
||||
T_ref: float
|
||||
|
||||
@property
|
||||
def cv(self) -> float: ...
|
||||
|
||||
@property
|
||||
def gamma(self) -> float: ...
|
||||
|
||||
def cp_at_temperature(self, T: float) -> float: ...
|
||||
|
||||
def cv_at_temperature(self, T: float) -> float: ...
|
||||
|
||||
def density(self, p: float, T: float) -> float: ...
|
||||
|
||||
def dynamic_viscosity(self, T: float) -> float: ...
|
||||
|
||||
def specific_internal_energy(self, T: float) -> float: ...
|
||||
|
||||
def specific_enthalpy(self, T: float) -> float: ...
|
||||
|
||||
def temperature_from_internal_energy(self, u: float) -> float: ...
|
||||
|
||||
def temperature_from_enthalpy(self, h: float) -> float: ...
|
||||
|
||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: ...
|
||||
|
||||
def pressure(self, m: float, T: float, V: float) -> float: ...
|
||||
|
||||
def properties_from_mU(
|
||||
self,
|
||||
m: float,
|
||||
U: float,
|
||||
V: float,
|
||||
) -> ThermodynamicProperties: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdealGasMedium:
|
||||
"""Temperature-dependent ideal-gas air approximation.
|
||||
@@ -27,6 +75,9 @@ class IdealGasMedium:
|
||||
cp_ref: float = 1005.0
|
||||
T_ref: float = 300.0
|
||||
cp_slope: float = 0.0
|
||||
viscosity_ref: float = 1.82e-5
|
||||
viscosity_T_ref: float = 293.15
|
||||
sutherland_constant: float = 110.4
|
||||
|
||||
@property
|
||||
def cv(self) -> float:
|
||||
@@ -45,6 +96,18 @@ class IdealGasMedium:
|
||||
def density(self, p: float, T: float) -> float:
|
||||
return p / (self.R_gas * T)
|
||||
|
||||
def dynamic_viscosity(self, T: float) -> float:
|
||||
"""Return dynamic viscosity using the default air Sutherland law."""
|
||||
|
||||
if T <= 0.0:
|
||||
raise ValueError("Temperature must be positive.")
|
||||
return (
|
||||
self.viscosity_ref
|
||||
* (T / self.viscosity_T_ref) ** 1.5
|
||||
* (self.viscosity_T_ref + self.sutherland_constant)
|
||||
/ (T + self.sutherland_constant)
|
||||
)
|
||||
|
||||
def specific_internal_energy(self, T: float) -> float:
|
||||
delta_T = T - self.T_ref
|
||||
return (
|
||||
@@ -77,6 +140,22 @@ class IdealGasMedium:
|
||||
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
||||
return self.T_ref + delta_T
|
||||
|
||||
def temperature_from_enthalpy(self, h: float) -> float:
|
||||
reference_enthalpy = self.cp_ref * self.T_ref
|
||||
delta_h = h - reference_enthalpy
|
||||
|
||||
if abs(self.cp_slope) <= 1e-15:
|
||||
return self.T_ref + delta_h / self.cp_ref
|
||||
|
||||
a = 0.5 * self.cp_slope
|
||||
b = self.cp_ref
|
||||
c = -delta_h
|
||||
discriminant = max(b * b - 4.0 * a * c, 0.0)
|
||||
positive_root = (-b + discriminant**0.5) / (2.0 * a)
|
||||
negative_root = (-b - discriminant**0.5) / (2.0 * a)
|
||||
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
||||
return self.T_ref + delta_T
|
||||
|
||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
||||
if m <= 0.0:
|
||||
raise ValueError("Mass must stay positive when recovering temperature.")
|
||||
|
||||
@@ -6,6 +6,10 @@ from typing import Literal
|
||||
|
||||
|
||||
ResultVariableScope = Literal["component", "port"]
|
||||
ParameterEditor = Literal[
|
||||
"amesimGasReference",
|
||||
"amesimGasPropertyModel",
|
||||
]
|
||||
|
||||
|
||||
SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||
@@ -34,6 +38,20 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterOption:
|
||||
"""One numeric choice exposed by a catalog-backed parameter editor."""
|
||||
|
||||
value: float
|
||||
label: str
|
||||
|
||||
def as_interface_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"value": self.value,
|
||||
"label": self.label,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterDefinition:
|
||||
"""User-configurable model input expressed in the backend SI contract."""
|
||||
@@ -46,6 +64,9 @@ class ParameterDefinition:
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
minimum_exclusive: bool = False
|
||||
editor: ParameterEditor | None = None
|
||||
options: tuple[ParameterOption, ...] = ()
|
||||
description: str = ""
|
||||
|
||||
def validation_message(self, value: float) -> str | None:
|
||||
if not isfinite(value):
|
||||
@@ -57,6 +78,11 @@ class ParameterDefinition:
|
||||
return f"must be at least {self.minimum:g}"
|
||||
if self.maximum is not None and value > self.maximum:
|
||||
return f"must be at most {self.maximum:g}"
|
||||
if self.options and value not in {
|
||||
float(option.value) for option in self.options
|
||||
}:
|
||||
available = ", ".join(f"{option.value:g}" for option in self.options)
|
||||
return f"must be one of {available}"
|
||||
return None
|
||||
|
||||
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
|
||||
@@ -72,6 +98,14 @@ class ParameterDefinition:
|
||||
payload["minimum"] = self.minimum
|
||||
if self.maximum is not None:
|
||||
payload["maximum"] = self.maximum
|
||||
if self.editor is not None:
|
||||
payload["editor"] = self.editor
|
||||
if self.options:
|
||||
payload["options"] = [
|
||||
option.as_interface_dict() for option in self.options
|
||||
]
|
||||
if self.description:
|
||||
payload["description"] = self.description
|
||||
if value is not None:
|
||||
payload["value"] = value
|
||||
return payload
|
||||
|
||||
File renamed without changes.
@@ -1,5 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.simulation.examples.test_mql.system import (
|
||||
TestMqlRunConfig,
|
||||
TestMqlSimulationResult,
|
||||
TestMqlSystem,
|
||||
)
|
||||
from app.simulation.examples.test_mql.run import (
|
||||
PreparedTestMqlRun,
|
||||
TestMqlRunResult,
|
||||
@@ -10,7 +15,10 @@ from app.simulation.examples.test_mql.run import (
|
||||
|
||||
__all__ = [
|
||||
"PreparedTestMqlRun",
|
||||
"TestMqlRunConfig",
|
||||
"TestMqlRunResult",
|
||||
"TestMqlSimulationResult",
|
||||
"TestMqlSystem",
|
||||
"prepare_test_mql_run",
|
||||
"run_prepared_test_mql",
|
||||
"run_test_mql",
|
||||
|
||||
+5
-5
@@ -3,17 +3,17 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||
from PythonModels.reporting.test_mql_observations import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||
from app.simulation.reporting.test_mql_observations import (
|
||||
TestMqlObservationCatalog,
|
||||
build_test_mql_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_schema import (
|
||||
from app.simulation.reporting.test_mql_output_schema import (
|
||||
TestMqlOutputSchema,
|
||||
build_test_mql_output_schema,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_validation import (
|
||||
from app.simulation.reporting.test_mql_output_validation import (
|
||||
TestMqlValidatedOutput,
|
||||
compare_validated_test_mql_output,
|
||||
validate_test_mql_output,
|
||||
+6
-6
@@ -3,21 +3,21 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
AmesimPneumaticGas,
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
)
|
||||
from PythonModels.components.amesim_pneumatic_line import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
|
||||
AmesimPnl0001Pipe,
|
||||
AmesimPnl0002Pipe,
|
||||
AmesimPnl0003Pipe,
|
||||
AmesimPnl00rPipe,
|
||||
)
|
||||
from PythonModels.core.medium import ThermodynamicProperties
|
||||
from PythonModels.core.ports import PortState
|
||||
from PythonModels.core.state import VolumeState
|
||||
from PythonModels.systems.test_mql_nodes import (
|
||||
from app.simulation.core.medium import ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortState
|
||||
from app.simulation.core.state import VolumeState
|
||||
from app.simulation.examples.test_mql.nodes import (
|
||||
TestMqlPneumaticNode3,
|
||||
TestMqlPneumaticNode3Balance,
|
||||
TestMqlPneumaticNode4,
|
||||
+9
-9
@@ -3,28 +3,28 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import m3_to_cm3
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||
from PythonModels.reporting.test_mql_observations import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import m3_to_cm3
|
||||
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||
from app.simulation.reporting.test_mql_observations import (
|
||||
TestMqlObservationCatalog,
|
||||
build_test_mql_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_schema import (
|
||||
from app.simulation.reporting.test_mql_output_schema import (
|
||||
TestMqlOutputSchema,
|
||||
build_test_mql_output_schema,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_validation import (
|
||||
from app.simulation.reporting.test_mql_output_validation import (
|
||||
TestMqlValidatedOutput,
|
||||
compare_validated_test_mql_output,
|
||||
validate_test_mql_output,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog
|
||||
from PythonModels.systems.test_mql_mechanical import (
|
||||
from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog
|
||||
from app.simulation.examples.test_mql.mechanical import (
|
||||
TestMqlMechanicalAssembly,
|
||||
build_test_mql_mechanical_assembly,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic import (
|
||||
from app.simulation.examples.test_mql.pneumatic import (
|
||||
TestMqlPneumaticAssembly,
|
||||
build_test_mql_pneumatic_assembly,
|
||||
)
|
||||
+2
-2
@@ -6,8 +6,8 @@ from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
from PythonModels.systems.test_mql import COMPONENT_SPECS, GLOBAL_PARAMETERS
|
||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, GLOBAL_PARAMETERS
|
||||
|
||||
|
||||
_BINARY_OPERATORS = {
|
||||
+2
-2
@@ -5,8 +5,8 @@ import tarfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.systems.test_mql import CONNECTION_SPECS, GLOBAL_PARAMETERS
|
||||
from PythonModels.systems.test_mql_config import resolve_numeric_expression
|
||||
from app.simulation.examples.test_mql.system import CONNECTION_SPECS, GLOBAL_PARAMETERS
|
||||
from app.simulation.examples.test_mql.config import resolve_numeric_expression
|
||||
|
||||
|
||||
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
||||
@@ -4,12 +4,12 @@ import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_variables import (
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql import CONNECTION_SPECS
|
||||
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
|
||||
|
||||
|
||||
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
|
||||
+4
-4
@@ -2,19 +2,19 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.components.amesim_mechanical import (
|
||||
from app.simulation.examples.test_mql.primitives.mechanical import (
|
||||
AmesimElasticEndstop,
|
||||
AmesimMassFrictionEndstops,
|
||||
AmesimPistonGeometry,
|
||||
circular_area,
|
||||
mm_to_m,
|
||||
)
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_variables import (
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
|
||||
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
|
||||
|
||||
|
||||
MM_TO_M = 1.0e-3
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.systems.test_mql import COMPONENT_SPECS
|
||||
from app.simulation.examples.test_mql.system import COMPONENT_SPECS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
+3
-3
@@ -2,14 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
HELIUM_PNEUMATIC_GAS,
|
||||
AmesimPneumaticGas,
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
AmesimVariablePneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
|
||||
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
|
||||
|
||||
|
||||
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
||||
@@ -162,7 +162,7 @@ def build_test_mql_pneumatic_assembly(
|
||||
def _build_variable_orifice_controls(
|
||||
config: TestMqlConfig,
|
||||
) -> dict[str, TestMqlVariableOrificeControl]:
|
||||
from PythonModels.systems.test_mql import CONNECTION_SPECS
|
||||
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
|
||||
|
||||
components_by_alias = {component.alias: component for component in config.components}
|
||||
variable_orifice_aliases = {
|
||||
+3
-3
@@ -3,17 +3,17 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
HELIUM_PNEUMATIC_GAS,
|
||||
AmesimPneumaticGas,
|
||||
)
|
||||
from PythonModels.components.amesim_pneumatic_line import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
|
||||
AmesimPnl0001Pipe,
|
||||
AmesimPnl0002Pipe,
|
||||
AmesimPnl0003Pipe,
|
||||
AmesimPnl00rPipe,
|
||||
)
|
||||
from PythonModels.systems.test_mql_line_parameters import (
|
||||
from app.simulation.examples.test_mql.line_parameters import (
|
||||
TestMqlPnl0001Spec,
|
||||
TestMqlPnl0002Spec,
|
||||
TestMqlPnl0003Spec,
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
|
||||
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
|
||||
from app.simulation.examples.test_mql.closure import TestMqlPneumaticChamberSegmentSpec
|
||||
from app.simulation.examples.test_mql.topology import TestMqlCirTopology
|
||||
|
||||
|
||||
def discover_fixed_chamber_segments(
|
||||
@@ -0,0 +1 @@
|
||||
"""Calibrated component primitives used only by the ``test_mql`` example."""
|
||||
File renamed without changes.
+5
-5
@@ -3,11 +3,11 @@ 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
|
||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||
from app.simulation.core.medium import ThermodynamicProperties
|
||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
from app.simulation.core.ports import PortState
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
+6
-6
@@ -3,16 +3,16 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from math import log10, pi, sqrt
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.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
|
||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||
from app.simulation.core.medium import ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortState
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -165,7 +165,7 @@ 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:
|
||||
resistance. Both connection mass flows use the simulation convention:
|
||||
positive values enter the pipe storage.
|
||||
|
||||
AMESim's proprietary ``pn2pipefr`` utility is represented by an
|
||||
@@ -5,8 +5,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
|
||||
from PythonModels.scripts.run_test_mql import format_test_mql_summary
|
||||
from PythonModels.systems.test_mql import (
|
||||
from app.simulation.examples.test_mql.system import (
|
||||
TestMqlRunConfig,
|
||||
TestMqlSimulationResult,
|
||||
TestMqlSystem,
|
||||
@@ -29,6 +28,25 @@ class TestMqlRunResult:
|
||||
summary_path: Path
|
||||
|
||||
|
||||
def format_test_mql_summary(system: TestMqlSystem) -> str:
|
||||
snapshot = system.snapshot()
|
||||
lines = [
|
||||
"Model: test_mql",
|
||||
f"Source archive: {system.archive_path}",
|
||||
f"Components: {snapshot.component_count}",
|
||||
f"Connections: {snapshot.connection_count}",
|
||||
f"Continuous states in AMESim modelinfo: {snapshot.continuous_state_count}",
|
||||
f"Discrete states in AMESim modelinfo: {snapshot.discrete_state_count}",
|
||||
"Global parameters:",
|
||||
]
|
||||
for name, value in sorted(snapshot.global_parameters.items()):
|
||||
lines.append(f" - {name}: {value}")
|
||||
lines.append("Component submodels:")
|
||||
for name, count in sorted(snapshot.submodel_counts.items()):
|
||||
lines.append(f" - {name}: {count}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _default_run_output_dir() -> Path:
|
||||
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
|
||||
return SIMULATION_RUNS_DIR / timestamp
|
||||
|
||||
+7
-4
@@ -4,16 +4,20 @@ from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.systems.test_mql_baseline import (
|
||||
from app.simulation.examples.test_mql.baseline import (
|
||||
TestMqlBaselineRun,
|
||||
run_test_mql_baseline_passthrough,
|
||||
)
|
||||
from app.simulation.paths import (
|
||||
AMESIM_TEST_MQL_ARCHIVE_PATH,
|
||||
SIMULATION_RUNS_DIR,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlBaselinePathConfig:
|
||||
archive_path: Path = field(
|
||||
default_factory=lambda: Path(__file__).resolve().parents[2] / "AmesimModels" / "test_mql.ame"
|
||||
default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||
)
|
||||
output_dir: Path | None = None
|
||||
|
||||
@@ -31,9 +35,8 @@ class TestMqlBaselineScriptConfig:
|
||||
|
||||
|
||||
def _default_output_dir() -> Path:
|
||||
pythonmodels_root = Path(__file__).resolve().parents[1]
|
||||
timestamp = datetime.now(UTC).strftime("test_mql_baseline_%Y%m%d_%H%M%S_%f")
|
||||
return pythonmodels_root / "runs" / timestamp
|
||||
return SIMULATION_RUNS_DIR / timestamp
|
||||
|
||||
|
||||
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
|
||||
+14
-13
@@ -7,30 +7,34 @@ from datetime import UTC, datetime
|
||||
from math import nextafter, sqrt
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import AmesimPneumaticGas
|
||||
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from PythonModels.reporting.test_mql_comparison import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import AmesimPneumaticGas
|
||||
from app.simulation.examples.test_mql.solver import SolveIVPConfig, integrate_ode
|
||||
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from app.simulation.reporting.test_mql_comparison import (
|
||||
TestMqlComparisonResult,
|
||||
interpolate_series_value,
|
||||
write_test_mql_comparison_csv,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_schema import (
|
||||
from app.simulation.reporting.test_mql_output_schema import (
|
||||
TestMqlOutputSchema,
|
||||
build_test_mql_output_schema,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_validation import (
|
||||
from app.simulation.reporting.test_mql_output_validation import (
|
||||
TestMqlValidatedOutput,
|
||||
compare_validated_test_mql_output,
|
||||
validate_test_mql_output,
|
||||
)
|
||||
from PythonModels.systems.test_mql import (
|
||||
from app.simulation.examples.test_mql.system import (
|
||||
TestMqlPnl0001LineRhsDiagnostic,
|
||||
TestMqlSimulationResult,
|
||||
TestMqlSystem,
|
||||
TestMqlVariableChamberRhsDiagnostic,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic import AMESIM_REFERENCE_PRESSURE_PA
|
||||
from app.simulation.examples.test_mql.pneumatic import AMESIM_REFERENCE_PRESSURE_PA
|
||||
from app.simulation.paths import (
|
||||
AMESIM_TEST_MQL_ARCHIVE_PATH,
|
||||
SIMULATION_RUNS_DIR,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS = (
|
||||
@@ -758,9 +762,7 @@ class TestMqlPnvoEventWindowCandidateComparisonDiagnostic:
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlFullStateComparisonPathConfig:
|
||||
archive_path: Path = field(
|
||||
default_factory=lambda: Path(__file__).resolve().parents[2]
|
||||
/ "AmesimModels"
|
||||
/ "test_mql.ame"
|
||||
default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||
)
|
||||
amesim_results_archive_path: Path | None = None
|
||||
output_dir: Path | None = None
|
||||
@@ -798,9 +800,8 @@ class TestMqlFullStateComparisonScriptConfig:
|
||||
|
||||
|
||||
def _default_output_dir() -> Path:
|
||||
pythonmodels_root = Path(__file__).resolve().parents[1]
|
||||
timestamp = datetime.now(UTC).strftime("test_mql_full_state_%Y%m%d_%H%M%S_%f")
|
||||
return pythonmodels_root / "runs" / timestamp
|
||||
return SIMULATION_RUNS_DIR / timestamp
|
||||
|
||||
|
||||
def run_test_mql_full_state_comparison(
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.simulation.solvers.solver import (
|
||||
SolveIVPConfig as _BaseSolveIVPConfig,
|
||||
integrate_ode,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolveIVPConfig(_BaseSolveIVPConfig):
|
||||
"""Solver defaults used by the calibrated ``test_mql`` example."""
|
||||
|
||||
atol: float = 1e-10
|
||||
|
||||
|
||||
__all__ = ["SolveIVPConfig", "integrate_ode"]
|
||||
+1
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.core.base import Component, DynamicComponent
|
||||
from app.simulation.core.base import Component, DynamicComponent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -4,13 +4,13 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from PythonModels.core.base import Component
|
||||
from PythonModels.core.network import SimulationNetwork
|
||||
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
|
||||
from app.simulation.core.base import Component
|
||||
from app.simulation.examples.test_mql.solver import SolveIVPConfig, integrate_ode
|
||||
from app.simulation.examples.test_mql.structural_network import SimulationNetwork
|
||||
from app.simulation.paths import AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||
|
||||
|
||||
MODEL_NAME = "test_mql"
|
||||
AMESIM_ARCHIVE_RELATIVE_PATH = Path("AmesimModels/test_mql.ame")
|
||||
|
||||
# Extracted from AmesimModels/test_mql.ame on the model-development branch.
|
||||
# This is a structural Python port scaffold: it preserves the AMESim component
|
||||
@@ -4237,7 +4237,7 @@ class TestMqlFullStateClosure:
|
||||
state_vector: list[float],
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
) -> dict[str, float]:
|
||||
from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa
|
||||
from app.simulation.examples.test_mql.pneumatic import pressure_to_amesim_gauge_pa
|
||||
|
||||
selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths()
|
||||
snapshot = self.snapshot_at(time_s, state_vector)
|
||||
@@ -4456,7 +4456,7 @@ class TestMqlFullStateClosure:
|
||||
}
|
||||
|
||||
def _piston_force_by_mass_alias(self, pneumatic_snapshot: object) -> dict[str, float]:
|
||||
from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa
|
||||
from app.simulation.examples.test_mql.pneumatic import pressure_to_amesim_gauge_pa
|
||||
|
||||
force_by_mass_alias: dict[str, float] = {}
|
||||
for mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS:
|
||||
@@ -4510,7 +4510,7 @@ class TestMqlSystem:
|
||||
"""Structural Python port scaffold for AMESim model ``test_mql``."""
|
||||
|
||||
def __init__(self, archive_path: Path | None = None) -> None:
|
||||
self.archive_path = archive_path or Path(__file__).resolve().parents[2] / AMESIM_ARCHIVE_RELATIVE_PATH
|
||||
self.archive_path = archive_path or AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||
self.network = SimulationNetwork(name=MODEL_NAME)
|
||||
self.pnl0001_assembly = self._build_pnl0001_assembly()
|
||||
self.pnl0002_assembly = self._build_pnl0002_assembly()
|
||||
@@ -4537,40 +4537,40 @@ class TestMqlSystem:
|
||||
@staticmethod
|
||||
def _build_pneumatic_assembly():
|
||||
# Local import avoids a module cycle: test_mql_config reads constants from this module.
|
||||
from PythonModels.systems.test_mql_pneumatic import build_test_mql_pneumatic_assembly
|
||||
from app.simulation.examples.test_mql.pneumatic import build_test_mql_pneumatic_assembly
|
||||
|
||||
return build_test_mql_pneumatic_assembly()
|
||||
|
||||
@staticmethod
|
||||
def _build_mechanical_assembly():
|
||||
# Local import avoids a module cycle: test_mql_config reads constants from this module.
|
||||
from PythonModels.systems.test_mql_mechanical import build_test_mql_mechanical_assembly
|
||||
from app.simulation.examples.test_mql.mechanical import build_test_mql_mechanical_assembly
|
||||
|
||||
return build_test_mql_mechanical_assembly()
|
||||
|
||||
def _build_pnl0001_assembly(self):
|
||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
||||
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||
build_test_mql_pnl0001_assembly,
|
||||
)
|
||||
|
||||
return build_test_mql_pnl0001_assembly(self.archive_path)
|
||||
|
||||
def _build_pnl0002_assembly(self):
|
||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
||||
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||
build_test_mql_pnl0002_assembly,
|
||||
)
|
||||
|
||||
return build_test_mql_pnl0002_assembly(self.archive_path)
|
||||
|
||||
def _build_pnl0003_assembly(self):
|
||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
||||
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||
build_test_mql_pnl0003_assembly,
|
||||
)
|
||||
|
||||
return build_test_mql_pnl0003_assembly(self.archive_path)
|
||||
|
||||
def _build_pnl00r_assembly(self):
|
||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
||||
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||
build_test_mql_pnl00r_assembly,
|
||||
)
|
||||
|
||||
@@ -4578,13 +4578,13 @@ class TestMqlSystem:
|
||||
|
||||
@staticmethod
|
||||
def _build_node3_assembly():
|
||||
from PythonModels.systems.test_mql_nodes import build_test_mql_node3_assembly
|
||||
from app.simulation.examples.test_mql.nodes import build_test_mql_node3_assembly
|
||||
|
||||
return build_test_mql_node3_assembly()
|
||||
|
||||
@staticmethod
|
||||
def _build_node4_assembly():
|
||||
from PythonModels.systems.test_mql_nodes import build_test_mql_node4_assembly
|
||||
from app.simulation.examples.test_mql.nodes import build_test_mql_node4_assembly
|
||||
|
||||
return build_test_mql_node4_assembly()
|
||||
|
||||
@@ -4637,7 +4637,7 @@ class TestMqlSystem:
|
||||
return self.pnl0001_assembly.lines[spec.alias]
|
||||
|
||||
def _pnl0002_connection_for_node_port(self, node_alias: str, port_name: str):
|
||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodePortConnection
|
||||
from app.simulation.examples.test_mql.nodes import TestMqlP4NodePortConnection
|
||||
|
||||
spec = self._pnl0002_spec_for_node_port(node_alias, port_name)
|
||||
if spec.source_component == node_alias and spec.source_port == port_name:
|
||||
@@ -4674,7 +4674,7 @@ class TestMqlSystem:
|
||||
return self.pnl0002_assembly.lines[spec.alias]
|
||||
|
||||
def _p4_primary_connection_for_node(self, node_alias: str):
|
||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodePrimaryConnection
|
||||
from app.simulation.examples.test_mql.nodes import TestMqlP4NodePrimaryConnection
|
||||
|
||||
spec = self._pnl0001_spec_for_node_port(node_alias, "port_2")
|
||||
if spec.source_component == node_alias and spec.source_port == "port_2":
|
||||
@@ -4694,7 +4694,7 @@ class TestMqlSystem:
|
||||
)
|
||||
|
||||
def _p4_port4_orifice_connection_for_node(self, node_alias: str):
|
||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodeOrificeConnection
|
||||
from app.simulation.examples.test_mql.nodes import TestMqlP4NodeOrificeConnection
|
||||
|
||||
for spec in CONNECTION_SPECS:
|
||||
if spec["submodel"] != "DIRECT":
|
||||
@@ -4720,7 +4720,7 @@ class TestMqlSystem:
|
||||
raise KeyError(f"No PNVO orifice attached to {node_alias}.port_4")
|
||||
|
||||
def p4_node_neighborhood(self, node_alias: str):
|
||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodeNeighborhood
|
||||
from app.simulation.examples.test_mql.nodes import TestMqlP4NodeNeighborhood
|
||||
|
||||
if node_alias not in self.node4_assembly:
|
||||
raise KeyError(f"Unknown P4 node: {node_alias}")
|
||||
@@ -4774,7 +4774,7 @@ class TestMqlSystem:
|
||||
return self.network.initial_state_vector()
|
||||
|
||||
def mechanical_mass_closure(self):
|
||||
from PythonModels.systems.test_mql_mechanical import TestMqlMechanicalMassClosure
|
||||
from app.simulation.examples.test_mql.mechanical import TestMqlMechanicalMassClosure
|
||||
|
||||
return TestMqlMechanicalMassClosure(self.mechanical_assembly)
|
||||
|
||||
@@ -4900,7 +4900,7 @@ class TestMqlSystem:
|
||||
downstream_volume_alias: str,
|
||||
source: str = "manual",
|
||||
):
|
||||
from PythonModels.systems.test_mql_closure import TestMqlPneumaticBranchSpec
|
||||
from app.simulation.examples.test_mql.closure import TestMqlPneumaticBranchSpec
|
||||
|
||||
return TestMqlPneumaticBranchSpec(
|
||||
name=name,
|
||||
@@ -4911,14 +4911,14 @@ class TestMqlSystem:
|
||||
)
|
||||
|
||||
def discover_pneumatic_branch_topology(self):
|
||||
from PythonModels.systems.test_mql_closure import (
|
||||
from app.simulation.examples.test_mql.closure import (
|
||||
TestMqlPneumaticTopologyCandidate,
|
||||
TestMqlPneumaticTopologyDiscovery,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic_topology import (
|
||||
from app.simulation.examples.test_mql.pneumatic_topology import (
|
||||
discover_fixed_chamber_segments,
|
||||
)
|
||||
from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology
|
||||
from app.simulation.examples.test_mql.topology import load_test_mql_cir_topology
|
||||
|
||||
topology = load_test_mql_cir_topology(self.archive_path)
|
||||
chamber_segment_specs = discover_fixed_chamber_segments(
|
||||
@@ -4999,11 +4999,11 @@ class TestMqlSystem:
|
||||
|
||||
Boundary values apply at the PNL0001/orifice interfaces, not at the nodes.
|
||||
"""
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_closure import (
|
||||
from app.simulation.examples.test_mql.closure import (
|
||||
TestMqlPneumaticBoundaryCondition,
|
||||
TestMqlPneumaticChamberSegmentClosure,
|
||||
TestMqlPneumaticChamberSegmentComponents,
|
||||
@@ -5076,11 +5076,11 @@ class TestMqlSystem:
|
||||
outlet_temperature_k: float = 293.15,
|
||||
):
|
||||
"""Insert the topology-derived inlet PNL0001 into a chamber segment."""
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_closure import (
|
||||
from app.simulation.examples.test_mql.closure import (
|
||||
TestMqlPneumaticBoundaryCondition,
|
||||
TestMqlPnl0001ChamberSegmentClosure,
|
||||
TestMqlPnl0001ChamberSegmentComponents,
|
||||
@@ -5154,11 +5154,11 @@ class TestMqlSystem:
|
||||
outlet_node_temperature_k: float = 293.15,
|
||||
):
|
||||
"""Insert both topology-derived PNL0001 states into the segment."""
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_closure import (
|
||||
from app.simulation.examples.test_mql.closure import (
|
||||
TestMqlPneumaticBoundaryCondition,
|
||||
TestMqlPnl0001PairChamberSegmentClosure,
|
||||
TestMqlPnl0001PairChamberSegmentComponents,
|
||||
@@ -5241,11 +5241,11 @@ class TestMqlSystem:
|
||||
port-1 compliance. The PNL0003 port-2 side is connected through the
|
||||
real PNVO001 orifice to a prescribed P4 pressure boundary.
|
||||
"""
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_closure import (
|
||||
from app.simulation.examples.test_mql.closure import (
|
||||
TestMqlPneumaticBoundaryCondition,
|
||||
TestMqlPn3NodeChamberSegmentClosure,
|
||||
TestMqlPn3NodeChamberSegmentComponents,
|
||||
@@ -5354,11 +5354,11 @@ class TestMqlSystem:
|
||||
PNVO upstream endpoints are closed through their adjacent PN3/PNL0003
|
||||
subnets.
|
||||
"""
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_closure import (
|
||||
from app.simulation.examples.test_mql.closure import (
|
||||
TestMqlPneumaticBoundaryCondition,
|
||||
TestMqlPn3P4NodeChamberSegmentClosure,
|
||||
TestMqlPn3P4NodeChamberSegmentComponents,
|
||||
@@ -6140,11 +6140,11 @@ class TestMqlSystem:
|
||||
downstream_volume_alias: str,
|
||||
spec=None,
|
||||
):
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_closure import (
|
||||
from app.simulation.examples.test_mql.closure import (
|
||||
TestMqlPneumaticBranchComponents,
|
||||
TestMqlPneumaticBranchSpec,
|
||||
TestMqlPneumaticClosure,
|
||||
File renamed without changes.
@@ -16,6 +16,7 @@ DATA_DIR = (
|
||||
|
||||
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
||||
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
|
||||
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
|
||||
MODELICA_TESTMODEL_RESULT_PATH = (
|
||||
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
||||
)
|
||||
@@ -17,9 +17,10 @@ from app.simulation.core.catalog import (
|
||||
from app.simulation.core.metadata import (
|
||||
SI_UNIT_BY_QUANTITY,
|
||||
ParameterDefinition,
|
||||
ParameterOption,
|
||||
ResultVariableDefinition,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.medium import GasMedium, IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
|
||||
|
||||
|
||||
@@ -81,7 +82,7 @@ class ComponentModelSpec:
|
||||
ports.append(payload)
|
||||
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
|
||||
|
||||
return {
|
||||
payload: dict[str, object] = {
|
||||
"type": self.model_type,
|
||||
"modelType": self.model_type,
|
||||
"modelVersion": self.model_version,
|
||||
@@ -94,11 +95,14 @@ class ComponentModelSpec:
|
||||
parameter.as_interface_dict() for parameter in self.parameters
|
||||
],
|
||||
}
|
||||
if self.display.role is not None:
|
||||
payload["role"] = self.display.role
|
||||
return payload
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
values: Mapping[str, float],
|
||||
) -> Component:
|
||||
unknown = sorted(set(values) - set(self.parameter_by_name))
|
||||
@@ -410,6 +414,14 @@ def _validate_parameter(
|
||||
raise ValueError(f"{field} declarations must use ParameterDefinition.")
|
||||
_validate_member_id(parameter.name, field=f"{field} name")
|
||||
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
|
||||
if not isinstance(parameter.description, str):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' description must be a string."
|
||||
)
|
||||
if parameter.description and not parameter.description.strip():
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' description must not be blank."
|
||||
)
|
||||
_validate_quantity_unit(
|
||||
parameter.quantity,
|
||||
parameter.unit,
|
||||
@@ -433,6 +445,74 @@ def _validate_parameter(
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
|
||||
)
|
||||
if parameter.editor not in {
|
||||
None,
|
||||
"amesimGasReference",
|
||||
"amesimGasPropertyModel",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' uses unsupported editor "
|
||||
f"'{parameter.editor}'."
|
||||
)
|
||||
if not isinstance(parameter.options, tuple):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' options must use a tuple."
|
||||
)
|
||||
if parameter.editor == "amesimGasPropertyModel" and not parameter.options:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' property-model editor must declare options."
|
||||
)
|
||||
if parameter.options and parameter.editor != "amesimGasPropertyModel":
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' options require the "
|
||||
"'amesimGasPropertyModel' editor."
|
||||
)
|
||||
option_values: list[float] = []
|
||||
for option in parameter.options:
|
||||
if not isinstance(option, ParameterOption):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' options must use ParameterOption."
|
||||
)
|
||||
if (
|
||||
isinstance(option.value, bool)
|
||||
or not isinstance(option.value, (int, float))
|
||||
or not isfinite(option.value)
|
||||
):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' option values must be finite numbers."
|
||||
)
|
||||
_validate_label(
|
||||
option.label,
|
||||
field=(
|
||||
f"{field} '{parameter.name}' option "
|
||||
f"'{option.value:g}' label"
|
||||
),
|
||||
)
|
||||
numeric_option = float(option.value)
|
||||
if (
|
||||
parameter.editor == "amesimGasPropertyModel"
|
||||
and not numeric_option.is_integer()
|
||||
):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' property-model option values "
|
||||
"must be integers."
|
||||
)
|
||||
option_message = parameter.validation_message(numeric_option)
|
||||
if option_message is not None:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' option {numeric_option:g} "
|
||||
f"{option_message}."
|
||||
)
|
||||
option_values.append(numeric_option)
|
||||
duplicate_option_values = sorted(
|
||||
{value for value in option_values if option_values.count(value) > 1}
|
||||
)
|
||||
if duplicate_option_values:
|
||||
duplicates = ", ".join(f"{value:g}" for value in duplicate_option_values)
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' contains duplicate option values: "
|
||||
f"{duplicates}."
|
||||
)
|
||||
if (
|
||||
parameter.minimum is not None
|
||||
and parameter.maximum is not None
|
||||
@@ -532,6 +612,11 @@ def validate_component_model_class(
|
||||
field=f"Component '{model_type}' display symbol",
|
||||
)
|
||||
_validate_order(display.order, field=f"Component '{model_type}' display order")
|
||||
if display.role not in {None, "amesimGasMediumDefinition"}:
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' DISPLAY uses unsupported role "
|
||||
f"'{display.role}'."
|
||||
)
|
||||
if not isinstance(display.ports, tuple):
|
||||
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
|
||||
if display.library_id != library.id:
|
||||
|
||||
File renamed without changes.
+3
-3
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic import (
|
||||
from app.simulation.examples.test_mql.pneumatic import (
|
||||
TestMqlPneumaticAssembly,
|
||||
build_test_mql_pneumatic_assembly,
|
||||
)
|
||||
+1
-1
@@ -5,7 +5,7 @@ import csv
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
|
||||
|
||||
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
|
||||
+3
-3
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_lines import (
|
||||
from app.simulation.examples.test_mql.lines import (
|
||||
TestMqlLineAssembly,
|
||||
build_test_mql_line_assembly,
|
||||
)
|
||||
+3
-3
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_mechanical import (
|
||||
from app.simulation.examples.test_mql.mechanical import (
|
||||
TestMqlMechanicalAssembly,
|
||||
build_test_mql_mechanical_assembly,
|
||||
)
|
||||
+6
-6
@@ -2,24 +2,24 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_chamber_observations import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_chamber_observations import (
|
||||
TestMqlChamberObservationCatalog,
|
||||
build_test_mql_chamber_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_line_observations import (
|
||||
from app.simulation.reporting.test_mql_line_observations import (
|
||||
TestMqlLineObservationCatalog,
|
||||
build_test_mql_line_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_mechanical_observations import (
|
||||
from app.simulation.reporting.test_mql_mechanical_observations import (
|
||||
TestMqlMechanicalObservationCatalog,
|
||||
build_test_mql_mechanical_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_orifice_observations import (
|
||||
from app.simulation.reporting.test_mql_orifice_observations import (
|
||||
TestMqlOrificeObservationCatalog,
|
||||
build_test_mql_orifice_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
from app.simulation.reporting.test_mql_variables import (
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
+3
-3
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic import (
|
||||
from app.simulation.examples.test_mql.pneumatic import (
|
||||
TestMqlPneumaticAssembly,
|
||||
build_test_mql_pneumatic_assembly,
|
||||
)
|
||||
+3
-3
@@ -3,12 +3,12 @@ from __future__ import annotations
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_observations import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_observations import (
|
||||
TestMqlObservationCatalog,
|
||||
build_test_mql_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_variables import TestMqlVariableBinding
|
||||
from app.simulation.reporting.test_mql_variables import TestMqlVariableBinding
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
+3
-3
@@ -3,12 +3,12 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_comparison import (
|
||||
from app.simulation.reporting.amesim_results import AmesimResults
|
||||
from app.simulation.reporting.test_mql_comparison import (
|
||||
TestMqlComparisonResult,
|
||||
compare_test_mql_series,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_schema import TestMqlOutputSchema
|
||||
from app.simulation.reporting.test_mql_output_schema import TestMqlOutputSchema
|
||||
|
||||
|
||||
class TestMqlOutputValidationError(ValueError):
|
||||
+2
-2
@@ -4,8 +4,8 @@ import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, AmesimVariable
|
||||
from PythonModels.systems.test_mql import COMPONENT_SPECS, CONNECTION_SPECS
|
||||
from app.simulation.reporting.amesim_results import AmesimResults, AmesimVariable
|
||||
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, CONNECTION_SPECS
|
||||
|
||||
|
||||
_UNIT_RE = re.compile(r"\[([^\]]+)\]\s*$")
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import sqrt
|
||||
from math import isfinite, sqrt
|
||||
|
||||
from app.simulation.core.ports import PortState, VariableRole
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
@@ -68,6 +68,7 @@ class PressureFlowSolver:
|
||||
self.residual_tolerance = residual_tolerance
|
||||
self.max_evaluations = max_evaluations
|
||||
self.unknowns = self._build_unknowns()
|
||||
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
|
||||
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||
|
||||
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
|
||||
@@ -91,48 +92,220 @@ class PressureFlowSolver:
|
||||
)
|
||||
return tuple(unknowns)
|
||||
|
||||
def _seed_equal_pressures(self) -> None:
|
||||
for _ in range(max(2, len(self.network.connections))):
|
||||
changed = False
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
first = self.network.components[
|
||||
connection.endpoint_a.component
|
||||
].get_port(connection.endpoint_a.port)
|
||||
second = self.network.components[
|
||||
connection.endpoint_b.component
|
||||
].get_port(connection.endpoint_b.port)
|
||||
if first.p > 0.0 and second.p <= 0.0:
|
||||
second.p = first.p
|
||||
changed = True
|
||||
elif second.p > 0.0 and first.p <= 0.0:
|
||||
first.p = second.p
|
||||
changed = True
|
||||
@staticmethod
|
||||
def _port_key(variable: str, expected_variable: str) -> tuple[str, str] | None:
|
||||
try:
|
||||
component_name, port_name, variable_name = variable.rsplit(".", 2)
|
||||
except ValueError:
|
||||
return None
|
||||
if variable_name != expected_variable:
|
||||
return None
|
||||
return component_name, port_name
|
||||
|
||||
for component in self.network.components.values():
|
||||
equal_pressure_equations = [
|
||||
equation
|
||||
for equation in component.pressure_flow_equation_residuals()
|
||||
if equation.relation == "equal" and equation.role == "effort"
|
||||
def _seed_equal_pressures(self) -> None:
|
||||
"""Lift current state pressures across their complete equality groups.
|
||||
|
||||
Dynamic components refresh their own pressure ports before each closure,
|
||||
while connected algebraic ports retain values from the preceding RHS
|
||||
evaluation. Merely filling non-positive pressures therefore leaves a
|
||||
stale, and sometimes badly conditioned, nonlinear initial guess. State
|
||||
equations expose the current pressure as ``port.p - target``; use that
|
||||
target as the authoritative anchor for every connected/equal port.
|
||||
"""
|
||||
|
||||
pressure_unknowns = {
|
||||
(unknown.component, unknown.port): unknown
|
||||
for unknown in self.unknowns
|
||||
if unknown.variable == "p"
|
||||
}
|
||||
if not pressure_unknowns:
|
||||
return
|
||||
|
||||
parent = {key: key for key in pressure_unknowns}
|
||||
|
||||
def find(key: tuple[str, str]) -> tuple[str, str]:
|
||||
root = key
|
||||
while parent[root] != root:
|
||||
root = parent[root]
|
||||
while parent[key] != key:
|
||||
next_key = parent[key]
|
||||
parent[key] = root
|
||||
key = next_key
|
||||
return root
|
||||
|
||||
def union(first: tuple[str, str], second: tuple[str, str]) -> None:
|
||||
first_root = find(first)
|
||||
second_root = find(second)
|
||||
if first_root != second_root:
|
||||
parent[second_root] = first_root
|
||||
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
first = connection.endpoint_a.key
|
||||
second = connection.endpoint_b.key
|
||||
if first in pressure_unknowns and second in pressure_unknowns:
|
||||
union(first, second)
|
||||
|
||||
component_equations = {
|
||||
component.name: component.pressure_flow_equation_residuals()
|
||||
for component in self.network.components.values()
|
||||
}
|
||||
for equations in component_equations.values():
|
||||
for equation in equations:
|
||||
if equation.relation != "equal" or equation.role != "effort":
|
||||
continue
|
||||
endpoints = [
|
||||
endpoint
|
||||
for variable in equation.variables
|
||||
if (
|
||||
(endpoint := self._port_key(variable, "p"))
|
||||
in pressure_unknowns
|
||||
)
|
||||
]
|
||||
for equation in equal_pressure_equations:
|
||||
states = []
|
||||
for variable in equation.variables:
|
||||
_, port_name, variable_name = variable.rsplit(".", 2)
|
||||
if variable_name == "p":
|
||||
states.append(component.get_port(port_name))
|
||||
if len(states) != 2:
|
||||
continue
|
||||
first, second = states
|
||||
if first.p > 0.0 and second.p <= 0.0:
|
||||
second.p = first.p
|
||||
changed = True
|
||||
elif second.p > 0.0 and first.p <= 0.0:
|
||||
first.p = second.p
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
for endpoint in endpoints[1:]:
|
||||
union(endpoints[0], endpoint)
|
||||
|
||||
members_by_root: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
||||
for endpoint in pressure_unknowns:
|
||||
members_by_root.setdefault(find(endpoint), []).append(endpoint)
|
||||
|
||||
anchors_by_root: dict[tuple[str, str], list[float]] = {}
|
||||
for equations in component_equations.values():
|
||||
for equation in equations:
|
||||
if equation.relation != "state" or equation.role != "effort":
|
||||
continue
|
||||
endpoints = [
|
||||
endpoint
|
||||
for variable in equation.variables
|
||||
if (
|
||||
(endpoint := self._port_key(variable, "p"))
|
||||
in pressure_unknowns
|
||||
)
|
||||
]
|
||||
if len(endpoints) != 1:
|
||||
continue
|
||||
endpoint = endpoints[0]
|
||||
unknown = pressure_unknowns[endpoint]
|
||||
target_pressure = unknown.read() - float(equation.value)
|
||||
if not isfinite(target_pressure):
|
||||
continue
|
||||
# Keep the state-owned port current even when an invalid model
|
||||
# has conflicting storage anchors in one equality group.
|
||||
unknown.write(target_pressure)
|
||||
anchors_by_root.setdefault(find(endpoint), []).append(target_pressure)
|
||||
|
||||
for root, members in members_by_root.items():
|
||||
anchors = anchors_by_root.get(root, [])
|
||||
if anchors:
|
||||
pressure_scale = max([abs(value) for value in anchors] + [1.0])
|
||||
if max(anchors) - min(anchors) > 1.0e-9 * pressure_scale:
|
||||
# A conflicting multi-storage group is structurally invalid;
|
||||
# leave it for the residual solver/preparation diagnostics.
|
||||
continue
|
||||
target_pressure = sum(anchors) / len(anchors)
|
||||
for endpoint in members:
|
||||
pressure_unknowns[endpoint].write(target_pressure)
|
||||
continue
|
||||
|
||||
positive_seed = next(
|
||||
(
|
||||
pressure_unknowns[endpoint].read()
|
||||
for endpoint in members
|
||||
if pressure_unknowns[endpoint].read() > 0.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if positive_seed is None:
|
||||
continue
|
||||
for endpoint in members:
|
||||
unknown = pressure_unknowns[endpoint]
|
||||
if unknown.read() <= 0.0:
|
||||
unknown.write(positive_seed)
|
||||
|
||||
def _seed_explicit_mass_flows(self) -> None:
|
||||
"""Initialize explicit ``m_flow - f(...)`` constitutive relations.
|
||||
|
||||
AMESim orifices and quasi-steady pneumatic lines expose one mass-flow
|
||||
unknown with unit coefficient. Once pressure anchors are current, a
|
||||
residual correction places that flow directly on its constitutive
|
||||
surface and avoids asking the nonlinear optimizer to discover the
|
||||
square-root branch from a stale preceding-step value.
|
||||
"""
|
||||
|
||||
seeded_ids: set[str] = set()
|
||||
for component in self.network.components.values():
|
||||
for equation in component.pressure_flow_equation_residuals():
|
||||
if equation.relation != "constitutive" or equation.role != "flow":
|
||||
continue
|
||||
mass_flow_unknowns = [
|
||||
self._unknowns_by_id[variable]
|
||||
for variable in equation.variables
|
||||
if variable in self._unknowns_by_id
|
||||
and self._unknowns_by_id[variable].variable == "m_flow"
|
||||
]
|
||||
if len(mass_flow_unknowns) != 1:
|
||||
continue
|
||||
unknown = mass_flow_unknowns[0]
|
||||
target_flow = unknown.read() - float(equation.value)
|
||||
if not isfinite(target_flow):
|
||||
continue
|
||||
unknown.write(target_flow)
|
||||
seeded_ids.add(unknown.id)
|
||||
|
||||
# Complete local two-port balances for explicit elements. Connection
|
||||
# flow equations remain available to align the adjacent component port.
|
||||
for component in self.network.components.values():
|
||||
for equation in component.pressure_flow_equation_residuals():
|
||||
if equation.relation != "sumToZero" or equation.role != "flow":
|
||||
continue
|
||||
mass_flow_unknowns = [
|
||||
self._unknowns_by_id[variable]
|
||||
for variable in equation.variables
|
||||
if variable in self._unknowns_by_id
|
||||
and self._unknowns_by_id[variable].variable == "m_flow"
|
||||
]
|
||||
if len(mass_flow_unknowns) != 2:
|
||||
continue
|
||||
seeded = [
|
||||
unknown for unknown in mass_flow_unknowns if unknown.id in seeded_ids
|
||||
]
|
||||
if len(seeded) != 1:
|
||||
continue
|
||||
other = next(
|
||||
unknown for unknown in mass_flow_unknowns if unknown.id not in seeded_ids
|
||||
)
|
||||
other.write(-seeded[0].read())
|
||||
seeded_ids.add(other.id)
|
||||
|
||||
# A physical connector imposes the same sum-to-zero flow rule as a
|
||||
# two-port component. Once an explicit component flow is known, carry
|
||||
# that guess to the connected storage/boundary port as well. For the
|
||||
# common volume-orifice-volume topology this makes the seeded state an
|
||||
# exact algebraic solution and avoids an unnecessary nonlinear solve on
|
||||
# every ODE/Jacobian evaluation.
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
endpoint_unknowns = []
|
||||
for endpoint in connection.endpoints:
|
||||
unknown = self._unknowns_by_id.get(
|
||||
f"{endpoint.component}.{endpoint.port}.m_flow"
|
||||
)
|
||||
if unknown is not None:
|
||||
endpoint_unknowns.append(unknown)
|
||||
if len(endpoint_unknowns) != 2:
|
||||
continue
|
||||
seeded = [
|
||||
unknown for unknown in endpoint_unknowns if unknown.id in seeded_ids
|
||||
]
|
||||
if len(seeded) != 1:
|
||||
continue
|
||||
other = next(
|
||||
unknown for unknown in endpoint_unknowns if unknown.id not in seeded_ids
|
||||
)
|
||||
other.write(-seeded[0].read())
|
||||
seeded_ids.add(other.id)
|
||||
|
||||
def _scales(self) -> dict[str, float]:
|
||||
pressure_scale = max(
|
||||
@@ -184,6 +357,7 @@ class PressureFlowSolver:
|
||||
) from exc
|
||||
|
||||
self._seed_equal_pressures()
|
||||
self._seed_explicit_mass_flows()
|
||||
scales = self._scales()
|
||||
pressure_scale = scales["p"]
|
||||
flow_scale = scales["m_flow"]
|
||||
@@ -216,6 +390,40 @@ class PressureFlowSolver:
|
||||
return pressure_scale
|
||||
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
|
||||
|
||||
seeded_equations = self.network.pressure_flow_equation_residuals()
|
||||
seeded_scaled = [
|
||||
abs(equation.value / equation_scale(equation))
|
||||
for equation in seeded_equations
|
||||
]
|
||||
seeded_max_scaled_residual = max(seeded_scaled, default=0.0)
|
||||
seeded_unknown_values = [
|
||||
(unknown, unknown.read()) for unknown in self.unknowns
|
||||
]
|
||||
seeded_unknowns_are_feasible = all(
|
||||
isfinite(value)
|
||||
and (unknown.variable != "p" or value >= 1.0)
|
||||
for unknown, value in seeded_unknown_values
|
||||
)
|
||||
if (
|
||||
seeded_unknowns_are_feasible
|
||||
and all(isfinite(value) for value in seeded_scaled)
|
||||
and seeded_max_scaled_residual <= self.residual_tolerance
|
||||
):
|
||||
diagnostics = AlgebraicSolveDiagnostics(
|
||||
success=True,
|
||||
message="Seeded pressure-flow state satisfies the residual tolerance.",
|
||||
evaluations=0,
|
||||
pressure_scale=pressure_scale,
|
||||
flow_scale=flow_scale,
|
||||
max_scaled_residual=seeded_max_scaled_residual,
|
||||
max_raw_residual=max(
|
||||
(abs(item.value) for item in seeded_equations),
|
||||
default=0.0,
|
||||
),
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
return diagnostics
|
||||
|
||||
x0 = np.asarray(
|
||||
[
|
||||
(
|
||||
@@ -269,14 +477,20 @@ class PressureFlowSolver:
|
||||
)
|
||||
for equation in equations
|
||||
]
|
||||
success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance
|
||||
max_scaled_residual = max(scaled, default=0.0)
|
||||
residuals_converged = (
|
||||
all(isfinite(value) for value in scaled)
|
||||
and max_scaled_residual <= self.residual_tolerance
|
||||
)
|
||||
optimizer_status_is_acceptable = bool(result.success) or int(result.status) == 0
|
||||
success = residuals_converged and optimizer_status_is_acceptable
|
||||
diagnostics = AlgebraicSolveDiagnostics(
|
||||
success=success,
|
||||
message=str(result.message),
|
||||
evaluations=int(result.nfev),
|
||||
pressure_scale=pressure_scale,
|
||||
flow_scale=flow_scale,
|
||||
max_scaled_residual=max(scaled, default=0.0),
|
||||
max_scaled_residual=max_scaled_residual,
|
||||
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
from typing import Protocol
|
||||
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
@@ -13,6 +14,21 @@ class SignalOutputComponent(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class SignalEventSource(Protocol):
|
||||
"""Optional contract for signal sources with known time discontinuities."""
|
||||
|
||||
name: str
|
||||
|
||||
def signal_event_times(
|
||||
self,
|
||||
start_time: float,
|
||||
stop_time: float,
|
||||
) -> tuple[float, ...]:
|
||||
"""Return event times strictly inside ``(start_time, stop_time)``."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignalSolveDiagnostics:
|
||||
propagated: int
|
||||
@@ -51,6 +67,37 @@ class SignalResolver:
|
||||
self.last_diagnostics = diagnostics
|
||||
return diagnostics
|
||||
|
||||
def event_times(self, start_time: float, stop_time: float) -> tuple[float, ...]:
|
||||
"""Collect optional source events that can be used as integration splits.
|
||||
|
||||
Event discovery is deliberately duck typed so existing signal-output
|
||||
components remain valid without implementing ``signal_event_times``.
|
||||
"""
|
||||
|
||||
start = float(start_time)
|
||||
stop = float(stop_time)
|
||||
if not isfinite(start) or not isfinite(stop):
|
||||
raise ValueError("Signal event interval must be finite.")
|
||||
if stop < start:
|
||||
raise ValueError("Signal event interval stop must not precede start.")
|
||||
if stop == start:
|
||||
return ()
|
||||
|
||||
events: set[float] = set()
|
||||
for component in self.network.components.values():
|
||||
source_event_times = getattr(component, "signal_event_times", None)
|
||||
if source_event_times is None:
|
||||
continue
|
||||
for raw_time in source_event_times(start, stop):
|
||||
event_time = float(raw_time)
|
||||
if not isfinite(event_time):
|
||||
raise ValueError(
|
||||
f"Signal event time from component '{component.name}' must be finite."
|
||||
)
|
||||
if start < event_time < stop:
|
||||
events.add(event_time)
|
||||
return tuple(sorted(events))
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Literal
|
||||
from typing import Callable, Literal, Sequence
|
||||
|
||||
|
||||
CancellationCheck = Callable[[], bool]
|
||||
@@ -21,6 +22,7 @@ class SolveIVPConfig:
|
||||
rtol: float = 1e-6
|
||||
atol: float = 1e-8
|
||||
max_step: float = 1e-3
|
||||
first_step: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -50,6 +52,33 @@ def _append_solution_sample(
|
||||
states[index].append(float(value))
|
||||
|
||||
|
||||
def _normalize_breakpoints(
|
||||
config: SolveIVPConfig,
|
||||
breakpoints: Sequence[float] | None,
|
||||
) -> list[float]:
|
||||
"""Return sorted, unique breakpoints strictly inside the integration span."""
|
||||
|
||||
if breakpoints is None or len(breakpoints) == 0:
|
||||
return []
|
||||
if config.t_stop < config.t_start:
|
||||
raise ValueError("Segmented integration requires t_stop to follow t_start.")
|
||||
|
||||
normalized: list[float] = []
|
||||
for raw_breakpoint in breakpoints:
|
||||
breakpoint = float(raw_breakpoint)
|
||||
if not math.isfinite(breakpoint):
|
||||
raise ValueError("Integration breakpoints must be finite numbers.")
|
||||
if config.t_start < breakpoint < config.t_stop:
|
||||
normalized.append(breakpoint)
|
||||
|
||||
normalized.sort()
|
||||
return [
|
||||
breakpoint
|
||||
for index, breakpoint in enumerate(normalized)
|
||||
if index == 0 or breakpoint != normalized[index - 1]
|
||||
]
|
||||
|
||||
|
||||
def _runge_kutta_4(
|
||||
rhs: Callable[[float, list[float]], list[float]],
|
||||
initial_state: list[float],
|
||||
@@ -113,6 +142,137 @@ def _runge_kutta_4(
|
||||
)
|
||||
|
||||
|
||||
def _runge_kutta_4_segmented(
|
||||
rhs: Callable[[float, list[float]], list[float]],
|
||||
initial_state: list[float],
|
||||
config: SolveIVPConfig,
|
||||
t_eval: list[float] | None,
|
||||
breakpoints: Sequence[float],
|
||||
cancel_check: CancellationCheck | None = None,
|
||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||
) -> ODESolution:
|
||||
"""RK4 fallback that never evaluates a pre-breakpoint step at the breakpoint."""
|
||||
|
||||
if t_eval is None:
|
||||
point_count = max(
|
||||
2,
|
||||
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
|
||||
)
|
||||
sample_step = (config.t_stop - config.t_start) / (point_count - 1)
|
||||
sample_times = [
|
||||
config.t_start + index * sample_step for index in range(point_count)
|
||||
]
|
||||
else:
|
||||
sample_times = [float(time) for time in t_eval]
|
||||
|
||||
state = [float(value) for value in initial_state]
|
||||
states = [[value] for value in state]
|
||||
times = [float(config.t_start)]
|
||||
current_time = float(config.t_start)
|
||||
sample_index = 0
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= config.t_start + 1e-12
|
||||
):
|
||||
sample_index += 1
|
||||
|
||||
status: IntegrationStatus = "completed"
|
||||
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
|
||||
error: Exception | None = None
|
||||
last_reported_step: float | None = None
|
||||
|
||||
def report_step(time: float) -> None:
|
||||
nonlocal last_reported_step
|
||||
if accepted_step_callback is None:
|
||||
return
|
||||
if last_reported_step is not None and time <= last_reported_step:
|
||||
return
|
||||
accepted_step_callback(float(time))
|
||||
last_reported_step = float(time)
|
||||
|
||||
def advance_to(
|
||||
target_time: float, reported_terminal_time: float | None = None
|
||||
) -> None:
|
||||
nonlocal current_time, state
|
||||
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
|
||||
report_time = current_time
|
||||
if (
|
||||
reported_terminal_time is not None
|
||||
and current_time >= target_time - 1e-15
|
||||
):
|
||||
report_time = reported_terminal_time
|
||||
report_step(report_time)
|
||||
|
||||
try:
|
||||
segment_ends = [*breakpoints, float(config.t_stop)]
|
||||
for segment_index, segment_end in enumerate(segment_ends):
|
||||
is_breakpoint = segment_index < len(breakpoints)
|
||||
integration_end = (
|
||||
math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end
|
||||
)
|
||||
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= integration_end
|
||||
):
|
||||
sample_time = float(sample_times[sample_index])
|
||||
advance_to(sample_time)
|
||||
_append_solution_sample(times, states, sample_time, state)
|
||||
sample_index += 1
|
||||
|
||||
advance_to(
|
||||
integration_end,
|
||||
segment_end if is_breakpoint else None,
|
||||
)
|
||||
|
||||
if is_breakpoint:
|
||||
current_time = float(segment_end)
|
||||
report_step(current_time)
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= segment_end
|
||||
):
|
||||
sample_time = float(sample_times[sample_index])
|
||||
_append_solution_sample(times, states, sample_time, state)
|
||||
sample_index += 1
|
||||
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],
|
||||
@@ -120,6 +280,7 @@ def _integrate_scipy_stepwise(
|
||||
t_eval: list[float] | None,
|
||||
cancel_check: CancellationCheck,
|
||||
accepted_step_callback: AcceptedStepCallback | None,
|
||||
breakpoints: Sequence[float] = (),
|
||||
) -> ODESolution:
|
||||
import numpy as np
|
||||
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
|
||||
@@ -140,7 +301,7 @@ def _integrate_scipy_stepwise(
|
||||
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_times = [float(time) for time in (t_eval or [])]
|
||||
sample_index = 0
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
@@ -153,90 +314,160 @@ def _integrate_scipy_stepwise(
|
||||
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",
|
||||
)
|
||||
|
||||
try:
|
||||
solver = solver_type(
|
||||
cancellable_rhs,
|
||||
config.t_start,
|
||||
np.asarray(initial_state, dtype=float),
|
||||
config.t_stop,
|
||||
rtol=config.rtol,
|
||||
atol=config.atol,
|
||||
max_step=config.max_step,
|
||||
)
|
||||
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
|
||||
last_reported_step: float | None = None
|
||||
|
||||
while solver.status == "running":
|
||||
def report_step(time: float) -> None:
|
||||
nonlocal last_reported_step
|
||||
if accepted_step_callback is None:
|
||||
return
|
||||
if last_reported_step is not None and time <= last_reported_step:
|
||||
return
|
||||
accepted_step_callback(float(time))
|
||||
last_reported_step = float(time)
|
||||
|
||||
segment_ends = [*breakpoints, float(config.t_stop)]
|
||||
for segment_index, segment_end in enumerate(segment_ends):
|
||||
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,
|
||||
message = (
|
||||
"Simulation was stopped before integration started."
|
||||
if segment_index == 0
|
||||
else "Simulation was stopped before reaching the requested end time."
|
||||
)
|
||||
if accepted_step_callback is not None:
|
||||
accepted_step_callback(last_accepted_time)
|
||||
break
|
||||
|
||||
is_breakpoint = segment_index < len(breakpoints)
|
||||
integration_end = (
|
||||
math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end
|
||||
)
|
||||
has_integration_interval = integration_end > last_accepted_time
|
||||
|
||||
if has_integration_interval:
|
||||
solver_options = {
|
||||
"rtol": config.rtol,
|
||||
"atol": config.atol,
|
||||
"max_step": config.max_step,
|
||||
}
|
||||
if config.first_step is not None:
|
||||
solver_options["first_step"] = min(
|
||||
config.first_step,
|
||||
integration_end - last_accepted_time,
|
||||
)
|
||||
|
||||
try:
|
||||
solver = solver_type(
|
||||
cancellable_rhs,
|
||||
last_accepted_time,
|
||||
np.asarray(last_accepted_state, dtype=float),
|
||||
integration_end,
|
||||
**solver_options,
|
||||
)
|
||||
except _IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
message = (
|
||||
"Simulation was stopped before integration started."
|
||||
if segment_index == 0
|
||||
else "Simulation was stopped before reaching the requested end time."
|
||||
)
|
||||
break
|
||||
except Exception as exc:
|
||||
status = "failed"
|
||||
message = str(exc)
|
||||
error = exc
|
||||
break
|
||||
|
||||
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]
|
||||
reported_time = (
|
||||
float(segment_end)
|
||||
if is_breakpoint and solver.status == "finished"
|
||||
else last_accepted_time
|
||||
)
|
||||
if sample_times:
|
||||
dense_output = solver.dense_output()
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= last_accepted_time
|
||||
):
|
||||
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,
|
||||
reported_time,
|
||||
last_accepted_state,
|
||||
)
|
||||
report_step(reported_time)
|
||||
|
||||
if status != "completed":
|
||||
break
|
||||
|
||||
if is_breakpoint:
|
||||
# The old equation is integrated only to the representable point just
|
||||
# left of the event. The continuous state is then lifted to the exact
|
||||
# event time, where the freshly constructed next solver sees the new
|
||||
# equation immediately.
|
||||
last_accepted_time = float(segment_end)
|
||||
if sample_times:
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= segment_end
|
||||
):
|
||||
sample_time = float(sample_times[sample_index])
|
||||
_append_solution_sample(
|
||||
times,
|
||||
states,
|
||||
sample_time,
|
||||
last_accepted_state,
|
||||
)
|
||||
sample_index += 1
|
||||
elif not has_integration_interval:
|
||||
_append_solution_sample(
|
||||
times,
|
||||
states,
|
||||
last_accepted_time,
|
||||
last_accepted_state,
|
||||
)
|
||||
report_step(last_accepted_time)
|
||||
|
||||
if status != "completed":
|
||||
_append_solution_sample(
|
||||
@@ -263,8 +494,14 @@ def integrate_ode(
|
||||
t_eval: list[float] | None = None,
|
||||
cancel_check: CancellationCheck | None = None,
|
||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||
breakpoints: Sequence[float] | None = None,
|
||||
):
|
||||
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
|
||||
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
||||
|
||||
Breakpoints are interpreted as right-continuous equation changes: the old
|
||||
equation is integrated to the floating-point left limit, then a fresh solver
|
||||
starts at the exact breakpoint with the unchanged continuous state.
|
||||
"""
|
||||
|
||||
if abs(config.t_stop - config.t_start) <= 1e-15:
|
||||
return ODESolution(
|
||||
@@ -274,9 +511,21 @@ def integrate_ode(
|
||||
message="Skipped integration because t_start equals t_stop.",
|
||||
)
|
||||
|
||||
normalized_breakpoints = _normalize_breakpoints(config, breakpoints)
|
||||
|
||||
try:
|
||||
from scipy.integrate import solve_ivp
|
||||
except ImportError:
|
||||
if normalized_breakpoints:
|
||||
return _runge_kutta_4_segmented(
|
||||
rhs,
|
||||
initial_state,
|
||||
config,
|
||||
t_eval,
|
||||
normalized_breakpoints,
|
||||
cancel_check,
|
||||
accepted_step_callback,
|
||||
)
|
||||
return _runge_kutta_4(
|
||||
rhs,
|
||||
initial_state,
|
||||
@@ -286,23 +535,27 @@ def integrate_ode(
|
||||
accepted_step_callback,
|
||||
)
|
||||
|
||||
if cancel_check is not None:
|
||||
if cancel_check is not None or normalized_breakpoints:
|
||||
return _integrate_scipy_stepwise(
|
||||
rhs,
|
||||
initial_state,
|
||||
config,
|
||||
t_eval,
|
||||
cancel_check,
|
||||
cancel_check or (lambda: False),
|
||||
accepted_step_callback,
|
||||
normalized_breakpoints,
|
||||
)
|
||||
|
||||
return solve_ivp(
|
||||
fun=rhs,
|
||||
t_span=(config.t_start, config.t_stop),
|
||||
y0=initial_state,
|
||||
method=config.method,
|
||||
rtol=config.rtol,
|
||||
atol=config.atol,
|
||||
max_step=config.max_step,
|
||||
t_eval=t_eval,
|
||||
)
|
||||
solve_options = {
|
||||
"fun": rhs,
|
||||
"t_span": (config.t_start, config.t_stop),
|
||||
"y0": initial_state,
|
||||
"method": config.method,
|
||||
"rtol": config.rtol,
|
||||
"atol": config.atol,
|
||||
"max_step": config.max_step,
|
||||
"t_eval": t_eval,
|
||||
}
|
||||
if config.first_step is not None:
|
||||
solve_options["first_step"] = config.first_step
|
||||
return solve_ivp(**solve_options)
|
||||
@@ -327,6 +327,10 @@ class GenericFluidSystem:
|
||||
|
||||
report_progress(0.0, "initializing", force=True)
|
||||
t_eval = simulation_sample_times(config, sample_step)
|
||||
signal_event_times = self.signal_resolver.event_times(
|
||||
config.t_start,
|
||||
config.t_stop,
|
||||
)
|
||||
initial_state = self.consistent_initial_state_vector()
|
||||
report_progress(0.0, "integrating", force=True)
|
||||
duration = config.t_stop - config.t_start
|
||||
@@ -356,6 +360,7 @@ class GenericFluidSystem:
|
||||
accepted_step_callback=(
|
||||
report_solver_time if cancel_check is not None else None
|
||||
),
|
||||
breakpoints=signal_event_times,
|
||||
)
|
||||
if isinstance(solution, ODESolution):
|
||||
run_status: SimulationRunStatus = solution.status
|
||||
@@ -428,6 +433,7 @@ class GenericFluidSystem:
|
||||
},
|
||||
"signal": {
|
||||
"propagations": self.signal_propagation_count,
|
||||
"eventTimes": list(signal_event_times),
|
||||
"last": (
|
||||
self.signal_resolver.last_diagnostics.as_dict()
|
||||
if self.signal_resolver.last_diagnostics is not None
|
||||
|
||||
+407
-3
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import lru_cache
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
@@ -138,18 +138,22 @@ class SystemXmlDocument:
|
||||
name: str
|
||||
schema_version: str
|
||||
unit_system: str
|
||||
medium_reference_version: str | None
|
||||
simulation: SystemXmlSimulation
|
||||
components: tuple[SystemXmlComponent, ...]
|
||||
connections: tuple[SystemXmlConnection, ...]
|
||||
|
||||
def summary(self) -> dict[str, object]:
|
||||
return {
|
||||
result: dict[str, object] = {
|
||||
"name": self.name,
|
||||
"schemaVersion": self.schema_version,
|
||||
"unitSystem": self.unit_system,
|
||||
"componentCount": len(self.components),
|
||||
"connectionCount": len(self.connections),
|
||||
}
|
||||
if self.medium_reference_version is not None:
|
||||
result["mediumReferenceVersion"] = self.medium_reference_version
|
||||
return result
|
||||
|
||||
def as_project_data(self) -> dict[str, object]:
|
||||
edges = []
|
||||
@@ -171,6 +175,7 @@ class SystemXmlDocument:
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"mediumReferenceVersion": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": component.id,
|
||||
@@ -281,7 +286,20 @@ def validate_system_xml_document(
|
||||
return SystemXmlValidationReport(document=None, issues=issues)
|
||||
|
||||
document = _parse_validated_root(root)
|
||||
issues = tuple(_semantic_issues(document))
|
||||
document, compatibility_issues = _normalize_legacy_amesim_gas_references(
|
||||
document
|
||||
)
|
||||
document, property_model_issues = _normalize_amesim_gas_property_models(
|
||||
document
|
||||
)
|
||||
document = replace(document, medium_reference_version="1")
|
||||
issues = tuple(
|
||||
[
|
||||
*compatibility_issues,
|
||||
*property_model_issues,
|
||||
*_semantic_issues(document),
|
||||
]
|
||||
)
|
||||
return SystemXmlValidationReport(document=document, issues=issues)
|
||||
|
||||
|
||||
@@ -331,6 +349,7 @@ def _parse_validated_root(root: etree._Element) -> SystemXmlDocument:
|
||||
name=str(root.get("name")),
|
||||
schema_version=str(root.get("schemaVersion")),
|
||||
unit_system=str(root.get("unitSystem")),
|
||||
medium_reference_version=root.get("mediumReferenceVersion"),
|
||||
simulation=simulation,
|
||||
components=components,
|
||||
connections=connections,
|
||||
@@ -373,6 +392,167 @@ def _parse_component(element: etree._Element) -> SystemXmlComponent:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_legacy_amesim_gas_references(
|
||||
document: SystemXmlDocument,
|
||||
) -> tuple[SystemXmlDocument, list[ValidationIssue]]:
|
||||
"""Safely normalize the pre-v1 implicit-air ``gi`` convention.
|
||||
|
||||
System XML v2 files without ``mediumReferenceVersion`` predate explicit
|
||||
project medium definitions. Such a document can only be migrated when
|
||||
every component type is known and no component declares the
|
||||
``amesimGasMediumDefinition`` catalog role. Parameter discovery is driven
|
||||
by the catalog editor contract instead of hard-coded AMESim model names.
|
||||
"""
|
||||
|
||||
if document.medium_reference_version is not None:
|
||||
return document, []
|
||||
|
||||
specs = [
|
||||
COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
for component in document.components
|
||||
]
|
||||
if any(spec is None for spec in specs):
|
||||
return document, []
|
||||
if any(
|
||||
spec is not None
|
||||
and spec.display.role == "amesimGasMediumDefinition"
|
||||
for spec in specs
|
||||
):
|
||||
return document, []
|
||||
|
||||
issues: list[ValidationIssue] = []
|
||||
normalized_components: list[SystemXmlComponent] = []
|
||||
changed = False
|
||||
for component, spec in zip(document.components, specs, strict=True):
|
||||
assert spec is not None
|
||||
reference_parameters = {
|
||||
parameter.name: parameter
|
||||
for parameter in spec.parameters
|
||||
if parameter.editor == "amesimGasReference"
|
||||
and parameter.default == 0.0
|
||||
}
|
||||
if not reference_parameters:
|
||||
normalized_components.append(component)
|
||||
continue
|
||||
|
||||
parameters_by_name = {
|
||||
parameter.name: parameter for parameter in component.parameters
|
||||
}
|
||||
normalized_parameters = list(component.parameters)
|
||||
for name, definition in reference_parameters.items():
|
||||
existing = parameters_by_name.get(name)
|
||||
path = (
|
||||
"/System/Components/"
|
||||
f"Component[@id='{component.id}']/Parameter[@name='{name}']"
|
||||
)
|
||||
if existing is None:
|
||||
normalized_parameters.append(
|
||||
SystemXmlParameter(
|
||||
name=name,
|
||||
value=definition.default,
|
||||
line=component.line,
|
||||
)
|
||||
)
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GI_DEFAULT_INJECTED",
|
||||
f"Legacy component {component.id} omitted {name}; "
|
||||
"the implicit ideal-air reference 0 was applied.",
|
||||
path,
|
||||
component.line,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
continue
|
||||
if existing.value != 1.0:
|
||||
continue
|
||||
normalized_parameters = [
|
||||
replace(parameter, value=0.0)
|
||||
if parameter is existing
|
||||
else parameter
|
||||
for parameter in normalized_parameters
|
||||
]
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GI_LEGACY_AIR_MAPPED",
|
||||
f"Legacy component {component.id} used {name}=1 without "
|
||||
"a medium-definition component; it was mapped to the "
|
||||
"implicit ideal-air reference 0.",
|
||||
path,
|
||||
existing.line,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
normalized_components.append(
|
||||
replace(component, parameters=tuple(normalized_parameters))
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return document, issues
|
||||
return replace(document, components=tuple(normalized_components)), issues
|
||||
|
||||
|
||||
def _normalize_amesim_gas_property_models(
|
||||
document: SystemXmlDocument,
|
||||
) -> tuple[SystemXmlDocument, list[ValidationIssue]]:
|
||||
"""Add the catalog default for pre-selector medium definitions.
|
||||
|
||||
Medium-definition nodes created before the property-model selector existed
|
||||
only contain ``gi``. The selector is catalog driven, so its default can be
|
||||
injected without hard-coding a component type or a calculation method.
|
||||
"""
|
||||
|
||||
issues: list[ValidationIssue] = []
|
||||
normalized_components: list[SystemXmlComponent] = []
|
||||
changed = False
|
||||
|
||||
for component in document.components:
|
||||
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
if spec is None or spec.display.role != "amesimGasMediumDefinition":
|
||||
normalized_components.append(component)
|
||||
continue
|
||||
|
||||
existing_names = {parameter.name for parameter in component.parameters}
|
||||
normalized_parameters = list(component.parameters)
|
||||
for definition in spec.parameters:
|
||||
if (
|
||||
definition.editor != "amesimGasPropertyModel"
|
||||
or definition.name in existing_names
|
||||
):
|
||||
continue
|
||||
normalized_parameters.append(
|
||||
SystemXmlParameter(
|
||||
name=definition.name,
|
||||
value=definition.default,
|
||||
line=component.line,
|
||||
)
|
||||
)
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_PROPERTY_MODEL_DEFAULTED",
|
||||
f"Medium definition {component.id} omitted "
|
||||
f"{definition.name}; catalog default {definition.default:g} "
|
||||
"was applied.",
|
||||
"/System/Components/"
|
||||
f"Component[@id='{component.id}']/"
|
||||
f"Parameter[@name='{definition.name}']",
|
||||
component.line,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
|
||||
normalized_components.append(
|
||||
replace(component, parameters=tuple(normalized_parameters))
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return document, issues
|
||||
return replace(document, components=tuple(normalized_components)), issues
|
||||
|
||||
|
||||
def _parse_connection(element: etree._Element) -> SystemXmlConnection:
|
||||
endpoints = tuple(
|
||||
SystemXmlEndpoint(
|
||||
@@ -398,6 +578,7 @@ def _semantic_issues(document: SystemXmlDocument) -> list[ValidationIssue]:
|
||||
_validate_system_and_simulation(document, issues)
|
||||
component_by_id = _validate_components(document, issues)
|
||||
_validate_connections(document, component_by_id, issues)
|
||||
_validate_amesim_medium_references(document, component_by_id, issues)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -664,6 +845,229 @@ def _validate_component_parameters(
|
||||
)
|
||||
|
||||
|
||||
def _validate_amesim_medium_references(
|
||||
document: SystemXmlDocument,
|
||||
component_by_id: Mapping[str, SystemXmlComponent],
|
||||
issues: list[ValidationIssue],
|
||||
) -> None:
|
||||
"""Validate project-scoped AMESim gas slots before model compilation."""
|
||||
|
||||
component_paths = {
|
||||
id(component): f"/System/Components/Component[{index}]"
|
||||
for index, component in enumerate(document.components, start=1)
|
||||
}
|
||||
definitions_by_index: dict[int, SystemXmlComponent] = {}
|
||||
|
||||
for component in document.components:
|
||||
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
if spec is None or spec.display.role != "amesimGasMediumDefinition":
|
||||
continue
|
||||
component_path = component_paths[id(component)]
|
||||
parameters = [
|
||||
parameter for parameter in component.parameters if parameter.name == "gi"
|
||||
]
|
||||
if len(parameters) != 1:
|
||||
if not parameters:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_MISSING",
|
||||
f"AMESim gas medium definition {component.id} must "
|
||||
"declare exactly one gi parameter.",
|
||||
component_path,
|
||||
component.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
parameter = parameters[0]
|
||||
parameter_path = f"{component_path}/Parameter[@name='gi']"
|
||||
gas_index = _integer_index(parameter.value, minimum=1, maximum=99)
|
||||
if gas_index is None:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_INVALID",
|
||||
f"AMESim gas medium definition {component.id} gi must be "
|
||||
"an integer between 1 and 99.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
existing = definitions_by_index.get(gas_index)
|
||||
if existing is not None:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_DUPLICATE",
|
||||
f"AMESim gas medium index gi={gas_index} is defined by "
|
||||
f"both {existing.id} and {component.id}.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
definitions_by_index[gas_index] = component
|
||||
|
||||
resolved_references: dict[
|
||||
str,
|
||||
list[tuple[str, int, SystemXmlParameter]],
|
||||
] = {}
|
||||
for component in document.components:
|
||||
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
if spec is None:
|
||||
continue
|
||||
component_path = component_paths[id(component)]
|
||||
for definition in spec.parameters:
|
||||
if definition.editor != "amesimGasReference":
|
||||
continue
|
||||
parameters = [
|
||||
parameter
|
||||
for parameter in component.parameters
|
||||
if parameter.name == definition.name
|
||||
]
|
||||
if len(parameters) != 1:
|
||||
continue
|
||||
|
||||
parameter = parameters[0]
|
||||
parameter_path = (
|
||||
f"{component_path}/Parameter[@name='{definition.name}']"
|
||||
)
|
||||
gas_index = _integer_index(parameter.value, minimum=0, maximum=99)
|
||||
if gas_index is None:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_REFERENCE_INDEX_INVALID",
|
||||
f"AMESim gas reference {component.id}.{definition.name} "
|
||||
"must be an integer between 0 and 99.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if gas_index > 0 and gas_index not in definitions_by_index:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_REFERENCE_UNDEFINED",
|
||||
f"AMESim gas reference {component.id}.{definition.name} "
|
||||
f"uses gi={gas_index}, but no gas medium definition "
|
||||
"declares that index.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
resolved_references.setdefault(component.id, []).append(
|
||||
(definition.name, gas_index, parameter)
|
||||
)
|
||||
|
||||
_validate_connected_amesim_gas_references(
|
||||
document,
|
||||
component_by_id,
|
||||
resolved_references,
|
||||
issues,
|
||||
)
|
||||
|
||||
|
||||
def _integer_index(value: float, *, minimum: int, maximum: int) -> int | None:
|
||||
if not isfinite(value) or value != int(value):
|
||||
return None
|
||||
index = int(value)
|
||||
if not minimum <= index <= maximum:
|
||||
return None
|
||||
return index
|
||||
|
||||
|
||||
def _validate_connected_amesim_gas_references(
|
||||
document: SystemXmlDocument,
|
||||
component_by_id: Mapping[str, SystemXmlComponent],
|
||||
resolved_references: Mapping[
|
||||
str,
|
||||
list[tuple[str, int, SystemXmlParameter]],
|
||||
],
|
||||
issues: list[ValidationIssue],
|
||||
) -> None:
|
||||
if len(component_by_id) != len(document.components):
|
||||
return
|
||||
|
||||
parents = {component_id: component_id for component_id in component_by_id}
|
||||
|
||||
def find(component_id: str) -> str:
|
||||
parent = parents[component_id]
|
||||
while parent != parents[parent]:
|
||||
parent = parents[parent]
|
||||
while component_id != parent:
|
||||
next_component = parents[component_id]
|
||||
parents[component_id] = parent
|
||||
component_id = next_component
|
||||
return parent
|
||||
|
||||
def union(left: str, right: str) -> None:
|
||||
left_root = find(left)
|
||||
right_root = find(right)
|
||||
if left_root != right_root:
|
||||
parents[right_root] = left_root
|
||||
|
||||
for connection in document.connections:
|
||||
if connection.kind != "physical" or connection.domain != "pneumatic":
|
||||
continue
|
||||
first_endpoint, second_endpoint = connection.endpoints
|
||||
first_component = component_by_id.get(first_endpoint.component)
|
||||
second_component = component_by_id.get(second_endpoint.component)
|
||||
if first_component is None or second_component is None:
|
||||
continue
|
||||
first_port = first_component.port_by_name.get(first_endpoint.port)
|
||||
second_port = second_component.port_by_name.get(second_endpoint.port)
|
||||
if (
|
||||
first_port is None
|
||||
or second_port is None
|
||||
or first_port.kind != "physical"
|
||||
or second_port.kind != "physical"
|
||||
or first_port.domain != "pneumatic"
|
||||
or second_port.domain != "pneumatic"
|
||||
):
|
||||
continue
|
||||
union(first_component.id, second_component.id)
|
||||
|
||||
references_by_root: dict[
|
||||
str,
|
||||
dict[int, list[tuple[str, str, SystemXmlParameter]]],
|
||||
] = {}
|
||||
for component_id, references in resolved_references.items():
|
||||
if component_id not in parents:
|
||||
continue
|
||||
by_index = references_by_root.setdefault(find(component_id), {})
|
||||
for parameter_name, gas_index, parameter in references:
|
||||
by_index.setdefault(gas_index, []).append(
|
||||
(component_id, parameter_name, parameter)
|
||||
)
|
||||
|
||||
for references_by_index in references_by_root.values():
|
||||
if len(references_by_index) <= 1:
|
||||
continue
|
||||
details = ", ".join(
|
||||
f"gi={gas_index} "
|
||||
f"({', '.join(sorted(component_id for component_id, _, _ in references))})"
|
||||
for gas_index, references in sorted(references_by_index.items())
|
||||
)
|
||||
first_reference = next(
|
||||
reference
|
||||
for references in references_by_index.values()
|
||||
for reference in references
|
||||
)
|
||||
component_id, parameter_name, parameter = first_reference
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_REFERENCE_CONFLICT",
|
||||
"Connected pneumatic circuit contains conflicting AMESim "
|
||||
f"gas references: {details}.",
|
||||
"/System/Components/"
|
||||
f"Component[@id='{component_id}']/"
|
||||
f"Parameter[@name='{parameter_name}']",
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_connections(
|
||||
document: SystemXmlDocument,
|
||||
component_by_id: dict[str, SystemXmlComponent],
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
`GET /api/components/catalog` 的机器可读结构。
|
||||
4. [AMESim 子模型公开组件迁移矩阵](amesim-component-migration-matrix.md)
|
||||
用于划分 `test_mql` 子模型族的公开组件、内部模型和暂不支持范围。
|
||||
5. [AMESim 氦气 Peng-Robinson 介质模型](amesim-helium-peng-robinson.md)
|
||||
记录本地 AMESim 资料、氦气参数、索引映射和首版计算边界。
|
||||
|
||||
建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有
|
||||
模型。不要从前端兜底数据反推后端物理契约。
|
||||
@@ -36,6 +38,8 @@
|
||||
| 库和显示声明 | [`app/simulation/core/catalog.py`](../app/simulation/core/catalog.py) |
|
||||
| 库发现与注册校验 | [`app/simulation/registry.py`](../app/simulation/registry.py) |
|
||||
| 临时库清单 | [`app/simulation/components/experimental/library.py`](../app/simulation/components/experimental/library.py) |
|
||||
| AMESim 第一版公开临时库清单 | [`app/simulation/components/amesim/library.py`](../app/simulation/components/amesim/library.py) |
|
||||
| `test_mql` 固定算例入口 | [`app/simulation/examples/test_mql/system.py`](../app/simulation/examples/test_mql/system.py) |
|
||||
| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) |
|
||||
|
||||
## AI 使用原则
|
||||
|
||||
@@ -1,62 +1,64 @@
|
||||
# AMESim 子模型公开组件迁移矩阵
|
||||
|
||||
状态:规划中
|
||||
适用模型:`AmesimModels/test_mql.ame` / `PythonModels.systems.test_mql`
|
||||
状态:21 个模型已在临时 AMESim 库完成第一版公开,持续校准中
|
||||
|
||||
适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system`
|
||||
|
||||
配套规范:[`component-model-authoring-spec-v1.md`](component-model-authoring-spec-v1.md)
|
||||
|
||||
## 目标
|
||||
|
||||
本文档把 `test_mql` 中的 AMESim 子模型族拆成可执行的迁移批次。它不是组件注册清单;只有完成公开模型契约、方程实现、最小 XML 编译/仿真测试后,子模型才能加入某个 `library.py` 的 `models` 清单并出现在前端组件库中。
|
||||
本文档记录 `test_mql` 中 AMESim 子模型族的公开状态和后续校准批次。实际注册清单以 `app/simulation/components/amesim/library.py` 为准。
|
||||
|
||||
当前原则:
|
||||
|
||||
- 不把现有固定拓扑 `TestMqlSystem` 整体注册为公开拖拽组件。
|
||||
- 不把机械、信号、事件元件提前伪装成可由当前气动求解器仿真的公开组件。
|
||||
- 可先公开当前 `app.simulation` 求解链路能承载的气动元件:储能、阻性、孔口、三通/四通、准稳态管段。
|
||||
- 对 AMESim 精确语义尚未复刻的元件,允许先做内部模型或文档化候选,不能在目录中标记为完成。
|
||||
- 第一版公开只表示模型契约、目录、XML 编译和最小仿真链路已接通,不表示 AMESim 精确语义已完成复刻。
|
||||
- 气动、标量信号和一维机械模型按当前求解链路能力公开;事件与跨域耦合继续保留明确限制。
|
||||
- 固定算例专用的校准原语留在 `examples/test_mql/`,不与公共组件目录混为同一实现。
|
||||
|
||||
## 当前求解器能力边界
|
||||
|
||||
`app.simulation` 当前支持:
|
||||
|
||||
- 物理域:`pneumatic`。
|
||||
- 端口变量:`p`、`m_flow`、`h_outflow`。
|
||||
- 物理域:`pneumatic`、标量 `signal` 和一维 `mechanical`。
|
||||
- 端口变量:气动 `p/m_flow/h_outflow`、信号 `signal`、机械 `x/v/f`。
|
||||
- 流量符号:`m_flow > 0` 表示流入当前组件。
|
||||
- 网络方程:连接压力相等、连接流量和为零、组件压力-流量残差、stream 焓传播。
|
||||
- 网络方程:气动连接压力相等、流量和为零、组件压力-流量残差与 stream 焓传播;标量信号传播;机械 `x/v` 等值和 `f` 平衡。
|
||||
- 动态:半显式 ODE,动态组件提供质量和内能状态导数。
|
||||
- 暂不支持:机械端口、信号端口仿真、事件系统、一般高指数 DAE、AMESim 气体定义组件对全局 medium 的动态切换。
|
||||
- 暂不支持:完整事件系统、一般高指数 DAE、气动-机械实时跨域组件,以及从 PNGD00 自动批量导入其他气体。公共编译链已经支持“介质 + 物性计算方法”定义元件:`gi=0` 使用内置空气理想气体,`gi=1..99` 引用画布中的显式介质物性实例;介质定义内部通过 `property_model` 下拉接口选择计算方法。氦气已提供 Peng-Robinson 首版。
|
||||
|
||||
## 子模型族矩阵
|
||||
|
||||
| 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` 参数和摩擦公式复核。 |
|
||||
| `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` | 已接入代数组件公开契约;仍需按 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` | 已接入三端等压、流量守恒基础版;AMESim port 2 参考温压语义和 stream 混合仍需单独测试。 |
|
||||
| `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_p4node2`,`junctions` | 已接入四端等压、流量守恒基础版;仍需复核 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 | 气动活塞与移动体耦合 | 暂不公开 | 内部固定算例;后续跨域组件 | 依赖气动端口和机械端口耦合;需要新增机械域、跨域状态和连接规则。 |
|
||||
| `PNGD00` | 1 | 氦气气体定义 | 氦气 Peng-Robinson 首版公开 | `amesim_helium_medium`,`media` | 已映射源模型 `fluidType=12/eosType=6`;密度和压力使用 PR EOS,热量学暂用手册参考点的定比热闭合。完整压力相关残余焓、比热和真实气体临界流仍待状态相关物性接口。 |
|
||||
| `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 仍留后续复核。 |
|
||||
| `UD00` | 2 | 分段线性信号源 | 第一版公开 | `amesim_ud00`,`signals` | 已接入标量信号输出端口、8 段 start/end/t 参数、循环模式和 System XML signal connection;当前按迁移实现语义处理最后一段外推,AMESim baseline 仍留后续复核。 |
|
||||
| `DIRECT` | 44 | 直接连接 | 不注册为组件 | System XML `Connection` | 连接不是组件;物理连接必须保持无方向端点语义。 |
|
||||
|
||||
## 建议迁移批次
|
||||
|
||||
### 批次 1:可进入当前气动求解器的公开组件
|
||||
### 批次 1:已进入当前气动求解器的公开组件
|
||||
|
||||
优先级最高,但仍需先补 AMESim 公式与测试:
|
||||
以下模型已完成第一版公开,后续重点是 AMESim 公式和 baseline 精修:
|
||||
|
||||
- `amesim_pnor001`
|
||||
- `amesim_pnl00r`
|
||||
@@ -64,7 +66,7 @@
|
||||
- `amesim_pn3node2`
|
||||
- `amesim_p4node2`
|
||||
|
||||
完成定义:每个模型必须有 `MODEL_TYPE/MODEL_VERSION/PORTS/PARAMETERS/RESULT_VARIABLES/DISPLAY/create()`,并补目录、参数边界、残差、零流量/反向流、最小 XML 编译和短时仿真测试。
|
||||
这些模型已有 `MODEL_TYPE/MODEL_VERSION/PORTS/PARAMETERS/RESULT_VARIABLES/DISPLAY/create()` 契约和目录、XML、最小仿真测试;第一版状态不代表数值对齐完成。
|
||||
|
||||
### 批次 2:动态管路
|
||||
|
||||
@@ -86,7 +88,7 @@
|
||||
|
||||
## 下一步执行建议
|
||||
|
||||
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` 或完整实时耦合。
|
||||
1. 以 `app.simulation.components.amesim.library` 的 21 个模型为公开清单唯一来源。
|
||||
2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2 / PNL00R` 与动态管路的 AMESim baseline 误差。
|
||||
3. 每次调整模型都同步补充目录校验、参数边界、System XML 编译和最小仿真测试。
|
||||
4. 跨域能力下一步聚焦 `PNRP17` 或等价实时气动-机械耦合;在事件和闭合能力完成前保持暂不公开。
|
||||
@@ -0,0 +1,76 @@
|
||||
# AMESim 氦气 Peng-Robinson 介质模型
|
||||
|
||||
## 本地资料依据
|
||||
|
||||
仓库中已经包含该模型所需的说明和算例数据:
|
||||
|
||||
- `AmesimModels/help/pneumatic_library_manual/03_gas_properties.pdf` 第 3.3 节
|
||||
给出 AMESim Pneumatic Library 的气体物性层级和 Peng-Robinson 状态方程。
|
||||
- `AmesimModels/help/pneumatic_library_manual/lib_pneumatic.pdf` 第 87--88 页
|
||||
给出 293.15 K、1 barA 下的氦气参考物性。
|
||||
- `AmesimModels/test_mql.ame` 中的 `PNGD00` 实例明确选择
|
||||
`fluidType=12`(helium)和 `eosType=6`(Peng-Robinson)。
|
||||
- `app/simulation/core/peng_robinson.py` 已实现纯物质 Peng-Robinson 方程,
|
||||
并定义了 `HELIUM_PR`。
|
||||
|
||||
AMESim 手册中的 Peng-Robinson 形式为:
|
||||
|
||||
```text
|
||||
p = r T / (v - b) - a alpha(T) / (v^2 + 2 b v - b^2)
|
||||
a = 0.457235583 r^2 Tc^2 / Pc
|
||||
b = 0.07779607 r Tc / Pc
|
||||
alpha(T) = [1 + m (1 - sqrt(T/Tc))]^2
|
||||
m = 0.37464 + 1.54226 omega - 0.26992 omega^2
|
||||
```
|
||||
|
||||
当前 Python EOS 使用等价的摩尔体积形式,并通过最大物理解选择气相根。
|
||||
|
||||
## 首版参数
|
||||
|
||||
| 参数 | 数值 | 本地依据 |
|
||||
| --- | ---: | --- |
|
||||
| 摩尔质量 | `0.004002602 kg/mol` | `HELIUM_PR` |
|
||||
| 临界温度 | `5.1953 K` | `HELIUM_PR` |
|
||||
| 临界压力 | `227460 Pa` | `HELIUM_PR` |
|
||||
| 偏心因子 | `-0.385` | `HELIUM_PR` |
|
||||
| 定压比热 | `5193 J/(kg*K)` | AMESim 手册表 11.1 |
|
||||
| 定容比热 | `3116 J/(kg*K)` | AMESim 手册表 11.1 |
|
||||
| 比气体常数 | 由 `R/M` 计算,约 `2077.26 J/(kg*K)` | EOS + 手册表 11.1 |
|
||||
| 293.15 K 黏度 | `1.96e-5 Pa*s` | AMESim 手册表 11.2 |
|
||||
| Sutherland 常数 | `79.4 K` | 已迁移 `test_mql` 管路近似 |
|
||||
|
||||
手册参考点还给出 `rho=0.164 kg/m3`、`gamma=1.667`、`Z=1.0005`。
|
||||
|
||||
## 组件与索引映射
|
||||
|
||||
公开介质组件使用:
|
||||
|
||||
```text
|
||||
modelType = amesim_helium_medium
|
||||
gi = 画布自动分配的 1..99 引用索引
|
||||
property_model = 0 (Peng-Robinson,本应用内的稳定编号)
|
||||
fluidType = 12 (AMESim 元数据)
|
||||
eosType = 6 (AMESim 元数据)
|
||||
```
|
||||
|
||||
`property_model` 与 AMESim 的 `eosType` 有意分离:前者是单个介质组件内部的
|
||||
下拉选项编号,后者用于保留 AMESim 原模型语义。以后给氦气增加其他计算方法时,
|
||||
只需扩展氦气的物性模型注册表。
|
||||
|
||||
源归档的 `gasName` 缓存文本与结构字段存在冲突,因此介质识别以
|
||||
`fluidType=12`、`PNGD_HELIUM` 和组件标签为准,不使用 `gasName` 猜测。
|
||||
|
||||
## 首版计算边界
|
||||
|
||||
首版公共组件实现以下闭合:
|
||||
|
||||
- `rho(p,T)`、`p(rho,T)` 和控制容积压力使用 Peng-Robinson EOS;
|
||||
- `u=cv*T`、`h=cp*T`,温度反解使用同一组定比热;
|
||||
- 黏度使用以 293.15 K 为参考点的 Sutherland 近似;
|
||||
- `gi` 注册、气动连通域一致性、XML/JSON 保存均复用现有介质机制。
|
||||
|
||||
这是一版可运行的“PR 压力--密度闭合 + 定比热热量学”,不是 AMESim 真实气体
|
||||
物性的完全复刻。AMESim 的 `Cp/Cv/h/mu/rho/Z` 接口均可随 `(p,T)` 变化,而当前
|
||||
`GasMedium` 的焓、内能和比热接口只有温度参数。后续若要接入完整残余焓、
|
||||
压力相关比热和真实气体临界流,应先扩展为状态相关物性接口,再调整气室、管路和
|
||||
孔口公式。
|
||||
@@ -550,6 +550,30 @@ GET /api/components/catalog
|
||||
6. 使用 `symbol` 选择图标渲染器。
|
||||
7. 不在前端重新定义参数默认值、边界或端口语义。
|
||||
|
||||
目录协议提供以下可选的 AMESim 介质扩展字段:
|
||||
|
||||
- 参数的 `editor: "amesimGasReference"` 表示该数值不是普通连续量,而是
|
||||
项目介质定义的 `gi` 引用。前端应保留索引 `0`,并从当前画布的介质定义
|
||||
组件生成其余下拉项;索引下拉项只显示数值,不拼接介质名称或中文说明。
|
||||
- 参数的 `editor: "amesimGasPropertyModel"` 表示该参数选择介质定义内部的
|
||||
物性计算模型。参数同时提供 `options: [{"value": 0, "label": "理想气体"}]`
|
||||
一类目录数据,前端据此生成下拉栏;后续增加算法时由介质模型注册新的选项,
|
||||
前端不硬编码算法名称。
|
||||
- 模型的 `role: "amesimGasMediumDefinition"` 表示该模型是项目级介质定义。
|
||||
此类模型允许 `ports: []`,在 System XML v2 中仍按普通零端口
|
||||
`Component` 保存。
|
||||
|
||||
这些字段在目录对象中均为可选。宽松读取目录的消费者可以把未知编辑器参数
|
||||
退化为普通数值输入;按本仓库 JSON Schema 严格校验的消费者必须与后端成套
|
||||
升级,才能识别新增的 `editor` 值和 `options` 字段。正式前端必须依据目录字段
|
||||
生成控件,不能硬编码具体 AMESim 模型名。
|
||||
|
||||
`property_model` 是每种介质组件内部的稳定选项编号,不等同于 AMESim 原始
|
||||
`eosType`。例如空气组件的 `property_model=0` 表示理想气体,并映射到
|
||||
`fluidType=2/eosType=1`;氦气组件的 `property_model=0` 表示
|
||||
Peng-Robinson,并映射到 `fluidType=12/eosType=6`。编译层负责保存这种映射,
|
||||
前端只使用目录选项。
|
||||
|
||||
当前前端保留内置兜底目录,用于后端未启动时继续打开工程。兜底只是一种开发期
|
||||
容错机制,不能成为新增模型的正式注册方式;正式环境应明确提示目录加载失败。
|
||||
|
||||
@@ -603,6 +627,12 @@ System XML 中:
|
||||
- `<Port name>`:必须存在于模型的 `PORTS`。
|
||||
- `<Parameter name>`:必须存在于模型的 `PARAMETERS`。
|
||||
|
||||
介质定义组件不通过物理端口连接。编译器先收集目录角色为
|
||||
`amesimGasMediumDefinition` 的零端口组件,再解析带
|
||||
`editor="amesimGasReference"` 参数的组件引用;介质定义组件本身不进入数值
|
||||
仿真网络。System XML 语义校验会在编译前检查介质索引的整数范围、定义唯一
|
||||
性、正索引引用完整性,以及同一气动连通分量的引用一致性。
|
||||
|
||||
XML 解析器只负责结构、引用和契约校验;模型注册中心负责选择 Python 类并创建实例;
|
||||
模型自身负责方程和状态。三层职责不得混合。
|
||||
|
||||
|
||||
@@ -249,10 +249,12 @@ 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` |
|
||||
@@ -262,8 +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`。
|
||||
|
||||
+68
-1
@@ -5,6 +5,8 @@ System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的
|
||||
## 基本约定
|
||||
|
||||
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
|
||||
- 新导出的介质引用语义使用可选根属性
|
||||
`mediumReferenceVersion="1"`。缺少该属性的文档按旧版介质索引规则读取。
|
||||
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
|
||||
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
|
||||
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
|
||||
@@ -16,7 +18,7 @@ System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<System name="transfer-system" schemaVersion="2" unitSystem="SI">
|
||||
<System name="transfer-system" schemaVersion="2" unitSystem="SI" mediumReferenceVersion="1">
|
||||
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
|
||||
<Components>
|
||||
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
|
||||
@@ -80,6 +82,71 @@ System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的
|
||||
|
||||
XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
|
||||
|
||||
### AMESim 介质定义与索引兼容
|
||||
|
||||
AMESim 介质定义继续使用普通的零端口 `Component`,不增加新的 XML
|
||||
层级。其介质索引和物性选择仍以数值 `Parameter` 保存,例如:
|
||||
|
||||
```xml
|
||||
<Component id="air_1" name="Air 1"
|
||||
type="amesim_ideal_air_medium"
|
||||
componentType="amesim_ideal_air_medium"
|
||||
x="40" y="40">
|
||||
<Parameter name="gi" value="1"/>
|
||||
<Parameter name="property_model" value="0"/>
|
||||
</Component>
|
||||
```
|
||||
|
||||
氦气 Peng-Robinson 定义使用相同结构:
|
||||
|
||||
```xml
|
||||
<Component id="helium_1" name="Helium 1"
|
||||
type="amesim_helium_medium"
|
||||
componentType="amesim_helium_medium"
|
||||
x="40" y="140">
|
||||
<Parameter name="gi" value="2"/>
|
||||
<Parameter name="property_model" value="0"/>
|
||||
</Component>
|
||||
```
|
||||
|
||||
这里的 `property_model=0` 是氦气组件内部的 Peng-Robinson 选项编号;编译时
|
||||
同时保留源 AMESim `fluidType=12/eosType=6` 元数据,不能把两个编号体系混用。
|
||||
|
||||
气动组件的 `gi=0` 表示内置的空气理想气体;正整数引用画布中的显式介质
|
||||
定义。介质定义的 `property_model` 是该介质内部的物性计算模型编号,当前
|
||||
空气定义中的 `0` 表示理想气体。界面中的 `gi` 下拉栏只显示索引数值,
|
||||
`property_model` 下拉栏的名称和可选值则来自组件目录。
|
||||
|
||||
解析旧文档时,只有同时满足以下条件才执行旧 `gi` 兼容转换:
|
||||
|
||||
- 根元素没有 `mediumReferenceVersion`;
|
||||
- 所有组件类型均可由当前注册表识别;
|
||||
- 画布中不存在目录角色为 `amesimGasMediumDefinition` 的组件。
|
||||
|
||||
在这种可证明没有显式介质定义的旧文档中,缺失的 `gi` 会补为 `0`,旧
|
||||
`gi=1` 会映射为 `0`,并返回语义层警告。带有
|
||||
`mediumReferenceVersion="1"` 的新文档不会重解释正整数索引。
|
||||
|
||||
在物性计算模型下拉接口加入之前保存的介质定义可能只有 `gi`。无论是否存在
|
||||
`mediumReferenceVersion`,解析器都会为缺失的 `property_model` 注入组件目录
|
||||
声明的默认值,并返回 `AMESIM_GAS_PROPERTY_MODEL_DEFAULTED` 警告;重新保存后
|
||||
该参数会显式写入 XML。
|
||||
|
||||
完成兼容转换后,解析器统一按介质引用版本 1 执行以下语义校验:
|
||||
|
||||
- 介质定义组件的 `gi` 必须是 `1..99` 的整数,且工程内不得重复。
|
||||
- 气动组件的介质引用必须是 `0..99` 的整数。
|
||||
- `gi=0` 始终引用内置空气理想气体;所有正索引必须存在对应介质定义。
|
||||
- 同一个气动连通分量内只能使用一个 `gi`,不同的独立气动网络可以选择不同
|
||||
介质。
|
||||
|
||||
相应错误码为 `AMESIM_GAS_MEDIUM_INDEX_INVALID`、
|
||||
`AMESIM_GAS_MEDIUM_INDEX_DUPLICATE`、
|
||||
`AMESIM_GAS_REFERENCE_INDEX_INVALID`、`AMESIM_GAS_REFERENCE_UNDEFINED` 和
|
||||
`AMESIM_GAS_REFERENCE_CONFLICT`。XML 解析得到的规范化工程 JSON 总是输出
|
||||
数值字段 `"mediumReferenceVersion": 1`;因此旧文档一经解析并重新保存,便
|
||||
不再依赖旧版启发式迁移。
|
||||
|
||||
## 仿真模型编译接口
|
||||
|
||||
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
|
||||
|
||||
+2744
-377
File diff suppressed because it is too large.
Load diff
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user