Compare commits

Author SHA1 Message Date
ljz 2f3f7d00ec 更新v2版本适配skill 2026-09-18 01:40:58 +08:00
ljz 7611f13208 修复循环信号与事件采样并接入 LSTP 接触定位,补充八路验证及复用实验
相较上一版 Jacobian 确定性复用更新,本次补齐事件边界一致性、结果两侧采样及接触事件定位;保留已有物性复用和组件力学公式。

- 统一 UD00 信号求值与下一事件查询的绝对时间边界,修复循环边界浮点舍入导致的阶段错位、重复或漏报,并覆盖零时长、多阶段及长周期场景。
- 引入原生输出语义 v2:保留规则网格真实时间,补充内部时间事件和状态事件的左邻及事件后采样,按保存时间、状态和离散模式重放结果。
- 两条代码生成路径均发出 LSTP 接触描述,默认定位间隙过零及非负力模式的力截断;仅在接受事件时更新防重复记录,增加 contactEvents 诊断计数。
- 补充 MASS/LSTP 独立事件实验、八路全曲线与驱动阶段配对评估,以及 Amesim 不连续点输出对照和力差定位报告;MASS 新增释放机制仍保留为独立实验。
- 保存局部 probe、context 访问与回退、shadow replay、R288 real skip/typed replay 及阀门数值尾部诊断工具和报告;未证明净收益的实验不启用为生产默认优化。
- 更新原生运行说明和元件建模规范,补充信号边界、输出语义、接触事件和实验依赖回归测试。

验证:五组专项回归共 34 项全部通过;37 个待提交 Python 文件语法检查通过;git diff --cached --check 通过。
2026-09-17 23:50:13 +08:00
ljz 1aac220084 优化 Jacobian 确定性复用并补充性能剖析与平台依赖文档
在单次 Jacobian 构建内按完整输入精确复用储气物性、PH 反算、密度和管路求根结果,保持原有求值副作用、差分政策与失败回退。八路模型求解 CPU 中位数减少 19.27%,循环和不循环的完整原始采样均与恢复基线一致。

增加独立的跨平台时间剖析工具,记录互斥阶段耗时、Newton/LU 统计、矩阵复用与内核复用,保存 UD00 两种工况的调查报告和机器可读汇总。

补充 Windows/Linux 运行、测试、原生编译和剖析所需依赖文档及索引,不修改依赖清单、版本锁或安装环境。

验证:8 项新增专项回归通过;2270 次完整 Jacobian 核对零差异;16 次剖析配对及预热运行保持完整数值一致。既有固定样本哈希失败和 Linux 实机验收限制见报告。
2026-09-16 13:39:53 +08:00
ljz 2b07d996cf 高温氦气物性补全;三通四通能量计算bug修正 2026-09-15 17:57:20 +08:00
ljz 6fc9afe41d 现有组件功能与Amesim组件比较对齐,补全功能;仿真服务启动环境检测,旧版json工程文件适配读取 2026-09-13 20:01:56 +08:00
lujingze b6c22a54f8 修复测试模型路径并完善四路八路回归与文档
- 按文件哈希区分当前八路工程和历史 Amesim 基准,修复测试与清单中的旧路径。
- 四路审计从 JSON 按需生成 XML,八路新增只核对模式,并显式使用 UTF-8 与 LF。
- 将缺失高刚度夹具的测试替换为当前八路完整 BDF 运行,明确未恢复原 RK45 专项覆盖。
- 更新前端大型工程测试、历史活动回放入口、数据说明、现行规范和相关报告。

验证:全量后台 374 项通过、1 项条件跳过;前端 11 项通过、历史独立服务用例 1 项跳过;四路八路 AME 审计通过。
环境、缓存和运行产物保持忽略;本次未实施工程 JSON 字段精简。
2026-09-12 15:49:09 +00:00
lujingze 44b6ea74ab 旧版前端工程文件导入时版本对比查验、审阅与仿真时部分阻挡功能实现;前端参数输入格式统一规范 2026-09-12 14:15:40 +00:00
lujingze 22579e51c9 缓存功能windows平台适配 2026-09-12 05:40:18 +00:00
lujingze 151e6e4b97 C内核按库功能拆解,编译结果缓存区构建,编译过程与已有缓存结果对照功能实现 2026-09-12 05:24:48 +00:00
lujingze aa4951b14e 优化雅可比矩阵计算;端口转发情况下仿真结果传输方式优化 2026-09-12 03:57:31 +00:00
lujingze 3bc4be3c06 优化原生结果编码传输与浏览器缓存,记录八路性能基线
原生结果series通过字节索引直传,C端使用Ryu精确回读编码和64 KiB批量写出;网页采用Float64缓存和CSV工作线程,减少结果处理与保存等待。

补充八路AME曲线核查、全流程分阶段计时、独立编码基准和复现工具,固定后续优化采用修正八路及rtol=1e-8。C写出1.1808→0.1638 s,点击到可查看8.0100→6.9756 s。

验证:最终10项编码专项、29项相关后端回归通过;8份原生结果逐位一致,16次网页结果/CSV/刷新恢复通过。前端构建及缓存/CSV专项在本轮结果处理工作中通过。环境、原始大结果与临时构建不纳入Git。
2026-09-11 15:09:15 +00:00
lujingze 808c484f5b C内核流量计算方法优化,前端文件名称读取优化 2026-09-11 08:48:56 +00:00
ljz 5d5a2e1843 优化端口传递物性参数方式,减少上下游元件重复计算量 2026-09-11 14:42:19 +08:00
ljz 91bd9fb252 前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解 2026-09-11 11:27:54 +08:00
ljz 0dcb465d84 Python内核代码移除,建模界面缩放bug修复 2026-09-10 11:57:39 +08:00
ljz 743663e3a6 Merge system-optimization IR contracts and retire legacy engine adapter 2026-09-10 01:21:53 +08:00
ljz 3b38f73fe0 Replace Python numerical kernels with native C execution 2026-09-10 01:12:18 +08:00
ljz 48da6be21c 新增基础版系统仿真 Skill 2026-09-03 16:15:44 +08:00
ljz ce353d2dd2 完成仿真系统IR-schema规定 2026-09-02 19:17:55 +08:00
ljz 03b86f52ba P0跨平台暂存问题解决 2026-09-02 19:14:40 +08:00
ljz ce62079335 P0跨平台暂存问题解决 2026-09-02 13:48:51 +08:00
huojiarong 408b4ecb22 修复机械事件力曲线并对齐气动孔口端口显示 2026-08-20 05:59:04 +00:00
lujingze e18399c022 整合求解器活动监控与步长回归证据
同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。

纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
2026-08-19 16:24:31 +00:00
huojiarong c19cf77aee docs: record August 19 updates 2026-08-19 11:35:59 +00:00
huojiarong eb6ea70e19 feat: extend stall timeout and remove sample cap 2026-08-19 11:34:31 +00:00
huojiarong 27f9f4add8 fix: align PNL0003 Reynolds diagnostic with AMESim 2026-08-19 10:40:57 +00:00
lujingze 60b743dd81 补记当前快照存档与推送结果
按北京时间记录 a8c7338 的远端存档、验证结果及仍待完成的 production 复跑和托管 workflow 检查。
2026-08-18 15:22:27 +00:00
lujingze a8c733883c 存档求解器回归基线与当前改动
纳管 AMESim 对齐基线、发布锁、回归测试及当前物理门禁调整。

更新日志仅记录已完成成果,并注明当前 HEAD 尚待真实 production 复跑与远端 workflow 验证。
2026-08-18 15:20:42 +00:00
huojiarong 53f8601fec 修正动态管与阀门诊断输出 2026-08-18 10:24:29 +00:00
huojiarong f725f038b6 完善 PNL00R 摩擦模型与八路回归 2026-08-18 09:30:29 +00:00
lujingze 684d28752a 更新八路仿真最新版本文件test-mql-8 2026-08-18 06:44:24 +00:00
lujingze b435daecf2 完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
2026-08-18 06:42:07 +00:00
huojiarong 143e8dd309 修复测试资源路径 2026-08-18 00:57:32 +00:00
huojiarong 0fa166c8e5 Fix pneumatic node zero-flow reversal 2026-08-17 09:24:35 +00:00
lujingze 16a7eb2d6c 完成求解器雅可比矩阵首轮优化,增加更新目录,整理了文档文件夹,增加了服务启动脚本 2026-08-17 07:33:31 +00:00
Codex 6bb0591d32 fix: align PNL0001 symbol causality 2026-08-16 11:37:02 +00:00
Codex cca9d1e883 fix: seed PNVO pipe series pressure 2026-08-16 11:34:35 +00:00
Codex 22b35b2945 perf: retain exact flow caches and compiled targets 2026-08-16 11:21:44 +00:00
Codex f09dfcf542 fix: bound reported pipe friction diagnostics 2026-08-16 11:03:21 +00:00
ljz 5332a788f3 优化仿真求解性能并修复流量闭合问题(初版) 2026-08-16 17:46:05 +08:00
ljz 57b459bc72 增加可选性能埋点并完成物性效率评估 2026-08-16 17:46:04 +08:00
huojiarong 4e0b9fd8cc fix: align mechanical dynamic port validation 2026-08-15 12:57:03 +00:00
huojiarong 6a064892e2 优化压力流量求解并达到四路性能门槛 2026-08-15 11:45:22 +00:00
ljz 6572defaa4 完善建模交互、组件图标与系统协议 2026-08-15 17:40:18 +08:00
huojiarong 456c29b3b6 验收四路模型并优化拓扑求解性能 2026-08-12 11:57:42 +00:00
huojiarong caca32a513 校准第二支路热流体能量与管路摩擦 2026-08-11 12:13:09 +00:00
huojiarong 0f73d5b568 对齐PNL0002上游温度并稳定热流体闭合 2026-08-11 07:12:52 +00:00
huojiarong 6abcc220de 对齐AMESim管阻孔口与储气耦合 2026-08-10 13:07:03 +00:00
ljz 7671418582 统一组件图标布局并完善选择吸附交互 2026-08-06 23:35:50 +08:00
huojiarong 40c72422ff 对齐PNVO近等压层流平滑 2026-08-05 12:59:05 +00:00
huojiarong e9fc855a1c 修复参数帮助浮层滚动触发 2026-08-05 12:58:08 +00:00
ljz 09778972a6 完善端口标号与MECMAS21动态图标 2026-08-04 12:55:50 +08:00
ljz df1d676131 完善AMESim可配置参数交互
补充气动孔口、动态管路、UD00 与弹性接触组件的离散选项、条件显示和折叠分组。

增加目录协议、非法选项和前端参数表交互回归测试,不修改现有求解公式。
2026-08-04 01:27:17 +08:00
ljz be54070855 完善MECMAS21参数配置与分组交互 2026-08-03 23:51:27 +08:00
ljz 32f21f08ff 优化参数表样式与科学计数法支持 2026-08-03 23:51:27 +08:00
huojiarong 046aa49814 对齐Amesim氦气PR物性与PNVO流量 2026-08-03 15:34:33 +00:00
huojiarong 18d9802f03 优化求解器重试并校正AMESim机械端口 2026-08-03 09:54:46 +00:00
ljz 971e8f2336 初版:实现 AMESim 机械因果化与事件求解
初步支持 MECMAS21 刚性质量状态归并、端止事件、恢复系数,以及 LSTP 接触和压力流量显式因果化。

已知问题:显式传播仍会重复扫描全网方程,长时刚性仿真性能待优化;自适应积分器遇到越出物理域的试探状态时,尚未实现恢复并缩步重试。
2026-08-03 15:45:48 +08:00
huojiarong de265cdde6 调整Playwright默认浏览器策略 2026-08-03 07:03:13 +00:00
ljz cf249d6c0d 优化组件选择交互与视图适配时机 2026-08-03 11:59:24 +08:00
ljz 11093613c5 完善 PNVO001 与 PNRP17 前端图标及端口交互
按 AMESim 参考样式重绘孔板与气动活塞图标,调整端口锚点和旋转后的连接方向,并补充组件图标及接触连接回归测试。
2026-08-03 09:49:55 +08:00
huojiarong 1f4027573f 公开PNRP17并接通实时气动机械耦合 2026-08-02 14:51:31 +00:00
ljz 503394f3ed 完善建模交互与结果曲线功能
支持元件接口接触吸附、旋转后兼容连接及无连线逻辑边。

完善 AMESim 图标显示、参数双栏、结果曲线待定窗口及相关端到端测试。
2026-08-02 17:31:28 +08:00
ljz 410ef535e8 完善AMESim组件界面与仿真求解稳定性 2026-08-02 00:57:48 +08:00
ljz e7177ab03e feat: integrate AMESim media models and editor UI 2026-07-31 23:36:58 +08:00
lujingze 3e4f466114 Merge pull request 'merge/model-development-into-main' (#2) from merge/model-development-into-main into main
Reviewed-on: #2
2026-07-31 09:52:44 +08:00
huojiarong 127ec36a55 Merge model-development into main 2026-07-30 10:53:35 +00:00
huojiarong e87df789b2 接入 AMESim 机械接触和节点组件 2026-07-30 10:34:29 +00:00
huojiarong 2ddaaeb56a 上传 AMESim 参考资料 2026-07-30 10:15:23 +00:00
huojiarong 3f51a124d6 公开更多 AMESim 组件并接入信号机械闭环 2026-07-30 10:11:08 +00:00
huojiarong db4bdb4b70 同步仿真框架并接入AMESim气动组件 2026-07-30 03:53:10 +00:00
huojiarong 420bafeb4e 补充test_mql pneumatic_69局部参考RHS候选 2026-07-29 09:23:05 +00:00
huojiarong e17a952bee 补充test_mql pneumatic_96候选短窗口对比 2026-07-29 09:02:55 +00:00
huojiarong a8a68f6dd4 添加test_mql pneumatic_96局部参考RHS候选 2026-07-29 08:38:37 +00:00
huojiarong 71da73ef70 补充test_mql pneumatic_96流量反事实诊断 2026-07-29 08:15:36 +00:00
huojiarong cd2fa800b9 补充test_mql pneumatic_96参考导数反事实诊断 2026-07-29 08:04:30 +00:00
huojiarong 532fdb4e6b 补充test_mql pneumatic_96导数分项诊断 2026-07-29 07:45:39 +00:00
ljz bbc88a6bbb 规范仿真模型库并完善前端交互
归档仿真模型并补充组件目录、建模规范与校验。

完善控制台、默认节点、视图适配及前端自动化测试。
2026-07-29 15:44:16 +08:00
huojiarong bd7c8c99e9 补充test_mql pneumatic_96入口支路诊断 2026-07-29 07:31:08 +00:00
huojiarong bf4dc0af3d 补充test_mql PNL0003质量导数链路诊断 2026-07-29 07:09:11 +00:00
huojiarong 200d4593e8 补充test_mql PNL0003压力导数分项诊断 2026-07-29 06:40:42 +00:00
huojiarong 890bf1d483 补充test_mql PNL0003双端温度导数诊断 2026-07-29 06:31:04 +00:00
huojiarong 137a4cf7d6 补充test_mql PNL0003温度导数分项诊断 2026-07-28 10:48:32 +00:00
huojiarong 920ee336e9 补充test_mql PNL0003储气质量诊断 2026-07-28 10:35:14 +00:00
huojiarong fc46c4272a 补充test_mql PNL0003中心流反算诊断 2026-07-28 10:07:13 +00:00
huojiarong 8b9f229b80 补充test_mql PNL0003温度反事实诊断 2026-07-28 09:37:34 +00:00
huojiarong 918952f130 补充test_mql PNL0003中心焓候选诊断 2026-07-28 09:16:33 +00:00
huojiarong d67fb251d2 补充test_mql P4节点真实气体焓诊断 2026-07-28 09:00:02 +00:00
huojiarong e473162d4c 校正test_mql PNVO事件流量系数 2026-07-28 04:16:45 +00:00
huojiarong 8a2d2ae190 修正test_mql PNL0003节点混合焓 2026-07-28 03:33:10 +00:00
huojiarong 7182f929e8 补充test_mql PNL0003储能诊断 2026-07-28 02:49:02 +00:00
huojiarong 021ea63a2b 接入test_mql pneumatic_69传输焓RHS 2026-07-28 02:20:33 +00:00
huojiarong f8af99ace1 接入test_mql高密度事件窗口诊断 2026-07-27 16:18:20 +00:00
huojiarong 01b606df30 修正test_mql事件窗口插值诊断 2026-07-26 14:48:38 +00:00
huojiarong 331435e78a 补充test_mql PNVO焓流诊断与系数校正 2026-07-24 10:58:10 +00:00
huojiarong a219cddc0b 修正test_mql节点焓流诊断口径 2026-07-23 08:40:56 +00:00
ljz f7f1078911 完善仿真交互、结果展示与模型元数据 2026-07-22 19:33:38 +08:00
huojiarong 1602e0dc51 补充pn2pipefr候选流量诊断 2026-07-22 09:50:48 +00:00
huojiarong 84d1675292 接入AMESim help文档与PNVO流量诊断 2026-07-22 08:56:04 +00:00
huojiarong f0f8f40c7a 接入PNL0001事件窗口RHS诊断 2026-07-22 04:21:38 +00:00
huojiarong 2120901909 修复test_mql事件后积分步长控制 2026-07-22 02:58:59 +00:00
huojiarong a0855018d5 对齐test_mql保存时刻与PNVO事件边界 2026-07-21 07:56:25 +00:00
huojiarong 7c68caed27 接入test_mql PNVO阶跃控制与流量诊断 2026-07-21 07:28:28 +00:00
ljz f1256a121d 完善 XML 通用仿真与结果查看 2026-07-21 13:42:55 +08:00
huojiarong 65f6d6dd87 更新test_mql迁移状态文档 2026-07-20 10:34:51 +00:00
huojiarong 2c2952cff1 拆解test_mql变容气室RHS 2026-07-20 10:12:07 +00:00
huojiarong a6e1faa2d8 诊断test_mql总闭包对比端点 2026-07-20 10:04:43 +00:00
huojiarong 3951bc372c 排序test_mql总闭包对比偏差 2026-07-20 09:59:09 +00:00
huojiarong eaafe5eafc 接入test_mql总闭包对比报告 2026-07-20 09:54:03 +00:00
huojiarong 6953c864c4 导出test_mql总闭包关键序列 2026-07-20 09:45:32 +00:00
huojiarong 958d813300 接入test_mql变容气室机械反馈 2026-07-20 09:37:40 +00:00
huojiarong d4edf1ccb3 导出test_mql活塞运动学 2026-07-20 09:26:01 +00:00
huojiarong 65cdd2284e 接入test_mql大质量块约束 2026-07-20 09:23:23 +00:00
huojiarong bb4a71c612 接入test_mql外力信号边界 2026-07-20 09:18:05 +00:00
huojiarong c8de46fac2 导出test_mql机械节点总力 2026-07-20 09:14:20 +00:00
huojiarong 8782f96baf 补充test_mql机械节点运动学 2026-07-20 09:09:49 +00:00
huojiarong 0035243361 接入test_mql端止动机械力 2026-07-20 09:07:00 +00:00
huojiarong 716a1c572a 组合test_mql 132状态总闭包 2026-07-20 09:01:17 +00:00
huojiarong 3d1de6d5d0 接入test_mql机械质量状态闭包 2026-07-20 08:54:04 +00:00
huojiarong 7cb050d235 抽象test_mql邻域与阻力流量计算 2026-07-20 03:56:50 +00:00
huojiarong 3d572767d2 抽象test_mql双状态孔口线流量计算 2026-07-20 03:43:43 +00:00
huojiarong be1eec1348 抽象test_mql固定气室段流量计算 2026-07-20 03:38:17 +00:00
huojiarong cdd17d29c8 抽象test_mql PN3节点平衡组装 2026-07-20 03:31:04 +00:00
huojiarong a52019c5e9 抽象test_mql P4节点平衡组装 2026-07-20 03:25:12 +00:00
huojiarong 777f943660 抽象test_mql代数元件端口写回 2026-07-20 03:18:24 +00:00
huojiarong 65623fb1dd 复用test_mql输出线闭包逻辑 2026-07-19 10:58:39 +00:00
huojiarong cf33d445a2 抽象test_mql双状态连接线闭包逻辑 2026-07-19 10:54:11 +00:00
huojiarong 13f37bfd0f 抽象test_mql相邻连接线闭包逻辑 2026-07-19 10:43:13 +00:00
huojiarong d02dacae0f 抽象test_mql主线气室闭包逻辑 2026-07-19 10:34:38 +00:00
huojiarong 7b9d28c0ee 抽象test_mql固定气室段闭包逻辑 2026-07-19 10:28:05 +00:00
huojiarong 2622bb8c92 补充test_mql pn_node3_8气室闭合断言 2026-07-19 10:17:53 +00:00
huojiarong dd2efff487 补充test_mql pneumatic_100闭合断言 2026-07-19 10:03:29 +00:00
huojiarong acee83fe53 接入test_mql pn_node3_10入口平衡 2026-07-19 09:55:25 +00:00
huojiarong bdbac6fbb5 接入test_mql pn_general_chamber_2出口支路 2026-07-19 09:48:08 +00:00
huojiarong 09fb3a5f72 接入test_mql pneumatic_107远端PN3子网 2026-07-19 09:34:05 +00:00
huojiarong 2afd5eeb11 接入test_mql pn_node3_12主压温支路 2026-07-19 09:22:00 +00:00
huojiarong 4242057bd8 接入test_mql pn_node3_13固定气室支路 2026-07-19 09:10:00 +00:00
huojiarong 492562a517 接入test_mql pneumatic_106远端PN3子网 2026-07-19 08:54:44 +00:00
huojiarong a1bddfcd98 接入test_mql pn_node3_14固定气室支路 2026-07-19 08:41:41 +00:00
huojiarong 8e1263833b 接入test_mql pneumatic_93直连P4端口 2026-07-19 08:27:14 +00:00
huojiarong b44bbbd110 接入test_mql pneumatic_94直连P4端口 2026-07-18 09:47:32 +00:00
huojiarong a09a8c8daf 接入test_mql P4桥接环网管路 2026-07-18 09:38:52 +00:00
huojiarong 8639b42574 接入test_mql pneumatic_105远端PN3子网 2026-07-18 09:24:36 +00:00
huojiarong f74b414b24 接入test_mql P4环网剩余管路 2026-07-18 09:10:31 +00:00
huojiarong 8c8b4f26e9 接入test_mql PNVO上游PN3子网 2026-07-18 08:56:23 +00:00
huojiarong 62c8aabeab 接入test_mql相邻P4远端主线 2026-07-18 08:35:42 +00:00
huojiarong 95ba5a5e81 接入test_mql相邻P4主压温边界 2026-07-17 10:20:43 +00:00
huojiarong 853ae3043d 复用test_mql P4邻接拓扑装配 2026-07-17 10:10:41 +00:00
huojiarong 43e238034a 补充test_mql P4邻接拓扑解析 2026-07-17 10:05:26 +00:00
huojiarong 8af0a27e4c 接入test_mql P4主线气室 2026-07-17 09:58:59 +00:00
huojiarong fafce0af87 接入test_mql P4节点局部闭合 2026-07-17 09:51:54 +00:00
huojiarong a071896834 实现test_mql PNL0002管路动态 2026-07-17 09:38:32 +00:00
huojiarong 7569917924 实现test_mql P4节点代数契约 2026-07-17 09:25:35 +00:00
huojiarong 5c3ad50090 接入test_mql PNVO孔口边界 2026-07-17 09:16:35 +00:00
huojiarong 78013b25c9 接入test_mql PN3节点管路闭合 2026-07-17 09:05:51 +00:00
huojiarong 568be2e632 实现test_mql PNL0003与PNL00R管路 2026-07-17 08:54:38 +00:00
huojiarong c4c1b35dee 接入test_mql双管路气室与PN3节点 2026-07-17 03:39:14 +00:00
huojiarong 1193b2dcee 实现test_mql PNL0001管路动态 2026-07-17 03:28:36 +00:00
huojiarong 2d33a8f4bc 接入test_mql真实固定气室拓扑 2026-07-17 02:49:06 +00:00
huojiarong c0f28520b4 补充test_mql气动拓扑候选发现 2026-07-16 09:42:25 +00:00
huojiarong 95ebca89f6 补充test_mql气动支路配置接口 2026-07-16 09:31:12 +00:00
huojiarong 2cea81739e 补充test_mql气动支路仿真入口 2026-07-16 09:22:51 +00:00
huojiarong 314b65872e 接入test_mql真实气动组件 2026-07-16 09:17:10 +00:00
huojiarong 94badfb56f 建立test_mql气动闭合骨架 2026-07-16 09:10:46 +00:00
huojiarong a1870f2789 补充test_mql管路反号观测输出 2026-07-16 08:54:59 +00:00
huojiarong 0cd4359964 补充test_mql气动反号观测输出 2026-07-16 08:51:59 +00:00
huojiarong bef9dce55e 补充test_mql机械零力计算输出 2026-07-16 08:44:34 +00:00
huojiarong 3ac83d9f00 补充test_mql质量端止动反号输出 2026-07-16 08:36:36 +00:00
huojiarong 999d840615 补充test_mql变容气室体积计算输出 2026-07-16 08:23:12 +00:00
huojiarong b3feb5c7e0 补充test_mql活塞几何计算输出 2026-07-16 02:52:26 +00:00
huojiarong 77e2f10cd5 补充test_mql基准透传脚本 2026-07-16 02:46:13 +00:00
huojiarong d35e85e066 补充test_mql基准透传运行器 2026-07-16 02:41:04 +00:00
huojiarong 6ccbf7eb5e 补充test_mql输出校验对比入口 2026-07-16 02:34:32 +00:00
huojiarong 9eb7d293f7 补充test_mql输出变量契约 2026-07-16 02:27:42 +00:00
huojiarong 143097dc28 补充test_mql统一观测目录 2026-07-16 02:21:49 +00:00
huojiarong c84a809f77 补齐test_mql机械观测覆盖 2026-07-16 02:15:47 +00:00
huojiarong f6871aefac 补充test_mql机械观测目录 2026-07-16 02:06:10 +00:00
huojiarong 6f9e7084dd 补充test_mql气室观测目录 2026-07-16 01:59:15 +00:00
huojiarong 7c820cbada 补充test_mql管路观测目录 2026-07-16 01:54:44 +00:00
huojiarong c7b92f99c6 补充test_mql孔口观测目录 2026-07-16 01:49:19 +00:00
huojiarong 2199f1c11e 修正test_mql气室压力约定 2026-07-15 10:25:50 +00:00
huojiarong f6dda509a2 补充test_mql质量端止动原语 2026-07-15 10:19:24 +00:00
huojiarong a63fda313b 校验test_mql机械观测量 2026-07-15 10:08:53 +00:00
huojiarong 6f34ac16bf 补充test_mql机械原语 2026-07-15 10:02:53 +00:00
huojiarong 8f14a3840f 装配test_mql机械组件 2026-07-15 09:56:57 +00:00
huojiarong 03a6db10ad 装配test_mql管路连接 2026-07-15 09:51:02 +00:00
huojiarong 3818b011d5 建立test_mql变量目录 2026-07-15 09:46:51 +00:00
huojiarong 33c8bfae3d 装配test_mql气动元件 2026-07-15 09:43:34 +00:00
huojiarong 1f47171d07 新增test_mql结果对齐工具 2026-07-15 09:40:07 +00:00
huojiarong 796433c275 解析AMESim二进制结果文件 2026-07-15 09:33:34 +00:00
huojiarong a598fcd5c3 新增test_mql气动组件原语 2026-07-15 09:21:03 +00:00
huojiarong cdead78977 补充氦气Peng-Robinson物性库 2026-07-15 09:16:11 +00:00
huojiarong 056b71fead 补充test_mql配置解析层 2026-07-15 09:05:53 +00:00
huojiarong c745fd2a5c Add test_mql structural Python model 2026-07-15 08:59:59 +00:00
huojiarong 2c38da16b5 Add AMESim test_mql model 2026-07-15 08:33:38 +00:00
ljz 104d41d294 规范化xml内容,并在xml中添加仿真设置,规范化版本号等内容,补充xml规范md文件 2026-07-15 15:16:58 +08:00
ljz c52266ba11 添加图形化界面撤销、复制、粘贴、删除等功能 2026-07-15 15:03:27 +08:00
648 changed files with 729987 additions and 3515 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
*.bat text eol=crlf
*.cmd text eol=crlf
*.sh text eol=lf
# Regression manifests hash these files as raw bytes. Keep their checkout
# representation identical on Windows and Linux so hashes remain portable.
tests/baselines/simulation/test_mql_full_branches/sources/test_mql-full-branches-01-04.xml text eol=lf
tests/baselines/simulation/**/*.json text eol=lf
tests/baselines/simulation/**/sources/* text eol=lf
# Browser projects and relocated native references.
tests/data/*.json text eol=lf
tests/baselines/native/*.json text eol=lf
+139
View File
@@ -0,0 +1,139 @@
name: Native backend regression
on:
push:
paths:
- "app/**"
- "native/**"
- "schemas/**"
- "tests/**"
- "requirements*.txt"
- "constraints/**"
- ".python-version"
- ".gitattributes"
- ".github/workflows/solver-regression.yml"
pull_request:
paths:
- "app/**"
- "native/**"
- "schemas/**"
- "tests/**"
- "requirements*.txt"
- "constraints/**"
- ".python-version"
- ".gitattributes"
- ".github/workflows/solver-regression.yml"
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
concurrency:
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
contracts:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: constraints/python312-linux-x86_64.lock
- name: Install backend runtime without numerical Python packages
run: |
python -m pip install -r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Validate portable metadata and XML contracts
env:
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
run: |
python -W error::ResourceWarning -m unittest \
tests.test_dependency_constraints \
tests.test_regression_fixture_line_endings \
tests.test_component_catalog \
tests.test_component_metadata \
tests.test_component_registry \
tests.test_port_computation \
tests.test_native_schedule.DependencyGraphTests \
tests.test_medium_reference_contract \
tests.test_system_xml_v3 \
tests.test_native_only_backend
native-linux:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install native dependencies with the existing Linux setup
run: |
python -m venv .venv
.venv/bin/python -m pip install -r requirements-test.txt
bash bat/setup-native-linux.sh
- name: Check startup diagnostics, native cache and solver control on Linux
env:
SIMULATION_NATIVE_REQUIRE_TOOLCHAIN: "1"
run: |
.venv/bin/python -W error::ResourceWarning -m unittest \
tests.test_simulation_warmup tests.test_native_cache_platform \
tests.test_native_solver_control tests.test_native_worker_control \
tests.test_result_storage tests.test_native_sample_storage tests.test_native_result_transport -v
native-windows:
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@v4
- uses: conda-incubator/setup-miniconda@v3
with:
python-version: "3.12"
activate-environment: simulation-native
auto-activate-base: false
- name: Install native compiler and SUNDIALS
run: |
conda install --yes -c conda-forge sundials=7.4.0 m2w64-gcc
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
"SUNDIALS_ROOT=$env:CONDA_PREFIX\Library" >> $env:GITHUB_ENV
"SIMULATION_NATIVE_CC=$env:CONDA_PREFIX\Library\mingw-w64\bin\gcc.exe" >> $env:GITHUB_ENV
python -m pip install -r requirements-test.txt
python -m pip check
- name: Verify native cache on the Windows runtime
env:
SIMULATION_NATIVE_REQUIRE_TOOLCHAIN: "1"
run: |
New-Item -ItemType Directory -Force test/ci-native-cache | Out-Null
python -W error::ResourceWarning -m unittest tests.test_native_cache_storage tests.test_native_cache_platform -v > test/ci-native-cache/regression.log 2>&1
$cacheRegressionExit = $LASTEXITCODE
Get-Content test/ci-native-cache/regression.log
if ($cacheRegressionExit -ne 0) { exit $cacheRegressionExit }
- name: Run catalog, numerical, API and schema regression
run: |
python -c "from app.simulation.native_codegen.build import toolchain; print(toolchain())"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python -W error::ResourceWarning -m unittest discover -s tests
- name: Complete the 10 second skill fixture in native RK45
run: |
python -m app.simulation.native_codegen tests/fixtures/native-skill-test.xml --output-dir test/ci-skill --method RK45 --max-step 0.001 --rtol 1e-7 --runs 1 --solve-only
- if: always()
uses: actions/upload-artifact@v4
with:
name: native-skill-regression
path: test/ci-skill/summary.json
if-no-files-found: warn
- if: always()
uses: actions/upload-artifact@v4
with:
name: native-windows-cache-regression
path: test/ci-native-cache/regression.log
if-no-files-found: warn
+12 -1
View File
@@ -12,11 +12,22 @@ htmlcov/
# Local virtual environments # Local virtual environments
.venv/ .venv/
.venv-win/ .venv-win/
PythonModels/runs/
# Local Linux toolchain (downloaded for the startup scripts)
.tools/node-*-linux-x64/
.tools/node-*-win-x64/
# Local benchmark archives, generated executables and comparison outputs
/test/
app/data/ app/data/
/simresults/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
venv/ venv/
env/ env/
+1
View File
@@ -0,0 +1 @@
3.12.3
-2
View File
@@ -1,2 +0,0 @@
__pycache__/
*.pyc
-340
View File
@@ -1,340 +0,0 @@
# PythonModels
`PythonModels` 用于承接 `ModelicaModels` 的 Python 平台移植。
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
当前状态不是“只有骨架”,而是“`Testmodel` 已有一版可运行的 ODE 近似实现,并具备基础结果导出与对比能力”。
## 当前目录
- `core/`: 通用基础设施
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。
- `components/`: 元件级 Python 实现
目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。
- `systems/`: 系统级装配与闭合
当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。
- `reporting/`: 结果导出与对比
当前承接主变量 CSV、温度 CSV/SVG、Python 对 OpenModelica 的对比表与误差摘要导出。
- `scripts/`: 运行脚本
当前入口是 `run_testmodel.py`。
- `baselines/`: 提交进仓库的稳定基线
当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。
- `runs/`: 每次实际运行的默认输出目录
当前脚本默认会在这里创建带时间戳的子目录,用来放这次运行生成的产物。
当前关键文件:
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
- `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` 当前专用的闭合、初始化投影、分支求解与端口回写
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
- `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](/home/lujz/projects/pressurization-transfer-system/PythonModels/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 结果。
## 当前架构判断
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
- 组件级:已完成首版
- 系统闭合:已完成首版
- 积分入口:已完成首版
- 基线结果对齐:已具备初步能力
- 去近似:仍在进行中
所以当前最准确的说法不是“已完成移植”,而是:
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
## 已知限制
当前最主要的限制可以直接理解成下面几条:
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
- 当前 `Tee` 的 stream 语义只覆盖了当前 `Testmodel` 需要的最小集合,还不是通用的 `inStream/actualStream` 框架。
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。
所以,当前版本适合:
- 架构验证
- 组件接口验证
- 基线工况对比
- 结果导出与误差定位
但当前版本还不适合:
- 直接宣称与 OpenModelica 严格等价
- 作为最终工程结论的唯一依据
- 直接扩展到更复杂拓扑而不补通用连接器语义
## 文件级现状
按代码现状逐项看:
- `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/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。
## 当前主技术债
目前最主要的技术债,可以直接理解成下面 4 件事:
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
-2
View File
@@ -1,2 +0,0 @@
"""Python port scaffold for the Modelica-based pressurization system."""
-55
View File
@@ -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)
-28
View File
@@ -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)
-133
View File
@@ -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)
-55
View File
@@ -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)
-172
View File
@@ -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)
-48
View File
@@ -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."""
-96
View File
@@ -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)
-78
View File
@@ -1,78 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.core.base import Component, DynamicComponent
@dataclass(frozen=True)
class Connection:
source_component: str
source_port: str
target_component: str
target_port: str
class SimulationNetwork:
"""Container for components, topology, and state-vector bookkeeping."""
def __init__(self, name: str) -> None:
self.name = name
self.components: dict[str, Component] = {}
self.connections: list[Connection] = []
def add_component(self, component: Component) -> None:
if component.name in self.components:
raise ValueError(f"Duplicate component name: {component.name}")
self.components[component.name] = component
def connect(
self,
source_component: str,
source_port: str,
target_component: str,
target_port: str,
) -> None:
self.connections.append(
Connection(
source_component=source_component,
source_port=source_port,
target_component=target_component,
target_port=target_port,
)
)
def dynamic_components(self) -> list[DynamicComponent]:
return [
component
for component in self.components.values()
if isinstance(component, DynamicComponent)
]
def initial_state_vector(self) -> list[float]:
values: list[float] = []
for component in self.dynamic_components():
values.extend(component.get_state_vector())
return values
def apply_state_vector(self, values: list[float]) -> None:
cursor = 0
for component in self.dynamic_components():
next_cursor = cursor + component.state_size
component.set_state_vector(values[cursor:next_cursor])
cursor = next_cursor
if cursor != len(values):
raise ValueError("State vector length does not match dynamic components.")
def summary(self) -> str:
lines = [f"Network: {self.name}", "Components:"]
for name, component in self.components.items():
lines.append(f" - {name}: {component.__class__.__name__}")
lines.append("Connections:")
for conn in self.connections:
lines.append(
f" - {conn.source_component}.{conn.source_port}"
f" -> {conn.target_component}.{conn.target_port}"
)
return "\n".join(lines)
-13
View File
@@ -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
-102
View File
@@ -1,102 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
@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-8
max_step: float = 1e-3
@dataclass(frozen=True)
class ODESolution:
t: list[float]
y: list[list[float]]
success: bool
message: str
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 _runge_kutta_4(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | 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])
for target_time in t_eval[1:]:
while current_time < target_time - 1e-15:
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
times.append(float(target_time))
for index, value in enumerate(state):
states[index].append(value)
return ODESolution(
t=times,
y=states,
success=True,
message="Integrated with built-in RK4 fallback because SciPy is unavailable.",
)
def integrate_ode(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | 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)
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,
t_eval=t_eval,
)
-21
View File
@@ -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])
-23
View File
@@ -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",
]
-393
View File
@@ -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,
)
-219
View File
@@ -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()
-303
View File
@@ -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
-668
View File
@@ -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,
]
+124
View File
@@ -1,2 +1,126 @@
# SystemSimulationApp # SystemSimulationApp
ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 开发环境准备
Windows/Linux 的运行、测试、原生工具链及时间剖析依赖,统一见 [平台依赖说明](docs/standard/platform-dependencies.md)。2026-09-16 的 Jacobian 复用与剖析工具继续使用现有 SUNDIALS 7.4.0,本轮未新增运行库依赖;以下安装命令供准备环境时使用。
后端编排层统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
`3.12.3`。`requirements.txt` 保留支持范围,
`constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本;
`constraints/python312-linux-x86_64.lock` 则完整固定发布与 CI 所用的 Linux x86_64
wheel、全部传递依赖及其 SHA-256。
Windows:
```powershell
py -3.12 -m venv .venv-win
.\.venv-win\Scripts\python.exe -m pip install `
-r requirements.txt `
-c constraints/python312-direct.txt
.\.venv-win\Scripts\python.exe -m pip check
```
Linux:
```bash
python3.12 -m venv .venv
./.venv/bin/python -m pip install \
-r constraints/python312-linux-x86_64.lock
./.venv/bin/python -m pip check
```
Linux 发布锁仅适用于兼容 manylinux_2_28 的 Linux x86_64 和 CPython 3.12。它启用
`--only-binary=:all:` 与 `--require-hashes`,因此不会静默改用源码包或未审计 wheel;
CI 和正式性能复测必须直接以 `-r` 安装该文件。Windows 或其他平台的开发环境继续
使用 `requirements.txt` 加 `constraints/python312-direct.txt`。若要测试
`requirements.txt` 声明的兼容范围,可显式省略约束,但这类结果不应与锁定环境的
性能数据直接比较。
升级参考版本时,应在干净的 Python 3.12 Linux x86_64 虚拟环境中解析范围文件,
仅下载兼容 wheel,逐个记录 wheel 的 SHA-256,再从空环境安装发布锁并运行
`pip check`、依赖契约测试与后端测试。不能只复制 `pip freeze`,因为它既不证明
依赖来源,也不校验安装产物。
前端使用 Vite 8,需要 Node.js `20.19+` 或 `22.12+`。首次启动前安装前端依赖。
Windows(PowerShell,使用仓库内的便携 Node.js):
```powershell
$nodeDir = Get-ChildItem .tools -Directory -Filter "node-*-win-x64" |
Where-Object { (Test-Path "$($_.FullName)\node.exe") -and (Test-Path "$($_.FullName)\npm.cmd") } |
Select-Object -First 1
& "$($nodeDir.FullName)\npm.cmd" --prefix frontend ci
```
Linux:
```bash
cd frontend
npm ci
cd ..
```
Windows 启动脚本会自动使用 `.tools/node-*-win-x64` 下兼容的便携 Node.js;Linux 启动脚本优先使用 `.tools/node-*-linux-x64` 下兼容的运行时(如果存在),否则使用 `PATH` 中的 `node` 和 `npm`。`start-all.sh` 需要 Bash 4.3 或更高版本。
## 启动项目
脚本统一存放在 `bat/` 目录。三个入口分别用于同时启动、只启动后端、只启动前端。
Windows:
```bat
bat\start-all.bat
bat\start-backend.bat
bat\start-reactflow.bat
```
Linux:
```bash
./bat/start-all.sh
./bat/start-backend.sh
./bat/start-reactflow.sh
```
后端地址为 `http://127.0.0.1:8000`,前端地址为 `http://127.0.0.1:5173`。Windows 的 `start-all.bat` 会分别打开两个命令行窗口;Linux 的 `start-all.sh` 会在同一终端管理两个进程,按 `Ctrl+C` 会同时停止它们。
网页的 System XML 仿真默认使用 C 内核(`native`);后端入口和启动脚本使用同一默认值,不需要每次手动设置环境变量。启动日志显示 `Simulation numeric engine: native`,启动预热仅检查 C 工具链和 XML Schema,不执行 Python/SciPy 求解器预热。模型专用 EXE 在提交模型时生成或从缓存复用。
当前 C 构建支持 Windows x64 和 Linux x86_64,已覆盖组件库当前注册的 27 类模型(22 类 Amesim、5 类实验组件)。具体公式范围与连接限制见[原生后端说明](native/README.md);不支持的自定义模型或未收敛的连接会明确报错,不自动切回 Python。旧 Python 数值后端已删除;Linux 原生工具链安装与静态链接说明见 [C 后端说明](native/README.md)。旧固定拓扑示例接口返回 HTTP 410,请改用统一 XML 接口。
## 后端接口
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
- `POST /api/reactflow/system-xml`:导出精简的 System XML v3。
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
- `POST /api/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。
- `POST /api/system-xml/validate`:接收原始 System XML v3,返回 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/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
当前网络层按端口域处理气动压力/流量与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡。默认 C 后端在生成的 EXE 内完成连接闭合、RK45/CVODE BDF 积分及信号/限位事件。通用仿真仍有已声明的拓扑和物理公式范围,并不等价于完整 Amesim 或 Modelica.Fluid 实现。
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
## 文档
- [开发文档索引](docs/README.md)
- [现行规范索引与新组件注册流程](docs/standard/README.md)
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
旧 Python 积分器、模型数值公式及物性缓存已移除。公共配置、进度和采样校验分别位于 `app/simulation/config.py` 与 `sampling.py`;模型 Python 文件仅保留参数、端口、结果和方程结构声明。质量/能量初值也由 C 计算。
后端运行依赖不再包含 NumPy/SciPy;运行测试请安装 `requirements-test.txt`。Windows C 回归需要配置 GCC 与 SUNDIALS,见 [C 后端说明](native/README.md)。删除范围和验证见 [Python 数值实现退役记录](docs/other/Python数值实现退役记录.md)。
+1351 -151
View File
File diff suppressed because it is too large. Load diff
+250
View File
@@ -0,0 +1,250 @@
"""User-input adapter shared by HTTP and CLI; the numerical layer stays SI-only.
JSON v1 numbers (including decimal strings) were SI, but expressions used the
selected unit. JSON v2 consistently uses the selected unit for both. Missing
parameters use catalog defaults, which are always SI. Never infer a format from
magnitudes or relabel a legacy project without converting its values.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import re
UNIT_TABLE = json.loads((Path(__file__).resolve().parent.parent / "schemas" / "parameter-units.json").read_text(encoding="utf-8"))
DECIMAL = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\Z", re.ASCII)
TOKEN = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|[A-Za-z_][A-Za-z_0-9]*|\*\*|[+*/^(),-]", re.ASCII)
def finite(value: float) -> float:
if not math.isfinite(value):
raise ValueError("Parameter expression must produce a finite real number.")
return value
def numeric_literal(value: object) -> float | None:
if type(value) in (int, float):
try:
return finite(float(value))
except OverflowError as exc:
raise ValueError("Parameter magnitude exceeds finite float range.") from exc
if isinstance(value, str) and DECIMAL.fullmatch(value.strip()):
return finite(float(value))
return None
def expression_value(source: str) -> float:
"""Same bounded recursive-descent grammar as parameterExpression.ts; no eval."""
source = source.strip().removeprefix("=").strip()
if not source or len(source) > 512:
raise ValueError("Parameter expression must contain 1..512 characters.")
tokens: list[str] = []
position = 0
while position < len(source):
if source[position].isspace():
position += 1
continue
match = TOKEN.match(source, position)
if match is None:
raise ValueError(f"Unsupported expression character at {position + 1}.")
tokens.append(match[0])
position = match.end()
if len(tokens) > 256:
raise ValueError("Parameter expression exceeds 256 tokens.")
tokens.append("")
index = 0
operations = 0
def current():
return tokens[index]
def take():
nonlocal index
token = current()
if token:
index += 1
return token
def operation():
nonlocal operations
operations += 1
if operations > 256:
raise ValueError("Parameter expression exceeds 256 operations.")
def depth_check(depth):
if depth > 32:
raise ValueError("Parameter expression exceeds 32 nesting levels.")
def additive(depth):
value = multiplicative(depth)
while current() in ("+", "-"):
op = take()
right = multiplicative(depth)
operation()
value = finite(value + right if op == "+" else value - right)
return value
def multiplicative(depth):
value = unary(depth)
while current() in ("*", "/"):
op = take()
right = unary(depth)
operation()
value = finite(value * right if op == "*" else value / right)
return value
def unary(depth):
depth_check(depth)
if current() in ("+", "-"):
op = take()
operation()
value = unary(depth + 1)
return value if op == "+" else -value
return power(depth)
def power(depth):
depth_check(depth)
value = primary(depth)
if current() in ("^", "**"):
take()
exponent = unary(depth + 1)
operation()
value = finite(math.pow(value, exponent))
return value
def primary(depth):
depth_check(depth)
token = take()
if token == "(":
value = additive(depth + 1)
if take() != ")":
raise ValueError("Missing closing parenthesis.")
return value
if token and (token[0].isdigit() or token[0] == "."):
return finite(float(token))
name = token.lower()
if token and (token[0].isalpha() or token[0] == "_"):
if current() != "(":
if name in ("pi", "e"):
return math.pi if name == "pi" else math.e
raise ValueError(f"Unknown identifier: {token}.")
depth_check(depth + 1)
take()
args = []
if current() != ")":
while True:
if len(args) >= 16:
raise ValueError("Functions accept at most 16 arguments.")
args.append(additive(depth + 1))
if current() != ",":
break
take()
if take() != ")":
raise ValueError("Missing function closing parenthesis.")
operation()
functions = {"sqrt": math.sqrt, "abs": abs, "sin": math.sin,
"cos": math.cos, "tan": math.tan, "asin": math.asin,
"acos": math.acos, "atan": math.atan, "exp": math.exp,
"ln": math.log, "log": math.log, "log10": math.log10,
"pow": math.pow}
if name in ("min", "max") and args:
return finite((min if name == "min" else max)(args))
if name not in functions or len(args) != (2 if name == "pow" else 1):
raise ValueError(f"Unsupported function or argument count: {token}.")
return finite(functions[name](*args))
raise ValueError("Expected a number, constant or function.")
try:
result = additive(0)
if current():
raise ValueError("Unexpected trailing expression content.")
return finite(result)
except (ArithmeticError, RecursionError) as exc:
raise ValueError("Invalid arithmetic or expression domain.") from exc
def unit_conversion(definition, unit: str) -> tuple[float, float]:
options = UNIT_TABLE.get(definition.quantity, {}) if definition.unit else {}
if unit in options:
scale, offset, _ = options[unit]
return scale, offset
if unit == definition.unit:
return 1.0, 0.0
raise ValueError(f"Unsupported unit '{unit}' for {definition.name} ({definition.quantity}).")
def prepare_project(project):
"""Copy external input to a current-version, numeric SI execution project.
Returns consolidated version notices to the caller. Does not mutate saved
data and does not weaken the strict XML/native model-version checks.
"""
from app.simulation.registry import get_component_model_spec
normalized = project.model_copy(deep=True)
notices = []
specs = {}
for node in normalized.nodes:
model = node.data
spec = specs.get(model.modelType)
if spec is None:
spec = get_component_model_spec(model.modelType)
specs[model.modelType] = spec
if model.componentType != spec.model_type:
raise ValueError(f"COMPONENT_MODEL_TYPE_MISMATCH: {node.id}.")
if model.modelVersion != spec.model_version:
notices.append({"componentId": node.id, "label": model.label or node.id,
"storedVersion": model.modelVersion,
"currentVersion": spec.model_version})
for name, value in model.parameters.items():
definition = spec.parameter_by_name.get(name)
if definition is None:
raise ValueError(f"Component '{node.id}' contains unsupported parameters: {name}.")
try:
scale, offset = unit_conversion(definition, model.parameterUnits.get(name, definition.unit))
number = numeric_literal(value)
is_expression = number is None
if is_expression:
if not isinstance(value, str) or definition.editor:
raise ValueError("Expected a numeric value; discrete parameters cannot use expressions.")
number = expression_value(value)
if project.projectSchemaVersion == 2 or is_expression:
number = finite(number * scale + offset)
model.parameters[name] = number
except ValueError as exc:
raise ValueError(f"{node.id}.{name}: {exc}") from exc
# Explicit legacy migrations also used by the browser.
if model.modelVersion == "0.1.0" and model.modelType == "amesim_forc":
model.parameters.setdefault("direction", 1.0)
if model.modelVersion == "0.1.0" and model.modelType == "amesim_lmechn1":
count = model.parameters.get("v1")
if count in range(1, 9):
for edge in normalized.edges:
if edge.source == node.id and edge.sourceHandle == "port_9":
edge.sourceHandle = f"port_{int(count) + 1}"
if edge.target == node.id and edge.targetHandle == "port_9":
edge.targetHandle = f"port_{int(count) + 1}"
model.parameters["sum"] = 1.0
# Historical LMECHN1 exposed only nine ports; use its migrated contract.
from app.main import ReactFlowPortDefinition
model.ports = [ReactFlowPortDefinition(name=p.name, kind=p.kind, domain=p.domain,
nominalRole=p.nominal_role, positiveFlowDirection=p.positive_flow_direction)
for p in spec.ports]
model.modelVersion = spec.model_version
model.parameterUnits = {p.name: p.unit for p in spec.parameters}
model.parameterScientificNotation = {}
for name in ("t_start", "t_stop", "step", "max_step"):
value = getattr(normalized.simulation, name)
number = numeric_literal(value)
if number is None:
number = expression_value(value)
setattr(normalized.simulation, name, number)
normalized.projectSchemaVersion = 1 # Internal numeric SI contract, never a v2 wire payload.
return normalized, notices
def version_warning(notices):
return {"code": "COMPONENT_MODEL_VERSION_WARNING",
"message": "旧版或版本未知的组件将使用当前模型执行,可能仿真失败或结果与实际不符。",
"components": notices}
+19
View File
@@ -0,0 +1,19 @@
# 仿真后端
当前采用 Python 编排、C 数值执行。输入 XML 经校验后,Python 根据模型参数和连接生成系统专用 C;GCC 编译成 EXE,EXE 内执行初始化、物性、流量、机械、RK45/CVODE BDF、事件和采样。求解循环不调用 Python。
## 目录
- `components/`:模型参数、端口、显示和结果声明。
- `core/`、`registry.py`、`systems/network.py`:模型合同与网络结构校验。
- `native_codegen/`:C 生成、构建、进程运行和 CLI。
- `config.py`、`sampling.py`、`results.py`:公共配置、进度、采样网格校验与结果类型。
- `performance.py`、`warmup.py`:编排计时和启动工具链检查。
- `reporting/amesim_results.py`:外部 Amesim 结果读取。
- 仓库根目录 `native/`:C 组件公式和求解器。
旧 Python 积分器、数值组件方法和固定算例专用求解器已经退役。旧 `/api/reactflow/simulate-testmodel`、`/api/reactflow/simulate-test-mql` 返回 410;使用 `/api/system-xml/simulate` 或流式接口。
旧 `ir/` 包、专属 schema、规范与测试已删除。当前 C 生成器直接使用经过校验的网络结构,不依赖旧 System IR v2。
运行、支持范围与依赖见 [C 后端说明](../../native/README.md)。模型开发规则见 [组件规范](../../docs/standard/component-model-authoring-spec-v1.md)。历史性能、Amesim 差异和旧公式说明位于 `docs/other/` 及 Git 历史,不能作为当前运行入口。
+2
View File
@@ -0,0 +1,2 @@
"""Simulation domain models, solvers, system assembly, and result tools."""
+38
View File
@@ -0,0 +1,38 @@
"""One execution boundary shared by XML API and native validation tools."""
from __future__ import annotations
import os
from app.simulation.config import SolveIVPConfig
DEFAULT_NUMERIC_ENGINE = "native"
def numeric_engine_name(backend: str | None = None) -> str:
configured = backend or os.environ.get("SIMULATION_NUMERIC_ENGINE", "")
selected = configured.strip().lower() or DEFAULT_NUMERIC_ENGINE
if selected == "native-c":
return "native"
if selected == "native":
return selected
if selected == "python":
raise ValueError("The Python numerical backend has been removed. Use SIMULATION_NUMERIC_ENGINE=native.")
raise ValueError(f"Unknown simulation engine: {selected}.")
def simulation_config(simulation) -> SolveIVPConfig:
# Match the validated native pipe/chamber accuracy. Per-state SI absolute
# floors are emitted by native_codegen.tolerances; XML tolerance fields
# remain a separate protocol change.
return SolveIVPConfig(t_start=simulation.t_start, t_stop=simulation.t_stop,
method=simulation.method, rtol=1e-8, max_step=simulation.max_step)
def simulate_network(network, simulation, *, progress_callback=None,
warning_callback=None, cancel_check=None, activity_tracker=None, backend=None, raw_series=False):
numeric_engine_name(backend)
config = simulation_config(simulation)
from app.simulation.native_codegen.runner import simulate_native
return simulate_native(network, config, sample_step=simulation.sample_step, progress_callback=progress_callback,
warning_callback=warning_callback, cancel_check=cancel_check, activity_tracker=activity_tracker, raw_series=raw_series)
File renamed without changes.
@@ -0,0 +1,3 @@
from __future__ import annotations
__all__: list[str] = []
@@ -0,0 +1 @@
"""AMESim pneumatic boundary components."""
@@ -0,0 +1,32 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import ZERO_FLOW_SUPPLY
class AmesimPnpl01(AlgebraicComponent):
"""AMESim PNPL01 zero pneumatic flow source.
The component behaves as a sealed pneumatic boundary in the current acausal
solver: it does not prescribe pressure, and only constrains its port mass
flow to zero.
"""
MODEL_TYPE = 'amesim_pnpl01'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=ZERO_FLOW_SUPPLY),)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='PNPL01 零气动流边界', library_id='amesim', category_id='boundary', symbol='amesim_pnpl01', ports=(PortDisplaySpec('port_1', 'left', order=10),), order=10)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.port_1 = self.register_declared_port('port_1')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPnpl01:
return cls(name=name)
EQUATIONS = ({'id': '__MODEL__:zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'},)
@@ -0,0 +1 @@
"""AMESim pneumatic flow components."""
@@ -0,0 +1,172 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
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, ParameterGroupDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
_FLOW_COEFFICIENT_OPTIONS = (ParameterOption(1.0, 'Cq'), ParameterOption(2.0, 'Cv'), ParameterOption(3.0, 'Kv'))
_FLOWSET_USES_CQ = (ParameterCondition('flowset', (1.0,)),)
_FLOWSET_USES_CV = (ParameterCondition('flowset', (2.0,)),)
_FLOWSET_USES_KV = (ParameterCondition('flowset', (3.0,)),)
_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(id='flow_coefficient', label='流量系数', parameters=('cq', 'area', 'Cv', 'Kv'), order=10)
_PNVO001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(id='flow_coefficient', label='流量系数', parameters=('cq', 'area0', 'Cv', 'Kv'), order=10)
class AmesimPnor001(AlgebraicComponent):
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
This public component preserves the PNOR001 catalog/XML contract and uses
real-gas pressure-ratio flow with AMESim-style near-equal-pressure smoothing.
"""
MODEL_TYPE = 'amesim_pnor001'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area', 5e-06, label='孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='选择 Cq/面积方式时用于流量计算的有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'))
RESULT_VARIABLES = (ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=20))
DISPLAY = ComponentDisplaySpec(label='PNOR001 常系数气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnor001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10, parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,))
def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'cq': cq, 'area': area, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset})
self.medium = medium
self.cq = float(cq)
self.area = float(area)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = 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.')
self.port_1 = self.register_declared_port('port_1')
self.port_2 = self.register_declared_port('port_2')
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12):
raise ValueError(f'PNOR001 parameter {name} must be an integer value.')
return int(rounded)
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnor001:
return cls(name=name, medium=medium, cq=parameters['cq'], area=parameters['area'], Cv=parameters['Cv'], Kv=parameters['Kv'], gi=parameters['gi'], flowset=parameters['flowset'])
@property
def effective_cq(self) -> float:
return self.cq if self.flowset == 1 else 0.72
@property
def effective_area(self) -> float:
if self.flowset == 1:
return self.area
if self.flowset == 2:
return self._area_from_cv(self.Cv, self.effective_cq)
return self._area_from_kv(self.Kv, self.effective_cq)
@staticmethod
def _area_from_cv(Cv: float, cq: float) -> float:
water_density = 999.0
reference_flow_m3_s = Cv * 6.30901964e-05
reference_dp_pa = 6894.75729
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
@staticmethod
def _area_from_kv(Kv: float, cq: float) -> float:
water_density = 999.0
reference_flow_m3_s = Kv / 3600.0
reference_dp_pa = 100000.0
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p', '__MODEL__.port_1.m_flow'], 'role': 'flow'})
class AmesimPnvo001FixedOpening(AlgebraicComponent):
"""Fixed-opening public variant of AMESim PNVO001.
Full PNVO001 has a signal input port. This optional variant exposes the
pneumatic ports and replaces that signal with a normalized `opening`
parameter; use AmesimPnvo001SignalOpening for a time-varying control signal.
"""
MODEL_TYPE = 'amesim_pnvo001_fixed'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening', 1.0, label='固定开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='固定的归一化阀门开度;0 表示关闭,1 表示完全开启。'))
RESULT_VARIABLES = (ResultVariableDefinition('xv', label='有效开度', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=30))
DISPLAY = ComponentDisplaySpec(label='PNVO001 固定开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001_fixed', ports=(PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=30, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area0: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0, opening: float=1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'cq': cq, 'area0': area0, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset, 'opening': opening})
self.medium = medium
self.cq = float(cq)
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = 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.')
self.opening = min(1.0, max(0.0, float(opening)))
self.port_2 = self.register_declared_port('port_2')
self.port_3 = self.register_declared_port('port_3')
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12):
raise ValueError(f'PNVO001 fixed-opening parameter {name} must be an integer value.')
return int(rounded)
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnvo001FixedOpening:
return cls(name=name, medium=medium, cq=parameters['cq'], area0=parameters['area0'], Cv=parameters['Cv'], Kv=parameters['Kv'], gi=parameters['gi'], flowset=parameters['flowset'], opening=parameters['opening'])
@property
def effective_cq(self) -> float:
return self.cq if self.flowset == 1 else 0.72
@property
def maximum_area(self) -> float:
if self.flowset == 1:
return self.area0
if self.flowset == 2:
return AmesimPnor001._area_from_cv(self.Cv, self.effective_cq)
return AmesimPnor001._area_from_kv(self.Kv, self.effective_cq)
@property
def effective_area(self) -> float:
return self.opening * self.maximum_area
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.port_3.p', '__MODEL__.port_2.m_flow'], 'role': 'flow'})
class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
MODEL_TYPE = 'amesim_pnvo001'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening0', 1.0, label='初始开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。'))
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNVO001 信号开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001', ports=(PortDisplaySpec('res', 'left', order=5), PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=35, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area0: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0, opening0: float=1.0) -> None:
AlgebraicComponent.__init__(self, name=name)
self.set_parameter_values({'cq': cq, 'area0': area0, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset, 'opening0': opening0})
self.medium = medium
self.cq = float(cq)
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = 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.')
self.opening0 = min(1.0, max(0.0, float(opening0)))
self.res = self.register_declared_port('res')
self.port_2 = self.register_declared_port('port_2')
self.port_3 = self.register_declared_port('port_3')
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnvo001SignalOpening':
return cls(name=name, medium=medium, **dict(parameters))
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.port_3.p', '__MODEL__.port_2.m_flow'], 'role': 'flow'})
@@ -0,0 +1,148 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from math import isclose, pi
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, ParameterGroupDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY, THERMODYNAMIC_SUPPLY
_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition('mode', (1.0,))
_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition('mode', (2.0,))
_DYNAMIC_PIPE_PARAMETER_GROUPS = (ParameterGroupDisplaySpec(id='thermodynamics', label='热力学', parameters=('k', 'kth', 'extemp'), order=10),)
class AmesimPnl00r(AlgebraicComponent):
"""AMESim PNL00R pneumatic pipe friction resistance.
The public model exposes the AMESim PNL00R catalog/XML contract and uses
the `pn2pipefr` compressible gas relation with a Reynolds/roughness-
dependent equivalent flow coefficient.
"""
MODEL_TYPE = 'amesim_pnl00r'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='参与摩擦压降计算的管路有效长度。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'))
RESULT_VARIABLES = (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=30), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=40))
DISPLAY = ComponentDisplaySpec(label='PNL00R 气动管路阻力', library_id='amesim', category_id='flow', symbol='amesim_pnl00r', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20)
def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, gi: float=1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'gi': gi})
self.medium = medium
self.diam = float(diam)
self.le = float(le)
self.rr = float(rr)
self.gi = normalize_amesim_gas_index(gi)
self.area = pi * self.diam * self.diam / 4.0
self.port_1 = self.register_declared_port('port_1')
self.port_2 = self.register_declared_port('port_2')
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12):
raise ValueError(f'PNL00R parameter {name} must be an integer value.')
return int(rounded)
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnl00r:
return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], gi=parameters['gi'])
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p', '__MODEL__.port_1.m_flow'], 'role': 'flow'})
class AmesimPnl0001(ThermodynamicVolumeComponent):
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
MODEL_TYPE = 'amesim_pnl0001'
MODEL_VERSION = '0.4.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积、储气容积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效长度,用于计算储气容积、换热面积和摩擦压降。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'), ParameterDefinition('k', 1.35, label='多方指数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='mode=1 多方过程指数。采用理想气体储气关系,按质量变化计算温度与压力变化;k=1 为等温。流阻和端口焓仍使用选定介质。', visible_when=(_DYNAMIC_PIPE_POLYTROPIC_MODE,)), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='mode=2 带换热过程使用的气体与外部环境对流换热系数。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='mode=2 带换热过程使用的外部环境绝对温度。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('mode', 2.0, label='热模型', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '多方过程'), ParameterOption(2.0, '带换热')), description='AMESim 原始编码:1 为多方过程,2 为带换热。多方模式按多方指数计算状态变化,不使用流入焓和环境换热决定温度;带换热模式采用质量与能量守恒。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对温度。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=100), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=110), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=120), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=130))
DISPLAY = ComponentDisplaySpec(label='PNL0001 C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=30, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, k: float=1.35, kth: float=0.0, extemp: float=293.15, gi: float=1.0, mode: float=2.0, p0: float=100000.0, T0: float=293.15) -> None:
super().__init__(name=name)
self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'k': k, 'kth': kth, 'extemp': extemp, 'gi': gi, 'mode': mode, 'p0': p0, 'T0': T0})
self.medium = medium
self.diam = float(diam)
self.le = float(le)
self.rr = float(rr)
self.k = float(k)
self.kth = float(kth)
self.extemp = float(extemp)
self.gi = 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
self.port_1 = self.register_declared_port('port_1')
self.port_2 = self.register_declared_port('port_2')
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12):
raise ValueError(f'PNL0001 parameter {name} must be an integer value.')
integer = int(rounded)
if name == 'mode' and integer not in {1, 2}:
raise ValueError('PNL0001 parameter mode must be one of 1, 2.')
return integer
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0001':
return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], k=parameters['k'], kth=parameters['kth'], extemp=parameters['extemp'], gi=parameters['gi'], mode=parameters['mode'], p0=parameters['p0'], T0=parameters['T0'])
EQUATIONS = ({'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_1_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p', '__MODEL__.port_1.m_flow'], 'role': 'flow'})
class AmesimPnl0002(AmesimPnl0001):
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
MODEL_TYPE = 'amesim_pnl0002'
MODEL_VERSION = '0.6.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = AmesimPnl0001.PARAMETERS
RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNL0002 R-C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0002', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0002':
return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], k=parameters['k'], kth=parameters['kth'], extemp=parameters['extemp'], gi=parameters['gi'], mode=parameters['mode'], p0=parameters['p0'], T0=parameters['T0'])
@property
def resistance_length(self) -> float:
return self.le / 2.0
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state', '__MODEL__.port_1.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:port_2_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state', '__MODEL__.port_2.m_flow'], 'role': 'flow'})
class AmesimPnl0003(DynamicComponent):
"""AMESim PNL0003 C-R-C pneumatic pipe with two end compliances."""
state_size = 4
MODEL_TYPE = 'amesim_pnl0003'
MODEL_VERSION = '0.4.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + (ParameterDefinition('p1_0', 100000.0, label='端口 1 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T1_0', 293.15, label='端口 1 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p2_0', 100000.0, label='端口 2 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T2_0', 293.15, label='端口 2 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = (ResultVariableDefinition('m1', '端口 1 侧质量', 'mass', 'kg', 'state', 10), ResultVariableDefinition('U1', '端口 1 侧内能', 'internal_energy', 'J', 'state', 20), ResultVariableDefinition('p1', '端口 1 侧压力', 'pressure', 'Pa', 'thermodynamic', 30), ResultVariableDefinition('T1', '端口 1 侧温度', 'temperature', 'K', 'thermodynamic', 40), ResultVariableDefinition('rho1', '端口 1 侧密度', 'density', 'kg/m³', 'thermodynamic', 50), ResultVariableDefinition('u1', '端口 1 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 60), ResultVariableDefinition('h1', '端口 1 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 70), ResultVariableDefinition('m2', '端口 2 侧质量', 'mass', 'kg', 'state', 80), ResultVariableDefinition('U2', '端口 2 侧内能', 'internal_energy', 'J', 'state', 90), ResultVariableDefinition('p2', '端口 2 侧压力', 'pressure', 'Pa', 'thermodynamic', 100), ResultVariableDefinition('T2', '端口 2 侧温度', 'temperature', 'K', 'thermodynamic', 110), ResultVariableDefinition('rho2', '端口 2 侧密度', 'density', 'kg/m³', 'thermodynamic', 120), ResultVariableDefinition('u2', '端口 2 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 130), ResultVariableDefinition('h2', '端口 2 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 140), ResultVariableDefinition('dmctr', '中心质量流量', 'mass_flow', 'kg/s', 'derived', 150), ResultVariableDefinition('re', 'Reynolds 数', 'dimensionless', '', 'derived', 160), ResultVariableDefinition('cm', '质量流量参数', 'dimensionless', '', 'derived', 170), ResultVariableDefinition('v', '平均气体速度', 'velocity', 'm/s', 'derived', 180), ResultVariableDefinition('ff', '摩擦因子', 'dimensionless', '', 'derived', 190))
DISPLAY = ComponentDisplaySpec(label='PNL0003 C-R-C 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0003', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=50, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, k: float=1.35, kth: float=0.0, extemp: float=293.15, gi: float=1.0, mode: float=2.0, p1_0: float=100000.0, T1_0: float=293.15, p2_0: float=100000.0, T2_0: float=293.15) -> None:
super().__init__(name=name)
self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'k': k, 'kth': kth, 'extemp': extemp, 'gi': gi, 'mode': mode, 'p1_0': p1_0, 'T1_0': T1_0, 'p2_0': p2_0, 'T2_0': T2_0})
self.medium = medium
self.diam = float(diam)
self.le = float(le)
self.rr = float(rr)
self.k = float(k)
self.kth = float(kth)
self.extemp = float(extemp)
self.gi = 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
self.compliance_volume = self.volume / 2.0
self.exchange_area = pi * self.diam * self.le
self.port_1 = self.register_declared_port('port_1')
self.port_2 = self.register_declared_port('port_2')
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0003':
return cls(name=name, medium=medium, **dict(parameters))
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'})
+264
View File
@@ -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()
@@ -0,0 +1,5 @@
from __future__ import annotations
from app.simulation.components.amesim.junctions.nodes import AmesimP4Node2, AmesimPn3Node2
__all__ = ["AmesimP4Node2", "AmesimPn3Node2"]
@@ -0,0 +1,55 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import NODE_BRANCH, NODE_REFERENCE
class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels.
PN3NODE2/P4NODE2 use port 2 as their pressure and temperature reference.
Non-reference outlet ports use that reference temperature. Signed branch
enthalpy flows are summed and delivered to port 2 independently of its net
mass flow, matching AMESim dh2 causality even at zero net mass flow.
The finite h_outflow diagnostic cannot encode that zero-flow energy.
Branch volumes and volume rates are also summed towards port 2.
"""
REFERENCE_PORT = 'port_2'
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.temperature_reference_h = 0.0
for definition in self.PORTS:
setattr(self, definition.name, self.register_declared_port(definition.name))
class AmesimPn3Node2(_AmesimPneumaticNode):
"""AMESim PN3NODE2 pneumatic three-port junction."""
MODEL_TYPE = 'amesim_pn3node2'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH))
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='PN3NODE2 三端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_pn3node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30)), order=10)
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPn3Node2:
return cls(name=name)
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'})
class AmesimP4Node2(_AmesimPneumaticNode):
"""AMESim P4NODE2 pneumatic four-port junction."""
MODEL_TYPE = 'amesim_p4node2'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH), PortDefinition.pneumatic('port_4', computation=NODE_BRANCH))
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='P4NODE2 四端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_p4node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimP4Node2:
return cls(name=name)
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_4_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_4.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow', '__MODEL__.port_4.m_flow'], 'role': 'flow'})
@@ -0,0 +1,49 @@
"""AMESim-compatible public component library."""
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentLibrarySpec,
)
LIBRARY = ComponentLibrarySpec(
id="amesim",
label="AMESim 组件库",
version="0.3.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),
ComponentCategorySpec(id="boundary", label="边界元件", order=40),
ComponentCategorySpec(id="signals", label="信号元件", order=50),
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",
"app.simulation.components.amesim.mechanical.translational:AmesimF000",
"app.simulation.components.amesim.mechanical.translational:AmesimForc",
"app.simulation.components.amesim.mechanical.translational:AmesimMecmas21",
"app.simulation.components.amesim.mechanical.translational:AmesimLstp00a",
"app.simulation.components.amesim.mechanical.translational:AmesimLmechn1",
"app.simulation.components.amesim.mechanical.pistons:AmesimPnrp17",
"app.simulation.components.amesim.storage.chambers:AmesimPnch023",
"app.simulation.components.amesim.storage.chambers:AmesimPnch012",
"app.simulation.components.amesim.flow.orifices:AmesimPnor001",
"app.simulation.components.amesim.flow.orifices:AmesimPnvo001FixedOpening",
"app.simulation.components.amesim.flow.orifices:AmesimPnvo001SignalOpening",
"app.simulation.components.amesim.flow.pipes:AmesimPnl00r",
"app.simulation.components.amesim.flow.pipes:AmesimPnl0001",
"app.simulation.components.amesim.flow.pipes:AmesimPnl0002",
"app.simulation.components.amesim.flow.pipes:AmesimPnl0003",
"app.simulation.components.amesim.junctions.nodes:AmesimPn3Node2",
"app.simulation.components.amesim.junctions.nodes:AmesimP4Node2",
),
)
@@ -0,0 +1 @@
"""AMESim mechanical components."""
@@ -0,0 +1,49 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from math import pi
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.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
class AmesimPnrp17(AlgebraicComponent):
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
Mechanical ports 2/5 share the piston-side motion and ports 3/4 share the
cylinder-side motion. The pneumatic port contributes its swept volume and
volume rate to the connected variable-volume chamber.
"""
MODEL_TYPE = 'amesim_pnrp17'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.mechanical_translational('port_2'), PortDefinition.mechanical_translational('port_3'), PortDefinition.mechanical_translational('port_4'), PortDefinition.mechanical_translational('port_5'))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('dp', 0.2, label='活塞直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='活塞外径;与活塞杆直径共同确定有效受压面积。'), ParameterDefinition('dr', 0.001, label='活塞杆直径', quantity='length', unit='m', minimum=0.0, description='穿过气室一侧的活塞杆直径,必须不大于活塞直径。'), ParameterDefinition('x0', 0.0, label='初始腔长', quantity='length', unit='m', description='机械端位移均为零时的气动腔长度。'))
RESULT_VARIABLES = (ResultVariableDefinition('volume', '扫掠容积', 'volume', 'm3', 'derived', 10), ResultVariableDefinition('volume_flow', '扫掠容积变化率', 'volume_flow', 'm3/s', 'derived', 20), ResultVariableDefinition('length', '气动腔长度', 'length', 'm', 'derived', 30), ResultVariableDefinition('pressure_force', '气压力', 'force', 'N', 'derived', 40))
DISPLAY = ComponentDisplaySpec(label='PNRP17 气动活塞', library_id='amesim', category_id='mechanical', symbol='amesim_pnrp17', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_3', 'left', order=20), PortDisplaySpec('port_2', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40), PortDisplaySpec('port_5', 'right', order=50)), order=60)
def __init__(self, name: str, medium: GasMedium, *, gi: float=0.0, dp: float=0.2, dr: float=0.001, x0: float=0.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'gi': gi, 'dp': dp, 'dr': dr, 'x0': x0})
self.medium = medium
self.gi = normalize_amesim_gas_index(gi)
self.dp = float(dp)
self.dr = float(dr)
self.x0 = float(x0)
if self.dr > self.dp:
raise ValueError('PNRP17 rod diameter dr must not exceed piston diameter dp.')
for definition in self.PORTS:
port = self.register_declared_port(definition.name)
setattr(self, definition.name, port)
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnrp17':
return cls(name=name, medium=medium, **dict(parameters))
@property
def effective_area(self) -> float:
return pi * (self.dp * self.dp - self.dr * self.dr) / 4.0
EQUATIONS = ({'id': '__MODEL__:pneumatic_zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:port_2_port_5_x_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_2.x', '__MODEL__.port_5.x'], 'role': 'effort'}, {'id': '__MODEL__:port_2_port_5_v_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_2.v', '__MODEL__.port_5.v'], 'role': 'effort'}, {'id': '__MODEL__:port_3_port_4_x_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.x', '__MODEL__.port_4.x'], 'role': 'effort'}, {'id': '__MODEL__:port_3_port_4_v_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.v', '__MODEL__.port_4.v'], 'role': 'effort'}, {'id': '__MODEL__:piston_side_force_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.port_5.f', '__MODEL__.port_1.p'], 'role': 'flow'}, {'id': '__MODEL__:cylinder_side_force_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_3.f', '__MODEL__.port_4.f', '__MODEL__.port_1.p'], 'role': 'flow'})
@@ -0,0 +1,181 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
_MECMAS21_FRICTION_ENABLED = ParameterCondition('useFriction', (2.0,))
_MECMAS21_NON_RESTITUTION = ParameterCondition('stoptype', (1.0, 2.0, 4.0))
_MECMAS21_LIMITS_ENABLED = ParameterCondition('stoptype', (1.0, 2.0, 3.0))
_MECMAS21_ELASTIC_STOP = ParameterCondition('stoptype', (2.0,))
_MECMAS21_RESTITUTION_STOP = ParameterCondition('stoptype', (3.0,))
_MECMAS21_ADVANCED_FRICTION = ParameterCondition('frictionType', (2.0,))
_MECMAS21_STRIBECK_ENABLED = ParameterCondition('strib', (2.0,))
_LSTP00A_NUMERICAL_STIFFNESS = ParameterCondition('stiffmode', (1.0,))
_LSTP00A_GEOMETRICAL_STIFFNESS = ParameterCondition('stiffmode', (2.0,))
class AmesimF000(AlgebraicComponent):
"""AMESim F000 zero force source."""
MODEL_TYPE = 'amesim_f000'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.mechanical_translational('port_1'),)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='F000 零力源', library_id='amesim', category_id='mechanical', symbol='amesim_f000', ports=(PortDisplaySpec('port_1', 'right', order=10),), order=10)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.port_1 = self.register_declared_port('port_1')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimF000':
return cls(name=name)
EQUATIONS = ({'id': '__MODEL__:zero_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.f'], 'role': 'flow'},)
class AmesimForc(AlgebraicComponent):
"""AMESim FORC signal-to-force converter."""
MODEL_TYPE = 'amesim_forc'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.mechanical_translational('port_2'))
PARAMETERS = (ParameterDefinition('direction', 1.0, label='力方向', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '正向'), ParameterOption(-1.0, '反向')), description='显式控制输入信号相对于机械端口正方向的力符号;图标旋转和镜像不会改变该参数。'),)
RESULT_VARIABLES = (ResultVariableDefinition('force', '输出力', 'force', 'N', 'signal', 10),)
DISPLAY = ComponentDisplaySpec(label='FORC 信号转力', library_id='amesim', category_id='mechanical', symbol='amesim_forc', ports=(PortDisplaySpec('res', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20)
def __init__(self, name: str, *, direction: float=1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'direction': direction})
self.direction = float(direction)
self.res = self.register_declared_port('res')
self.port_2 = self.register_declared_port('port_2')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimForc':
return cls(name=name, direction=parameters['direction'])
EQUATIONS = ({'id': '__MODEL__:signal_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.res.signal'], 'role': 'flow'},)
class AmesimMecmas21(DynamicComponent):
"""AMESim MECMAS21 first public one-dimensional translational mass."""
MODEL_TYPE = 'amesim_mecmas21'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2'))
PARAMETERS = (ParameterDefinition('mass', 1.0, label='质量', quantity='mass', unit='kg', minimum=0.0, minimum_exclusive=True, description='平动质量,必须大于零。'), ParameterDefinition('fstick', 0.0, label='静摩擦力', quantity='force', unit='N', minimum=0.0, description='静止时可抵消的外力阈值;超过阈值后进入滑动,停止后重新保持。恢复碰撞模式不使用干摩擦。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('fcoul', 0.0, label='库仑摩擦力', quantity='force', unit='N', minimum=0.0, description='滑动时的库仑摩擦力绝对值,方向与速度相反;启用干摩擦时不得大于 fstick。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('rvisc', 0.0, label='黏性摩擦系数', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='与速度成正比的黏性摩擦系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('wind', 0.0, label='风阻系数', quantity='windage', unit='N/(m/s)^2', minimum=0.0, description='与速度平方成正比、方向与速度相反的风阻系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('dvel', 1e-06, label='粘滞速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='高级摩擦模型的低速保持区间半宽;减速进入该区间时检查静摩擦保持条件。必须大于零。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION)), ParameterDefinition('restdvel', 1e-06, label='恢复速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='恢复碰撞低于该入射速度时按无回弹处理。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('restcoeff', 0.65, label='恢复系数', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='恢复碰撞后的速度与碰撞前速度绝对值之比。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('astrib', 0.001, label='Stribeck 常数', quantity='velocity', unit='m/s', minimum=0.0, description='高级 Stribeck 摩擦模型的速度常数,控制摩擦力从静摩擦阈值向库仑摩擦力的指数过渡;必须大于零。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, _MECMAS21_STRIBECK_ENABLED)), ParameterDefinition('xmin', -1.0, label='下位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的下边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmin', 1000000000.0, label='下限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性下限位的接触刚度,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmin', 10000.0, label='下限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性下限位的最大接触阻尼,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmin', 0.0001, label='下限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性下限位阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('xmax', 0.8, label='上位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的上边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmax', 1000000000.0, label='上限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性上限位的接触刚度,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmax', 10000.0, label='上限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性上限位的最大接触阻尼,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmax', 0.0001, label='上限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性上限位阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('theta', 0.0, label='倾角(度)', quantity='dimensionless', unit='', description='源 AMESim 参数以度为单位:+90° 表示端口 1 位于最低端,-90° 表示端口 1 位于最高端。按标准重力加速度 9.80665 m/s² 计算沿运动方向的重力分量;正倾角产生正向加速度。'), ParameterDefinition('useFriction', 2.0, label='启用摩擦', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '否'), ParameterOption(2.0, '是')), description='AMELine truncated
RESULT_VARIABLES = (ResultVariableDefinition('a', '加速度', 'acceleration', 'm/s2', 'state', 10), ResultVariableDefinition('v', '速度', 'velocity', 'm/s', 'state', 20), ResultVariableDefinition('x', '位移', 'length', 'm', 'state', 30), ResultVariableDefinition('Fvisc', '黏性摩擦力', 'force', 'N', 'derived', 40), ResultVariableDefinition('Ffric', '干摩擦力', 'force', 'N', 'derived', 50), ResultVariableDefinition('Fmin', '下限位力', 'force', 'N', 'derived', 60), ResultVariableDefinition('Fmax', '上限位力', 'force', 'N', 'derived', 70))
DISPLAY = ComponentDisplaySpec(label='MECMAS21 一维质量', library_id='amesim', category_id='mechanical', symbol='amesim_mecmas21', ports=(PortDisplaySpec('port_2', 'left', order=10), PortDisplaySpec('port_1', 'right', order=20)), order=30, parameter_groups=(ParameterGroupDisplaySpec(id='friction', label='摩擦', parameters=('frictionType', 'strib', 'astrib', 'fstick', 'fcoul', 'rvisc', 'wind', 'dvel'), order=10), ParameterGroupDisplaySpec(id='endstops', label='限位', parameters=('discContactOption', 'xmax', 'Kbmax', 'Dbmax', 'Pdmax', 'xmin', 'Kbmin', 'Dbmin', 'Pdmin', 'restcoeff', 'restdvel'), order=20)))
state_size = 2
def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None:
super().__init__(name=name)
resolved = {definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS}
self.set_parameter_values(resolved)
for name, value in resolved.items():
setattr(self, name, float(value))
self.use_friction = int(self.useFriction) == 2
self.port_1 = self.register_declared_port('port_1')
self.port_2 = self.register_declared_port('port_2')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimMecmas21':
definitions = {definition.name: definition for definition in cls.PARAMETERS}
for integer_name in ('useFriction', 'stoptype', 'discContactOption', 'strib', 'frictionType'):
value = float(parameters[integer_name])
if not value.is_integer():
raise ValueError(f'MECMAS21 {integer_name} must be an integer.')
message = definitions[integer_name].validation_message(value)
if message is not None:
raise ValueError(f'MECMAS21 {integer_name} {message}.')
return cls(name=name, medium=medium, **dict(parameters))
EQUATIONS = ({'id': '__MODEL__:port_1_x_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.x'], 'role': 'effort'}, {'id': '__MODEL__:port_1_v_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.v'], 'role': 'effort'}, {'id': '__MODEL__:port_2_x_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.x'], 'role': 'effort'}, {'id': '__MODEL__:port_2_v_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.v'], 'role': 'effort'})
class AmesimLstp00a(AlgebraicComponent):
"""AMESim LSTP00A first public elastic contact component."""
MODEL_TYPE = 'amesim_lstp00a'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2'))
PARAMETERS = (ParameterDefinition('na', 10.0, label='有效圈数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='几何刚度模式下使用的弹簧有效圈数。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('gap0', 0.0, label='初始间隙', quantity='length', unit='m', description='两个机械端口开始产生接触力前的初始间隙。'), ParameterDefinition('kcont', 1000000.0, label='接触刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='数值刚度模式下直接指定的接触刚度,执行仿真时必须大于零。', visible_when=(_LSTP00A_NUMERICAL_STIFFNESS,)), ParameterDefinition('G', 85700000000.0, label='剪切模量', quantity='pressure', unit='Pa', minimum=0.0, description='几何刚度模式下的材料剪切模量,允许为零;此时仅保留接触阻尼。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('sdiam', 0.02, label='弹簧直径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧平均直径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('wdiam', 0.002, label='线径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧线径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('rcont', 0.0, label='接触阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='接触穿透过程中使用的最大阻尼系数。'), ParameterDefinition('Pdis', 1e-07, label='满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='接触阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。'), ParameterDefinition('stiffmode', 1.0, label='刚度模式', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '数值刚度'), ParameterOption(2.0, '几何参数')), description='AMESim 原始编码:1 直接使用接触刚度,2 使用弹簧几何参数。几何模式按 G×线径⁴/(8×平均直径³×有效圈数) 计算刚度;此时不使用 kcont。'), ParameterDefinition('discContactOption', 1.0, label='允许负接触力', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '允许负接触力'), ParameterOption(2.0, '不允许负接触力')), description='AMESim 原始编码:1 保留阻尼项可能产生的负接触力,2 将接触力限制为非负。'))
RESULT_VARIABLES = (ResultVariableDefinition('gap', '间隙', 'length', 'm', 'derived', 10), ResultVariableDefinition('penetration', '穿透', 'length', 'm', 'derived', 20), ResultVariableDefinition('force', '接触力', 'force', 'N', 'derived', 30))
DISPLAY = ComponentDisplaySpec(label='LSTP00A 弹性接触', library_id='amesim', category_id='mechanical', symbol='amesim_lstp00a', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=(ParameterGroupDisplaySpec(id='stiffness', label='刚度', parameters=('kcont', 'G', 'sdiam', 'wdiam', 'na'), order=10), ParameterGroupDisplaySpec(id='contact', label='接触', parameters=('gap0', 'rcont', 'Pdis'), order=20)))
def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None:
super().__init__(name=name)
resolved = {definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS}
self.set_parameter_values(resolved)
for name, value in resolved.items():
setattr(self, name, float(value))
self.port_1 = self.register_declared_port('port_1')
self.port_2 = self.register_declared_port('port_2')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimLstp00a':
definitions = {definition.name: definition for definition in cls.PARAMETERS}
for integer_name in ('stiffmode', 'discContactOption'):
value = float(parameters[integer_name])
if not value.is_integer():
raise ValueError(f'LSTP00A {integer_name} must be an integer.')
message = definitions[integer_name].validation_message(value)
if message is not None:
raise ValueError(f'LSTP00A {integer_name} {message}.')
return cls(name=name, medium=medium, **dict(parameters))
EQUATIONS = ({'id': '__MODEL__:port_1_contact_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.f', '__MODEL__.port_1.x', '__MODEL__.port_1.v', '__MODEL__.port_2.x', '__MODEL__.port_2.v'], 'role': 'flow'}, {'id': '__MODEL__:port_2_contact_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.port_1.x', '__MODEL__.port_1.v', '__MODEL__.port_2.x', '__MODEL__.port_2.v'], 'role': 'flow'})
class AmesimLmechn1(AlgebraicComponent):
"""AMESim LMECHN1 first public dynamic linear mechanical node."""
MODEL_TYPE = 'amesim_lmechn1'
MODEL_VERSION = '0.2.0'
PORTS = tuple((PortDefinition.mechanical_translational(f'port_{index}') for index in range(1, 22)))
PARAMETERS = (ParameterDefinition('v1', 2.0, label='右侧端口数', quantity='dimensionless', unit='', minimum=1.0, maximum=20.0, description='设置工作区中显示的右侧机械端口数量,最多 20 个。'), ParameterDefinition('sum', 1.0, label='节点求和模式', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '各端口力代数和为零(标准节点)'),)))
RESULT_VARIABLES = (ResultVariableDefinition('tforce', '节点合力', 'force', 'N', 'derived', 10),)
DISPLAY = ComponentDisplaySpec(label='LMECHN1 线性机械节点', library_id='amesim', category_id='mechanical', symbol='amesim_lmechn1', ports=tuple([PortDisplaySpec(f'port_{index}', 'right', order=index * 10) for index in range(1, 21)] + [PortDisplaySpec('port_21', 'left', order=210)]), order=50)
def __init__(self, name: str, medium: IdealGasMedium, *, v1: float=2.0, sum: float=1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'v1': v1, 'sum': sum})
self.v1 = int(v1)
self.sum = int(sum)
for definition in self.PORTS:
setattr(self, definition.name, self.register_declared_port(definition.name))
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimLmechn1':
for integer_name in ('v1', 'sum'):
if not float(parameters[integer_name]).is_integer():
raise ValueError(f'LMECHN1 {integer_name} must be an integer.')
return cls(name=name, medium=medium, v1=parameters['v1'], sum=parameters['sum'])
@classmethod
def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]:
right_port_count = float(parameters['v1'])
if not right_port_count.is_integer():
raise ValueError('LMECHN1 v1 must be an integer.')
count = int(right_port_count)
if count < 1 or count > 20:
raise ValueError('LMECHN1 v1 must be between 1 and 20.')
return cls.PORTS[:count + 1]
@property
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
return self.PORTS[:self.v1 + 1]
@property
def active_ports(self) -> tuple[str, ...]:
return tuple((definition.name for definition in self.active_port_definitions))
@property
def reference_port_name(self) -> str:
return f'port_{self.v1 + 1}'
@property
def required_connection_ports(self) -> tuple[str, ...]:
return self.active_ports
def equation_definitions(self):
from app.simulation.core.equations import EquationDefinition
result = []
reference = self.reference_port_name
for name in self.active_ports[:-1]:
for field in ('x', 'v'):
result.append(EquationDefinition(id=f'{self.name}:{name}_{field}_equal', owner='component', owner_id=self.name, relation='equal', variables=(f'{self.name}.{name}.{field}', f'{self.name}.{reference}.{field}'), role='effort'))
result.append(EquationDefinition(id=f'{self.name}:force_balance', owner='component', owner_id=self.name, relation='sumToZero', variables=tuple((f'{self.name}.{name}.f' for name in self.active_ports)), role='flow'))
return tuple(result)
@@ -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,57 @@
"""Gas identities and constants passed to the native compiler."""
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
@dataclass(frozen=True)
class AmesimIdealAirMedium(IdealGasMedium):
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-05
viscosity_T_ref: float = 293.15
sutherland_constant: float = 110.4
@dataclass(frozen=True)
class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
"""Helium identity; reference constants below describe the lower range.
The native evaluator uses Amesim's piecewise NASA heat capacity/enthalpy
(6000 K transition) and viscosity (1000/5000 K transitions), together
with PR departure properties. ``cp_ref`` is not a global constant Cp.
"""
SUBSTANCE_ID: ClassVar[str] = 'helium'
PROPERTY_METHOD_ID: ClassVar[str] = 'peng_robinson'
nasa_cp_over_R: ClassVar[float] = 2.5
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (0.7501594, 35.76324, -2212.129, 0.9212635)
name: str = 'AMESimHeliumPengRobinson'
R_gas: float = 8.31446261815324 / 0.004002602
cp_ref: float = 2.5 * (8.31446261815324 / 0.004002602)
T_ref: float = 293.15
cp_slope: float = 0.0
viscosity_ref: float = 1.96e-05
viscosity_T_ref: float = 293.15
sutherland_constant: float = 79.4
@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"],
)
@@ -0,0 +1,74 @@
"""External Amesim encodings, separate from the saved public model contract.
Apply these mappings only when reading Amesim parameters. A browser JSON/XML
already uses public values: applying the mapping again changes its meaning.
Reviewed against the installed Amesim 2404 submodel parameter declarations.
"""
from __future__ import annotations
AMESIM_CHOICE_VALUES = {
("amesim_ud00", "nstages"): {i: i for i in range(1, 9)},
("amesim_ud00", "iscyclic"): {1: 0, 2: 1},
("amesim_lstp00a", "stiffmode"): {1: 1, 2: 2},
("amesim_lstp00a", "discContactOption"): {1: 1, 2: 2},
("amesim_mecmas21", "useFriction"): {1: 1, 2: 2},
("amesim_mecmas21", "stoptype"): {1: 1, 2: 2, 3: 3, 4: 4},
("amesim_mecmas21", "discContactOption"): {1: 1, 2: 2},
("amesim_mecmas21", "strib"): {1: 1, 2: 2},
("amesim_mecmas21", "frictionType"): {1: 1, 2: 2},
("amesim_lmechn1", "sum"): {1: 1},
**{(model, "mode"): {1: 1, 2: 2} for model in
("amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003")},
**{(model, "flowset"): {1: 1, 2: 2, 3: 3} for model in
("amesim_pnor001", "amesim_pnvo001", "amesim_pnvo001_fixed")},
}
def amesim_choice_to_public(model_type: str, parameter: str, value: float) -> float:
mapping = AMESIM_CHOICE_VALUES.get((model_type, parameter))
if mapping is None:
return value
if value not in mapping:
raise ValueError(f"{model_type}.{parameter}: unknown Amesim encoding {value}; "
f"expected one of {tuple(mapping)}")
return float(mapping[value])
def contact_stiffness(component) -> float:
"""SI constant lowering; the contact force is evaluated in C."""
if int(component.stiffmode) == 1:
if component.kcont <= 0:
raise ValueError(f"{component.name}: LSTP00A 数值刚度模式要求 kcont > 0")
return component.kcont
if component.G < 0:
raise ValueError(f"{component.name}: LSTP00A 几何刚度模式要求 G >= 0")
for name in ("sdiam", "wdiam", "na"):
if getattr(component, name) <= 0:
raise ValueError(f"{component.name}: LSTP00A 几何刚度模式要求 {name} > 0")
return component.G * component.wdiam**4 / (8 * component.sdiam**3 * component.na)
def validate_numerical_semantics(component) -> None:
"""Reject requested behavior that cannot yet be faithfully executed.
Keep this at numerical compilation, so incomplete/future configurations
can still be edited and saved. Disabled friction settings have no effect.
"""
if component.model_type == "amesim_mecmas21" and component.use_friction and int(component.stoptype) != 3:
for name in ('dvel', 'astrib'):
if getattr(component, name) <= 0:
raise ValueError(f"{component.name}: MECMAS21 启用摩擦时 {name} 必须大于零(Amesim 参数约束)")
if component.fcoul > component.fstick:
raise ValueError(f"{component.name}: MECMAS21 库仑摩擦 fcoul 不能大于静摩擦 fstick(Amesim 参数约束)")
if component.model_type == 'amesim_mecmas21':
if int(component.stoptype) in (1, 2, 3) and component.xmin > component.xmax:
raise ValueError(f'{component.name}: MECMAS21 限位要求 xmin <= xmax')
if int(component.stoptype) == 2:
for name in ('Kbmin', 'Kbmax', 'Dbmin', 'Dbmax'):
if getattr(component, name) <= 0:
raise ValueError(f'{component.name}: MECMAS21 弹性限位要求 {name} > 0')
if component.model_type == 'amesim_mecmas21' and int(component.stoptype) == 3 and component.restdvel <= 0:
raise ValueError(f'{component.name}: MECMAS21 恢复碰撞模式要求 restdvel > 0(Amesim 参数约束)')
if component.model_type == "amesim_lstp00a":
contact_stiffness(component)
Whitespace-only changes.
@@ -0,0 +1,79 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
def _ud00_stage_parameters(index: int) -> tuple[ParameterDefinition, ...]:
visible_when = () if index == 1 else (ParameterCondition('nstages', tuple((float(stage_count) for stage_count in range(index, 9)))),)
return (ParameterDefinition(f'start{index}', 0.0 if index == 1 else 1.0, label=f'第 {index} 段起点', quantity='dimensionless', unit='', description=f'第 {index} 段开始时的输出值。', visible_when=visible_when), ParameterDefinition(f'end{index}', 1.0, label=f'第 {index} 段终点', quantity='dimensionless', unit='', description=f'第 {index} 段结束时的输出值。', visible_when=visible_when), ParameterDefinition(f't{index}', 1.0 if index == 1 else 0.0, label=f'第 {index} 段时长', quantity='time', unit='s', minimum=0.0, description=f'第 {index} 段的持续时间。', visible_when=visible_when))
_UD00_STAGE_PARAMETERS = tuple((parameter for stage_index in range(1, 9) for parameter in _ud00_stage_parameters(stage_index)))
class AmesimStep0(AlgebraicComponent):
"""AMESim STEP0 scalar step signal source."""
MODEL_TYPE = 'amesim_step0'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
PARAMETERS = (ParameterDefinition('initial', 0.0, label='初始值', quantity='dimensionless', unit=''), ParameterDefinition('final', 1.0, label='阶跃后值', quantity='dimensionless', unit=''), ParameterDefinition('time', 0.0, label='阶跃时间', quantity='time', unit='s'))
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
DISPLAY = ComponentDisplaySpec(label='STEP0 阶跃信号', library_id='amesim', category_id='signals', symbol='amesim_step0', ports=(PortDisplaySpec('out', 'right', order=10),), order=10)
def __init__(self, name: str, medium: IdealGasMedium, *, initial: float=0.0, final: float=1.0, time: float=0.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'initial': initial, 'final': final, 'time': time})
self.initial = float(initial)
self.final = float(final)
self.time = float(time)
self.out = self.register_declared_port('out')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimStep0':
return cls(name=name, medium=medium, initial=parameters['initial'], final=parameters['final'], time=parameters['time'])
EQUATIONS = ()
class AmesimUd00(AlgebraicComponent):
"""AMESim UD00 piecewise-linear scalar signal source."""
MODEL_TYPE = 'amesim_ud00'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
PARAMETERS = (ParameterDefinition('tstart', 0.0, label='启动时间', quantity='time', unit='s', description='分段信号开始输出第一段之前的等待时间。'), *_UD00_STAGE_PARAMETERS, ParameterDefinition('nstages', 1.0, label='段数', quantity='dimensionless', unit='', minimum=1.0, maximum=8.0, editor='choice', options=tuple((ParameterOption(float(stage_count), str(stage_count)) for stage_count in range(1, 9))), description='参与输出计算的有效线性分段数量。'), ParameterDefinition('iscyclic', 0.0, label='循环', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, editor='choice', options=(ParameterOption(0.0, '否'), ParameterOption(1.0, '是')), description='当前公共协议编码:0 表示单次输出,1 表示循环输出。'))
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
DISPLAY = ComponentDisplaySpec(label='UD00 分段线性信号', library_id='amesim', category_id='signals', symbol='amesim_ud00', ports=(PortDisplaySpec('out', 'right', order=10),), order=20, parameter_groups=(ParameterGroupDisplaySpec(id='stages', label='分段参数', parameters=tuple((parameter.name for parameter in _UD00_STAGE_PARAMETERS)), order=10),))
def __init__(self, name: str, medium: IdealGasMedium, *, tstart: float=0.0, starts: tuple[float, ...]=(0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), ends: tuple[float, ...]=(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), durations: tuple[float, ...]=(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), nstages: int=1, iscyclic: bool=False) -> None:
super().__init__(name=name)
if len(starts) != 8 or len(ends) != 8 or len(durations) != 8:
raise ValueError('UD00 requires exactly eight start, end, and duration values.')
if nstages < 1 or nstages > 8:
raise ValueError('UD00 nstages must be between 1 and 8.')
self.tstart = float(tstart)
self.starts = tuple((float(value) for value in starts))
self.ends = tuple((float(value) for value in ends))
self.durations = tuple((float(value) for value in durations))
self.nstages = int(nstages)
self.iscyclic = bool(iscyclic)
values: dict[str, float] = {'tstart': self.tstart, 'nstages': float(self.nstages), 'iscyclic': float(int(self.iscyclic))}
for index in range(1, 9):
values[f'start{index}'] = self.starts[index - 1]
values[f'end{index}'] = self.ends[index - 1]
values[f't{index}'] = self.durations[index - 1]
self.set_parameter_values(values)
self.out = self.register_declared_port('out')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimUd00':
nstages = parameters['nstages']
iscyclic = parameters['iscyclic']
definitions = {definition.name: definition for definition in cls.PARAMETERS}
for parameter_name, value in (('nstages', nstages), ('iscyclic', iscyclic)):
numeric_value = float(value)
if not numeric_value.is_integer():
raise ValueError(f'UD00 {parameter_name} must be an integer.')
message = definitions[parameter_name].validation_message(numeric_value)
if message is not None:
raise ValueError(f'UD00 {parameter_name} {message}.')
return cls(name=name, medium=medium, tstart=parameters['tstart'], starts=tuple((parameters[f'start{index}'] for index in range(1, 9))), ends=tuple((parameters[f'end{index}'] for index in range(1, 9))), durations=tuple((parameters[f't{index}'] for index in range(1, 9))), nstages=int(nstages), iscyclic=bool(int(iscyclic)))
EQUATIONS = ()
@@ -0,0 +1 @@
"""AMESim pneumatic storage components."""
@@ -0,0 +1,82 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
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.metadata import ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
The AMESim submodel owns pressure and temperature states and exposes two
pneumatic flow ports. This public component maps those states onto the
framework's mass/internal-energy volume state and keeps the AMESim
heat-transfer contract `kth * sth * (extemp - T)`.
"""
MODEL_TYPE = 'amesim_pnch023'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol', 0.057, label='气室容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='气室内部用于储存气体的固定有效容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNCH023 固定容积气室', library_id='amesim', category_id='storage', symbol='amesim_pnch023', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10)
def __init__(self, name: str, medium: GasMedium, *, cvol: float=0.057, kth: float=0.0, sth: float=0.1, extemp: float=293.15, gi: float=1.0, p0: float=100000.0, T0: float=293.15) -> None:
super().__init__(name=name)
self.set_parameter_values({'cvol': cvol, 'kth': kth, 'sth': sth, 'extemp': extemp, 'gi': gi, 'p0': p0, 'T0': T0})
self.medium = medium
self.cvol = float(cvol)
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = normalize_amesim_gas_index(gi)
self.p0 = float(p0)
self.T0 = float(T0)
self.port_1 = self.register_declared_port('port_1')
self.port_2 = self.register_declared_port('port_2')
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnch023:
return cls(name=name, medium=medium, cvol=parameters['cvol'], kth=parameters['kth'], sth=parameters['sth'], extemp=parameters['extemp'], gi=parameters['gi'], p0=parameters['p0'], T0=parameters['T0'])
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'})
class AmesimPnch012(ThermodynamicVolumeComponent):
"""AMESim PNCH012 variable-volume pneumatic chamber.
AMESim supplies four external volume and volume-rate inputs through the
chamber ports. The SI vol1..4 parameters specify initial external volumes;
dvol1..4 prescribe constant rates integrated from the simulation start.
Connected moving boundaries supply their live geometry and rate directly,
including through reference nodes, without integrating that geometry again.
"""
MODEL_TYPE = 'amesim_pnch012'
MODEL_VERSION = '0.1.0'
PORTS = tuple(PortDefinition.pneumatic(f'port_{i}', computation=THERMODYNAMIC_SUPPLY) for i in range(1, 5))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110))
DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
def __init__(self, name: str, medium: GasMedium, *, cvol0: float=0.015, kth: float=0.0, sth: float=0.1, extemp: float=293.15, gi: float=1.0, p0: float=100000.0, T0: float=293.15, vol1: float=0.0, vol2: float=0.0, vol3: float=0.0, vol4: float=0.0, dvol1: float=0.0, dvol2: float=0.0, dvol3: float=0.0, dvol4: float=0.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'cvol0': cvol0, 'kth': kth, 'sth': sth, 'extemp': extemp, 'gi': gi, 'p0': p0, 'T0': T0, 'vol1': vol1, 'vol2': vol2, 'vol3': vol3, 'vol4': vol4, 'dvol1': dvol1, 'dvol2': dvol2, 'dvol3': dvol3, 'dvol4': dvol4})
self.medium = medium
self.cvol0 = float(cvol0)
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = normalize_amesim_gas_index(gi)
self.p0 = float(p0)
self.T0 = float(T0)
self.external_volumes = {'port_1': float(vol1), 'port_2': float(vol2), 'port_3': float(vol3), 'port_4': float(vol4)}
self.external_volume_rates = {'port_1': float(dvol1), 'port_2': float(dvol2), 'port_3': float(dvol3), 'port_4': float(dvol4)}
for port_name in ('port_1', 'port_2', 'port_3', 'port_4'):
port = self.register_declared_port(port_name)
setattr(self, port_name, port)
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnch012':
return cls(name=name, medium=medium, **dict(parameters))
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_3.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_4_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_4.p', '__MODEL__.state'], 'role': 'effort'})
@@ -0,0 +1,3 @@
"""Temporary component library used to validate the model authoring contract."""
from app.simulation.components.experimental.library import LIBRARY
@@ -0,0 +1 @@
"""Flow-path and resistance components."""
@@ -0,0 +1,35 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice."""
MODEL_TYPE = 'orifice'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
PARAMETERS = (ParameterDefinition('K', 1e-05, label='流量系数', quantity='flow_coefficient', unit='kg/(s*Pa^0.5)', minimum=0.0), ParameterDefinition('opening', 1.0, label='开度', minimum=0.0, maximum=1.0))
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='孔板/阀门', library_id='experimental', category_id='flow', symbol='orifice', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=40)
def __init__(self, name: str, opening: float=1.0, K: float=1e-05) -> None:
super().__init__(name=name)
self.set_parameter_values({'K': K, 'opening': opening})
self.opening = opening
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]) -> Orifice:
return cls(name=name, opening=parameters['opening'], K=parameters['K'])
@property
def K_eff(self) -> float:
return self.K * max(self.opening, 0.001)
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_a.m_flow', '__MODEL__.port_b.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_a.p', '__MODEL__.port_b.p', '__MODEL__.port_a.m_flow'], 'role': 'flow'})
@@ -0,0 +1,37 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from math import pi
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
class ResistivePipe(AlgebraicComponent):
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
MODEL_TYPE = 'pipe'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
PARAMETERS = (ParameterDefinition('length', 5.0, label='长度', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('diameter', 0.02, label='直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('lambda_darcy', 0.02, label='摩阻系数', minimum=0.0), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='管段', library_id='experimental', category_id='flow', symbol='pipe', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=30)
def __init__(self, name: str, medium: IdealGasMedium, L: float=5.0, D: float=0.02, lambda_darcy: float=0.02, p0: float=100000.0, T0: float=300.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'length': L, 'diameter': D, 'lambda_darcy': lambda_darcy, 'p0': p0, 'T0': T0})
self.medium = medium
self.L = L
self.D = D
self.lambda_darcy = lambda_darcy
self.p0 = p0
self.T0 = T0
self.area = pi * D * D / 4.0
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]) -> ResistivePipe:
return cls(name=name, medium=medium, L=parameters['length'], D=parameters['diameter'], lambda_darcy=parameters['lambda_darcy'], p0=parameters['p0'], T0=parameters['T0'])
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_a.m_flow', '__MODEL__.port_b.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:darcy_pressure_loss', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_a.p', '__MODEL__.port_b.p', '__MODEL__.port_a.m_flow'], 'role': 'effort'})
@@ -0,0 +1 @@
"""Flow junction components."""
@@ -0,0 +1,28 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class Tee(AlgebraicComponent):
"""Python port of ModelicaModels.Mytee."""
MODEL_TYPE = 'tee'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_in', nominal_role='bidirectional'), PortDefinition.pneumatic('port_out1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_out2', nominal_role='bidirectional'))
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='三通', library_id='experimental', category_id='junctions', symbol='tee', ports=(PortDisplaySpec('port_in', 'left', order=10), PortDisplaySpec('port_out1', 'right', order=20), PortDisplaySpec('port_out2', 'right', order=30)), order=50)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.port_in = self.register_declared_port('port_in')
self.port_out1 = self.register_declared_port('port_out1')
self.port_out2 = self.register_declared_port('port_out2')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tee:
return cls(name=name)
EQUATIONS = ({'id': '__MODEL__:common_pressure_out1', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_in.p', '__MODEL__.port_out1.p'], 'role': 'effort'}, {'id': '__MODEL__:common_pressure_out2', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_in.p', '__MODEL__.port_out2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_in.m_flow', '__MODEL__.port_out1.m_flow', '__MODEL__.port_out2.m_flow'], 'role': 'flow'})
@@ -0,0 +1,28 @@
"""Manifest for the temporary library used to validate component authoring."""
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentLibrarySpec,
)
LIBRARY = ComponentLibrarySpec(
id="experimental",
label="临时测试组件库",
version="0.1.0",
source_package="app.simulation.components.experimental",
temporary=True,
order=100,
categories=(
ComponentCategorySpec(id="storage", label="储能元件", order=10),
ComponentCategorySpec(id="flow", label="流动元件", order=20),
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
),
models=(
"app.simulation.components.experimental.storage.cylinder:Cylinder",
"app.simulation.components.experimental.storage.tank:Tank",
"app.simulation.components.experimental.flow.resistive_pipe:ResistivePipe",
"app.simulation.components.experimental.flow.orifice:Orifice",
"app.simulation.components.experimental.junctions.tee:Tee",
),
)
@@ -0,0 +1 @@
"""Storage and thermodynamic volume components."""
@@ -0,0 +1,30 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class Cylinder(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mycylinder."""
MODEL_TYPE = 'cylinder'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=THERMODYNAMIC_SUPPLY),)
PARAMETERS = (ParameterDefinition('volume', 0.01, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 35000000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='气瓶', library_id='experimental', category_id='storage', symbol='cylinder', ports=(PortDisplaySpec('port_b', 'right'),), order=10)
def __init__(self, name: str, medium: IdealGasMedium, V: float=0.01, p0: float=35000000.0, T0: float=300.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'volume': V, 'p0': p0, 'T0': T0})
self.medium = medium
self.V = V
self.port_b = self.register_declared_port('port_b')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Cylinder:
return cls(name=name, medium=medium, V=parameters['volume'], p0=parameters['p0'], T0=parameters['T0'])
EQUATIONS = ({'id': '__MODEL__:port_b_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_b.p', '__MODEL__.state'], 'role': 'effort'},)
@@ -0,0 +1,30 @@
"""Component parameters, ports and output definitions; numerical equations execute in C."""
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class Tank(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mytank."""
MODEL_TYPE = 'tank'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=THERMODYNAMIC_SUPPLY),)
PARAMETERS = (ParameterDefinition('volume', 0.1, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='贮箱', library_id='experimental', category_id='storage', symbol='tank', ports=(PortDisplaySpec('port_a', 'left'),), order=20)
def __init__(self, name: str, medium: IdealGasMedium, V: float=0.1, p0: float=100000.0, T0: float=300.0) -> None:
super().__init__(name=name)
self.set_parameter_values({'volume': V, 'p0': p0, 'T0': T0})
self.medium = medium
self.V = V
self.port_a = self.register_declared_port('port_a')
@classmethod
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tank:
return cls(name=name, medium=medium, V=parameters['volume'], p0=parameters['p0'], T0=parameters['T0'])
EQUATIONS = ({'id': '__MODEL__:port_a_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_a.p', '__MODEL__.state'], 'role': 'effort'},)
+148
View File
@@ -0,0 +1,148 @@
"""Simulation options and progress data; no numerical solver implementation."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence
@dataclass(frozen=True)
class SolverActivitySnapshot:
"""Low-cost, additive view of work inside an integration task.
``accepted_time`` deliberately changes only after an accepted solver step.
Trial evaluations may continue to advance ``activity_sequence`` and
``current_trial_time`` while that public progress value stays fixed.
"""
activity_sequence: int
activity_kind: str
current_trial_time: float | None
rhs_call_count: int
accepted_step_sequence: int
accepted_time: float | None
solver_step_sequence: int
jacobian_evaluation_count: int
thermofluid_closure_count: int
def as_dict(self) -> dict[str, object]:
return {
"activitySequence": self.activity_sequence,
"activityKind": self.activity_kind,
"currentTrialTime": self.current_trial_time,
"rhsCallCount": self.rhs_call_count,
"acceptedStepSequence": self.accepted_step_sequence,
"acceptedTime": self.accepted_time,
"solverStepSequence": self.solver_step_sequence,
"jacobianEvaluationCount": self.jacobian_evaluation_count,
"thermofluidClosureCount": self.thermofluid_closure_count,
}
class SolverActivityTracker:
"""Single-writer activity telemetry for a solver worker.
The solver thread is the only writer and the stream thread only snapshots
scalar attributes. The sequence is published last, so a reader never
treats partially published fields as a newer completed activity update.
The tracker receives aggregate counters from the independent C worker.
"""
__slots__ = (
"_accepted_step_sequence",
"_accepted_time",
"_activity_kind",
"_activity_sequence",
"_current_trial_time",
"_jacobian_evaluation_count",
"_rhs_call_count",
"_solver_step_sequence",
"_thermofluid_closure_count",
)
def __init__(self) -> None:
self._activity_sequence = 0
self._activity_kind = "idle"
self._current_trial_time: float | None = None
self._rhs_call_count = 0
self._accepted_step_sequence = 0
self._accepted_time: float | None = None
self._solver_step_sequence = 0
self._jacobian_evaluation_count = 0
self._thermofluid_closure_count = 0
def _publish(self, kind: str, time: float | None = None) -> None:
self._activity_kind = kind
if time is not None:
self._current_trial_time = float(time)
self._activity_sequence += 1
def start_integration(self, time: float) -> None:
self._accepted_time = float(time)
self._publish("solver_initialization", time)
def record_phase(self, kind: str, time: float | None = None) -> None:
self._publish(kind, time)
def record_solver_step(self, time: float) -> None:
self._solver_step_sequence += 1
self._publish("solver_step", time)
def record_rhs(self, time: float) -> None:
self._rhs_call_count += 1
self._publish("rhs", time)
def record_native_progress(self, time: float, rhs_count: int, accepted_count: int) -> None:
"""Publish aggregate counters from an isolated C worker without per-RHS callbacks."""
self._rhs_call_count = max(self._rhs_call_count, rhs_count)
self._accepted_step_sequence = max(self._accepted_step_sequence, accepted_count)
self._accepted_time = max(self._accepted_time or time, time)
self._publish("native_solver", time)
def record_jacobian(self, time: float) -> None:
self._jacobian_evaluation_count += 1
self._publish("jacobian", time)
def record_thermofluid_closure(self, time: float) -> None:
self._thermofluid_closure_count += 1
self._publish("thermofluid_closure", time)
def record_accepted_step(self, time: float) -> None:
accepted_time = float(time)
if (
self._accepted_time is not None
and accepted_time <= self._accepted_time
):
return
self._accepted_step_sequence += 1
self._accepted_time = accepted_time
self._publish("accepted_step", accepted_time)
def snapshot(self) -> SolverActivitySnapshot:
# ``activity_sequence`` is read last because writers publish it last.
activity_kind = self._activity_kind
current_trial_time = self._current_trial_time
rhs_call_count = self._rhs_call_count
accepted_step_sequence = self._accepted_step_sequence
accepted_time = self._accepted_time
solver_step_sequence = self._solver_step_sequence
jacobian_evaluation_count = self._jacobian_evaluation_count
thermofluid_closure_count = self._thermofluid_closure_count
activity_sequence = self._activity_sequence
return SolverActivitySnapshot(
activity_sequence=activity_sequence,
activity_kind=activity_kind,
current_trial_time=current_trial_time,
rhs_call_count=rhs_call_count,
accepted_step_sequence=accepted_step_sequence,
accepted_time=accepted_time,
solver_step_sequence=solver_step_sequence,
jacobian_evaluation_count=jacobian_evaluation_count,
thermofluid_closure_count=thermofluid_closure_count,
)
@dataclass(frozen=True)
class SolveIVPConfig:
t_start: float = 0.0
t_stop: float = 20.0
method: str = "BDF"
rtol: float = 1e-6
atol: float | Sequence[float] = 1e-8
max_step: float = 1e-3
first_step: float | None = None
File renamed without changes.
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from abc import ABC
from collections.abc import Mapping
from typing import TYPE_CHECKING, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
from app.simulation.core.equations import EquationDefinition
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, ResultVariableMetadata, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import GasMedium
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
DISPLAY: ClassVar[ComponentDisplaySpec | None] = None
def __init__(self, name: str) -> None:
self.name = name
self.model_type = self.MODEL_TYPE or self.__class__.__name__.lower()
self._ports: dict[str, PortState] = {}
self._parameter_values: dict[str, float] = {}
@property
def ports(self) -> dict[str, PortState]:
return dict(self._ports)
@property
def port_definitions(self) -> tuple[PortDefinition, ...]:
return tuple((port.definition for port in self._ports.values() if port.definition is not None))
@classmethod
def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]:
"""Declared ports enabled by one normalized parameter set."""
return cls.PORTS
@property
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
"""Instance ports that participate in execution and result reporting."""
return self.port_definitions
@property
def required_connection_ports(self) -> tuple[str, ...]:
"""Physical ports that must have an external connection before simulation."""
return tuple((definition.name for definition in self.active_port_definitions if definition.kind == 'physical'))
def register_port(self, port: PortState) -> PortState:
definition = port.definition
if definition is None:
raise ValueError(f'Component {self.name} cannot register an undefined port.')
if definition.name in self._ports:
raise ValueError(f'Duplicate port {self.name}.{definition.name}.')
self._ports[definition.name] = port
return port
def register_declared_port(self, name: str) -> PortState:
try:
definition = next((item for item in self.PORTS if item.name == name))
except StopIteration as exc:
raise ValueError(f'Component model {self.model_type} does not declare port {name}.') from exc
return self.register_port(PortState(definition=definition))
def set_parameter_values(self, values: Mapping[str, float]) -> None:
definitions = {definition.name: definition for definition in self.PARAMETERS}
unknown = sorted(set(values) - set(definitions))
if unknown:
raise ValueError(f'Component {self.name} contains unsupported parameters: ' + ', '.join(unknown) + '.')
missing = sorted(set(definitions) - set(values))
if missing:
raise ValueError(f'Component {self.name} is missing parameters: ' + ', '.join(missing) + '.')
resolved: dict[str, float] = {}
for name, definition in definitions.items():
value = float(values[name])
message = definition.validation_message(value)
if message is not None:
raise ValueError(f"Parameter '{name}' on component '{self.name}' {message}.")
resolved[name] = value
self._parameter_values = resolved
@property
def parameter_values(self) -> dict[str, float]:
return dict(self._parameter_values)
def get_port(self, name: str) -> PortState:
try:
return self._ports[name]
except KeyError as exc:
raise ValueError(f'Component {self.name} has no port named {name}.') from exc
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
metadata = [ResultVariableMetadata(key=f'{self.name}.{definition.name}', component_id=self.name, component_type=self.model_type, scope='component', name=definition.name, label=definition.label, quantity=definition.quantity, unit=definition.unit, category=definition.category, order=definition.order) for definition in self.RESULT_VARIABLES if definition.visible]
for port_definition in self.active_port_definitions:
for variable in port_definition.variables:
if not variable.result_visible:
continue
metadata.append(ResultVariableMetadata(key=f'{self.name}.{port_definition.name}.{variable.name}', component_id=self.name, component_type=self.model_type, scope='port', port_name=port_definition.name, name=variable.name, label=variable.label or variable.name, quantity=variable.quantity or variable.name, unit=variable.unit, category=variable.role, order=variable.order))
return tuple(metadata)
def parameter_interface_dicts(self) -> list[dict[str, object]]:
return [definition.as_interface_dict(value=self._parameter_values.get(definition.name)) for definition in self.PARAMETERS]
@classmethod
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> Component:
"""Create a catalog model from normalized SI parameters."""
raise NotImplementedError(f'Component model {cls.__name__} must implement create().')
EQUATIONS = ()
def equation_definitions(self):
def bind(value):
if isinstance(value, str):
return value.replace('__MODEL__', self.name)
return tuple((bind(v) for v in value))
return tuple((EquationDefinition(id=bind(e['id']), owner=e['owner'], owner_id=self.name, relation=e['relation'], variables=bind(e['variables']), role=e['role']) for e in self.EQUATIONS))
class DynamicComponent(Component):
state_size = 2
class ThermodynamicVolumeComponent(DynamicComponent):
"""Two-state gas volume exposing the shared thermodynamic result contract."""
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
class AlgebraicComponent(Component):
"""Stateless element described by algebraic constraints only."""
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
PortDisplaySide = Literal["left", "right"]
ComponentCatalogRole = Literal["amesimGasMediumDefinition"]
@dataclass(frozen=True)
class ComponentCategorySpec:
"""A presentation-only category declared by one component library."""
id: str
label: str
order: int = 0
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"order": self.order,
}
@dataclass(frozen=True)
class PortDisplaySpec:
"""Canvas placement for one port without changing its physical contract."""
name: str
side: PortDisplaySide
order: int = 0
@dataclass(frozen=True)
class ParameterGroupDisplaySpec:
"""Ordered, collapsible presentation group for component parameters."""
id: str
label: str
parameters: tuple[str, ...]
order: int = 0
default_expanded: bool = False
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"parameters": list(self.parameters),
"order": self.order,
"defaultExpanded": self.default_expanded,
}
@dataclass(frozen=True)
class ComponentDisplaySpec:
"""Frontend metadata co-located with a component implementation."""
label: str
library_id: str
category_id: str
symbol: str
ports: tuple[PortDisplaySpec, ...]
order: int = 0
role: ComponentCatalogRole | None = None
parameter_groups: tuple[ParameterGroupDisplaySpec, ...] = ()
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
return {port.name: port for port in self.ports}
@dataclass(frozen=True)
class ComponentLibrarySpec:
"""Manifest for one explicitly enabled component library."""
id: str
label: str
version: str
source_package: str
categories: tuple[ComponentCategorySpec, ...]
models: tuple[str, ...]
temporary: bool = False
order: int = 0
@property
def category_by_id(self) -> dict[str, ComponentCategorySpec]:
return {category.id: category for category in self.categories}
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"version": self.version,
"sourcePackage": self.source_package,
"temporary": self.temporary,
"order": self.order,
"categories": [
category.as_catalog_dict()
for category in sorted(
self.categories,
key=lambda item: (item.order, item.id),
)
],
}
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from app.simulation.core.ports import VariableRole
EquationOwner = Literal["connection", "component"]
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
@dataclass(frozen=True, slots=True)
class EquationDefinition:
"""One declarative equation in the compiled model interface."""
id: str
owner: EquationOwner
owner_id: str
relation: EquationRelation
variables: tuple[str, ...]
role: VariableRole | None = None
def as_definition_dict(self) -> dict[str, object]:
return {
"id": self.id,
"owner": self.owner,
"ownerId": self.owner_id,
"relation": self.relation,
"variables": list(self.variables),
"role": self.role,
}
def as_interface_dict(self) -> dict[str, object]:
return self.as_definition_dict()
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
class RecoverableTrialStateError(ValueError):
"""A physical-domain failure caused by an integrator trial state."""
+16
View File
@@ -0,0 +1,16 @@
"""Compile-time gas property constants. No Python property evaluator."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class IdealGasMedium:
name: str = 'SimpleAirApprox'
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-05
viscosity_T_ref: float = 293.15
sutherland_constant: float = 110.4
GasMedium = IdealGasMedium
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Literal
ResultVariableScope = Literal["component", "port"]
ParameterEditor = Literal[
"amesimGasReference",
"amesimGasPropertyModel",
"choice",
]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
"acceleration": "m/s2",
"area": "m2",
"dimensionless": "",
"density": "kg/m³",
"flow_coefficient": "kg/(s*Pa^0.5)",
"force": "N",
"heat_transfer_coefficient": "W/(m2*K)",
"internal_energy": "J",
"length": "m",
"mass": "kg",
"mass_flow": "kg/s",
"pressure": "Pa",
"specific_enthalpy": "J/kg",
"specific_internal_energy": "J/kg",
"temperature": "K",
"translational_damping": "N/(m/s)",
"translational_stiffness": "N/m",
"time": "s",
"velocity": "m/s",
"volume": "m3",
"volume_flow": "m3/s",
"windage": "N/(m/s)^2",
}
@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 ParameterCondition:
"""Match when the controlling parameter equals any declared value."""
parameter: str
values: tuple[float, ...]
def as_interface_dict(self) -> dict[str, object]:
return {
"parameter": self.parameter,
"values": list(self.values),
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract.
Every ``visible_when`` condition must match for the catalog parameter to
be visible; each individual condition matches any one of its ``values``.
"""
name: str
default: float
label: str = ""
quantity: str = "dimensionless"
unit: str = ""
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
editor: ParameterEditor | None = None
options: tuple[ParameterOption, ...] = ()
description: str = ""
visible_when: tuple[ParameterCondition, ...] = ()
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
return "must be finite"
if self.minimum is not None:
if self.minimum_exclusive and value <= self.minimum:
return f"must be greater than {self.minimum:g}"
if not self.minimum_exclusive and value < self.minimum:
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]:
payload: dict[str, object] = {
"name": self.name,
"label": self.label or self.name,
"quantity": self.quantity,
"unit": self.unit,
"default": self.default,
"minimumExclusive": self.minimum_exclusive,
}
if self.minimum is not None:
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 self.visible_when:
payload["visibleWhen"] = [
condition.as_interface_dict() for condition in self.visible_when
]
if value is not None:
payload["value"] = value
return payload
@dataclass(frozen=True)
class ResultVariableDefinition:
"""Component-relative declaration of a user-visible simulation result."""
name: str
label: str
quantity: str
unit: str = ""
category: str = "derived"
order: int = 0
visible: bool = True
@dataclass(frozen=True)
class ResultVariableMetadata:
"""A result declaration bound to one concrete component instance."""
key: str
component_id: str
component_type: str
scope: ResultVariableScope
name: str
label: str
quantity: str
unit: str
category: str
order: int
port_name: str | None = None
def as_dict(self) -> dict[str, object]:
return {
"key": self.key,
"componentId": self.component_id,
"componentType": self.component_type,
"scope": self.scope,
"portName": self.port_name,
"name": self.name,
"label": self.label,
"quantity": self.quantity,
"unit": self.unit,
"category": self.category,
"order": self.order,
}
THERMODYNAMIC_VOLUME_RESULT_VARIABLES = (
ResultVariableDefinition(
name="m",
label="质量",
quantity="mass",
unit="kg",
category="state",
order=10,
),
ResultVariableDefinition(
name="U",
label="内能",
quantity="internal_energy",
unit="J",
category="state",
order=20,
),
ResultVariableDefinition(
name="p",
label="压力",
quantity="pressure",
unit="Pa",
category="thermodynamic",
order=30,
),
ResultVariableDefinition(
name="T",
label="温度",
quantity="temperature",
unit="K",
category="thermodynamic",
order=40,
),
ResultVariableDefinition(
name="rho",
label="密度",
quantity="density",
unit="kg/m³",
category="thermodynamic",
order=50,
),
ResultVariableDefinition(
name="u",
label="比内能",
quantity="specific_internal_energy",
unit="J/kg",
category="thermodynamic",
order=60,
),
ResultVariableDefinition(
name="h",
label="比焓",
quantity="specific_enthalpy",
unit="J/kg",
category="thermodynamic",
order=70,
),
)
+152
View File
@@ -0,0 +1,152 @@
"""Compile-time variable supply contracts, separate from physical flow direction.
Equation ports may participate in a simultaneous solve. Fixed ports (for example
an Amesim node's reference/branch ports) require complementary local supplies.
These declarations do not add numerical state or Python evaluation callbacks.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Mapping
if TYPE_CHECKING:
from .ports import PortDefinition
VARIABLE_LABELS = {
"p": "压力", "T": "温度", "m_flow": "质量流率", "H_flow": "能量流率",
}
@dataclass(frozen=True)
class PortComputation:
inputs: tuple[str, ...] = ()
outputs: tuple[str, ...] = ()
mode: Literal["equation", "fixed"] = "equation"
# Output p/T aliases an input on another port of the same component.
reference_port: str | None = None
def __post_init__(self) -> None:
if self.mode not in {"equation", "fixed"}:
raise ValueError(f"Unknown port computation mode: {self.mode}")
members = (*self.inputs, *self.outputs)
if len(set(members)) != len(members) or set(members) - VARIABLE_LABELS.keys():
raise ValueError("Port computation variables must be unique, supported quantities.")
if self.reference_port and not {"p", "T"}.issubset(self.outputs):
raise ValueError("A reference alias must supply pressure and temperature.")
def as_dict(self) -> dict[str, object]:
return {
"mode": self.mode,
"inputs": list(self.inputs),
"outputs": list(self.outputs),
**({"referencePort": self.reference_port} if self.reference_port else {}),
}
THERMODYNAMIC_SUPPLY = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"))
FLOW_SUPPLY = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"))
ZERO_FLOW_SUPPLY = PortComputation(outputs=("m_flow", "H_flow"))
IMPLICIT_PNEUMATIC = PortComputation()
NODE_REFERENCE = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"), mode="fixed")
NODE_BRANCH = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"),
mode="fixed", reference_port="port_2")
@dataclass(frozen=True)
class PortSupplyIssue:
code: str
message: str
endpoint: tuple[str, str] | None = None
class PortSupplyError(ValueError):
def __init__(self, issue: PortSupplyIssue):
self.issue = issue
super().__init__(f"{issue.code}: {issue.message}")
def port_supply_issue(first: PortDefinition, second: PortDefinition,
first_label: str | None = None, second_label: str | None = None
) -> PortSupplyIssue | None:
"""Check fixed causality; ordinary equation-to-equation links stay legal."""
if first.kind != second.kind or first.domain != second.domain:
return None # Domain/type checks own their existing, more specific errors.
a, b = first.computation, second.computation
if not any(item and item.mode == "fixed" for item in (a, b)):
return None
for consumer, supplier, consumer_label, supplier_label in (
(a, b, first_label or first.name, second_label or second.name),
(b, a, second_label or second.name, first_label or first.name),
):
if consumer is None:
continue
missing = [name for name in consumer.inputs
if supplier is None or name not in supplier.outputs]
if missing:
quantities = "、".join(VARIABLE_LABELS[name] for name in missing)
return PortSupplyIssue(
"CONNECTION_VARIABLE_SUPPLY_MISSING",
f"{consumer_label} 需要对端提供{quantities},但 {supplier_label} 未提供;"
"请检查参考口与支路口的连接。气体流向反转不会改变这一供需关系。",
)
return None
def reference_supply_issues(
ports: Mapping[tuple[str, str], PortDefinition],
adjacency: Mapping[tuple[str, str], tuple[str, str]],
) -> list[PortSupplyIssue]:
"""Follow declared aliases to reject a reference ring without an origin.
This is a supply check, not a whole-system execution scheduler. Reference
chains are iterative to support deep networks without Python recursion.
"""
issues = []
resolved: dict[str, set[tuple[str, str]]] = {'p': set(), 'T': set()}
for endpoint, port in ports.items():
contract = port.computation
if not contract or contract.mode != "fixed" or not {"p", "T"}.issubset(contract.inputs):
continue
if endpoint not in adjacency:
continue # Existing unconnected-port checks handle incomplete drawings.
for variable in ("p", "T"):
current = endpoint
visited: set[tuple[str, str]] = set()
chain: list[str] = []
while True:
if current in resolved[variable]:
resolved[variable].update(visited)
break
if current in visited:
issues.append(PortSupplyIssue(
"REFERENCE_SUPPLY_CYCLE",
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考形成循环,"
f"没有实际提供者:{' → '.join(chain)} → {'.'.join(current)}。",
endpoint,
))
break
visited.add(current)
chain.append('.'.join(current))
supplier = adjacency.get(current)
if supplier is None:
issues.append(PortSupplyIssue(
"REFERENCE_SUPPLY_UNCONNECTED",
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考链在 "
f"{'.'.join(current)} 中断:该参考输入尚未连接。", endpoint,
))
break
supplied = ports.get(supplier)
supply = supplied.computation if supplied else None
if supply is None or variable not in supply.outputs:
# Direct errors are already reported per connection. An
# indirect failure is explained at that failing connection.
break
if supply.reference_port is None:
resolved[variable].update(visited)
break
chain.append('.'.join(supplier))
current = supplier[0], supply.reference_port
if current not in ports:
raise ValueError(f"Invalid reference port declaration: {current}")
return issues
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from .port_computation import IMPLICIT_PNEUMATIC, PortComputation
PortKind = Literal["physical", "signal"]
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
ActualFlowDirection = Literal["in", "out", "stagnant"]
VariableRole = Literal["effort", "flow", "stream", "signal"]
ConnectionRule = Literal["equal", "sumToZero", "streamMix", "directed"]
@dataclass(frozen=True)
class PortVariableDefinition:
name: str
role: VariableRole
connection_rule: ConnectionRule
label: str = field(default="", compare=False)
quantity: str = field(default="", compare=False)
unit: str = field(default="", compare=False)
result_visible: bool = field(default=True, compare=False)
order: int = field(default=0, compare=False)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
"role": self.role,
"connectionRule": self.connection_rule,
"label": self.label or self.name,
"quantity": self.quantity or self.name,
"unit": self.unit,
"resultVisible": self.result_visible,
"order": self.order,
}
@dataclass(frozen=True)
class PortDefinition:
"""Stable connector contract shared by components, XML, and the compiler."""
name: str
kind: PortKind
domain: str
nominal_role: PortNominalRole
positive_flow_direction: Literal["intoComponent"] | None = None
variables: tuple[PortVariableDefinition, ...] = ()
computation: PortComputation | None = None
@classmethod
def pneumatic(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
computation: PortComputation = IMPLICIT_PNEUMATIC,
) -> PortDefinition:
return cls(
name=name,
kind="physical",
domain="pneumatic",
nominal_role=nominal_role,
positive_flow_direction="intoComponent",
computation=computation,
variables=(
PortVariableDefinition(
"p",
"effort",
"equal",
label="压力",
quantity="pressure",
unit="Pa",
order=10,
),
PortVariableDefinition(
"m_flow",
"flow",
"sumToZero",
label="质量流量",
quantity="mass_flow",
unit="kg/s",
order=20,
),
PortVariableDefinition(
"h_outflow",
"stream",
"streamMix",
label="流出比焓",
quantity="specific_enthalpy",
unit="J/kg",
order=30,
),
PortVariableDefinition(
"volume",
"signal",
"directed",
label="外部容积",
quantity="volume",
unit="m3",
result_visible=False,
order=40,
),
PortVariableDefinition(
"volume_flow",
"signal",
"directed",
label="外部容积变化率",
quantity="volume_flow",
unit="m3/s",
result_visible=False,
order=50,
),
),
)
@classmethod
def mechanical_translational(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
) -> PortDefinition:
return cls(
name=name,
kind="physical",
domain="mechanical",
nominal_role=nominal_role,
positive_flow_direction="intoComponent",
variables=(
PortVariableDefinition(
"x",
"effort",
"equal",
label="位移",
quantity="length",
unit="m",
order=10,
),
PortVariableDefinition(
"v",
"effort",
"equal",
label="速度",
quantity="velocity",
unit="m/s",
order=20,
),
PortVariableDefinition(
"f",
"flow",
"sumToZero",
label="力",
quantity="force",
unit="N",
order=30,
),
),
)
@classmethod
def signal(
cls,
name: str,
*,
nominal_role: Literal["input", "output"],
domain: str = "signal",
) -> PortDefinition:
return cls(
name=name,
kind="signal",
domain=domain,
nominal_role=nominal_role,
variables=(
PortVariableDefinition(
"signal",
"signal",
"directed",
label="信号值",
quantity="dimensionless",
unit="",
order=10,
),
),
)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
"kind": self.kind,
"domain": self.domain,
"nominalRole": self.nominal_role,
"positiveFlowDirection": self.positive_flow_direction,
"variables": [variable.as_interface_dict() for variable in self.variables],
**({"computation": self.computation.as_dict()} if self.computation else {}),
}
@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
volume: float = 0.0
volume_flow: float = 0.0
signal: float = 0.0
x: float = 0.0
v: float = 0.0
f: float = 0.0
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
@classmethod
def pneumatic(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
) -> PortState:
return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role))
@property
def inflow_rate(self) -> float:
return max(self.m_flow, 0.0)
@property
def outflow_rate(self) -> float:
return max(-self.m_flow, 0.0)
def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection:
if self.m_flow > tolerance:
return "in"
if self.m_flow < -tolerance:
return "out"
return "stagnant"
@@ -0,0 +1 @@
"""Compilation of supported XML networks to independent native executables."""
+82
View File
@@ -0,0 +1,82 @@
"""python -m app.simulation.native_codegen INPUT.xml|json --output-dir DIR"""
from __future__ import annotations
import argparse
from dataclasses import replace
from hashlib import sha256
import json
from pathlib import Path
import shutil
import statistics
import time
from app.main import compile_system_xml_network
from app.simulation.backends import simulation_config
from .build import build_native
from .compiler import compile_native_program
from .input import load_input
from .runner import execute_native
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--method", choices=("RK45", "BDF"))
parser.add_argument("--max-step", type=float)
parser.add_argument("--rtol", type=float)
parser.add_argument("--runs", type=int, default=3)
parser.add_argument("--timeout", type=float, default=300)
parser.add_argument("--solve-only", action="store_true")
args = parser.parse_args()
if args.runs < 1:
parser.error("--runs must be positive")
out = args.output_dir.resolve()
out.mkdir(parents=True, exist_ok=True)
if (out / "summary.json").exists():
parser.error("Output directory already contains a completed run; choose a new directory.")
started = time.perf_counter()
xml, document = load_input(args.input)
program = compile_native_program(compile_system_xml_network(document))
config = simulation_config(document.simulation)
for name in ("method", "max_step", "rtol"):
if getattr(args, name) is not None:
config = replace(config, **{name: getattr(args, name)})
preparation = time.perf_counter()-started
(out / "input.xml").write_bytes(xml)
build = build_native(program)
package = out / "program"
package.mkdir(exist_ok=True)
for name in (*build.manifest["artifacts"], "manifest.json"):
shutil.copy2(build.executable.parent / name, package / name)
(out / "model-manifest.json").write_text(json.dumps(build.manifest, ensure_ascii=False, indent=2), encoding="utf-8")
rows = []
for i in range(args.runs+1):
data = execute_native(build, config, document.simulation.sample_step,
run_dir=out / ("warmup" if i == 0 else f"run-{i}"),
record_samples=not args.solve_only, timeout=args.timeout)
row = {k: v for k, v in data.items() if k not in ("series", "final", "finalState")}
row["run"] = "warmup" if i == 0 else i
rows.append(row)
print(json.dumps(row, ensure_ascii=False), flush=True)
if not data["success"]:
break
measured = rows[1:]
summary = {
"source": str(args.input.resolve()), "sourceSha256": sha256(args.input.read_bytes()).hexdigest(),
"xmlSha256": sha256(xml).hexdigest(), "executable": str(package / build.executable.name),
"cachedExecutable": str(build.executable),
"buildKey": build.manifest["buildKey"], "buildSeconds": build.seconds,
"preparationSeconds": preparation, "cacheHit": build.cache_hit,
"settings": vars(config), "sampleStep": document.simulation.sample_step,
"recordSamples": not args.solve_only, "runs": rows,
"success": len(measured) == args.runs and all(row["success"] for row in rows),
"medianSolveSeconds": statistics.median(row["solveSeconds"] for row in measured) if measured else None,
}
(out / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({k: v for k, v in summary.items() if k != "runs"}, ensure_ascii=False))
return 0 if summary["success"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+403
View File
@@ -0,0 +1,403 @@
"""On-demand native modules, checked object reuse and bounded executable caches."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from hashlib import sha256
import json
import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
from typing import Any, Callable
from .compiler import NativeProgram
from .modules import component_modules
from .processes import command_environment, is_driver_launch_failure, is_transient_process_error, LAUNCH_RETRY_DELAYS
from .cache_storage import acquire_cache_lease, touch_cache_entry, prune_cache
LOGGER = logging.getLogger(__name__)
WINDOWS = os.name == 'nt'
class NativeCommandError(RuntimeError):
def __init__(self, message, command, *, exit_code=None, stdout=b'', stderr='', error_type='CalledProcessError', attempts=1):
super().__init__(message)
self.details = {'command': list(command), 'cwd': str(Path.cwd()), 'exitCode': exit_code,
'stdout': stdout.decode('utf-8',errors='replace')[-8000:], 'stderr': stderr[-8000:],
'errorType': error_type, 'attempts': attempts}
ROOT = Path(__file__).resolve().parents[3]
NATIVE = ROOT / "native"
CACHE = ROOT / "app/data/native-builds"
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
COMPILER_FLAGS = ("-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math")
RUNTIME_SOURCES = ("runtime/main.c", "runtime/common.c", "runtime/sample_storage.c", "runtime/rk45.c",
"runtime/cvode_solver.c", "runtime/json_numbers.c", "encoding/ryu/d2s.c")
@dataclass(frozen=True)
class NativeBuild:
executable: Path
manifest: dict
cache_hit: bool
seconds: float
details: dict = field(default_factory=dict)
_lease: Any = field(default=None, repr=False, compare=False)
_cache: Path | None = field(default=None, repr=False, compare=False)
def close(self) -> None:
"""Release this caller's executable pin after its last use."""
if self._lease is not None:
self._lease.close()
if self._cache is not None:
_prune(self._cache)
def _hash(path: Path) -> str:
return sha256(path.read_bytes()).hexdigest()
def _key(value: object) -> str:
return sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _runtime_sources(program: NativeProgram) -> list[Path]:
return [NATIVE / name for name in RUNTIME_SOURCES] + [
NATIVE / "components/modules" / f"{name}.c"
for name in component_modules(program.source + "\n" + program.header)
]
def _budget(name: str, default: int) -> int:
value = os.environ.get(name, str(default))
if not re.fullmatch(r"[0-9]+", value):
raise ValueError(f"{name} must be a nonnegative integer in MiB.")
return int(value) * 1024**2
def _prune(cache: Path) -> dict:
return prune_cache(cache,
model_limit_bytes=_budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256),
object_limit_bytes=_budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128))
def _checked_manifest(target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict | None:
if not target.exists() and not target.is_symlink():
return None
message = f"Native cache integrity check failed: {target}"
if target.is_symlink() or not target.is_dir():
raise RuntimeError(message)
try:
path = target / "manifest.json"
if path.is_symlink():
raise ValueError("linked manifest")
manifest = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict) or manifest.get(key_name) != key or manifest.get("cacheVersion") != version:
raise ValueError("identity mismatch")
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, dict) or set(artifacts) != required:
raise ValueError("incomplete artifacts")
if key_name == "objectKey":
recipe = {name: manifest[name] for name in ("cacheVersion", "sourceName", "preprocessedSha256", "compiler")}
if _key(recipe) != key:
raise ValueError("object identity mismatch")
else:
recipe = manifest.get("buildIdentity")
if not isinstance(recipe, dict) or _key(recipe) != key or manifest.get("objectKeys") != recipe["objectKeys"]:
raise ValueError("model identity mismatch")
if artifacts["model.c"] != recipe["sourceSha256"] or artifacts["model.h"] != recipe["headerSha256"]:
raise ValueError("model source identity mismatch")
if _key({name: manifest[name] for name in recipe["contractKeys"]}) != recipe["contractSha256"]:
raise ValueError("model contract mismatch")
if artifacts["THIRD_PARTY_NOTICES.txt"] != recipe["notice"]:
raise ValueError("license artifact mismatch")
for name in required:
if name.lower().endswith(".dll") and artifacts[name] != recipe["dependencies"][name]:
raise ValueError("runtime dependency artifact mismatch")
for name, digest in artifacts.items():
if not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in (".", ".."):
raise ValueError("invalid artifact name")
path = target / name
if path.is_symlink() or not path.is_file() or not isinstance(digest, str) or _hash(path) != digest:
raise ValueError("artifact mismatch")
return manifest
except (OSError, ValueError, TypeError, KeyError) as exc:
raise RuntimeError(message) from exc
def _publish(stage: Path, target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict:
try:
stage.rename(target)
except OSError:
# Another reader/build may publish the same content while we compile.
# Its artifacts need not be byte-identical (e.g. PE timestamps), but
# must satisfy the same complete identity and its own recorded hashes.
winner = _checked_manifest(target, key_name, key, version, required)
if winner is None:
raise
shutil.rmtree(stage)
return winner
manifest = _checked_manifest(target, key_name, key, version, required)
assert manifest is not None
return manifest
def _command(command: list[str], *, log: list[str], timeout: float = 120,
require_stdout: bool = False) -> bytes:
environment = command_environment(command[0])
for attempt in range(len(LAUNCH_RETRY_DELAYS) + 1):
try:
result = subprocess.run(command, capture_output=True, timeout=timeout,
stdin=subprocess.DEVNULL, close_fds=True, env=environment,
creationflags=subprocess.CREATE_NO_WINDOW if WINDOWS else 0)
except OSError as exc:
if not is_transient_process_error(exc) or attempt == len(LAUNCH_RETRY_DELAYS):
raise
reason = f'{type(exc).__name__}: {exc}'
else:
stderr = result.stderr.decode('utf-8', errors='replace')
log.append(' '.join(command) + '\n' + stderr)
empty = result.returncode == 0 and require_stdout and not result.stdout.strip()
transient = is_driver_launch_failure(stderr) or (empty and WINDOWS)
if not result.returncode and not empty:
return result.stdout
reason = 'Compiler returned no identification output.' if empty else stderr[-3000:]
if not transient or attempt == len(LAUNCH_RETRY_DELAYS):
prefix = ('GCC 已启动,但编译子进程启动失败(已完成短间隔重试);'
if is_driver_launch_failure(stderr) else 'Native compilation failed: ')
raise NativeCommandError(prefix + reason, command, exit_code=result.returncode,
stdout=result.stdout, stderr=stderr, error_type='EmptyCompilerOutput' if empty else 'CalledProcessError',
attempts=attempt+1)
delay = LAUNCH_RETRY_DELAYS[attempt]
log.append(f'Compiler launch retry {attempt+1}/{len(LAUNCH_RETRY_DELAYS)} after {delay:g}s: {reason}')
LOGGER.warning('Compiler process launch failed; retry %d/%d in %.2fs: %s',
attempt+1, len(LAUNCH_RETRY_DELAYS), delay, reason.strip())
time.sleep(delay)
raise AssertionError('Unreachable compiler retry state')
def toolchain() -> tuple[str, Path, str]:
if os.name != "nt" and not sys.platform.startswith("linux"):
raise RuntimeError("Native builds support Windows and Linux; this platform is not supported.")
compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
if not compiler:
raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.")
configured = os.environ.get("SUNDIALS_ROOT")
candidates = ([Path(configured)] if configured else
[Path(sys.base_prefix) / "Library"] if os.name == "nt" else
[Path(sys.prefix) / "native/sundials-7.4.0", Path("/usr/local"), Path("/usr")])
base = next((path for path in candidates if (path / "include/cvode/cvode.h").is_file()), candidates[0])
if not (base / "include/cvode/cvode.h").is_file():
raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.")
if os.name == 'nt':
compiler = str(Path(shutil.which(compiler) or compiler).resolve())
version = _command([compiler, '--version'], log=[], timeout=15, require_stdout=True).decode('utf-8', errors='replace').strip().splitlines()[0]
return compiler, base, version
def platform_build_inputs(sundials: Path) -> tuple[list[str], list[Path], list[Path], str]:
"""Share production flags and dependencies with the startup smoke check."""
flags = list(COMPILER_FLAGS)
executable_name = "model.exe" if os.name == "nt" else "model"
if os.name == "nt":
flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"]
libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES]
dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES]
if (sundials / "bin/vcruntime140.dll").is_file():
dlls.append(sundials / "bin/vcruntime140.dll")
else:
flags += ["-D_POSIX_C_SOURCE=200809L"]
candidates = [sundials / "lib", sundials / "lib64", sundials / "lib/x86_64-linux-gnu"]
directory = next((p for p in candidates if all((p / f"libsundials_{name}.a").is_file() for name in LIBRARIES)), None)
if directory is None:
raise RuntimeError("SUNDIALS static libraries not found; set SUNDIALS_ROOT.")
libraries = [directory / f"libsundials_{name}.a" for name in LIBRARIES]
dlls = []
return flags, libraries, dlls, executable_name
def link_library_arguments(libraries: list[Path]) -> list[str]:
arguments = list(map(str, libraries))
if sys.platform.startswith("linux"):
arguments = ["-Wl,--start-group", *arguments, "-Wl,--end-group"]
return arguments
def build_native(program: NativeProgram, *, cache_dir: Path | None = None,
progress_callback: Callable[[str], None] | None = None) -> NativeBuild:
started = time.perf_counter()
if progress_callback:
progress_callback("native-cache-check")
compiler, sundials, compiler_version = toolchain()
flags, libraries, dlls, executable_name = platform_build_inputs(sundials)
cache = (cache_dir or CACHE).resolve()
cache.mkdir(parents=True, exist_ok=True)
for kind in ("models", "objects"):
path = cache / kind
if path.is_symlink():
raise RuntimeError(f"Native cache directory must not be a symbolic link: {path}")
path.mkdir(exist_ok=True)
_budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256)
_budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128)
work_key = sha256(os.urandom(32)).hexdigest()
work_lease = acquire_cache_lease(cache, "models", work_key)
try:
stage = Path(tempfile.mkdtemp(prefix=f"building-{work_key}-", dir=cache))
except BaseException:
work_lease.close()
raise
logs: list[str] = []
model_lease = None
try:
(stage / "model.c").write_text(program.source, encoding="utf-8", newline="\n")
(stage / "model.h").write_text(program.header, encoding="utf-8", newline="\n")
compiler_path = Path(shutil.which(compiler) or compiler).resolve()
compiler_identity = {"version": compiler_version, "binarySha256": _hash(compiler_path),
"target": _command([compiler, "-dumpmachine"], log=logs, require_stdout=True).decode().strip(),
"flags": flags, "platform": sys.platform}
runtime = _runtime_sources(program)
sources = [stage / "model.c", *runtime]
native_headers = {p: _hash(p) for p in (NATIVE / "include").rglob("*.h")}
dependency_headers = {p: _hash(p) for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol")
for p in (sundials / "include" / directory).glob("*.h")}
preprocessing_start = time.perf_counter()
# Preprocessed bytes include the actual transitive headers and all
# effective macros/ABI switches. Compile precisely these bytes, so a
# header edit cannot race the cache identity and the compiler input.
def preprocess(source: Path) -> dict:
local_log: list[str] = []
source_hash = _hash(source)
data = _command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={stage}=/generated",
"-I", str(stage), "-I", str(NATIVE / "include"), "-I", str(sundials / "include"),
str(source)], log=local_log)
name = "model.c" if source == stage / "model.c" else "native/" + source.relative_to(NATIVE).as_posix()
if _hash(source) != source_hash:
raise RuntimeError(f"Native source changed during preprocessing: {source}")
digest = sha256(data).hexdigest()
identity = {"cacheVersion": 1, "sourceName": name, "preprocessedSha256": digest, "compiler": compiler_identity}
return {"sourceName": name, "sourceSha256": source_hash, "data": data,
"objectKey": _key(identity), "identity": identity, "log": local_log}
with ThreadPoolExecutor(max_workers=min(4, len(sources))) as pool:
units = list(pool.map(preprocess, sources))
for unit in units:
logs.extend(unit.pop("log"))
preprocessing_seconds = time.perf_counter() - preprocessing_start
if any(_hash(path) != digest for path, digest in {**native_headers, **dependency_headers}.items()):
raise RuntimeError("Native headers changed during preprocessing; retry with stable sources.")
dependencies = {p.name: _hash(p) for p in libraries + dlls}
notice = (NATIVE / "THIRD_PARTY_NOTICES.txt").read_bytes()
identity = {"cacheVersion": 2, "sourceSha256": sha256(program.source.encode()).hexdigest(),
"headerSha256": sha256(program.header.encode()).hexdigest(),
"contractSha256": _key(program.manifest()), "contractKeys": sorted(program.manifest()),
"objectKeys": [unit["objectKey"] for unit in units],
"compiler": compiler_identity, "dependencies": dependencies, "notice": sha256(notice).hexdigest()}
signature = _key(identity)
target = cache / "models" / signature
required = {"model.c", "model.h", executable_name, "THIRD_PARTY_NOTICES.txt", *(p.name for p in dlls)}
model_lease = acquire_cache_lease(cache, "models", signature)
manifest = _checked_manifest(target, "buildKey", signature, 2, required)
details = {"selectedModules": list(component_modules(program.source + "\n" + program.header)),
"unitCount": len(units), "objectCacheHits": 0, "objectCompilations": 0,
"preprocessSeconds": preprocessing_seconds, "compileSeconds": 0.0,
"compileWallSeconds": 0.0, "linkSeconds": 0.0, "modelCacheHit": manifest is not None}
details['compilerLaunchRetries'] = sum(line.startswith('Compiler launch retry ') for line in logs)
if manifest is not None:
if progress_callback:
progress_callback("native-cache-hit")
touch_cache_entry(cache, "models", signature)
details["pruning"] = _prune(cache)
result = NativeBuild(target / executable_name, manifest, True, time.perf_counter() - started,
details, model_lease, cache)
model_lease = None
return result
if progress_callback:
progress_callback("native-compilation")
compile_start = time.perf_counter()
def compile_unit(index_unit: tuple[int, dict]) -> dict:
index, unit = index_unit
key = unit["objectKey"]
destination = cache / "objects" / key
with acquire_cache_lease(cache, "objects", key):
object_manifest = _checked_manifest(destination, "objectKey", key, 1, {"unit.o"})
hit = object_manifest is not None
elapsed = 0.0
local_log: list[str] = []
if not hit:
temporary = Path(tempfile.mkdtemp(prefix=f"building-{key}-", dir=cache / "objects"))
try:
(temporary / "unit.i").write_bytes(unit["data"])
before = time.perf_counter()
_command([compiler, *flags, "-x", "cpp-output", "-c", str(temporary / "unit.i"),
"-o", str(temporary / "unit.o")], log=local_log)
elapsed = time.perf_counter() - before
(temporary / "unit.i").unlink()
object_manifest = {**unit["identity"], "objectKey": key, "artifacts": {"unit.o": _hash(temporary / "unit.o")}}
(temporary / "manifest.json").write_text(json.dumps(object_manifest, indent=2) + "\n")
_publish(temporary, destination, "objectKey", key, 1, {"unit.o"})
finally:
if temporary.exists():
shutil.rmtree(temporary)
# Linking uses private copies, so another model's LRU pass can
# safely reclaim the shared object after this lease is released.
local_object = stage / f"object-{index}.o"
shutil.copyfile(destination / "unit.o", local_object)
touch_cache_entry(cache, "objects", key)
return {"path": local_object, "hit": hit, "seconds": elapsed, "log": local_log}
with ThreadPoolExecutor(max_workers=min(4, len(units))) as pool:
objects = list(pool.map(compile_unit, enumerate(units)))
details["compileWallSeconds"] = time.perf_counter() - compile_start
details["compileSeconds"] = sum(item["seconds"] for item in objects)
details["objectCacheHits"] = sum(item["hit"] for item in objects)
details["objectCompilations"] = len(objects) - details["objectCacheHits"]
for item in objects:
logs.extend(item["log"])
link_libraries = link_library_arguments(libraries)
if progress_callback:
progress_callback("native-linking")
before_link = time.perf_counter()
_command([compiler, *flags, *[str(item["path"]) for item in objects], *link_libraries,
"-lm", "-o", str(stage / executable_name)], log=logs)
details['compilerLaunchRetries'] = sum(line.startswith('Compiler launch retry ') for line in logs)
details["linkSeconds"] = time.perf_counter() - before_link
if {p.name: _hash(p) for p in libraries + dlls} != dependencies:
raise RuntimeError("Native dependencies changed during linking; retry with a stable toolchain.")
for item in objects:
item["path"].unlink()
for library in dlls:
shutil.copyfile(library, stage / library.name)
if _hash(stage / library.name) != dependencies[library.name]:
raise RuntimeError("Native runtime dependency changed during copying; retry with a stable toolchain.")
(stage / "THIRD_PARTY_NOTICES.txt").write_bytes(notice)
(stage / "build.log").write_text("\n".join(logs), encoding="utf-8")
manifest = {**program.manifest(), "cacheVersion": 2, "buildKey": signature, "buildIdentity": identity,
"compiler": compiler_version, "compilerFlags": flags,
"sourceHashes": {unit["sourceName"]: unit["sourceSha256"] for unit in units},
"nativeHeaderHashes": {"native/" + p.relative_to(NATIVE).as_posix(): digest
for p, digest in native_headers.items()},
"dependencyHashes": dependencies, "selectedModules": details["selectedModules"],
"objectKeys": [unit["objectKey"] for unit in units],
"artifacts": {name: _hash(stage / name) for name in sorted(required)}}
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
manifest = _publish(stage, target, "buildKey", signature, 2, required)
touch_cache_entry(cache, "models", signature)
details["pruning"] = _prune(cache)
result = NativeBuild(target / executable_name, manifest, False, time.perf_counter() - started,
details, model_lease, cache)
model_lease = None
return result
finally:
if model_lease is not None:
model_lease.close()
# Failed compilations never publish partial entries or retain unlimited
# model sources/preprocessed files. Error stderr is included in the exception.
try:
if stage.exists():
shutil.rmtree(stage)
finally:
work_lease.close()
@@ -0,0 +1,459 @@
"""Leases and bounded LRU eviction for immutable native cache entries.
Builds publish and validate their own artifacts. Readers hold a shared lease
before checking an entry and until they finish using it. Eviction only removes
an entry while holding the corresponding nonblocking exclusive lease.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
import errno
import os
from pathlib import Path
import re
import shutil
import stat
import weakref
MODEL_LIMIT_BYTES = 256 * 1024**2
OBJECT_LIMIT_BYTES = 128 * 1024**2
LOCK_SHARDS = 128
_KINDS = frozenset(("models", "objects"))
_KEY = re.compile(r"[0-9a-f]{64}\Z")
# tempfile.mkdtemp uses eight lowercase letters, digits or underscores. Older
# stages lacking a lease key are deliberately outside automatic cleanup.
_STAGE = re.compile(r"building-([0-9a-f]{64})-[a-z0-9_]{8}\Z")
if os.name == "nt":
import ctypes
from ctypes import wintypes
import msvcrt
class _Offset(ctypes.Structure):
_fields_ = [("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD)]
class _OffsetUnion(ctypes.Union):
_fields_ = [("offset", _Offset), ("Pointer", ctypes.c_void_p)]
class _Overlapped(ctypes.Structure):
_fields_ = [
("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
("offset", _OffsetUnion), ("hEvent", wintypes.HANDLE),
]
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_lock_file = _kernel32.LockFileEx
_lock_file.argtypes = [
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_Overlapped),
]
_lock_file.restype = wintypes.BOOL
_unlock_file = _kernel32.UnlockFileEx
_unlock_file.argtypes = [
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
wintypes.DWORD, ctypes.POINTER(_Overlapped),
]
_unlock_file.restype = wintypes.BOOL
else:
import fcntl
def _validate(kind: str, key: str) -> None:
if kind not in _KINDS or not isinstance(key, str) or _KEY.fullmatch(key) is None:
raise ValueError("Native cache leases require models/objects and a lowercase SHA-256 key.")
def _reparse(info: os.stat_result) -> bool:
# Junctions as well as symbolic links are excluded on Windows.
return stat.S_ISLNK(info.st_mode) or bool(
getattr(info, "st_file_attributes", 0)
& getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
)
def _directory(path: Path) -> os.stat_result | None:
try:
info = path.lstat()
except FileNotFoundError:
return None
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
return None
return info
def _require_directory(path: Path, *, create: bool = False) -> None:
if create:
path.mkdir(parents=True, exist_ok=True)
if _directory(path) is None:
raise RuntimeError(f"Native cache directory must be a real directory: {path}")
def _lock(fd: int, *, exclusive: bool, blocking: bool) -> bool:
if os.name == "nt":
# os.open supplies a synchronous handle. LockFileEx therefore really
# waits without FAIL_IMMEDIATELY; unlike msvcrt.locking it also supports
# overlapping shared leases, including multiple builds in one process.
# The one-byte lock may extend beyond EOF, so lock files remain empty.
overlapped = _Overlapped()
flags = (2 if exclusive else 0) | (0 if blocking else 1)
if _lock_file(msvcrt.get_osfhandle(fd), flags, 0, 1, 0, ctypes.byref(overlapped)):
return True
code = ctypes.get_last_error()
if not blocking and code == 33: # ERROR_LOCK_VIOLATION
return False
raise ctypes.WinError(code)
operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
if not blocking:
operation |= fcntl.LOCK_NB
while True:
try:
fcntl.flock(fd, operation)
return True
except InterruptedError:
continue
except OSError as exc:
if not blocking and exc.errno in (errno.EACCES, errno.EAGAIN):
return False
raise
def _release(fd: int, owner_pid: int) -> None:
# A forked child's finalizer must not explicitly unlock its parent's flock.
# Closing the inherited descriptor alone leaves the parent's lease intact.
with suppress(OSError):
if os.getpid() == owner_pid:
if os.name == "nt":
overlapped = _Overlapped()
_unlock_file(msvcrt.get_osfhandle(fd), 0, 1, 0, ctypes.byref(overlapped))
else:
fcntl.flock(fd, fcntl.LOCK_UN)
with suppress(OSError):
os.close(fd)
class CacheLease:
"""An acquired cache-use lock; close explicitly or use as a context manager."""
__slots__ = ("path", "kind", "key", "exclusive", "_finalizer", "__weakref__")
def __init__(self, fd: int, path: Path, kind: str, key: str, exclusive: bool):
self.path = path
self.kind = kind
self.key = key
self.exclusive = exclusive
self._finalizer = weakref.finalize(self, _release, fd, os.getpid())
@property
def closed(self) -> bool:
return not self._finalizer.alive
def close(self) -> None:
self._finalizer()
def __enter__(self) -> CacheLease:
if self.closed:
raise RuntimeError("Cannot reuse a closed native cache lease.")
return self
def __exit__(self, *_: object) -> None:
self.close()
def acquire_cache_lease(
cache_dir: Path, kind: str, key: str, *, exclusive: bool = False,
blocking: bool = True,
) -> CacheLease | None:
"""Acquire a shared-use/exclusive-eviction lease; return None only if busy.
Locks use 128 stable shards per namespace, bounding metadata as model keys
accumulate. A collision can defer eviction but cannot admit unsafe deletion.
Lock files must never be unlinked while the application is running: waiters
may already hold the old inode. There are at most 256 empty lock files.
"""
_validate(kind, key)
shard = int(key[:8], 16) % LOCK_SHARDS
return _acquire_lock_file(
Path(cache_dir).absolute(), kind, key, f"{kind}-{shard:03d}.lock",
exclusive=exclusive, blocking=blocking,
)
def _acquire_lock_file(
cache: Path, kind: str, key: str, lock_name: str, *, exclusive: bool,
blocking: bool,
) -> CacheLease | None:
_require_directory(cache, create=True)
lock_dir = cache / ".locks"
_require_directory(lock_dir, create=True)
lock_path = lock_dir / lock_name
try:
info = lock_path.lstat()
except FileNotFoundError:
info = None
if info is not None and (_reparse(info) or not stat.S_ISREG(info.st_mode)):
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
fd = os.open(lock_path, flags, 0o600)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
os.set_inheritable(fd, False)
if not _lock(fd, exclusive=exclusive, blocking=blocking):
os.close(fd)
return None
return CacheLease(fd, cache / kind / key, kind, key, exclusive)
except BaseException:
os.close(fd)
raise
def touch_cache_entry(cache_dir: Path, kind: str, key: str) -> None:
"""Mark an immutable entry as recently used while its caller holds a lease."""
_validate(kind, key)
cache = Path(cache_dir).absolute()
_require_directory(cache)
_require_directory(cache / kind)
entry = cache / kind / key
_require_directory(entry)
# Directory mtime is separate from the checksummed artifact/manifest bytes.
# Windows Python may expose utime without the no-follow operation. The
# checks above reject symlinks and reparse points before either call, and
# the caller's use lease protects the entry from application eviction.
if os.utime in os.supports_follow_symlinks:
os.utime(entry, None, follow_symlinks=False)
else:
os.utime(entry, None)
@dataclass(frozen=True)
class _Entry:
path: Path
size: int
mtime_ns: int
device: int
inode: int
def _tree_size(path: Path) -> int | None:
"""Count regular file bytes without reading files or following any links."""
size = 0
try:
with os.scandir(path) as children:
for child in children:
info = child.stat(follow_symlinks=False)
if _reparse(info):
return None
if stat.S_ISREG(info.st_mode):
size += info.st_size
elif stat.S_ISDIR(info.st_mode):
nested = _tree_size(Path(child.path))
if nested is None:
return None
size += nested
else:
return None
except FileNotFoundError:
return None # A concurrent sweep may already have removed this entry.
return size
def _scan(namespace: Path) -> tuple[list[_Entry], int]:
entries = []
skipped = 0
if _directory(namespace) is None:
try:
namespace.lstat()
except FileNotFoundError:
return entries, 0
return entries, 1
with os.scandir(namespace) as children:
for child in children:
if _KEY.fullmatch(child.name) is None:
skipped += 1
continue
path = Path(child.path)
info = _directory(path)
if info is None:
skipped += 1
continue
size = _tree_size(path)
if size is None:
skipped += 1
continue
entries.append(_Entry(path, size, info.st_mtime_ns, info.st_dev, info.st_ino))
return entries, skipped
def _empty_report(limit: int) -> dict:
return {
"limitBytes": limit, "beforeBytes": 0, "afterBytes": 0,
"removedBytes": 0, "removedEntries": 0, "skippedInUse": 0,
"skippedChanged": 0, "skippedUnmanaged": 0, "oversizedEntries": 0,
"overLimitBytes": 0, "skippedConcurrentSweep": False, "errors": [],
}
def _prune_kind(cache: Path, kind: str, limit: int) -> dict:
report = _empty_report(limit)
namespace = cache / kind
try:
entries, skipped = _scan(namespace)
except OSError as exc:
report["errors"].append(str(exc))
return report
report["skippedUnmanaged"] = skipped
remaining = report["beforeBytes"] = sum(entry.size for entry in entries)
remaining_count = len(entries)
for entry in sorted(entries, key=lambda item: (item.mtime_ns, item.path.name)):
if remaining <= limit:
break
# Keep the last entry if it alone exceeds the budget. Older oversized
# entries are still evictable, so they cannot accumulate unboundedly.
if remaining_count == 1 and entry.size > limit:
continue
try:
lease = acquire_cache_lease(cache, kind, entry.path.name, exclusive=True, blocking=False)
if lease is None:
report["skippedInUse"] += 1
continue
with lease:
info = _directory(entry.path)
if info is None:
report["skippedChanged"] += 1
continue
if (info.st_dev, info.st_ino, info.st_mtime_ns) != (
entry.device, entry.inode, entry.mtime_ns,
):
# It was republished or reused after sorting; defer it to
# the next sweep instead of evicting a freshly used build.
report["skippedChanged"] += 1
continue
size = _tree_size(entry.path)
if size is None:
report["skippedUnmanaged"] += 1
continue
shutil.rmtree(entry.path)
remaining -= entry.size
remaining_count -= 1
report["removedBytes"] += size
report["removedEntries"] += 1
except (OSError, RuntimeError) as exc:
# Cleanup is best effort. A locked executable, permissions or a
# damaged unrelated cache entry must not fail a valid simulation.
report["errors"].append(str(exc))
try:
current, _ = _scan(namespace)
report["afterBytes"] = sum(entry.size for entry in current)
report["oversizedEntries"] = sum(entry.size > limit for entry in current)
except OSError as exc:
report["errors"].append(str(exc))
report["afterBytes"] = remaining
report["overLimitBytes"] = max(0, report["afterBytes"] - limit)
return report
def _empty_orphan_report() -> dict:
return {
"orphanStagesRemoved": 0, "orphanStagesBytes": 0,
"skippedInUse": 0, "skippedUnmanaged": 0,
"skippedConcurrentSweep": False, "errors": [],
}
def _prune_orphan_stages(cache: Path) -> dict:
"""Remove only named stages whose builder's lease is no longer held.
Callers acquire the embedded key's shared lease BEFORE creating the stage
and retain it until publication or cleanup. The OS releases that protection
if the builder is killed; no age threshold or unreliable PID test is needed.
Root/models stages use models leases; objects stages use objects leases.
"""
report = _empty_orphan_report()
for namespace, kind in ((cache, "models"), (cache / "models", "models"),
(cache / "objects", "objects")):
try:
if _directory(namespace) is None:
continue
with os.scandir(namespace) as children:
candidates = []
for child in children:
match = _STAGE.fullmatch(child.name)
if match is not None:
candidates.append((Path(child.path), match.group(1)))
elif child.name.startswith("building-"):
report["skippedUnmanaged"] += 1
except OSError as exc:
report["errors"].append(f"{namespace}: {exc}")
continue
for stage, key in candidates:
try:
lease = acquire_cache_lease(cache, kind, key, exclusive=True, blocking=False)
if lease is None:
report["skippedInUse"] += 1
continue
with lease:
if _directory(stage) is None:
report["skippedUnmanaged"] += 1
continue
size = _tree_size(stage)
if size is None:
report["skippedUnmanaged"] += 1
continue
shutil.rmtree(stage)
report["orphanStagesRemoved"] += 1
report["orphanStagesBytes"] += size
except (OSError, RuntimeError) as exc:
report["errors"].append(f"{stage}: {exc}")
return report
def prune_cache(
cache_dir: Path, *, model_limit_bytes: int = MODEL_LIMIT_BYTES,
object_limit_bytes: int = OBJECT_LIMIT_BYTES,
) -> dict:
"""Apply separate soft LRU byte budgets to managed models and objects.
In-use entries (including conservative shard collisions), the final entry
when it alone exceeds the budget, unknown names, links and legacy root-level
builds are retained. Returned counts explain any remaining overage. The
cache may transiently exceed its budgets while builds or solvers are active.
Strictly named stages abandoned by killed builders are removed under their
matching lease and counted separately in the orphanStages report.
"""
for limit in (model_limit_bytes, object_limit_bytes):
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
raise ValueError("Native cache byte limits must be nonnegative integers.")
cache = Path(cache_dir).absolute()
empty = {
"models": _empty_report(model_limit_bytes),
"objects": _empty_report(object_limit_bytes),
"orphanStages": _empty_orphan_report(),
}
try:
if _directory(cache) is None:
# Do not create a missing cache or inspect an alias to a directory.
return empty
# One additional stable lock file (257 total) serializes sweep decisions
# without serializing readers/builds. Otherwise simultaneous sweeps can
# each remove a different entry and incorrectly discard the final one.
sweep = _acquire_lock_file(
cache, "models", "0" * 64, "prune.lock", exclusive=True, blocking=False,
)
if sweep is None:
for report in empty.values():
report["skippedConcurrentSweep"] = True
return empty
with sweep:
orphan_stages = _prune_orphan_stages(cache)
return {
"models": _prune_kind(cache, "models", model_limit_bytes),
"objects": _prune_kind(cache, "objects", object_limit_bytes),
"orphanStages": orphan_stages,
}
except (OSError, RuntimeError) as exc:
for report in empty.values():
report["errors"].append(str(exc))
return empty
+427
View File
@@ -0,0 +1,427 @@
"""Lower reviewed component contracts to a static C evaluation schedule.
The compact storage-anchored schedule and the extended catalog schedule both
produce standalone C numerics, without Python callbacks or numerical fallback.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from math import isfinite
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.systems.network import SimulationNetwork
from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS
from .tolerances import state_absolute_tolerance
from .jacobian import JacobianStructure
class NativeCapabilityError(ValueError):
"""The complete model cannot be represented by this native backend."""
_STORAGE_ANCHORED_TYPES = frozenset(
"amesim_" + name for name in (
"pnch023", "pnch012", "pnvo001", "pnpl01", "step0", "ud00",
"forc", "pnrp17", "mecmas21", "f000", "lstp00a",
)
)
@dataclass(frozen=True)
class NativeProgram:
source: str
header: str
state_keys: tuple[str, ...]
variables: tuple[ResultVariableMetadata, ...]
component_types: tuple[str, ...]
evaluation_schedule: dict | None = None
jacobian_structure: dict | None = None
def manifest(self) -> dict:
return {
"abiVersion": 1, "stateKeys": self.state_keys,
"variables": [v.as_dict() for v in self.variables],
"componentTypes": self.component_types,
"componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types},
"jacobianPolicy": (self.jacobian_structure or {}).get("policy", "CVODE default; no custom Jacobian"),
"jacobianStructure": self.jacobian_structure,
"evaluationSchedule": self.evaluation_schedule or {"strategy": "storage-anchored", "cyclicBlockCount": 0},
}
class _Groups:
def __init__(self, items):
self.parent = {x: x for x in items}
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, a, b):
self.parent[self.find(b)] = self.find(a)
def _number(value):
value = float(value)
if not isfinite(value):
raise NativeCapabilityError("Native constants must be finite.")
return repr(value)
def compile_native_program(network: SimulationNetwork) -> NativeProgram:
network.validate_port_supplies()
from .extended import catalog_contracts
contracts = catalog_contracts()
from app.simulation.components.amesim.semantics import validate_numerical_semantics
for component in network.components.values():
if type(component) is not contracts.get(component.model_type):
raise NativeCapabilityError(f'{component.name}: no native contract for {component.model_type}')
if component.MODEL_VERSION != SUPPORTED_VERSIONS.get(component.model_type):
raise NativeCapabilityError(f'{component.name}: native kernel version does not match the component contract')
try:
validate_numerical_semantics(component)
except ValueError as exc:
raise NativeCapabilityError(str(exc)) from exc
# Preserve the compact, verified schedule for its supported topology.
# Both paths emit C; this never falls back to Python numerical execution.
try:
return _compile_storage_anchored_program(network)
except NativeCapabilityError:
from .extended import compile_extended_program
return compile_extended_program(network)
def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgram:
components = list(network.components.values())
if not components:
raise NativeCapabilityError("Native simulation requires a dynamic model.")
for c in components:
if c.model_type not in _STORAGE_ANCHORED_TYPES:
raise NativeCapabilityError(f"{c.name}: unsupported native component {c.model_type}.")
if c.MODEL_VERSION != SUPPORTED_VERSIONS[c.model_type]:
raise NativeCapabilityError(f"{c.name}: native kernel version does not match the component contract.")
if c.model_type == "amesim_pnch023" and c.kth*c.sth != 0:
raise NativeCapabilityError(f"{c.name}: native v1 supports adiabatic PNCH023 only.")
medium = getattr(c, "medium", None)
if medium is not None and (
getattr(medium, "SUBSTANCE_ID", None),
getattr(medium, "PROPERTY_METHOD_ID", None),
) != ("helium", "peng_robinson"):
raise NativeCapabilityError(f"{c.name}: native v1 requires helium / Peng-Robinson.")
if c.model_type == "amesim_mecmas21":
if int(c.stoptype) not in (1, 4) or any(
getattr(c, key) != 0 for key in ("fcoul", "fstick", "rvisc", "wind", "theta")
):
raise NativeCapabilityError(f"{c.name}: native v1 supports friction-free masses with stoptype 1 or 4.")
if c.model_type == "amesim_lstp00a" and int(c.stiffmode) != 1:
raise NativeCapabilityError(f"{c.name}: native v1 requires explicit contact stiffness.")
ports = {
(c.name, p.name): p for c in components for p in c.active_port_definitions
}
adjacent = {}
groups = _Groups(ports)
for edge in network.connections:
a, b = (p.key for p in edge.endpoints)
# Signal fan-out is allowed; physical ports have one external connection.
if edge.kind == "physical":
if a in adjacent or b in adjacent:
raise NativeCapabilityError("Native physical ports require one connection each.")
adjacent[a], adjacent[b] = b, a
groups.union(a, b)
else:
source, target = (a, b) if ports[a].nominal_role == "output" else (b, a)
adjacent[target] = source
for c in components:
for name in c.required_connection_ports:
if (c.name, name) not in adjacent:
raise NativeCapabilityError(f"{c.name}.{name}: unconnected physical port.")
names = [p.name for p in c.active_port_definitions if p.domain == "pneumatic"]
if c.model_type in ("amesim_pnch023", "amesim_pnch012"):
for name in names[1:]:
groups.union((c.name, names[0]), (c.name, name))
if c.model_type == "amesim_mecmas21":
groups.union((c.name, "port_1"), (c.name, "port_2"))
if c.model_type == "amesim_pnrp17":
groups.union((c.name, "port_2"), (c.name, "port_5"))
groups.union((c.name, "port_3"), (c.name, "port_4"))
chambers = [c for c in components if c.model_type in ("amesim_pnch023", "amesim_pnch012")]
masses = [c for c in components if c.model_type == "amesim_mecmas21"]
chamber_by_group, mass_by_group = {}, {}
for items, mapping in ((chambers, chamber_by_group), (masses, mass_by_group)):
for c in items:
root = groups.find((c.name, "port_1"))
if root in mapping:
raise NativeCapabilityError(f"{c.name}: coupled storage/mass reduction is outside native v1.")
mapping[root] = c
for ep, port in ports.items():
mapping = chamber_by_group if port.domain == "pneumatic" else mass_by_group
if port.kind == "physical" and groups.find(ep) not in mapping:
raise NativeCapabilityError(f"{ep}: no unique storage/mass anchor; native v1 cannot close this block.")
variables = tuple(v for c in components for v in c.result_variable_metadata())
slots = {v.key: i for i, v in enumerate(variables)}
assigned = set()
lines, init, declarations = [], [], []
state_keys, state_index = [], {}
for c in components:
fields = ("m", "U") if c in chambers else (("v", "x") if c in masses else ())
if c.model_type == "amesim_pnch012" and sum(c.external_volume_rates.values()) != 0:
fields += ("_volume_displacement",)
for field in fields:
key = f"{c.name}.{field}"
state_index[key] = len(state_keys)
state_keys.append(key)
if not state_keys:
raise NativeCapabilityError("Native v1 requires continuous states.")
if len(state_keys) > 256 or len(variables) > 8192:
raise NativeCapabilityError("Native v1 supports at most 256 states and 8192 outputs per model.")
def w(key):
return f"w[{slots[key]}]"
def key(c, field):
return f"{c.name}.{field}"
def get(c, field):
return w(key(c, field))
def put(c, field, expression):
k = key(c, field)
lines.append(f"{w(k)} = {expression};")
assigned.add(k)
def si(c, field):
return state_index[key(c, field)]
def mass_at(c, port):
return mass_by_group[groups.find((c.name, port))]
def chamber_at(c, port):
return chamber_by_group[groups.find((c.name, port))]
for c in masses:
init.extend([f"y[{si(c, 'v')}] = {_number(c.v0)};", f"y[{si(c, 'x')}] = {_number(c.x0)};"])
for field in ("v", "x"):
put(c, field, f"y[{si(c, field)}]")
for (cid, pname), port in ports.items():
if port.domain == "mechanical":
m = mass_by_group[groups.find((cid, pname))]
for field in ("v", "x"):
k = f"{cid}.{pname}.{field}"
lines.append(f"{w(k)} = y[{si(m, field)}];")
assigned.add(k)
signal_specs = []
for c in components:
if c.model_type == "amesim_step0":
put(c, "y", f"t < {_number(c.time)} ? {_number(c.initial)} : {_number(c.final)}")
signal_specs.append(("step", c))
elif c.model_type == "amesim_ud00":
index = len(signal_specs)
declarations.append(f"static const double signal_{index}[24] = {{" + ",".join(
_number(v) for values in (c.starts, c.ends, c.durations) for v in values
) + "};")
put(c, "y", f"native_signal(t, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index})")
signal_specs.append((index, c))
else:
continue
put(c, "out.signal", get(c, "y"))
for c in components:
for port in c.active_port_definitions:
if port.kind == "signal" and port.nominal_role == "input":
ep = (c.name, port.name)
if ep not in adjacent:
# PNVO's explicit unconnected opening is a supported default.
if c.model_type == "amesim_pnvo001":
put(c, port.name + ".signal", _number(c.opening0))
continue
raise NativeCapabilityError(f"{ep}: missing signal input.")
source = adjacent[ep]
source_key = f"{source[0]}.{source[1]}.signal"
if source_key not in assigned:
raise NativeCapabilityError(f"{ep}: unsupported signal dependency.")
put(c, port.name + ".signal", w(source_key))
pistons = [c for c in components if c.model_type == "amesim_pnrp17"]
for c in pistons:
put(c, "length", f"{_number(c.x0)} + {get(c, 'port_5.x')} - {get(c, 'port_4.x')}")
put(c, "volume", f"{_number(c.effective_area)} * {get(c, 'length')}")
put(c, "volume_flow", f"{_number(c.effective_area)} * ({get(c, 'port_5.v')} - {get(c, 'port_4.v')})")
if chamber_at(c, "port_1").model_type != "amesim_pnch012":
raise NativeCapabilityError(f"{c.name}: moving volume requires PNCH012.")
volume_expressions = {}
for gi, c in enumerate(chambers):
connected = [p for p in pistons if chamber_at(p, "port_1") is c]
base = c.cvol if c.model_type == "amesim_pnch023" else c.cvol0 + sum(c.external_volumes.values())
volume = _number(base)
rate = "0.0"
if c.model_type == "amesim_pnch012":
volume += "".join(" + " + get(p, "volume") for p in connected)
if key(c, '_volume_displacement') in state_index:
index = si(c, '_volume_displacement')
init.append(f"y[{index}] = 0.0;")
lines.append(f"dy[{index}] = {_number(sum(c.external_volume_rates.values()))};")
volume += f" + y[{index}]"
put(c, "vol", f"fmax({_number(c.cvol0 / 100)}, {volume})")
rate = _number(sum(c.external_volume_rates.values())) + "".join(" + " + get(p, "volume_flow") for p in connected)
limit = _number(c.cvol0 / 100)
put(c, "dvol", f"({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})")
volume, rate = get(c, "vol"), get(c, "dvol")
volume_expressions[c.name] = (volume, rate)
# p0/T0 refer to the complete connected volume at the initial positions.
initial_volume = base
if c.model_type == "amesim_pnch012":
initial_volume += sum(p.effective_area * (p.x0 + mass_at(p, "port_5").x0 - mass_at(p, "port_4").x0) for p in connected)
initial_volume = max(initial_volume, c.cvol0 / 100)
init.append(f"if (!native_gas_init({_number(c.p0)}, {_number(c.T0)}, {_number(initial_volume)}, &y[{si(c, 'm')}])) return 0;")
lines.append(f"NativeGas gas_{gi};")
lines.append(f"if (!native_gas_context(properties, y[{si(c, 'm')}], y[{si(c, 'U')}], {volume}, &gas_{gi})) return 0;")
for field in ("m", "U"):
put(c, field, f"y[{si(c, field)}]")
for field in ("p", "T", "rho", "u", "h"):
put(c, field, f"gas_{gi}.{field}")
for (cid, pname), port in ports.items():
if port.domain == "pneumatic":
c = chamber_by_group[groups.find((cid, pname))]
for field, expr in (("p", get(c, "p")), ("h_outflow", get(c, "h")), ("m_flow", "0.0")):
k = f"{cid}.{pname}.{field}"
lines.append(f"{w(k)} = {expr};")
assigned.add(k)
for c in components:
if c.model_type != "amesim_pnvo001":
continue
a, b = chamber_at(c, "port_2"), chamber_at(c, "port_3")
put(c, "xv", f"fmax(0.0, fmin(1.0, {get(c, 'res.signal')}))")
lines.append(f"if (!native_orifice_context(properties, {get(a, 'p')}, {get(b, 'p')}, {get(a, 'h')}, {get(b, 'h')}, {_number(c.effective_cq * c.maximum_area)}, {get(c, 'xv')}, &{get(c, 'port_2.m_flow')}, &{get(c, 'cm')}, &{get(c, 'gasvel')})) return 0;")
assigned.update((key(c, "cm"), key(c, "gasvel")))
put(c, "port_3.m_flow", f"-{get(c, 'port_2.m_flow')}")
put(c, "port_2.h_outflow", get(b, "h"))
put(c, "port_3.h_outflow", get(a, "h"))
for pname in ("port_2", "port_3"):
other = adjacent[c.name, pname]
# Resistance-to-resistance streams need the extended pressure/enthalpy closure.
if network.components[other[0]] not in chambers:
raise NativeCapabilityError(f"{c.name}: native v1 requires valve ports directly connected to storage.")
lines.append(f"{w(f'{other[0]}.{other[1]}.m_flow')} = -{get(c, pname + '.m_flow')};")
force_known = set()
def force(c, port, expr):
put(c, port + ".f", expr)
force_known.add((c.name, port))
equations = []
for c in components:
if c.model_type == "amesim_forc":
put(c, "force", f"{_number(c.direction)} * {get(c, 'res.signal')}")
force(c, "port_2", f"-{get(c, 'force')}")
elif c.model_type == "amesim_f000":
force(c, "port_1", "0.0")
elif c.model_type == "amesim_lstp00a":
put(c, "gap", f"{_number(c.gap0)} + ({get(c, 'port_2.x')} - {get(c, 'port_1.x')})")
put(c, "penetration", f"fmax(-{get(c, 'gap')}, 0.0)")
put(c, "force", f"native_contact({get(c, 'penetration')}, {get(c, 'port_1.v')} - {get(c, 'port_2.v')}, {_number(c.kcont)}, {_number(c.rcont)}, {_number(c.Pdis)}, {int(c.discContactOption)})")
force(c, "port_1", get(c, "force"))
force(c, "port_2", f"-{get(c, 'force')}")
elif c.model_type == "amesim_pnrp17":
put(c, "pressure_force", f"({get(c, 'port_1.p')} - 101300.0) * {_number(c.effective_area)}")
equations.extend([
((c.name, "port_2"), (c.name, "port_5"), f"-{get(c, 'pressure_force')}"),
((c.name, "port_3"), (c.name, "port_4"), get(c, "pressure_force")),
])
for edge in network.connections:
if edge.domain == "mechanical":
equations.append((*[p.key for p in edge.endpoints], "0.0"))
pending = equations
while pending:
remaining = []
for a, b, total in pending:
if a in force_known and b in force_known:
raise NativeCapabilityError("Overconstrained native force balance.")
if a not in force_known and b not in force_known:
remaining.append((a, b, total))
continue
target, source = (b, a) if a in force_known else (a, b)
c = network.components[target[0]]
force(c, target[1], f"({total}) - {w(f'{source[0]}.{source[1]}.f')}")
if len(remaining) == len(pending):
raise NativeCapabilityError("Native v1 cannot resolve this mechanical force loop.")
pending = remaining
for c in masses:
expression = f"({get(c, 'port_1.f')} + {get(c, 'port_2.f')}) / {_number(c.mass)}"
put(c, "a", expression)
lines.append(f"dy[{si(c, 'v')}] = {get(c, 'a')}; dy[{si(c, 'x')}] = {get(c, 'v')};")
if int(c.stoptype) == 1:
lines.append(f"native_stop_motion({get(c, 'x')}, {get(c, 'v')}, {_number(c.xmin)}, {_number(c.xmax)}, &dy[{si(c, 'v')}], &dy[{si(c, 'x')}]);")
put(c, "a", f"dy[{si(c, 'v')}]")
for field in ("Fvisc", "Ffric", "Fmin", "Fmax"):
put(c, field, "0.0")
for c in chambers:
mass_terms, energy_terms = [], []
for port in c.active_port_definitions:
q = get(c, port.name + ".m_flow")
other = adjacent[c.name, port.name]
inlet_h = w(f"{other[0]}.{other[1]}.h_outflow")
mass_terms.append(q)
energy_terms.append(f"{q} * ({q} > 0.0 ? {inlet_h} : {get(c, 'h')})")
volume, rate = volume_expressions[c.name]
energy_terms.extend([f"{_number(c.kth*c.sth)} * ({_number(c.extemp)} - {get(c, 'T')})", f"-{get(c, 'p')} * ({rate})"])
lines.extend([f"dy[{si(c, 'm')}] = " + " + ".join(mass_terms) + ";", f"dy[{si(c, 'U')}] = " + " + ".join(energy_terms) + ";"])
missing = set(slots) - assigned
if missing:
raise NativeCapabilityError(f"Native output coverage is incomplete: {sorted(missing)}")
stops = [(si(c, "v"), c.xmin, c.xmax) for c in masses if int(c.stoptype) == 1]
from .contacts import contact_table
contact_source, contact_header = contact_table([
(si(mass_at(c, 'port_1'), 'v'), si(mass_at(c, 'port_2'), 'v'),
c.gap0, c.kcont, c.rcont, c.Pdis, int(c.discContactOption), 1)
for c in components if c.model_type == 'amesim_lstp00a'])
stop_c = ",".join(f"{{{v},{_number(lo)},{_number(hi)},0,0,0,0}}" for v, lo, hi in stops) or "{0,0,0,0,0,0,0}"
next_event = []
for index, c in signal_specs:
if index == "step":
next_event.append(f"if (t < {_number(c.time)}) result = fmin(result, {_number(c.time)});")
else:
next_event.append(f"result = fmin(result, native_signal_break(t, end, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index}));")
jacobian = JacobianStructure.dense(len(state_keys), 'Compact storage-anchored lowering retains default differences')
source = '\n'.join([
'#include "model.h"', '#include <math.h>', *declarations,
f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};",
contact_source,
f"const double model_atol[NSTATES] = {{{','.join(map(state_absolute_tolerance, state_keys))}}};",
"const char *const model_output_keys[NOUTPUTS] = {" + ",".join(json.dumps(v.key, ensure_ascii=True) for v in variables) + "};",
"int model_init(double *y) {", *init, "return 1; }",
"static int model_eval_internal(double t, const double *y, double *dy, double *w, NativePropertyTemperatures *temperatures) {", "(void)t;",
f"NativePropertyState property_states[{min(256,max(16,4*len(components)))}];",
"NativePropertyCache property_cache, *properties=&property_cache;",
"native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));",
"properties->temperatures=temperatures;", *lines,
"for (int i=0;i<NSTATES;i++) if (!isfinite(dy[i])) return 0;",
"for (int i=0;i<NOUTPUTS;i++) if (!isfinite(w[i])) return 0;",
"return 1; }",
"int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,NULL);}",
"int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,temperatures);}",
"double model_next_break(double t, double end) { (void)t; double result=end;", *next_event,
"return result; }", "",
])
header = f'''#ifndef GENERATED_NATIVE_MODEL_H
#define GENERATED_NATIVE_MODEL_H
#include "kernels.h"
#define MODEL_PROPERTY_TEMPERATURES 1
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
#define NSTATES {len(state_keys)}
#define NOUTPUTS {len(variables)}
#define NSTOPS {len(stops)}
{contact_header}
extern const NativeStop model_stops[{max(1,len(stops))}];
extern const double model_atol[NSTATES];
extern const char *const model_output_keys[NOUTPUTS];
{chr(10).join(jacobian.header_lines())}
int model_init(double *y);
int model_eval(double t, const double *y, double *dy, double *w);
double model_next_break(double t, double end);
#endif
'''
return NativeProgram(source, header, tuple(state_keys), variables, tuple(sorted({c.model_type for c in components})),jacobian_structure=jacobian.manifest())
+24
View File
@@ -0,0 +1,24 @@
"""Emit state-only LSTP event descriptors for both native lowering paths."""
from .compiler import _number
def contact_table(contacts):
"""Rows: velocity indices, gap, stiffness, damping, depth, force option,
subtract-first flag. Keep the force expression's floating operation order.
Position follows velocity in each reviewed mechanical state pair.
"""
rows = []
for v1, v2, gap, stiffness, damping, depth, option, subtract_first in contacts:
rows.append('{' + ','.join((str(v1), str(v2), _number(gap),
_number(stiffness), _number(damping), _number(depth),
str(option), str(subtract_first))) + '}')
count = len(rows)
header = f'''#define NCONTACTS {count}
typedef struct {{
int velocity1, velocity2;
double gap0, stiffness, damping, depth;
int signed_force, subtract_first;
}} NativeContact;
extern const NativeContact model_contacts[{max(1, count)}];'''
source = f'const NativeContact model_contacts[{max(1, count)}] = {{' + (','.join(rows) or '{0,0,0,0,0,0,0,0}') + '};'
return source, header
@@ -0,0 +1,32 @@
"""Reviewed model contract versions implemented by the C kernels.
A new registration/version must be explicitly ported and tested; it does not
gain native support just by inheriting a supported Python component class.
"""
SUPPORTED_VERSIONS = {
'amesim_ideal_air_medium': '0.2.0',
'amesim_helium_medium': '0.1.0',
'amesim_pnpl01': '0.1.0',
'amesim_step0': '0.1.0',
'amesim_ud00': '0.2.0',
'amesim_f000': '0.1.0',
'amesim_forc': '0.2.0',
'amesim_mecmas21': '0.2.0',
'amesim_lstp00a': '0.2.0',
'amesim_lmechn1': '0.2.0',
'amesim_pnrp17': '0.1.0',
'amesim_pnch023': '0.1.0',
'amesim_pnch012': '0.1.0',
'amesim_pnor001': '0.3.0',
'amesim_pnvo001_fixed': '0.2.0',
'amesim_pnvo001': '0.2.0',
'amesim_pnl00r': '0.3.0',
'amesim_pnl0001': '0.4.0',
'amesim_pnl0002': '0.6.0',
'amesim_pnl0003': '0.4.0',
'amesim_pn3node2': '0.3.0',
'amesim_p4node2': '0.3.0',
'cylinder': '1.0.0', 'tank': '1.0.0', 'pipe': '1.0.0',
'orifice': '1.0.0', 'tee': '1.0.0',
}
SUPPORTED_TYPES = frozenset(SUPPORTED_VERSIONS)
+796
View File
@@ -0,0 +1,796 @@
"""Static C lowering for the complete built-in component catalog.
Python constructs the graph and eliminates constant linear constraints once.
All thermodynamics, flow/stream closure and derivatives execute in the EXE.
"""
from __future__ import annotations
import json
import math
import re
from importlib import import_module
from graphlib import TopologicalSorter
from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num
from .contracts import SUPPORTED_VERSIONS
from .schedule import Computation, EvaluationSchedule, references
from .tolerances import state_absolute_tolerance
from .jacobian import StateDependencies, expression_inputs
from app.simulation.components.amesim.semantics import contact_stiffness, validate_numerical_semantics
GAS_TYPES = {'amesim_pnch023', 'amesim_pnch012', 'amesim_pnl0001',
'amesim_pnl0002', 'amesim_pnl0003', 'cylinder', 'tank'}
NODES = {'amesim_pn3node2', 'amesim_p4node2', 'tee'}
RESISTORS = {'amesim_pnor001', 'amesim_pnvo001_fixed', 'amesim_pnvo001',
'amesim_pnl00r', 'pipe', 'orifice'}
def is_polytropic(c):
return c.model_type in {'amesim_pnl0001', 'amesim_pnl0002', 'amesim_pnl0003'} and int(c.mode) == 1
def catalog_contracts():
from app.simulation.components.amesim.library import LIBRARY as a
from app.simulation.components.experimental.library import LIBRARY as e
result = {}
for entry in (*a.models, *e.models):
module, name = entry.split(':')
cls = getattr(import_module(module), name)
result[cls.MODEL_TYPE] = cls
return result
def linear_assignments(equations, unknowns, free_flows=()):
"""Eliminate constant coefficients; retain RHS expressions as C temporaries.
Exact pivot elimination avoids a numeric pseudoinverse and its tiny spurious
dependencies. Redundant rows are left to the nodal pressure closure.
"""
rows = [[dict(a), {i: 1.0}] for i, (a, _) in enumerate(equations)]
pivots = []
for key in unknowns:
found = next((i for i in range(len(pivots), len(rows)) if abs(rows[i][0].get(key, 0)) > 1e-14), None)
if found is None and key in free_flows:
# Ideal coupled pipe compliances have a redundant internal flow.
# Their total m/U derivative is distributed by physical volume.
index = len(equations)
equations.append(({key: 1.0}, '0.0'))
rows.append([{key: 1.0}, {index: 1.0}])
found = len(rows)-1
if found is None:
raise NativeCapabilityError(f'Underdetermined native connection constraint: {key}')
j = len(pivots)
rows[j], rows[found] = rows[found], rows[j]
a, b = rows[j]
pivot = a[key]
rows[j] = [{k: v/pivot for k, v in a.items()}, {k: v/pivot for k, v in b.items()}]
for i, (a, b) in enumerate(rows):
if i == j:
continue
scale = a.get(key, 0)
if not scale:
continue
for target, source in zip((a, b), rows[j]):
for k, v in source.items():
value = target.get(k, 0) - scale*v
if abs(value) < 1e-14:
target.pop(k, None)
else:
target[k] = value
pivots.append(key)
lines = [(f'b{i}', expr) for i, (_, expr) in enumerate(equations)
if any(i in rows[j][1] for j in range(len(pivots)))]
for j, key in enumerate(pivots):
expr = ' + '.join(f'({num(v)})*b{i}' for i, v in rows[j][1].items()) or '0.0'
lines.append((key, expr))
return lines
def linear_schedule(equations, unknowns, free_flows=()):
return [f'{"double " if key.startswith("b") else ""}{key} = {expr};'
for key, expr in linear_assignments(equations, unknowns, free_flows)]
def compile_extended_program(network):
network.validate_port_supplies()
components = list(network.components.values())
contracts = catalog_contracts()
for c in components:
if type(c) is not contracts.get(c.model_type):
raise NativeCapabilityError(f'{c.name}: no native contract for {c.model_type} / {type(c).__name__}')
if c.MODEL_VERSION != SUPPORTED_VERSIONS.get(c.model_type):
raise NativeCapabilityError(f'{c.name}: native kernel version does not match the component contract')
try:
validate_numerical_semantics(c)
except ValueError as exc:
raise NativeCapabilityError(str(exc)) from exc
variables = tuple(v for c in components for v in c.result_variable_metadata())
if not variables:
raise NativeCapabilityError('Simulation requires a runtime component; medium definitions alone have no outputs')
slots = {v.key: i for i, v in enumerate(variables)}
ports = {(c.name, p.name): p for c in components for p in c.active_port_definitions}
groups = _Groups(ports)
adjacent = {}
for edge in network.connections:
a, b = (p.key for p in edge.endpoints)
if edge.kind == 'physical':
adjacent[a], adjacent[b] = b, a
groups.union(a, b)
else:
source, target = (a, b) if ports[a].nominal_role == 'output' else (b, a)
adjacent[target] = source
for c in components:
for port in c.required_connection_ports:
if (c.name, port) not in adjacent:
raise NativeCapabilityError(f'{c.name}.{port}: unconnected required port')
names = [p.name for p in c.active_port_definitions]
if c.model_type in NODES | {'amesim_pnch023', 'amesim_pnch012', 'amesim_mecmas21', 'amesim_lmechn1'} or (c.model_type=='pipe' and c.lambda_darcy==0):
for p in names[1:]:
groups.union((c.name, names[0]), (c.name, p))
if c.model_type == 'amesim_pnrp17':
for a, b in [('port_2', 'port_5'), ('port_3', 'port_4')]:
groups.union((c.name, a), (c.name, b))
state_keys, initial, gas_initializers = [], [], []
states = {}
def add_states(c, fields, values):
for field, value in zip(fields, values):
states[c.name, field] = len(state_keys)
state_keys.append(f'{c.name}.{field}')
initial.append(float(value))
mass_groups = {}
for c in components:
if c.model_type in GAS_TYPES:
thermal = 'T' if is_polytropic(c) else 'U'
fields = ('m1', thermal+'1', 'm2', thermal+'2') if c.model_type == 'amesim_pnl0003' else ('m', thermal)
add_states(c, fields, [0.0]*len(fields))
if c.model_type == 'amesim_pnch012' and sum(c.external_volume_rates.values()) != 0:
add_states(c, ('_volume_displacement',), (0.0,))
elif c.model_type == 'amesim_mecmas21':
root = groups.find((c.name, 'port_1'))
mass_groups.setdefault(root, []).append(c)
if len(mass_groups[root]) == 1:
add_states(c, ('v', 'x'), (c.v0, c.x0))
else:
ref = mass_groups[root][0]
if abs(c.v0-ref.v0)>1e-10*max(abs(c.v0),1) or abs(c.x0-ref.x0)>1e-10*max(abs(c.x0),1):
raise NativeCapabilityError('Rigidly connected masses require consistent initial x/v')
for field in ('v', 'x'):
states[c.name, field] = states[ref.name, field]
friction_groups = []
for group in mass_groups.values():
dry = [c for c in group if c.use_friction and int(c.stoptype) != 3 and c.fstick > 0]
if dry:
ref = group[0]
threshold = min(c.dvel if int(c.frictionType) == 2 else 0 for c in dry)
mode = (1 if ref.v0 > 0 else -1) if abs(ref.v0) > threshold else 0
if mode == 0:
initial[states[ref.name, 'v']] = 0.0
add_states(ref, ('_friction_mode',), (mode,))
friction_groups.append((group, dry, threshold))
if len(state_keys)>1024 or len(variables)>16384:
raise NativeCapabilityError('Native model exceeds the 1024-state / 16384-output resource limit')
# Algebraic models use an internal constant state; the public state map stays empty.
nstates = max(len(state_keys), 1)
if not initial:
initial = [0.0]
dependencies = StateDependencies(len(state_keys))
lines, declarations, breaks = [], [], []
assigned = set()
def w(c, field):
name = c if isinstance(c, str) else c.name
return f'w[{slots[name+"."+field]}]'
def put(c, field, expr, dest=None):
(lines if dest is None else dest).append(f'{w(c,field)} = {expr};')
dependencies.expression(w(c,field), expr)
assigned.add((c if isinstance(c,str) else c.name)+'.'+field)
def y(c, field):
return f'y[{states[c.name,field]}]'
def ep(c, port):
return c.name, port
def pnames(c):
return [p.name for p in c.active_port_definitions if p.domain == 'pneumatic']
def mnames(c):
return [p.name for p in c.active_port_definitions if p.domain == 'mechanical']
for c in components:
if c.model_type == 'amesim_mecmas21':
for f in ('v', 'x'):
put(c, f, y(c, f))
for name in mnames(c):
root = groups.find(ep(c, name))
if root not in mass_groups:
raise NativeCapabilityError(f'{c.name}.{name}: mechanical group has no inertia anchor')
mass = mass_groups[root][0]
for f in ('v', 'x'):
put(c, name+'.'+f, y(mass, f))
if c.model_type == 'amesim_step0':
put(c, 'y', f't < {num(c.time)} ? {num(c.initial)} : {num(c.final)}')
breaks.append(f'if(t < {num(c.time)}) result=fmin(result,{num(c.time)});')
elif c.model_type == 'amesim_ud00':
ident = 'signal_'+str(len(declarations))
declarations.append(f'static const double {ident}[24] = {{'+','.join(num(v) for vs in (c.starts,c.ends,c.durations) for v in vs)+'};')
args=f'{num(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, {ident}'
put(c, 'y', f'native_signal(t, {args})')
breaks.append(f'result=fmin(result,native_signal_break(t,end,{args}));')
else:
continue
put(c, 'out.signal', w(c, 'y'))
for c in components:
for port in c.active_port_definitions:
if port.kind == 'signal' and port.nominal_role == 'input':
target = adjacent.get(ep(c, port.name))
if target:
expr = w(target[0], target[1]+'.signal')
elif c.model_type == 'amesim_pnvo001':
expr = num(c.opening0)
else:
raise NativeCapabilityError(f'{c.name}.{port.name}: signal input missing')
put(c, port.name+'.signal', expr)
media = {}
def medium(c):
m = c.medium
key = (getattr(m, 'SUBSTANCE_ID', None), getattr(m, 'PROPERTY_METHOD_ID', None),
m.R_gas, m.cp_ref, m.T_ref, m.cp_slope, m.viscosity_ref, m.viscosity_T_ref, m.sutherland_constant)
if key not in media:
if key[1] not in (None, 'ideal_gas', 'peng_robinson') or (key[1]=='peng_robinson' and key[0]!='helium'):
raise NativeCapabilityError(f'{c.name}: unsupported medium contract {key[:2]}')
ident = f'medium_{len(media)}'
declarations.append(f'static const NativeMedium {ident} = {{'+str(int(key[1]=='peng_robinson'))+','+','.join(num(v) for v in key[2:])+'};')
media[key]=ident
return '&'+media[key]
pistons = [c for c in components if c.model_type == 'amesim_pnrp17']
for c in pistons:
put(c, 'length', f'{num(c.x0)}+{w(c,"port_5.x")}-{w(c,"port_4.x")}')
put(c, 'volume', f'{num(c.effective_area)}*{w(c,"length")}')
put(c, 'volume_flow', f'{num(c.effective_area)}*({w(c,"port_5.v")}-{w(c,"port_4.v")})')
pneu = [endpoint for endpoint, port in ports.items() if port.domain == 'pneumatic']
pi = {endpoint: i for i, endpoint in enumerate(pneu)}
pgroups = list(dict.fromkeys(groups.find(endpoint) for endpoint in pneu))
pgi = {root: i for i, root in enumerate(pgroups)}
def p(c, name): return f'p[{pgi[groups.find(ep(c,name))]}]'
def q(c, name): return f'q[{pi[ep(c,name)]}]'
def h(c, name): return f'h[{pi[ep(c,name)]}]'
def hin(c, name, temperature=False):
other = adjacent[ep(c,name)]
obj = network.components[other[0]]
if temperature and obj.model_type in NODES-{'tee'}:
other = adjacent[(obj.name, 'port_2')]
return f'h[{pi[other]}]'
# Reference nodes aggregate branch quantities towards port 2. Follow this
# directed contract, not the undirected pressure group (which can span
# unrelated volumes). Port-supply validation has already rejected cycles.
reference_nodes = {c.name: c for c in components if c.model_type in NODES-{'tee'}}
node_graph = {c.name: [other[0] for name in pnames(c) if name != 'port_2'
for other in [adjacent[ep(c, name)]]
if other[0] in reference_nodes and other[1] == 'port_2']
for c in reference_nodes.values()}
node_order = [reference_nodes[name] for name in TopologicalSorter(node_graph).static_order()]
volume_sources = {ep(c, 'port_1'): [c] for c in pistons}
node_energy = {ep(c, 'port_2'): f'node_energy[{i}]' for i, c in enumerate(node_order)}
for c in node_order:
volume_sources[ep(c, 'port_2')] = [source for name in pnames(c) if name != 'port_2'
for source in volume_sources.get(adjacent[ep(c, name)], ())]
def energy_into(c, name, outflow_h):
other = adjacent[ep(c, name)]
# Net mass flow can be zero while the sum of branch enthalpy flows
# remains nonzero. Carry that signed energy directly, without H/q.
if other in node_energy:
return node_energy[other]
return f'{q(c,name)}*({q(c,name)}>0?{hin(c,name)}:{outflow_h})'
gases, anchor, port_gas, volume_rate = {}, {}, {}, {}
anchor_partitions = {}
gas_count = 0
for c in components:
if c.model_type not in GAS_TYPES:
continue
kind = c.model_type
if kind == 'amesim_pnch023':
volume, rate = num(c.cvol), '0.0'
elif kind == 'amesim_pnch012':
attached = [source for name in pnames(c)
for source in volume_sources.get(adjacent[ep(c, name)], ())]
volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached)
if (c.name, '_volume_displacement') in states:
volume += '+'+y(c, '_volume_displacement')
target = f'dy[{states[c.name,"_volume_displacement"]}]'
expr = num(sum(c.external_volume_rates.values()))
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
put(c, 'vol', f'fmax({num(c.cvol0/100)}, {volume})')
rate = num(sum(c.external_volume_rates.values()))+''.join('+'+w(d,'volume_flow') for d in attached)
limit = num(c.cvol0/100)
put(c, 'dvol', f'({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})')
volume, rate = w(c,'vol'), w(c,'dvol')
elif kind in ('cylinder', 'tank'):
volume, rate = num(c.V), '0.0'
else:
volume, rate = num(c.volume), '0.0'
volume_rate[c.name] = rate
halves = (1,2) if kind == 'amesim_pnl0003' else (0,)
for half in halves:
suffix = str(half) if half else ''
gas = f'g[{gas_count}]';gas_count += 1
gases[c.name,half] = gas
V = num(c.compliance_volume) if half else volume
if half:
p0,T0=c.parameter_values[f'p{half}_0'],c.parameter_values[f'T{half}_0']
initial_volume=c.compliance_volume
else:
p0,T0=c.parameter_values['p0'],c.parameter_values['T0']
initial_volume=(max(c.cvol0+sum(c.external_volumes.values()),c.cvol0/100)
if kind=='amesim_pnch012' else
c.cvol if kind=='amesim_pnch023' else c.V if kind in ('cylinder','tank') else c.volume)
if kind == 'amesim_pnch012':
def initial_x(d, port):
return mass_groups[groups.find(ep(d, port))][0].x0
initial_volume = max(c.cvol0 + sum(c.external_volumes.values()) + sum(
d.effective_area * (d.x0 + initial_x(d, 'port_5') - initial_x(d, 'port_4'))
for d in attached), c.cvol0 / 100)
thermal = ('T' if is_polytropic(c) else 'U') + suffix
if is_polytropic(c):
gas_initializers += [f'{y(c,"m"+suffix)}={num(p0*initial_volume/(c.medium.R_gas*T0))};', f'{y(c,thermal)}={num(T0)};']
gas_function = 'native_polytropic_gas_context'
else:
gas_initializers.append(f'if(!native_medium_init({medium(c)},{num(p0)},{num(T0)},{num(initial_volume)},{int(kind in ("cylinder","tank"))},&y[{states[c.name,"m"+suffix]}])) return 0;')
gas_function = 'native_medium_gas_context'
gas_arguments = f'{medium(c)}, {y(c,"m"+suffix)}, {y(c,thermal)}, {V}, &{gas}'
lines.append(f'if(!(jacobian ? native_jacobian_gas(&jacobian->gases[{gas_count-1}],{gas_function},{gas_arguments},&jacobian->stats) : '
f'{gas_function}(gas_properties,{gas_arguments}))) return 0;')
gas_inputs = expression_inputs(f'{y(c,"m"+suffix)}+{y(c,thermal)}+({V})')
for field in ('p','T','rho','u','h'):
dependencies.assign(gas+'.'+field, gas_inputs)
put(c, 'm'+suffix, y(c, 'm'+suffix))
put(c, 'U'+suffix, f'{y(c,"m"+suffix)}*{gas}.u' if is_polytropic(c) else y(c,'U'+suffix))
for field in ('p','T','rho','u','h'):
put(c, field+suffix, gas+'.'+field)
anchored = pnames(c)
if kind == 'amesim_pnl0001': anchored=['port_2']
if kind == 'amesim_pnl0002': anchored=[]
if half: anchored=['port_'+suffix]
for name in anchored:
root=groups.find(ep(c,name))
anchor[root]=gas
partition=(c.name,half)
anchor_partitions.setdefault(root, {})[partition]=(c, float(c.compliance_volume if half else c.volume) if kind in ('amesim_pnl0001','amesim_pnl0003') else None)
for name in (['port_'+suffix] if half else pnames(c)):
port_gas[ep(c,name)]=gas
coupled=[]
project=[]
for root,partitions in anchor_partitions.items():
if len(partitions)<2: continue
if any(volume is None for _,volume in partitions.values()) or len({id(c.medium) for c,_ in partitions.values()})!=1:
raise NativeCapabilityError('Direct gas-storage coupling requires compatible fixed pipe compliances; insert a resistance between independent chambers')
laws = {(is_polytropic(c), c.k if is_polytropic(c) else None) for c,_ in partitions.values()}
if len(laws) != 1:
raise NativeCapabilityError('Directly coupled pipe compliances require the same thermodynamic mode and polytropic exponent')
poly = next(iter(laws))[0]
offsets=[];volumes=[]
for (name,half),(c,volume) in partitions.items():
offsets.append(states[name,'m'+(str(half) if half else '')]);volumes.append(volume)
for prefix in ('p','T'):
values=[c.parameter_values[f'{prefix}{half}_0'] if half else getattr(c,prefix+'0')
for (_,half),(c,_) in partitions.items()]
if max(values)-min(values)>1e-9*max(1,*map(abs,values)):
raise NativeCapabilityError('Ideally coupled pipe compliances require consistent initial pressure and temperature')
total=sum(volumes)
thermal_sum = '+'.join(f'y[{i+1}]*{num(v/total)}' if poly else f'y[{i+1}]' for i,v in zip(offsets,volumes))
project.append('{ double mass='+ '+'.join(f'y[{i}]' for i in offsets)+',thermal='+thermal_sum+';')
for i,volume in zip(offsets,volumes):
project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=thermal*{num(1 if poly else volume/total)};']
project.append('}')
coupled.append((root,offsets,volumes,poly))
dependencies.project_states(offsets)
dependencies.project_states([i+1 for i in offsets])
for root, gas in anchor.items():
lines.append(f'p[{pgi[root]}]={gas}.p;')
dependencies.assign(f'p[{pgi[root]}]', (gas+'.p',))
h_initial = {f'h[{pi[endpoint]}]': gas+'.h' for endpoint,gas in port_gas.items()}
operations, flow_known, flow_eq = [], set(), []
pipe_cache_count = 0
def pipe_flow(expression):
nonlocal pipe_cache_count
result = f'native_pipe_flow_cached_context(properties,&pipe_cache[{pipe_cache_count}],{expression})'
pipe_cache_count += 1
return result
def flow(c, name, expr):
target=q(c,name)
operations.append(Computation.assignment(f'flow:{c.name}.{name}',target,expr))
flow_known.add(target)
def flow_equation(terms):
flow_eq.append(({q(c,name):coef for c,name,coef in terms},'0.0'))
for c in components:
kind=c.model_type
names=pnames(c)
if kind in RESISTORS or kind in NODES:
flow_equation([(c,name,1) for name in names])
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
flow(c,'port_1','0.0')
if kind in RESISTORS:
a,b=names
pa,pb=p(c,a),p(c,b)
if kind=='pipe' and c.lambda_darcy==0:
# A zero-loss pipe is an ideal connection, whose flow is
# determined by the neighbouring constitutive equations.
continue
if kind=='orifice':
flow(c,a,f'{num(c.K_eff)}*copysign(sqrt(fabs({pa}-{pb})),{pa}-{pb})')
elif kind=='pipe':
resistance=c.lambda_darcy*c.L/c.D
flow(c,a,f'copysign(sqrt(fabs({pa}-{pb})*2*fmax(native_density_context(properties,{medium(c)},fmax(.5*({pa}+{pb}),1),{num(c.T0)}),1e-12)*{num(c.area*c.area/resistance)}),{pa}-{pb})')
elif kind=='amesim_pnl00r':
T=f'native_temperature_ph_context(properties,{medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})'
flow(c,a,pipe_flow(f'{medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0'))
else:
opening = w(c,'xv') if kind!='amesim_pnor001' else '1.0'
if kind!='amesim_pnor001':
value = f'fmax(0,fmin(1,{w(c,"res.signal")}))' if kind=='amesim_pnvo001' else num(c.opening)
put(c,'xv',value)
area = c.effective_cq*(c.effective_area if kind=='amesim_pnor001' else c.maximum_area)
inputs=f'{medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening}'
outputs=(q(c,a),w(c,'cm'),w(c,'gasvel'))
code=f'if(!native_medium_orifice_context(properties,{inputs},&{outputs[0]},&{outputs[1]},&{outputs[2]})) return 0;'
operations.append(Computation(f'flow:{c.name}.{a}',outputs,references(inputs),(code,)))
flow_known.add(q(c,a));assigned.update((c.name+'.cm',c.name+'.gasvel'))
flow(c,b,f'-{q(c,a)}')
elif kind in ('amesim_pnl0001','amesim_pnl0002'):
gas=gases[c.name,0]
for name in (['port_1'] if kind.endswith('1') else names):
# Resistance uses the gas arriving from the upstream side,
# including inflow into a PNL0001 storage volume.
T=f'({p(c,name)}>{gas}.p?native_temperature_ph_context(properties,{medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)'
flow(c,name,pipe_flow(f'{medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1'))
for edge in network.connections:
if edge.domain=='pneumatic':
flow_eq.append(({f'q[{pi[e.key]}]':1 for e in edge.endpoints},'0.0'))
unknownq=[f'q[{i}]' for i in range(len(pneu)) if f'q[{i}]' not in flow_known]
reduced=[]
for terms,rhs in flow_eq:
known=''.join(f'-({num(v)})*{k}' for k,v in terms.items() if k in flow_known)
reduced.append(({k:v for k,v in terms.items() if k not in flow_known},rhs+known))
free_flows={f'q[{pi[e]}]' for root,_,_,_ in coupled for e in pneu if groups.find(e)==root}
flow_bindings=linear_assignments(reduced,unknownq,free_flows)
flow_temporary_count=max((int(key[1:])+1 for key,_ in flow_bindings if key.startswith('b')),default=0)
for key,expr in flow_bindings:
target=re.sub(r'\bb(\d+)\b',r'fb[\1]',key)
value=re.sub(r'\bb(\d+)\b',r'fb[\1]',expr)
operations.append(Computation.assignment('connection:'+key,target,value,'linear'))
unknownp=[root for root in pgroups if root not in anchor]
for root in unknownp:
terms=[f'q[{pi[e]}]' for e in pneu if groups.find(e)==root and f'q[{pi[e]}]' in flow_known]
if not terms:
raise NativeCapabilityError('Unanchored pneumatic pressure group has no constitutive flow relation')
expr=' + '.join(terms)
operations.append(Computation('pressure:'+str(root),(f'p[{pgi[root]}]',),references(expr),(),'pressure',expr))
for c in components:
names=pnames(c);kind=c.model_type
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
operations.append(Computation.assignment(f'alias:{c.name}.port_1',h(c,'port_1'),hin(c,'port_1'),'alias'))
elif kind in RESISTORS:
a,b=names
for name,other in ((a,b),(b,a)):
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,other),'alias'))
elif kind in NODES:
if kind!='tee':
# Reference copies have no flow dependency. Keep them separate
# from the reference port's finite h_outflow diagnostic below;
# actual reference energy is carried by node_energy, not q*h.
for name in names:
if name!='port_2':
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,'port_2'),'alias'))
stream_lines = ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]]
if kind=='tee':
stream_lines += [f'double mixed=total>1e-12?energy/total:average/{len(names)};', *[f'{h(c,name)}=mixed;' for name in names]]
else:
stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;',
*[f'e+={q(c,name)}*({q(c,name)}>1e-12?{hin(c,name)}:ref);scale+=fabs({q(c,name)});' for name in names if name!='port_2'],
f'double flow={q(c,"port_2")},transition=fmax(.05*scale,1e-12);',
'double inv=-flow>=transition?1/flow:flow*(2*transition*transition-flow*flow)/pow(transition,4);',
f'{h(c,"port_2")}=mixed-(e+flow*mixed)*inv;','}']
# Nodes expose their reference temperature as an output too.
if c.name+'.T' in slots:
raise NativeCapabilityError('Unexpected node temperature output contract')
stream_lines += ['}']
outputs=tuple(h(c,name) for name in (names if kind=='tee' else ['port_2']))
inputs=frozenset(q(c,name) for name in names)|frozenset(hin(c,name) for name in names)
operations.append(Computation(f'stream:{c.name}',outputs,inputs,tuple(stream_lines),'stream'))
# Current state/parameter/signal values are available before this phase.
# Default h guesses are deliberately absent from the known-source map.
labels={f'w[{i}]': key for key,i in slots.items()}
known={f'w[{slots[key]}]': 'prepared:'+key for key in assigned}
gas_origins={gas: 'state:'+','.join(f'{name}.{field}{str(half) if half else ""}' for field in ('m','U'))
for (name,half),gas in gases.items()}
for gas,origin in gas_origins.items():
for field in ('p','T','rho','u','h'):
known[gas+'.'+field]=origin
for root,gas in anchor.items():
known[f'p[{pgi[root]}]']=gas_origins[gas]
for endpoint,gas in port_gas.items():
known[f'h[{pi[endpoint]}]']=gas_origins[gas]
for endpoint,index in pi.items():
labels[f'h[{index}]']='.'.join(endpoint)+'.h_outflow'
labels[f'q[{index}]']='.'.join(endpoint)+'.m_flow'
for root,index in pgi.items():
labels[f'p[{index}]']='pressure:'+','.join('.'.join(ep) for ep in pneu if groups.find(ep)==root)
schedule=EvaluationSchedule(operations,known,labels)
for operation in operations:
dependencies.computation(operation)
for target,expr in h_initial.items():
if target not in schedule.producers:
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
schedule_helpers, scheduled_lines=schedule.emit()
lines += scheduled_lines
if node_order:
lines.append(f'double node_energy[{len(node_order)}];')
for c in node_order:
target = node_energy[ep(c, 'port_2')]
expr = ' + '.join(energy_into(c, name, h(c, name)) for name in pnames(c) if name != 'port_2')
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
for c in components:
for name in pnames(c):
for field,expr in [('p',p(c,name)),('m_flow',q(c,name)),('h_outflow',h(c,name))]:
put(c,name+'.'+field,expr)
# Mechanical force balance includes shared accelerations for rigid groups.
feq=[]
for edge in network.connections:
if edge.domain=='mechanical':
feq.append(({w(e.component,e.port+'.f'):1 for e in edge.endpoints},'0.0'))
for c in components:
kind=c.model_type
if kind=='amesim_forc':
put(c,'force',f'{num(c.direction)}*{w(c,"res.signal")}')
feq.append(({w(c,'port_2.f'):1},'-'+w(c,'force')))
elif kind=='amesim_f000':
feq.append(({w(c,'port_1.f'):1},'0.0'))
elif kind=='amesim_lstp00a':
put(c,'gap',f'{num(c.gap0)}+{w(c,"port_2.x")}-{w(c,"port_1.x")}')
put(c,'penetration',f'fmax(-{w(c,"gap")},0)')
put(c,'force',f'native_contact({w(c,"penetration")},{w(c,"port_1.v")}-{w(c,"port_2.v")},{num(contact_stiffness(c))},{num(c.rcont)},{num(c.Pdis)},{int(c.discContactOption)})')
feq += [({w(c,'port_1.f'):1},w(c,'force')),({w(c,'port_2.f'):1},'-'+w(c,'force'))]
elif kind=='amesim_pnrp17':
put(c,'pressure_force',f'({w(c,"port_1.p")}-101300)*{num(c.effective_area)}')
feq += [({w(c,'port_2.f'):1,w(c,'port_5.f'):1},'-'+w(c,'pressure_force')),({w(c,'port_3.f'):1,w(c,'port_4.f'):1},w(c,'pressure_force'))]
elif kind=='amesim_lmechn1':
feq.append(({w(c,name+'.f'):1 for name in mnames(c)},'0.0'))
elif kind=='amesim_mecmas21':
v,x=w(c,'v'),w(c,'x')
put(c,'Fvisc',f'-{num(c.rvisc)}*{v}' if c.use_friction else '0.0')
put(c,'Ffric','0.0')
for field,penetration,velocity,suffix in [('Fmin',num(c.xmin)+'-'+x,'-'+v,'min'),('Fmax',x+'-'+num(c.xmax),v,'max')]:
expr=f'native_limit_force({penetration},{velocity},{num(getattr(c,"Kb"+suffix))},{num(getattr(c,"Db"+suffix))},{num(getattr(c,"Pd"+suffix))},{int(c.discContactOption)})' if int(c.stoptype)==2 else '0.0'
put(c,field,expr)
ref=mass_groups[groups.find(ep(c,'port_1'))][0]
extra=f'{w(c,"Fvisc")}+{w(c,"Ffric")}+{w(c,"Fmin")}-{w(c,"Fmax")}'
if c.use_friction: extra+=f'-{num(c.wind)}*{v}*fabs({v})'
extra += f'+({num(c.mass * 9.80665 * math.sin(math.radians(c.theta)))})'
feq.append(({w(c,'port_1.f'):1,w(c,'port_2.f'):1,w(ref,'a'):-c.mass},f'-({extra})'))
unknownf=[w(c,name+'.f') for c in components for name in mnames(c)]+[w(group[0],'a') for group in mass_groups.values()]
# Keep the same elimination/order while registering its structured bindings.
mechanical_assignments = linear_assignments(feq, unknownf)
for target, expression in mechanical_assignments:
lines.append(f'{"double " if target.startswith("b") else ""}{target} = {expression};')
dependencies.expression(target, expression)
for index, (group, dry, threshold) in enumerate(friction_groups):
ref = group[0]
drive = f'friction_drive_{index}'
expression = f'{num(sum(c.mass for c in group))}*{w(ref,"a")}'
lines.append(f'double {drive}={expression};')
dependencies.expression(drive, expression)
# Amesim ideal stops take priority over dry friction. A blocked mass
# carries its load through the stop, not through an extra friction force.
blocked = f'friction_blocked_{index}'
ideal = [c for c in group if int(c.stoptype) == 1]
conditions = []
for c in ideal:
x, v = y(ref, 'x'), y(ref, 'v')
conditions += [f'({x}<={num(c.xmin+1e-12*max(abs(c.xmin),1))} && {v}<=1e-12 && {drive}<=0)',
f'({x}>={num(c.xmax-1e-12*max(abs(c.xmax),1))} && {v}>=-1e-12 && {drive}>=0)']
blocked_expression = ' || '.join(conditions) or '0'
lines.append(f'double {blocked}=({blocked_expression});')
dependencies.expression(blocked, blocked_expression)
lines.append(f'{drive}={blocked}?0:{drive};')
dependencies.expression(drive, f'{blocked}?0:{drive}')
lines.append(f'if(friction_drives) friction_drives[{index}]={drive};')
capacity = sum(c.fstick for c in dry)
for c in dry:
put(c, 'Ffric', f'{blocked}?0:native_dry_friction({y(ref,"v")},{drive}*{num(c.fstick/capacity)},'
f'{num(c.fcoul)},{num(c.fstick)},{num(c.astrib)},'
f'{int(int(c.frictionType)==2 and int(c.strib)==2)},{y(ref,"_friction_mode")})')
lines.append(f'dy[{states[ref.name,"_friction_mode"]}]=0;')
dependencies.expression(f'dy[{states[ref.name,"_friction_mode"]}]', '0')
if friction_groups:
# The first linear solve supplies the drive without dry friction. The
# second redistributes actual friction forces through the same network.
# No pressure/flow iteration or mutable RHS mode is introduced.
for target, expression in mechanical_assignments:
lines.append(f'{target}={expression};')
dependencies.expression(target, expression)
for c in components:
for name in mnames(c): assigned.add(c.name+'.'+name+'.f')
if c.model_type=='amesim_lmechn1':
put(c,'tforce',' + '.join(w(c,name+'.f') for name in mnames(c)[:-1]))
stops=[]
for group in mass_groups.values():
ref=group[0];vi=states[ref.name,'v'];xi=states[ref.name,'x']
limits=[c for c in group if int(c.stoptype) in (1,3)]
lines += [f'dy[{vi}]={w(ref,"a")};dy[{xi}]={y(ref,"v")};']
dependencies.expression(f'dy[{vi}]', w(ref,'a'))
dependencies.expression(f'dy[{xi}]', y(ref,'v'))
if limits:
lower=max(c.xmin for c in limits);upper=min(c.xmax for c in limits)
if lower>upper or ref.x0<lower-1e-12 or ref.x0>upper+1e-12:
raise NativeCapabilityError('Inconsistent discrete endstop bounds or initial position')
restitution, thresholds = [], []
for parameter, bound in [('xmin',lower),('xmax',upper)]:
active=[c for c in limits if abs(getattr(c,parameter)-bound)<=1e-12*max(abs(bound),1)]
restitution.append(0 if any(int(c.stoptype)==1 for c in active) else min(c.restcoeff for c in active))
thresholds.append(max((c.restdvel for c in active if int(c.stoptype)==3),default=0))
stops.append((vi,lower,upper,*restitution,*thresholds))
lines.append(f'native_stop_motion({y(ref,"x")},{y(ref,"v")},{num(lower)},{num(upper)},&dy[{vi}],&dy[{xi}]);')
dependencies.stop_motion(vi, xi)
for c in group: put(c,'a',f'dy[{vi}]')
for c in components:
kind=c.model_type
if kind not in GAS_TYPES and kind!='amesim_pnl00r': continue
if kind in GAS_TYPES:
halves=(1,2) if kind=='amesim_pnl0003' else (0,)
center='0.0'
if kind=='amesim_pnl0003':
a,b=gases[c.name,1],gases[c.name,2]
center=w(c,'dmctr')
put(c,'dmctr',f'native_pipe_flow_context(properties,{medium(c)},{a}.p,{b}.p,{a}.p>={b}.p?{a}.T:{b}.T,{num(c.diam)},{num(c.le)},{num(c.rr)},3)')
for half in halves:
gas=gases[c.name,half];suffix=str(half) if half else ''
names=['port_'+suffix] if half else pnames(c)
mass=' + '.join(q(c,name) for name in names)
energy=' + '.join(energy_into(c, name, gas+'.h') for name in names)
if half:
sign='-' if half==1 else '+'
mass+=sign+center
energy+=f'{sign}{center}*({center}>0?{gases[c.name,1]}.h:{gases[c.name,2]}.h)'
heat='0.0'
if kind.startswith('amesim_pnch'):
heat=f'{num(c.kth*c.sth)}*({num(c.extemp)}-{gas}.T)-{gas}.p*({volume_rate[c.name]})'
elif kind.startswith('amesim_pnl') and int(c.mode)!=1:
temp=gas+'.T'
heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))'
thermal = ('T' if is_polytropic(c) else 'U') + suffix
thermal_rhs = f'{num(c.k-1)}*{gas}.T/({y(c,"m"+suffix)})*({mass})' if is_polytropic(c) else f'{energy}+({heat})'
lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,thermal]}]={thermal_rhs};']
dependencies.expression(f'dy[{states[c.name,"m"+suffix]}]', mass)
dependencies.expression(f'dy[{states[c.name,thermal]}]', thermal_rhs)
if kind.startswith('amesim_pnl'):
diag=[]
if kind=='amesim_pnl0002':
gas=gases[c.name,0]
for name in pnames(c):
flow=q(c,name);pp=f'({flow}>=0?fmax({p(c,name)},1):fmax({gas}.p,1))'
temp=f'({flow}>=0?fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{hin(c,name,True)}),1):{gas}.T)'
diag.append((flow,pp,temp,c.le/2,0))
elif kind=='amesim_pnl0003':
a,b=gases[c.name,1],gases[c.name,2];flow=w(c,'dmctr')
diag=[(flow,f'fmax(fmax({a}.p,{b}.p),1)',f'({flow}>=0?{a}.T:{b}.T)',c.le,1)]
else:
pa,pb=p(c,'port_1'),p(c,'port_2');pp=f'fmax(fmax({pa},{pb}),1)'
if kind=='amesim_pnl0001':
gas=gases[c.name,0]
temp=f'({pa}>{gas}.p?fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{hin(c,"port_1",True)}),1):{gas}.T)'
else:
temp=f'fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)'
diag=[(q(c,'port_1'),pp,temp,c.le,0)]
lines.append('{ double d[4],acc[4]={0};')
for flow,pp,temp,length,diagnostic in diag:
lines.append(f'native_pipe_diagnostics_context(properties,{medium(c)},{flow},{pp},{temp},{num(c.diam)},{num(length)},{num(c.rr)},{diagnostic},d);')
if len(diag)>1: lines.append('d[2]=fabs(d[2]);')
lines.append('for(int i=0;i<4;i++) acc[i]+=d[i];')
for i,field in enumerate(('re','cm','v','ff')):
expr=f'acc[{i}]/{len(diag)}'
put(c,field,f'fmin({expr},64000000)' if field=='ff' else expr)
lines.append('}')
for _,offsets,volumes,poly in coupled:
thermal_sum = '+'.join(f'dy[{i+1}]*{num(v/sum(volumes))}' if poly else f'dy[{i+1}]' for i,v in zip(offsets,volumes))
lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',thermal='+thermal_sum+';')
for i,volume in zip(offsets,volumes):
lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=thermal*{num(1 if poly else volume/sum(volumes))};')
dependencies.assign(f'dy[{i}]', (f'dy[{j}]' for j in offsets))
dependencies.assign(f'dy[{i+1}]', (f'dy[{j+1}]' for j in offsets))
lines.append('}')
missing=set(slots)-assigned
if missing: raise NativeCapabilityError(f'Native output mapping incomplete: {sorted(missing)}')
if not state_keys: lines.append('dy[0]=0;')
jacobian = dependencies.build()
memo_kernels = ('native_temperature_ph_context(', 'native_density_context(',
'native_pipe_flow_context(', 'native_pipe_flow_cached_context(')
has_memo_work = gas_count > 0 or any(name in '\n'.join([*lines,*schedule_helpers]) for name in memo_kernels)
reuse_enabled = jacobian.enabled and 0 < jacobian.color_count < nstates and has_memo_work
jacobian_info = jacobian.manifest(canonical_rhs=True)
jacobian_info['reuse'] = dict(enabled=reuse_enabled,gasSlots=gas_count,scalarSlots=1024,
scope='one Jacobian construction',key='kernel and bit-exact complete physical inputs',
policy='immutable canonical baseline for gas, PH inversion, density and pipe roots; changed inputs recomputed',
verification='uncached canonical dense differences')
np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu))
from .contacts import contact_table
contact_source, contact_header = contact_table([
(states[mass_groups[groups.find(ep(c, 'port_1'))][0].name, 'v'],
states[mass_groups[groups.find(ep(c, 'port_2'))][0].name, 'v'],
c.gap0, contact_stiffness(c), c.rcont, c.Pdis, int(c.discContactOption), 0)
for c in components if c.model_type == 'amesim_lstp00a'])
# Advanced-friction reference runs at 5 N and 7 N confirm Amesim 2404's
# 0.1% breakaway hysteresis. Simple friction uses the exact static threshold.
source='\n'.join(['#include "model.h"','#include <math.h>','#include <string.h>',*declarations,
f'const NativeFriction model_frictions[{max(1,len(friction_groups))}] = {{'+(','.join(
'{'+str(states[group[0].name,'v'])+','+str(states[group[0].name,'_friction_mode'])+','+num(threshold)+','+
num(sum(c.fstick*(1.001 if int(c.frictionType)==2 else 1) for c in dry))+'}'
for group,dry,threshold in friction_groups) or '{0,0,0,0}')+'};',
f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};',
contact_source,
'const double model_atol[NSTATES] = {'+','.join(map(state_absolute_tolerance, state_keys or ['dummy']))+'};',
'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};',
*jacobian.source_lines(),
*schedule_helpers,
'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}',
'static int model_eval_internal(double t,const double *y,double *dy,double *w,int canonical,double *friction_drives,NativePropertyTemperatures *temperatures,ModelJacobianWorkspace *jacobian) {',
'(void)friction_drives;(void)jacobian;',
f'NativePropertyState property_states[{min(256,max(16,4*gas_count+2*len(components)))}];',
'NativePropertyCache property_cache, *properties=&property_cache;',
'native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));',
'properties->temperatures=temperatures;',
'properties->jacobian=jacobian?&jacobian->scalars:NULL;',
'NativePropertyCache *gas_properties=canonical?NULL:properties;(void)gas_properties;',
*(['double projected[NSTATES];for(int i=0;i<NSTATES;i++) projected[i]=y[i];',*project,'y=projected;'] if project else []),
f'double p[{np}]={{0}},h[{nq}]={{0}},q[{nq}]={{0}};NativeGas g[{ng}];',
f'double fb[{max(1,flow_temporary_count)}]={{0}};(void)fb;',
*([f'NativePipeCache pipe_cache[{max(1,pipe_cache_count)}]={{0}};(void)pipe_cache;'] if pneu else []),
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;',*lines,
'for(int i=0;i<NSTATES;i++) if(!isfinite(dy[i])) return 0;',
'for(int i=0;i<NOUTPUTS;i++) if(!isfinite(w[i])) return 0;','return 1;}',
'int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,0,NULL,NULL,NULL);}',
'int model_eval_jacobian(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,1,NULL,NULL,NULL);}',
'void model_jacobian_begin(ModelJacobianWorkspace *workspace) {memset(workspace,0,sizeof(*workspace));workspace->scalars.entries=workspace->scalar_entries;workspace->scalars.capacity=MODEL_JACOBIAN_SCALAR_COUNT;workspace->scalars.recording=1;}',
'int model_eval_jacobian_reuse(double t,const double *y,double *dy,double *w,ModelJacobianWorkspace *workspace) {int ok=model_eval_internal(t,y,dy,w,1,NULL,NULL,workspace);if(workspace)workspace->scalars.recording=0;return ok;}',
'int model_friction_drives(double t,const double *y,double *drives) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,drives,NULL,NULL);}',
'int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,NULL,temperatures,NULL);}',
'double model_next_break(double t,double end) { double result=end;(void)t;',*breaks,'return result;}',''])
header=f'''#ifndef GENERATED_NATIVE_MODEL_H
#define GENERATED_NATIVE_MODEL_H
#include "kernels.h"
#define MODEL_JACOBIAN_GAS_REUSE {int(reuse_enabled)}
#define MODEL_JACOBIAN_GAS_COUNT {gas_count}
#define MODEL_JACOBIAN_SCALAR_COUNT 1024
typedef struct {{ NativeJacobianGasMemo gases[{ng}]; NativeJacobianGasStats stats;
NativeJacobianScalars scalars; NativeJacobianScalarEntry scalar_entries[MODEL_JACOBIAN_SCALAR_COUNT]; }} ModelJacobianWorkspace;
void model_jacobian_begin(ModelJacobianWorkspace *workspace);
int model_eval_jacobian_reuse(double t,const double *y,double *dy,double *w,ModelJacobianWorkspace *workspace);
#define MODEL_PROPERTY_TEMPERATURES {int(any(key[1] == 'peng_robinson' for key in media))}
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
#define NSTATES {nstates}
#define NOUTPUTS {max(1,len(variables))}
#define NSTOPS {len(stops)}
#define NFRICTIONS {len(friction_groups)}
{contact_header}
extern const NativeFriction model_frictions[{max(1,len(friction_groups))}];
int model_friction_drives(double t,const double *y,double *drives);
extern const NativeStop model_stops[{max(1,len(stops))}];
extern const double model_atol[NSTATES];
extern const char *const model_output_keys[NOUTPUTS];
{chr(10).join(jacobian.header_lines(canonical_rhs=True))}
int model_init(double *y);
int model_eval(double t,const double *y,double *dy,double *w);
double model_next_break(double t,double end);
#endif
'''
return NativeProgram(source,header,tuple(state_keys),variables,tuple(sorted({c.model_type for c in components})),schedule.report(),jacobian_info)
+25
View File
@@ -0,0 +1,25 @@
"""CLI input adapter shared with HTTP; numerical execution accepts SI XML only."""
from __future__ import annotations
import json
from pathlib import Path
import warnings
from app.project_parameters import expression_value as arithmetic_value, prepare_project, version_warning
from app.system_xml import validate_system_xml_document
def project_xml(data: dict) -> bytes:
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(data))
if notices:
warnings.warn(json.dumps(version_warning(notices), ensure_ascii=False), UserWarning, stacklevel=2)
return build_reactflow_system_xml(normalized)
def load_input(path: Path):
xml = project_xml(json.loads(path.read_text(encoding="utf-8"))) if path.suffix.lower() == ".json" else path.read_bytes()
report = validate_system_xml_document(xml)
if not report.valid or report.document is None:
raise ValueError(f"Invalid simulation XML: {report.as_dict()}")
return xml, report.document
+214
View File
@@ -0,0 +1,214 @@
"""Conservative state dependencies for compiler-owned expressions.
This is structural bookkeeping, not C parsing or numerical Jacobian evaluation.
Native multi-output operations, projections and mode-dependent writes must be
registered explicitly by the lowering path. Unresolved reachable inputs disable
coloring for the complete model.
"""
from collections import deque
from dataclasses import dataclass
import hashlib
import json
import re
# Reviewed expression leaves. Unknown arrays/scalars remain unresolved instead
# of silently becoming constants; the flow schedule supplies its own explicit IR.
_ARRAY = re.compile(r'\b[A-Za-z_]\w*\[\d+\](?:\.[A-Za-z_]\w*)?|\bgas_\d+\.[A-Za-z_]\w*')
_IDENTIFIER = re.compile(r'\b[A-Za-z_]\w*\b')
_NUMBER = re.compile(r'(?<![\w.])(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?')
_FUNCTIONS = frozenset({
'fmax', 'fmin', 'fabs', 'sqrt', 'copysign', 'pow',
'native_signal', 'native_contact', 'native_limit_force', 'native_dry_friction',
'native_pipe_flow_context', 'native_pipe_flow_cached_context',
'native_temperature_ph_context', 'native_density_context',
})
_CONSTANTS = frozenset({'t', 'properties', 'NAN', 'INFINITY', 'NULL', 'true', 'false'})
def expression_inputs(expression):
"""Extract leaves from a reviewed expression; unfamiliar names fail closed."""
inputs = set()
def leaf(match):
key = match.group()
# Per-evaluation cache scratch is an implementation detail of reviewed
# kernels; each value is determined by that kernel's explicit arguments.
if not re.fullmatch(r'pipe_cache\[\d+\]', key):
inputs.add(key)
return ' '
remainder = _ARRAY.sub(leaf, expression)
remainder = _NUMBER.sub(' ', remainder)
for name in _IDENTIFIER.findall(remainder):
if name in _FUNCTIONS or name in _CONSTANTS or re.fullmatch(r'(?:medium|signal)_\d+', name):
continue
inputs.add(name)
return frozenset(inputs)
@dataclass(frozen=True)
class JacobianStructure:
state_count: int
rows: tuple[tuple[int, ...], ...] = ()
colors: tuple[int, ...] = ()
reason: str | None = None
@classmethod
def dense(cls, state_count, reason):
return cls(state_count, reason=reason)
@property
def enabled(self):
return self.reason is None
@property
def color_count(self):
return max(self.colors, default=-1) + 1 if self.enabled else self.state_count
@property
def nonzeros(self):
return sum(map(len, self.rows)) if self.enabled else self.state_count ** 2
def manifest(self, *, canonical_rhs=False):
# Use proven coloring automatically when it reduces finite-difference
# groups; unsupported or unhelpful patterns retain dense differences.
runtime_eligible = self.enabled and 0 < self.color_count < self.state_count
fallback_reason = None if runtime_eligible else (
self.reason or 'Coloring does not reduce finite-difference groups')
pattern = {'rows': self.rows, 'columnColors': self.colors}
policy = ('CVODE colored forward differences; canonical-property-cache RHS' if canonical_rhs else 'CVODE colored forward differences') if runtime_eligible else 'CVODE default dense differences'
return {
'policyScope': 'default-runtime',
'defaultRuntimePolicy': policy,
'verification': '--verify-jacobian',
'policy': policy,
'rhsPolicy': 'canonical-property-cache finite differences' if canonical_rhs and runtime_eligible else 'ordinary model_eval',
'runtimeEligible': runtime_eligible, 'runtimeFallbackReason': fallback_reason,
'canonicalRhs': canonical_rhs, 'ordinaryRhsUnchanged': True,
'enabled': self.enabled, 'reason': self.reason, 'stateCount': self.state_count,
'nonzeros': self.nonzeros, 'density': self.nonzeros / self.state_count ** 2 if self.state_count else 0,
'colorCount': self.color_count,
'patternSha256': hashlib.sha256(json.dumps(pattern, separators=(',', ':')).encode()).hexdigest() if self.enabled else None,
'columnColors': list(self.colors) if self.enabled else None,
}
def header_lines(self, *, canonical_rhs=False):
lines = [f'#define MODEL_JACOBIAN_COLORED {int(self.enabled)}',
f'#define MODEL_JACOBIAN_CANONICAL_RHS {int(canonical_rhs)}',
f'#define MODEL_JACOBIAN_COLOR_COUNT {self.color_count}',
f'#define MODEL_JACOBIAN_NNZ {self.nonzeros}']
if canonical_rhs:
lines += ['int model_eval_jacobian(double t, const double *y, double *dy, double *w);']
if self.enabled:
lines += ['extern const int model_jacobian_column_color[NSTATES];',
'extern const int model_jacobian_col_ptr[NSTATES+1];',
'extern const int model_jacobian_row_index[MODEL_JACOBIAN_NNZ];']
return lines
def source_lines(self):
if not self.enabled:
return []
columns = [[] for _ in range(self.state_count)]
for row, entries in enumerate(self.rows):
for column in entries:
columns[column].append(row)
pointers = [0]
indices = []
for column in columns:
indices.extend(column)
pointers.append(len(indices))
return [f'const int {name}[{len(values)}] = {{'+','.join(map(str, values))+'};'
for name, values in [('model_jacobian_column_color', self.colors),
('model_jacobian_col_ptr', pointers),
('model_jacobian_row_index', indices)]]
class StateDependencies:
def __init__(self, state_count):
self.state_count = state_count
self.seeds = {f'y[{i}]': 1 << i for i in range(state_count)}
self.inputs = {}
def assign(self, target, inputs):
# Union repeated writes, rather than dropping dependencies from another
# branch or an earlier in-place value (stop motion is mode dependent).
self.inputs.setdefault(target, set()).update(inputs)
def expression(self, target, expression):
self.assign(target, expression_inputs(expression))
def project_states(self, offsets):
refs = {f'y[{i}]' for i in offsets}
for target in refs:
self.assign(target, refs)
def stop_motion(self, velocity_index, position_index):
refs = {f'y[{velocity_index}]', f'y[{position_index}]',
f'dy[{velocity_index}]', f'dy[{position_index}]'}
for target in (f'dy[{velocity_index}]', f'dy[{position_index}]'):
self.assign(target, refs)
def computation(self, operation):
# A schedule SCC reaches a fixed point in build(), so every member gains
# every external state dependency even through pressure/stream loops.
for output in operation.outputs:
self.assign(output, operation.inputs)
def build(self):
if self.state_count == 0:
return JacobianStructure.dense(1, 'Algebraic-only internal state uses default differences')
required = {f'dy[{i}]' for i in range(self.state_count)}
pending = list(required)
reachable = set()
missing = set()
while pending:
key = pending.pop()
if key in reachable:
continue
reachable.add(key)
if key not in self.inputs and key not in self.seeds:
missing.add(key)
pending.extend(self.inputs.get(key, ()))
if missing:
return JacobianStructure.dense(self.state_count,
'Unresolved structural inputs: '+', '.join(sorted(missing)[:16]))
consumers = {key: set() for key in reachable}
for target in reachable:
for source in self.inputs.get(target, ()):
consumers[source].add(target)
masks = dict(self.seeds)
queue = deque(key for key in self.seeds if key in reachable)
queued = set(queue)
while queue:
source = queue.popleft()
queued.remove(source)
for target in consumers[source]:
merged = masks.get(target, 0) | masks[source]
if merged != masks.get(target, 0):
masks[target] = merged
if target not in queued:
queue.append(target)
queued.add(target)
# A diagonal overestimate is safe and keeps isolated/dummy rows valid
# without introducing zero-length C arrays or uncolored state columns.
rows = tuple(tuple(column for column in range(self.state_count)
if (masks.get(f'dy[{row}]', 0) | (1 << row)) >> column & 1)
for row in range(self.state_count))
conflicts = [set() for _ in range(self.state_count)]
for entries in rows:
for column in entries:
conflicts[column].update(set(entries) - {column})
colors = {}
while len(colors) < self.state_count:
column = max((i for i in range(self.state_count) if i not in colors),
key=lambda i: (len({colors[j] for j in conflicts[i] if j in colors}),
len(conflicts[i]), -i))
used = {colors[j] for j in conflicts[column] if j in colors}
color = 0
while color in used:
color += 1
colors[column] = color
ordered = tuple(colors[i] for i in range(self.state_count))
for entries in rows:
if len({ordered[column] for column in entries}) != len(entries):
raise AssertionError('Jacobian coloring contains a row conflict')
return JacobianStructure(self.state_count, rows, ordered)
+36
View File
@@ -0,0 +1,36 @@
"""Reviewed native component module exports and their compilation dependencies."""
from __future__ import annotations
import re
EXPORTS = {
"properties": frozenset({
"native_properties_init", "native_temperature_ph_context", "native_density_context",
"native_gas_init", "native_gas", "native_gas_context", "native_medium_init",
"native_density", "native_temperature_ph", "native_viscosity",
"native_medium_gas", "native_medium_gas_context", "native_polytropic_gas_context",
"native_jacobian_gas",
"native_jacobian_scalar_get", "native_jacobian_scalar_put",
}),
"orifice": frozenset({"native_orifice", "native_orifice_context", "native_medium_orifice", "native_medium_orifice_context"}),
"pipe": frozenset({"native_pipe_resistance", "native_pipe_flow", "native_pipe_flow_context",
"native_pipe_flow_cached", "native_pipe_flow_cached_context",
"native_pipe_diagnostics", "native_pipe_diagnostics_context"}),
"mechanics": frozenset({"native_contact", "native_stop_motion", "native_limit_force", "native_dry_friction"}),
"signal": frozenset({"native_signal", "native_signal_break"}),
}
DEPENDENCIES = {"orifice": ("properties",), "pipe": ("properties",)}
def component_modules(source: str) -> tuple[str, ...]:
# Generated C refers to these functions directly. Include identifier uses
# (also function pointers), conservatively including any comments/strings.
identifiers = set(re.findall(r"\bnative_[A-Za-z0-9_]+\b", source))
selected = {name for name, exports in EXPORTS.items() if identifiers & exports}
pending = list(selected)
while pending:
for dependency in DEPENDENCIES.get(pending.pop(), ()):
if dependency not in selected:
selected.add(dependency)
pending.append(dependency)
return tuple(name for name in EXPORTS if name in selected)
@@ -0,0 +1,35 @@
"""Portable process setup and classification for the native compiler driver.
MinGW maps different CreateProcess failures to ENOENT. That text does not prove
the compiler/subprogram is missing; never treat a C diagnostic as a launch retry.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import shutil
IS_WINDOWS = os.name == 'nt'
LAUNCH_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.35, 0.5)
def command_environment(executable: str) -> dict[str, str] | None:
if not IS_WINDOWS:
return None # Preserve the Linux toolchain and environment unchanged.
env = os.environ.copy()
path_key = next((key for key in env if key.upper() == 'PATH'), 'PATH')
directory = str(Path(shutil.which(executable) or executable).resolve().parent)
env[path_key] = directory + os.pathsep + env.get(path_key, '')
return env
def is_driver_launch_failure(stderr: str) -> bool:
# GCC 8 emits "gcc.exe: error: CreateProcess: ..."; other releases add
# "cannot execute 'cc1':". Anchor the driver diagnostic, not source text.
return IS_WINDOWS and bool(re.search(
r'(?im)^(?:[^\r\n]*[/\\])?[\w.+-]*gcc(?:\.exe)?: (?:fatal )?error: (?:cannot execute [^\r\n]*: )?CreateProcess:', stderr))
def is_transient_process_error(exc: OSError) -> bool:
return IS_WINDOWS and getattr(exc, 'winerror', None) in (5, 32, 1450, 1455)
@@ -0,0 +1,17 @@
"""User-facing wording shared by live events and saved result diagnostics."""
def format_property_warning(warning: dict) -> dict:
domains = {
"equation-of-state": "状态方程及热力性质的真实气体修正项",
"ideal-caloric": "理想气体热容、焓和内能",
"dynamic-viscosity": "动力黏度",
}
high = warning["direction"] == "high"
message = (
f"氦气物性有效温度越界:{domains[warning['property']]},"
f"t = {warning['time']:.9g} s,T = {warning['temperature']:.9g} K "
f"{'>' if high else '<'} {'Tmax' if high else 'Tmin'} = {warning['limit']:.9g} K。"
"仿真继续计算,超范围物性不保证有效性。"
)
return {**warning, "message": message}
@@ -0,0 +1,327 @@
"""Quota-controlled, append-only result archives in PROJECT/simresults.
Reservations cover state AND future output blocks before the worker may write.
A global process lock serializes grants; per-run leases protect active writers.
Grants are persisted in bounded windows; individual blocks consume local credit
without scanning history or synchronously rewriting the manifest.
Only recognized archives are evicted, oldest first. Unknown user files count
toward the quota but are never deleted. File sizes are logical bytes, not the
filesystem's allocation-unit overhead.
"""
from __future__ import annotations
from contextlib import contextmanager
import ctypes
import json
import os
from pathlib import Path
import re
import stat
import struct
import threading
import time
from uuid import uuid4
import zlib
from .cache_storage import acquire_cache_lease, _acquire_lock_file, _reparse, _require_directory
RESULT_ROOT = Path(__file__).resolve().parents[3] / "simresults"
DEFAULT_LIMIT_BYTES = 1024**3
_RESERVATION_WINDOW_BYTES = 16 * 1024**2
_RUN = re.compile(r"run-([0-9a-f]{64})\Z")
_FILES = frozenset(("manifest.json", "manifest.tmp", "states.bin", "outputs.bin"))
_HEADER = struct.Struct("<8sQQQQ")
class ResultQuotaError(RuntimeError):
pass
def result_archive_path(result_id: str, *, root: Path | None = None) -> Path:
"""Locate an archive by ID under this installation's result root.
Never use a saved absolute directory as a locator: older manifests may
contain one from a different machine. Moving a stopped project and starting
it again establishes its new root through __file__ above.
"""
if not isinstance(result_id, str) or _RUN.fullmatch(result_id) is None:
raise ValueError("Invalid result archive ID.")
base = Path(root or RESULT_ROOT).absolute()
_require_directory(base)
path = base / result_id
_require_directory(path)
return path
def result_limit_bytes() -> int:
raw = os.environ.get("SIMULATION_RESULT_STORAGE_MB", str(DEFAULT_LIMIT_BYTES // 1024**2))
if not re.fullmatch(r"[0-9]+", raw) or int(raw) < 1:
raise ValueError("SIMULATION_RESULT_STORAGE_MB must be a positive integer in MiB.")
return int(raw) * 1024**2
def _alive(pid: int | None) -> bool:
if not pid:
return False
if os.name == "nt":
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
kernel.OpenProcess.restype = ctypes.c_void_p
kernel.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel.OpenProcess(0x100000, False, pid) # SYNCHRONIZE
if not handle:
return ctypes.get_last_error() == 5 # Access denied: conservatively pin.
try:
return kernel.WaitForSingleObject(handle, 0) != 0
finally:
kernel.CloseHandle(handle)
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _size(path: Path) -> int:
info = path.lstat()
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
return info.st_size
return sum(_size(child) for child in path.iterdir())
def _record(path: Path) -> dict | None:
try:
info = (path / "manifest.json").lstat()
if _reparse(info) or not stat.S_ISREG(info.st_mode):
return None
data = json.loads((path / "manifest.json").read_bytes())
if (data.get("format") != "simulation-blocks-v1" or
data.get("id") != path.name or type(data.get("reservedBytes")) is not int or
data["reservedBytes"] < 0 or type(data.get("createdAt")) is not int or
type(data.get("finishedAt", data["createdAt"])) is not int or
data.get("status") not in ("running", "completed", "cancelled", "failed", "interrupted") or
(data.get("workerPid") is not None and
(type(data["workerPid"]) is not int or data["workerPid"] <= 0))):
return None
return data
except (OSError, ValueError, AttributeError):
return None
def scan_blocks(path: Path, *, expected_columns: int | None = None) -> dict:
"""Validate a committed prefix using bounded reads; ignore an incomplete tail.
A CRC mismatch is reported as corruption, never a valid sample. This reader
works after process termination without loading the trajectory into RAM.
"""
result = {"blocks": 0, "samples": 0, "validBytes": 0, "storedUntil": None,
"incompleteTail": False, "corrupt": False}
if not path.exists():
return result
with path.open("rb") as stream:
size = os.fstat(stream.fileno()).st_size
while stream.tell() < size:
raw = stream.read(_HEADER.size)
if len(raw) != _HEADER.size:
result["incompleteTail"] = True
break
magic, sequence, rows, columns, expected_crc = _HEADER.unpack(raw)
if (magic != b"SIMBLK01" or sequence != result["blocks"] or not 0 < rows <= 1024 or
not columns or (expected_columns is not None and columns != expected_columns)):
result["corrupt"] = True
break
length = rows * columns * 8
if length + 8 > size - stream.tell():
result["incompleteTail"] = True
break
# The first column is always time; at most 1024 timestamps.
times = stream.read(rows * 8)
crc = zlib.crc32(times)
remaining = length - len(times)
while remaining:
block = stream.read(min(65536, remaining))
if not block:
raise OSError("Result block changed during inspection.")
crc = zlib.crc32(block, crc)
remaining -= len(block)
if stream.read(8) != b"COMMIT01" or crc != expected_crc:
result["corrupt"] = True
break
result["blocks"] += 1
result["samples"] += rows
result["validBytes"] = stream.tell()
result["storedUntil"] = struct.unpack_from("<d", times, len(times) - 8)[0]
return result
class ResultArchive:
def __init__(self, metadata: dict, *, root: Path | None = None, limit_bytes: int | None = None):
self.root = Path(root or RESULT_ROOT).absolute()
_require_directory(self.root, create=True)
self.limit = result_limit_bytes() if limit_bytes is None else limit_bytes
if self.limit <= 0:
raise ValueError("Result quota must be positive.")
self.key = uuid4().hex + uuid4().hex
self.path = self.root / ("run-" + self.key)
self.lease = acquire_cache_lease(self.root, "models", self.key)
self.closed = False
self._mutex = threading.RLock()
self._credit = 0
# This is accounting credit, not an allocated memory/disk buffer. Bound
# speculative grants for small quotas and concurrent simulations.
self._reservation_window = min(_RESERVATION_WINDOW_BYTES, self.limit // 16)
self.data = {"format": "simulation-blocks-v1", "id": self.path.name,
"createdAt": time.time_ns(), "status": "running", "workerPid": None,
"metadata": metadata, "reservedBytes": 0}
# Covers final scalars, JSON framing and both copies during atomic update.
self.metadata_budget = max(65536, len(json.dumps(self.data).encode()) * 8)
self.data["reservedBytes"] = self.metadata_budget
try:
with self._locked():
self._make_room(self.metadata_budget)
self.path.mkdir()
self._publish()
except BaseException:
self.lease.close()
raise
@contextmanager
def _locked(self):
with _acquire_lock_file(self.root, "models", "0" * 64, "results-quota.lock",
exclusive=True, blocking=True):
yield
def _publish(self):
encoded = json.dumps(self.data, ensure_ascii=False, allow_nan=False,
separators=(",", ":")).encode("utf-8")
if len(encoded) * 2 > self.metadata_budget:
raise ResultQuotaError("Result metadata exceeded its reserved space.")
temporary = self.path / "manifest.tmp"
with temporary.open("wb") as stream:
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path / "manifest.json")
def _make_room(self, extra: int) -> int:
"""Make room for required bytes and return free bytes BEFORE this grant.
Caller holds the global quota lock through publishing the reservation.
Optional ahead-of-write credit must never trigger extra eviction.
"""
usage = 0
candidates = []
for path in self.root.iterdir():
info = path.lstat()
actual = _size(path)
match = _RUN.fullmatch(path.name)
record = (_record(path) if match and stat.S_ISDIR(info.st_mode) and not _reparse(info) else None)
if record is None:
usage += actual
continue
lease = acquire_cache_lease(self.root, "models", match[1], exclusive=True, blocking=False)
active = lease is None or (record.get("status") == "running" and _alive(record.get("workerPid")))
if lease is not None:
lease.close()
usage += max(actual, record["reservedBytes"]) if active else actual
if not active and path != self.path:
candidates.append((record.get("finishedAt", record["createdAt"]), path, match[1], actual))
if usage + extra <= self.limit:
return self.limit - usage
for _, path, key, actual in sorted(candidates):
lease = acquire_cache_lease(self.root, "models", key, exclusive=True, blocking=False)
if lease is None:
continue
with lease:
# No recursive deletion, links, unknown files, or paths outside
# the configured root. User-owned additions prevent eviction.
if path.resolve().parent != self.root.resolve():
continue
files = list(path.iterdir())
if any(p.name not in _FILES or _reparse(p.lstat()) or
not stat.S_ISREG(p.lstat().st_mode) for p in files):
continue
for item in files:
item.unlink()
path.rmdir()
usage -= actual
if usage + extra <= self.limit:
return self.limit - usage
raise ResultQuotaError("Result storage quota exhausted; active runs and unrelated files were retained.")
def worker_started(self, pid: int):
with self._mutex, self._locked():
self._ensure_open()
self.data["workerPid"] = pid
self._publish()
def _ensure_open(self):
if self.closed:
raise OSError("Result archive is closed.")
def reserve(self, amount: int):
if type(amount) is not int or not 0 < amount <= self.limit:
raise ResultQuotaError("Invalid or oversized result block reservation.")
with self._mutex:
self._ensure_open()
if amount <= self._credit:
self._credit -= amount
return
needed = amount - self._credit
with self._locked():
available = self._make_room(needed)
grant = min(available, max(needed, self._reservation_window))
previous = self.data["reservedBytes"]
self.data["reservedBytes"] += grant
try:
# Publish BEFORE granting permission to write, so another
# process (or an orphan worker) cannot reuse our quota.
self._publish()
except BaseException:
self.data["reservedBytes"] = previous
raise
self._credit += grant - amount
def finish(self, payload: dict | None = None, error: str | None = None) -> dict:
with self._mutex:
self._ensure_open()
return self._finish(payload, error)
def _finish(self, payload: dict | None, error: str | None) -> dict:
try:
metadata = self.data["metadata"]
states = scan_blocks(self.path / "states.bin",
expected_columns=len(metadata["stateColumns"]) if "stateColumns" in metadata
else max(1, len(metadata["stateKeys"])) + 1 if "stateKeys" in metadata else None)
outputs = scan_blocks(self.path / "outputs.bin",
expected_columns=len(metadata["outputColumns"]) if "outputColumns" in metadata
else max(1, len(metadata["variables"])) + 1 if "variables" in metadata else None)
status = payload.get("status", "failed") if payload is not None else "interrupted"
if states["corrupt"] or outputs["corrupt"]:
status = "failed"
summary = {"id": self.path.name, "directory": f"simresults/{self.path.name}",
"directoryBase": "project", "limitBytes": self.limit,
"status": status, "states": states, "outputs": outputs}
with self._locked():
# Release unused ahead-of-write credit even if a result reader
# subsequently pins this completed archive with a shared lease.
self.data["reservedBytes"] -= self._credit
self._credit = 0
self.data.update(status=status, finishedAt=time.time_ns(), blocks=summary,
error=error, workerPid=None)
if payload is not None:
self.data["result"] = {k: v for k, v in payload.items()
if k not in ("series", "buildDetails", "resultStorage")}
self._publish()
return summary
finally:
self.closed = True
self.lease.close()
def close(self):
with self._mutex:
if not self.closed:
self.finish(error="Execution ended before a result was returned.")
+260
View File
@@ -0,0 +1,260 @@
"""Isolated native numerical execution; Python only handles process I/O."""
from __future__ import annotations
import json
import os
from pathlib import Path
import queue
import subprocess
import tempfile
import threading
import time
from app.simulation.config import SolveIVPConfig
from app.simulation.results import GenericSimulationResult
from .transport import NativeSeriesJson, read_indexed_result
from .build import NativeBuild, build_native
from .compiler import NativeCapabilityError, compile_native_program
from .result_storage import ResultArchive, ResultQuotaError
from .property_warnings import format_property_warning
_WORKER_SHUTDOWN_GRACE_SECONDS = 5.0
def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
run_dir: Path, record_samples=True, cancel_check=None,
progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False) -> dict:
# Reject unsupported calls before allocating quota or evicting any history.
if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None:
raise NativeCapabilityError("Native uses generated per-state absolute tolerances and automatic initial step; custom config.atol/first_step are not supported.")
if any((run_dir / name).exists() for name in ("result.json", "cancel.request", "result-index.json")):
raise ValueError("Native execution requires a fresh run directory.")
archive = None
if record_samples:
metadata = {key: build.manifest[key] for key in ("buildKey", "stateKeys", "variables")
if key in build.manifest}
if "stateKeys" in metadata:
# Algebraic-only models carry one private constant integrator state.
metadata["stateColumns"] = ["time", *(metadata["stateKeys"] or [None])]
if "variables" in metadata:
metadata["outputColumns"] = ["time", *([v["key"] for v in metadata["variables"]] or [""])]
metadata["simulation"] = {"method": config.method, "start": config.t_start,
"stop": config.t_stop, "sampleStep": sample_step,
"maxStep": config.max_step, "rtol": config.rtol}
archive = ResultArchive(metadata)
try:
payload = _execute_native(build, config, sample_step, run_dir=run_dir,
record_samples=record_samples, cancel_check=cancel_check,
progress_callback=progress_callback, warning_callback=warning_callback, activity_tracker=activity_tracker,
timeout=timeout, raw_series=raw_series, archive=archive)
if archive is not None:
payload["resultStorage"] = archive.finish(payload)
return payload
except BaseException as exc:
if archive is not None and not archive.closed:
try:
archive.finish(error=str(exc))
except Exception as storage_error:
exc.add_note(f"Could not finalize result archive: {storage_error}")
raise
finally:
if archive is not None:
archive.close()
def _execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
run_dir: Path, record_samples=True, cancel_check=None,
progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False,
archive: ResultArchive | None = None) -> dict:
run_dir.mkdir(parents=True, exist_ok=True)
output = run_dir / "result.json"
cancel_path = run_dir / "cancel.request"
index_path = run_dir / "result-index.json"
command = [str(build.executable), "--method", config.method,
"--start", str(config.t_start), "--stop", str(config.t_stop),
"--sample-step", str(sample_step), "--max-step", str(config.max_step),
"--rtol", str(config.rtol), "--timeout", str(timeout),
"--cancel-file", str(cancel_path.resolve()), "--output", str(output.resolve())]
if raw_series:
command.extend(["--result-index", str(index_path.resolve())])
if not record_samples:
command.append("--solve-only")
if archive is not None:
command.extend(["--sample-file", str(archive.path / "states.bin"),
"--output-block-file", str(archive.path / "outputs.bin"), "--storage-control"])
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
started = time.perf_counter()
# A fast worker may finish before the monitor's first iteration. Honor a
# cancellation already requested during preparation before spawning it.
cancelled_at = started if cancel_check is not None and cancel_check() else None
if cancelled_at is not None:
cancel_path.write_text("cancel\n", encoding="ascii")
process = subprocess.Popen(command, cwd=build.executable.parent,
stdin=subprocess.PIPE if archive is not None else subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", creationflags=creationflags)
messages: queue.Queue[str] = queue.Queue(maxsize=128)
reader_stop = threading.Event()
def read_stderr():
assert process.stderr is not None
for line in process.stderr:
while not reader_stop.is_set():
try:
messages.put(line, timeout=.1)
break
except queue.Full:
continue
if reader_stop.is_set():
break
reader = threading.Thread(target=read_stderr, daemon=True)
reader.start()
last_time = config.t_start
last_nfev = last_accepted = 0
forced_cancel = False
property_warnings = []
try:
if archive is not None:
archive.worker_started(process.pid)
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
with (run_dir / "worker.log").open("w", encoding="utf-8") as log:
while process.poll() is None or not messages.empty() or reader.is_alive():
now = time.perf_counter()
if cancel_check is not None and cancel_check() and cancelled_at is None:
cancel_path.write_text("cancel\n", encoding="ascii")
cancelled_at = now
cancellation_expired = (cancelled_at is not None and
now-cancelled_at > _WORKER_SHUTDOWN_GRACE_SECONDS)
deadline_expired = timeout > 0 and now-started > timeout+_WORKER_SHUTDOWN_GRACE_SECONDS
if process.poll() is None and (cancellation_expired or deadline_expired):
process.kill()
process.wait(timeout=5)
if cancelled_at is not None:
forced_cancel = True
break
raise RuntimeError("Native worker time limit expired; the unresponsive process was terminated before it returned results.")
try:
line = messages.get(timeout=0.05)
except queue.Empty:
continue
log.write(line)
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("phase") == "storage-reserve" and archive is not None:
reply = "ok\n"
try:
archive.reserve(event.get("bytes"))
except ResultQuotaError as exc:
reply = "full\n"
log.write(f"Result storage reservation failed: {exc}\n")
except OSError as exc:
reply = "io\n"
log.write(f"Result storage I/O failed: {exc}\n")
try:
process.stdin.write(reply)
process.stdin.flush()
except (BrokenPipeError, OSError):
if process.poll() is None:
raise
continue
if event.get("phase") == "property-warning":
warning = format_property_warning(event["warning"])
property_warnings.append(warning)
if warning_callback:
warning_callback(warning)
continue
if event.get("phase") == "integrating":
last_time = max(last_time, min(config.t_stop, float(event["time"])))
last_nfev = int(event["nfev"])
last_accepted = int(event["acceptedSteps"])
if progress_callback:
progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating")
if activity_tracker is not None:
activity_tracker.record_native_progress(last_time, int(event["nfev"]), int(event["acceptedSteps"]))
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
reader_stop.set()
reader.join(timeout=2)
if process.stdin is not None:
process.stdin.close()
if process.stderr is not None:
process.stderr.close()
if forced_cancel:
# A killed worker cannot publish trustworthy result artifacts. Retain
# only its last progress report; do not invent a trajectory or totals.
payload = {
"success": False, "status": "cancelled",
"message": "Simulation cancelled; the unresponsive worker was terminated. No complete result artifact is available; time and counters are the last reported progress.",
"backend": "native-c", "method": config.method,
"simulatedUntil": last_time, "nfev": last_nfev, "acceptedSteps": last_accepted,
"series": {}, "final": {}, "finalState": [],
"solverControl": {"reason": "cancelled", "operation": "worker-termination",
"forcedTermination": True, "workerExitCode": process.returncode,
"resultAvailable": False, "statisticsComplete": False},
}
else:
if not output.is_file():
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.")
if process.returncode not in (0, 2):
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
try:
payload = (read_indexed_result(output, index_path) if raw_series
else json.loads(output.read_text(encoding="utf-8")))
except OSError as exc:
raise RuntimeError(f"Cannot read native worker result artifacts: {exc}") from exc
payload["propertyWarnings"] = (
property_warnings if forced_cancel else
[format_property_warning(w) for w in payload.get("propertyWarnings", [])])
payload["processWallSeconds"] = time.perf_counter()-started
payload["buildKey"] = build.manifest["buildKey"]
payload["cacheHit"] = build.cache_hit
payload["buildSeconds"] = build.seconds
payload["buildDetails"] = build.details
if activity_tracker is not None:
activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"])
return payload
def simulate_native(network, config, *, sample_step, progress_callback=None,
warning_callback=None, cancel_check=None, activity_tracker=None, raw_series=False):
if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if progress_callback:
progress_callback(0.0, "native-generation")
program = compile_native_program(network)
build = build_native(program, progress_callback=(
(lambda phase: progress_callback(0.0, phase)) if progress_callback else None
))
try:
with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory:
data = execute_native(build, config, sample_step, run_dir=Path(directory),
cancel_check=cancel_check, progress_callback=progress_callback,
warning_callback=warning_callback,
activity_tracker=activity_tracker, raw_series=raw_series)
finally:
# Keep the executable pinned through process exit and result reading;
# only then may cache eviction reclaim this completed model version.
build.close()
totals = {target: data[source] for source, target in (
("nfev", "nfev"), ("njev", "njev"), ("nlu", "nlu"),
("acceptedSteps", "acceptedStepCount"), ("rejectedSteps", "rejectedStepCount"),
("stateTransitions", "stateTransitionCount"), ("solverStarts", "solverStartCount"),
) if source in data}
if progress_callback:
fraction = (data["simulatedUntil"]-config.t_start)/(config.t_stop-config.t_start)
progress_callback(fraction, "complete" if data["success"] else data["status"])
return GenericSimulationResult(
success=data["success"], status=data["status"], message=data["message"],
simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop,
variables=program.variables, series=data["series"], final=data["final"],
diagnostics={"backend": "native-c", "propertyWarnings": data.get("propertyWarnings", []), "native": {k: v for k, v in data.items()
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "rtol": config.rtol, "totals": totals},
"stateCount": len(program.state_keys), "sampleCount": (data["series"].sample_count if isinstance(data["series"], NativeSeriesJson)
else len(data["series"].get("time", [])))},
)
+257
View File
@@ -0,0 +1,257 @@
"""Dependency ordering and local algebraic blocks for generated C expressions.
This module only arranges reviewed C computations. It never evaluates a model
numerically and has no dependency on the retired Python numerical backend.
"""
from __future__ import annotations
from dataclasses import dataclass
import heapq
import re
from .compiler import NativeCapabilityError
# Only compiler-owned array expressions are inspected, never arbitrary user C.
_REFERENCE = re.compile(r"\b(?:[phqw]|fb)\[\d+\]|\bg\[\d+\]\.[A-Za-z_]\w*")
def references(expression: str) -> frozenset[str]:
return frozenset(_REFERENCE.findall(expression))
@dataclass(frozen=True)
class Computation:
key: str
outputs: tuple[str, ...]
inputs: frozenset[str]
code: tuple[str, ...]
kind: str = "flow"
residual: str | None = None
@classmethod
def assignment(cls, key, target, expression, kind="flow"):
return cls(key, (target,), references(expression), (f'{target}={expression};',), kind)
@dataclass(frozen=True)
class Block:
members: tuple[int, ...]
cyclic: bool
class EvaluationSchedule:
def __init__(self, computations, known, labels=None):
self.computations = tuple(computations)
self.labels = labels or {}
self.producers = {}
for i, op in enumerate(self.computations):
for output in op.outputs:
if output in self.producers:
raise NativeCapabilityError(f'Multiple native producers for {self.label(output)}')
self.producers[output] = i
# An iterative initial guess is not a known source if an equation owns it.
used = set().union(*(op.inputs for op in self.computations)) if self.computations else set()
self.known = {key: value for key, value in known.items() if key not in self.producers and key in used}
self.dependencies = []
for op in self.computations:
missing = op.inputs - self.producers.keys() - self.known.keys()
if missing:
raise NativeCapabilityError(f'{op.key}: missing native input sources: {sorted(map(self.label, missing))}')
self.dependencies.append({self.producers[key] for key in op.inputs if key in self.producers})
self.blocks = self._blocks()
def label(self, key):
return self.labels.get(key, key)
def _blocks(self):
"""Iterative SCC discovery followed by deterministic topological order."""
count = len(self.computations)
consumers = [set() for _ in range(count)]
for target, sources in enumerate(self.dependencies):
for source in sources:
consumers[source].add(target)
visited, finish = set(), []
for start in range(count):
if start in visited:
continue
visited.add(start)
stack = [(start, iter(sorted(consumers[start])))]
while stack:
node, edges = stack[-1]
child = next(edges, None)
if child is None:
finish.append(node)
stack.pop()
elif child not in visited:
visited.add(child)
stack.append((child, iter(sorted(consumers[child]))))
groups, owner = [], {}
for start in reversed(finish):
if start in owner:
continue
index = len(groups)
owner[start] = index
members, stack = [], [start]
while stack:
node = stack.pop()
members.append(node)
for child in sorted(self.dependencies[node]):
if child not in owner:
owner[child] = index
stack.append(child)
groups.append(tuple(sorted(members)))
incoming = [set() for _ in groups]
outgoing = [set() for _ in groups]
for target, sources in enumerate(self.dependencies):
for source in sources:
a, b = owner[source], owner[target]
if a != b:
incoming[b].add(a)
outgoing[a].add(b)
ready = [(min(groups[i]), i) for i, inputs in enumerate(incoming) if not inputs]
heapq.heapify(ready)
blocks = []
while ready:
_, index = heapq.heappop(ready)
members = groups[index]
cyclic = len(members) > 1 or members[0] in self.dependencies[members[0]]
blocks.append(Block(members, cyclic))
for child in sorted(outgoing[index]):
incoming[child].remove(index)
if not incoming[child]:
heapq.heappush(ready, (min(groups[child]), child))
return tuple(blocks)
def ordered_subset(self, members):
"""Order a trial's computations while pressure/stream guesses are fixed."""
pending = set(members)
result = []
while pending:
ready = sorted(i for i in pending if not self.dependencies[i] & pending)
if not ready:
raise NativeCapabilityError('Unsupported cycle within native flow expressions')
result.extend(ready)
pending.difference_update(ready)
return result
def ancestors(self, inputs, allowed):
pending = [self.producers[key] for key in inputs if key in self.producers]
found = set()
while pending:
index = pending.pop()
if index in found or index not in allowed:
continue
found.add(index)
pending.extend(self.dependencies[index])
return self.ordered_subset(found)
def report(self):
result = []
sources = {key: {str(origin)} for key, origin in self.known.items()}
for block in self.blocks:
outputs = {key for i in block.members for key in self.computations[i].outputs}
inputs = {key for i in block.members for key in self.computations[i].inputs} - outputs
origins = set().union(*(sources[key] for key in inputs)) if inputs else set()
for key in outputs:
sources[key] = origins
result.append({
'cyclic': block.cyclic,
'inputs': sorted(map(self.label, inputs)),
'outputs': sorted(map(self.label, outputs)),
'origins': sorted(origins),
'operations': [self.computations[i].key for i in block.members],
'pressureUnknowns': [self.label(key) for i in block.members
if self.computations[i].kind == 'pressure'
for key in self.computations[i].outputs],
})
return {
'strategy': 'dependency-blocks',
'operationCount': len(self.computations),
'cyclicBlockCount': sum(b.cyclic for b in self.blocks),
'knownSources': {self.label(key): value for key, value in self.known.items()},
'blocks': result,
'operations': [{'key': op.key, 'kind': op.kind,
'inputs': sorted(map(self.label, op.inputs)),
'outputs': list(map(self.label, op.outputs))}
for op in self.computations],
}
def emit(self):
"""Return C helper definitions and a straight-line/local-block schedule.
Existing scalar pressure bisection and stream convergence tolerances are
retained. Only the computations in the relevant SCC participate in each
closure; a pressure trial further restricts work to its residual inputs.
"""
helpers, lines = [], []
args = 'p,h,g,w,q,fb,pipe_cache,properties'
signature = ('double *p,double *h,const NativeGas *g,double *w,double *q,'
'double *fb,NativePipeCache *pipe_cache,NativePropertyCache *properties')
def code(indices):
return [line for i in indices for line in
(f'/* schedule operation {i}: {self.computations[i].kind} */', *self.computations[i].code)]
def helper(name, indices):
helpers.extend([f'static int {name}({signature}) {{',
'(void)p;(void)h;(void)g;(void)w;(void)q;(void)fb;(void)pipe_cache;(void)properties;',
*code(indices), 'return 1;', '}'])
return f'if(!{name}({args})) return 0;'
for number, block in enumerate(self.blocks):
if not block.cyclic:
if self.computations[block.members[0]].kind == 'pressure':
raise NativeCapabilityError('Pressure balance has no pressure-dependent flow relation')
lines.extend(code(block.members))
continue
pressures = [i for i in block.members if self.computations[i].kind == 'pressure']
streams = [i for i in block.members if self.computations[i].kind in ('stream', 'alias')]
flows = set(block.members) - set(pressures) - set(streams)
if all(self.computations[i].kind == 'alias' for i in block.members):
raise NativeCapabilityError('Enthalpy reference cycle has no thermodynamic source: ' +
', '.join(self.computations[i].key for i in block.members))
refresh = helper(f'model_block_{number}_flows', self.ordered_subset(flows)) if flows else ''
lines.append(f'{{ /* local algebraic block {number} */')
hvars = [key for i in streams for key in self.computations[i].outputs]
outputs = {key for i in block.members for key in self.computations[i].outputs}
inputs = sorted({key for i in block.members for key in self.computations[i].inputs} - outputs)
if hvars:
seeds = [key for key in inputs if key.startswith('h[') or key.endswith('.h')]
if not seeds:
raise NativeCapabilityError('Local stream loop has no supplied thermodynamic state')
lines.extend([*[f'{key}={seeds[0]};' for key in hvars], 'int closure_ok=0;',
f'for(int closure=0;closure<{max(64,4*len(hvars))};closure++) {{',
'double previous[]={' + ','.join(hvars) + '};'])
if pressures:
pressure_bounds = [key for key in inputs if key.startswith('p[') or key.endswith('.p')]
if not pressure_bounds:
raise NativeCapabilityError('Local pressure block has no pressure boundary')
lines.extend(['double plo=INFINITY,phi=0;',
*[f'plo=fmin(plo,{p});phi=fmax(phi,{p});' for p in pressure_bounds],
*[f'{self.computations[i].outputs[0]}=.5*(plo+phi);' for i in pressures],
'int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {'])
for i in pressures:
op = self.computations[i]
p = op.outputs[0]
trial = helper(f'model_block_{number}_pressure_{i}', self.ancestors(op.inputs, flows))
lines.extend(['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',
f'{p}=.5*(lo+hi);', trial,
f'double balance={op.residual};if(!isfinite(balance)) return 0;',
f'if(balance>0) hi={p};else lo={p};', '}}'])
lines.extend([refresh, 'double residual=0;',
*[f'{{double balance={self.computations[i].residual};if(!isfinite(balance)) return 0;residual=fmax(residual,fabs(balance));}}' for i in pressures],
'if(residual<1e-11) {pressure_ok=1;break;}', '}',
'if(!pressure_ok) return 0;'])
elif refresh:
lines.append(refresh)
if hvars:
# Gauss-Seidel within a genuine stream loop, in stable emission order.
lines.extend([*code(streams), 'double change=0;',
*[line for j,key in enumerate(hvars) for line in
(f'if(!isfinite({key})) return 0;',
f'change=fmax(change,fabs({key}-previous[{j}])/fmax(1,fabs({key})));')],
'if(change<1e-12) {closure_ok=1;break;}', '}',
'if(!closure_ok) return 0;', refresh])
lines.append('}')
return helpers, lines
+208
View File
@@ -0,0 +1,208 @@
"""Small, uncached compile/link/run check using the production native toolchain."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
from threading import Event
from time import monotonic
from typing import Callable
from .build import CACHE, NativeCommandError, link_library_arguments, platform_build_inputs, toolchain
from .processes import command_environment, is_driver_launch_failure
STAGE_MESSAGES = {
"toolchain": "正在查找编译器与 SUNDIALS",
"preparing": "正在准备最小自检程序",
"preprocessing": "正在预处理最小自检程序",
"compiling": "正在编译最小自检程序",
"linking": "正在链接最小自检程序",
"running": "正在运行最小自检程序",
"schema": "正在检查模型 XML 解析环境",
}
OUTPUT_MARKER = "native-runtime-check-ok"
# One smooth scalar equation exercises every library used by production BDF.
# Its known answer is independent of the user's model and cannot hit model cache.
SOURCE = r'''
#include <cvode/cvode.h>
#include <cvode/cvode_ls.h>
#include <nvector/nvector_serial.h>
#include <sunmatrix/sunmatrix_dense.h>
#include <sunlinsol/sunlinsol_dense.h>
#include <math.h>
#include <stdio.h>
static int rhs(sunrealtype t, N_Vector y, N_Vector dy, void *data) {
(void)t; (void)data;
NV_Ith_S(dy,0)=-NV_Ith_S(y,0);
return 0;
}
int main(void) {
SUNContext context=NULL;
N_Vector y=NULL;
SUNMatrix matrix=NULL;
SUNLinearSolver linear=NULL;
void *solver=NULL;
int result=1;
sunrealtype time=0;
if (SUNContext_Create(SUN_COMM_NULL,&context)) goto done;
y=N_VNew_Serial(1,context);
if (!y) goto done;
NV_Ith_S(y,0)=1;
matrix=SUNDenseMatrix(1,1,context);
if (!matrix) goto done;
linear=SUNLinSol_Dense(y,matrix,context);
solver=CVodeCreate(CV_BDF,context);
if (!linear || !solver) goto done;
if (CVodeInit(solver,rhs,0,y)<0 ||
CVodeSStolerances(solver,1e-8,1e-10)<0 ||
CVodeSetLinearSolver(solver,linear,matrix)<0 ||
CVode(solver,0.1,y,&time,CV_NORMAL)<0) goto done;
if (!isfinite(NV_Ith_S(y,0)) || fabs(NV_Ith_S(y,0)-exp(-0.1))>1e-6) goto done;
puts("native-runtime-check-ok");
result=0;
done:
if (result) fputs("Minimal BDF self-check failed.\n",stderr);
if (solver) CVodeFree(&solver);
if (linear) SUNLinSolFree(linear);
if (matrix) SUNMatDestroy(matrix);
if (y) N_VDestroy(y);
if (context) SUNContext_Free(&context);
return result;
}
'''
class CheckCancelled(Exception):
pass
class CheckFailure(RuntimeError):
def __init__(self, message: str, *, command=(), cwd=None, exit_code=None,
stdout="", stderr="", error_type="RuntimeError"):
super().__init__(message)
self.details = {"command": list(command), "cwd": str(cwd) if cwd else None,
"exitCode": exit_code, "stdout": stdout[-8000:],
"stderr": stderr[-8000:], "errorType": error_type}
def _stop_process_tree(process: subprocess.Popen) -> None:
# GCC starts cc1/as/ld. Killing only its driver can leave inherited pipes open.
if os.name == "nt":
try:
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW)
except (OSError, subprocess.SubprocessError):
pass
if process.poll() is None:
process.kill()
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def _command(command: list[str], directory: Path, stop: Event, timeout: float = 30) -> str:
if stop.is_set():
raise CheckCancelled()
try:
with subprocess.Popen(command, cwd=directory, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env=command_environment(command[0]), close_fds=True,
start_new_session=os.name != "nt",
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) as process:
deadline = monotonic() + timeout
while True:
try:
stdout, stderr = process.communicate(timeout=0.2)
break
except subprocess.TimeoutExpired:
if stop.is_set() or monotonic() >= deadline:
_stop_process_tree(process)
stdout, stderr = process.communicate()
if stop.is_set():
raise CheckCancelled()
raise CheckFailure(f"命令执行超过 {timeout:g} 秒", command=command,
cwd=directory, exit_code=process.returncode,
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
error_type="TimeoutExpired")
except OSError as exc:
raise CheckFailure(str(exc), command=command, cwd=directory,
error_type=type(exc).__name__) from exc
output = stdout.decode("utf-8", errors="replace")
if process.returncode:
stderr_text = stderr.decode('utf-8', errors='replace')
message = ('GCC 已启动,但无法启动编译子进程;该错误不能直接判定为编译器不存在'
if is_driver_launch_failure(stderr_text) else '命令执行失败')
# Whole-check retries remain owned by warmup (initial check + 5 rounds).
raise CheckFailure(message, command=command, cwd=directory,
exit_code=process.returncode, stdout=output,
stderr=stderr_text,
error_type="CalledProcessError")
return output
def check_native_runtime(progress: Callable[..., None], stop: Event) -> None:
def stage(name: str, **details):
if stop.is_set():
raise CheckCancelled()
progress(stage=name, message=STAGE_MESSAGES[name], **details)
candidate = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
stage("toolchain", compiler=candidate)
try:
compiler, sundials, version = toolchain()
except NativeCommandError as exc:
details = exc.details
failure = CheckFailure(str(exc), command=details['command'], cwd=details['cwd'],
exit_code=details['exitCode'], stdout=details['stdout'], stderr=details['stderr'],
error_type=details['errorType'])
failure.details['attempts'] = details['attempts']
raise failure from exc
except subprocess.SubprocessError as exc:
def decoded(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else (value or "")
raise CheckFailure(str(exc), command=getattr(exc, "cmd", ()), cwd=Path.cwd(),
exit_code=getattr(exc, "returncode", None),
stdout=decoded(getattr(exc, "stdout", "")),
stderr=decoded(getattr(exc, "stderr", "")),
error_type=type(exc).__name__) from exc
except OSError as exc:
raise CheckFailure(str(exc), command=[candidate, "--version"] if candidate else [],
cwd=Path.cwd(), error_type=type(exc).__name__) from exc
compiler = str(Path(shutil.which(compiler) or compiler).resolve())
stage("preparing", compiler=compiler, compilerVersion=version, sundialsRoot=str(sundials))
flags, libraries, dlls, executable_name = platform_build_inputs(sundials)
# Keep the probe on the build filesystem: Linux /tmp may be mounted noexec.
# Disposable probes never populate or evict the model/object caches.
directory_root = CACHE.parent / "native-runtime-checks"
directory_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="check-", dir=directory_root) as temporary:
directory = Path(temporary).resolve()
source, preprocessed, obj = directory / "check.c", directory / "check.i", directory / "check.o"
executable = directory / executable_name
source.write_text(SOURCE, encoding="utf-8", newline="\n")
stage("preprocessing")
_command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={directory}=/generated",
"-I", str(sundials / "include"), str(source), "-o", str(preprocessed)], directory, stop)
stage("compiling")
_command([compiler, *flags, "-x", "cpp-output", "-c", str(preprocessed), "-o", str(obj)], directory, stop)
stage("linking")
_command([compiler, *flags, str(obj), *link_library_arguments(libraries),
"-lm", "-o", str(executable)], directory, stop)
stage("running")
for dll in dlls:
shutil.copyfile(dll, directory / dll.name)
output = _command([str(executable)], directory, stop, timeout=10)
if output.strip() != OUTPUT_MARKER:
raise CheckFailure("最小程序未返回预期的自检成功标记", command=[str(executable)],
cwd=directory, exit_code=0, stdout=output, error_type="UnexpectedOutput")
stage("schema")
from app.system_xml import validate_system_xml_document
validate_system_xml_document(b"<System/>")
@@ -0,0 +1,18 @@
"""SI absolute error floors shared by both native code generators.
Mass must not inherit the energy floor: 1e-8 kg overwhelmed relative error
control in small pipe volumes. These conservative defaults were validated on
the pipe and chamber regressions; model-scale/user tolerance settings remain
a separate protocol change.
"""
def state_absolute_tolerance(key: str) -> str:
field = key.rsplit('.', 1)[-1]
if field in ('m', 'm1', 'm2'):
return '1e-14' # kg
if field in ('v', 'x'):
return '1e-12' # m/s or m
if field == '_volume_displacement':
return '1e-14' # m3, prescribed external volume change
return '1e-8' # J (or the internal constant state of an algebraic model)
@@ -0,0 +1,63 @@
"""Carry trusted C-generated series JSON without a Python float-array round trip.
Only native output plus its byte index may construct this transport object. Public
JSON still has the ordinary object/array schema; synchronous callers materialize
it explicitly. Bytes own their lifetime independently of the worker directory.
"""
from dataclasses import dataclass
import json
from pathlib import Path
@dataclass(frozen=True)
class NativeSeriesJson:
data: bytes
sample_count: int
def materialize(self) -> dict[str, list[float]]:
return json.loads(self.data)
def read_indexed_result(output: Path, index_path: Path) -> dict:
index = json.loads(index_path.read_bytes())
if not isinstance(index, dict) or type(index.get('version')) is not int or index['version'] != 1:
raise ValueError('Unsupported native result index.')
names = ('seriesStart', 'seriesEnd', 'resultBytes', 'sampleCount')
if any(type(index.get(key)) is not int for key in names):
raise ValueError('Invalid native result index integers.')
start, end, length, count = (index[key] for key in names)
if not (0 < start < end < length and count >= 0):
raise ValueError('Invalid native result index bounds.')
if output.stat().st_size != length:
raise ValueError('Incomplete native result file.')
raw = output.read_bytes()
if (len(raw) != length or not raw[:start].endswith(b'"series":')
or raw[start:start+1] != b'{' or raw[end-1:end] != b'}'
or not raw[end:].startswith(b',"final":')):
raise ValueError('Native result index does not match the output layout.')
# The C writer supplies exact boundaries. No search through strings or numeric
# arrays, and no large json.loads call: only diagnostics/final values are read.
metadata = json.loads(raw[:start] + b'{}' + raw[end:])
if not isinstance(metadata, dict) or metadata.get('series') != {}:
raise ValueError('Invalid native result metadata.')
metadata['series'] = NativeSeriesJson(raw[start:end], count)
return metadata
def serialize_result_parts(payload: dict) -> tuple[bytes, ...]:
"""Return one JSON object in three byte segments, without copying its series.
The only raw position is payload.result.series, produced by our C writer;
all model-provided labels, messages and keys use the standard JSON encoder.
"""
result = payload.get('result')
series = result.get('series') if isinstance(result, dict) else None
if not isinstance(series, NativeSeriesJson):
return (json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode('utf-8'),)
metadata = {key: value for key, value in result.items() if key != 'series'}
outer = {key: value for key, value in payload.items() if key != 'result'}
encoded = json.dumps(metadata, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
remainder = json.dumps(outer, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
prefix = b'{"result":' + encoded[:-1] + (b',' if metadata else b'') + b'"series":'
suffix = b'}' + (b',' + remainder[1:] if outer else b'}')
return prefix, series.data, suffix
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import os
from pathlib import Path
APP_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = APP_DIR.parent
_configured_data_dir = os.getenv("SIMULATIONAPP_DATA_DIR")
DATA_DIR = (
Path(_configured_data_dir).expanduser().resolve()
if _configured_data_dir
else APP_DIR / "data"
)
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines"
# Current browser/solver input and the matching AME drawing (September 2026).
CURRENT_MQL8_PROJECT_PATH = PROJECT_ROOT / "tests" / "data" / "test-mql-8-corrected.json"
CURRENT_MQL8_AME_PATH = PROJECT_ROOT / "tests" / "data" / "test_mql.ame"
# Frozen August reference: its AME bytes differ from the current drawing.
HISTORICAL_MQL8_AME_PATH = PROJECT_ROOT / "tests" / "data" / "AmesimModels" / "test_mql.ame"
HISTORICAL_MQL8_PROJECT_PATH = SIMULATION_BASELINES_DIR / "simulation" / "test_mql_8" / "sources" / "test-mql-8.json"
HISTORICAL_MQL8_XML_PATH = HISTORICAL_MQL8_PROJECT_PATH.with_suffix(".xml")
AMESIM_TEST_MQL_ARCHIVE_PATH = HISTORICAL_MQL8_AME_PATH
MODELICA_TESTMODEL_RESULT_PATH = (
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
)
+188
View File
@@ -0,0 +1,188 @@
"""Run-local timing of Python orchestration; numeric timings come from C."""
from __future__ import annotations
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from functools import wraps
import inspect
import os
from time import perf_counter_ns
from typing import Any, Literal, ParamSpec, TypeVar, cast
ProfileMode = Literal['off', 'standard', 'audit']
_P = ParamSpec('_P')
_R = TypeVar('_R')
_MODE_RANK: Mapping[ProfileMode, int] = {'off': 0, 'standard': 1, 'audit': 2}
def _read_startup_mode() -> ProfileMode:
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
aliases: dict[str, ProfileMode] = {
"": "off",
"0": "off",
"false": "off",
"no": "off",
"off": "off",
"1": "standard",
"true": "standard",
"yes": "standard",
"on": "standard",
"standard": "standard",
"audit": "audit",
}
try:
return aliases[raw_mode]
except KeyError as exc:
raise ValueError(
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
) from exc
def _minimum_mode(value: str) -> ProfileMode:
normalized = value.strip().lower()
if normalized not in _MODE_RANK:
raise ValueError("minimum_mode must be one of: off, standard, audit.")
return cast(ProfileMode, normalized)
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
# ``minimum_mode="off"`` by mistake.
return (
PROFILE_MODE != "off"
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
)
@dataclass
class _TimingStats:
calls: int = 0
inclusive_ns: int = 0
self_ns: int = 0
max_ns: int = 0
errors: int = 0
def record(self, inclusive_ns: int, self_ns: int, error: bool) -> None:
self.calls += 1
self.inclusive_ns += inclusive_ns
self.self_ns += self_ns
self.max_ns = max(self.max_ns, inclusive_ns)
if error:
self.errors += 1
def snapshot(self) -> dict[str, int]:
return {
"calls": self.calls,
"inclusiveNs": self.inclusive_ns,
"selfNs": self.self_ns,
"maxNs": self.max_ns,
"errors": self.errors,
}
def profile_phase(
name: str,
minimum_mode: str = "standard",
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Decorate a simulation phase while preserving the off-mode callable."""
minimum = _minimum_mode(minimum_mode)
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
if not _mode_enabled(minimum):
return function
if inspect.iscoroutinefunction(function):
@wraps(function)
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
trace = _CURRENT_TRACE.get()
if trace is None:
return await function(*args, **kwargs)
with _tracked_span(
trace,
name,
):
return await function(*args, **kwargs)
return cast(Callable[_P, _R], async_wrapper)
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
trace = _CURRENT_TRACE.get()
if trace is None:
return function(*args, **kwargs)
with _tracked_span(
trace,
name,
):
return function(*args, **kwargs)
return wrapper
return decorate
PROFILE_MODE = _read_startup_mode()
@dataclass
class _ActiveSpan:
name: str
started_ns: int
child_ns: int = 0
@dataclass
class PerformanceTrace:
mode: ProfileMode
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
@property
def enabled(self):
return self.mode != 'off'
def snapshot(self):
return {'mode': self.mode, 'phases': {key: value.snapshot() for key, value in sorted(self._phases.items())}}
_CURRENT_TRACE = ContextVar('simulation_trace', default=None)
_ACTIVE_SPANS = ContextVar('simulation_spans', default=())
@contextmanager
def _tracked_span(trace, name):
stack = _ACTIVE_SPANS.get()
frame = _ActiveSpan(name, perf_counter_ns())
token = _ACTIVE_SPANS.set((*stack, frame))
error = False
try:
yield
except BaseException:
error = True
raise
finally:
elapsed = max(0, perf_counter_ns()-frame.started_ns)
_ACTIVE_SPANS.reset(token)
if stack:
stack[-1].child_ns += elapsed
trace._phases.setdefault(name, _TimingStats()).record(elapsed, max(0, elapsed-frame.child_ns), error)
@contextmanager
def profile_run():
trace = PerformanceTrace(PROFILE_MODE)
token = _CURRENT_TRACE.set(trace)
spans = _ACTIVE_SPANS.set(())
try:
if trace.enabled:
with _tracked_span(trace, 'simulation.total'):
yield trace
else:
yield trace
finally:
_ACTIVE_SPANS.reset(spans)
_CURRENT_TRACE.reset(token)
@contextmanager
def performance_span(name: str, minimum_mode: str = 'standard'):
minimum = _minimum_mode(minimum_mode)
trace = _CURRENT_TRACE.get()
if trace is None or not _mode_enabled(minimum):
yield
return
with _tracked_span(trace, name):
yield
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
"""Readers for external simulation results."""
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import re
import struct
import tarfile
from dataclasses import dataclass
from pathlib import Path
class AmesimResultsError(ValueError):
"""Raised when AMESim result files cannot be parsed consistently."""
@dataclass(frozen=True)
class AmesimVariable:
index: int
label: str
data_path: str | None
param_id: int | None
hidden: bool
@dataclass(frozen=True)
class AmesimResults:
times: tuple[float, ...]
variables: tuple[AmesimVariable, ...]
saved_variable_indices: tuple[int, ...]
series_by_data_path: dict[str, tuple[float, ...]]
final_values_by_data_path: dict[str, float]
@property
def point_count(self) -> int:
return len(self.times)
@property
def saved_variable_count(self) -> int:
return len(self.saved_variable_indices)
def series(self, data_path: str) -> tuple[float, ...]:
return self.series_by_data_path[data_path]
def final_value(self, data_path: str) -> float:
return self.final_values_by_data_path[data_path]
_DATA_PATH_RE = re.compile(r"Data_Path=(\S+)")
_PARAM_ID_RE = re.compile(r"Param_Id=(\d+)")
def load_test_mql_amesim_results(
archive_path: str | Path,
*,
time_stop_s: float | None = None,
) -> AmesimResults:
return load_amesim_results_from_archive(
archive_path=archive_path,
var_member=None,
results_member=None,
time_stop_s=time_stop_s,
)
def load_amesim_results_from_archive(
*,
archive_path: str | Path,
var_member: str | None,
results_member: str | None,
time_stop_s: float | None = None,
) -> AmesimResults:
with tarfile.open(archive_path) as archive:
var_member, results_member = _resolve_result_members(
archive,
var_member=var_member,
results_member=results_member,
)
var_file = archive.extractfile(var_member)
results_file = archive.extractfile(results_member)
if var_file is None:
raise AmesimResultsError(f"Missing AMESim variable member: {var_member}")
if results_file is None:
raise AmesimResultsError(f"Missing AMESim results member: {results_member}")
var_lines = var_file.read().decode("latin1").splitlines()
variables = tuple(
_parse_variable_line(index, line) for index, line in enumerate(var_lines)
)
if time_stop_s is not None:
return _parse_amesim_results_window(
results_file,
variables,
time_stop_s=time_stop_s,
)
results_data = results_file.read()
return parse_amesim_results_bytes(results_data, variables)
def _resolve_result_members(
archive: tarfile.TarFile,
*,
var_member: str | None,
results_member: str | None,
) -> tuple[str, str]:
member_names = set(archive.getnames())
if var_member is not None or results_member is not None:
if var_member is None or results_member is None:
raise AmesimResultsError(
"var_member and results_member must either both be set or both be omitted."
)
return var_member, results_member
preferred = ("test_mql_.var", "test_mql_.results")
if preferred[0] in member_names and preferred[1] in member_names:
return preferred
pairs = sorted(
(name, f"{name[:-4]}.results")
for name in member_names
if name.endswith(".var") and f"{name[:-4]}.results" in member_names
)
if len(pairs) != 1:
raise AmesimResultsError(
"Unable to identify a unique AMESim .var/.results member pair."
)
return pairs[0]
def _parse_amesim_results_window(
results_file,
variables: tuple[AmesimVariable, ...],
*,
time_stop_s: float,
) -> AmesimResults:
header = results_file.read(8)
if len(header) < 8:
raise AmesimResultsError("AMESim results data is too small.")
point_count, encoded_saved_variable_count = struct.unpack("<2i", header)
saved_variable_count = abs(encoded_saved_variable_count)
if point_count <= 0 or saved_variable_count <= 0:
raise AmesimResultsError("Invalid AMESim results header.")
mapping_data = results_file.read(saved_variable_count * 4)
if len(mapping_data) != saved_variable_count * 4:
raise AmesimResultsError("AMESim results variable mapping is truncated.")
saved_variable_indices = struct.unpack(
f"<{saved_variable_count}i",
mapping_data,
)
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
raise AmesimResultsError(
"AMESim results variable mapping references unknown .var rows."
)
row_length = 1 + saved_variable_count
row_byte_count = row_length * 8
times: list[float] = []
series_lists: dict[str, list[float]] = {}
saved_paths: list[tuple[int, str]] = []
for column, variable_index in enumerate(saved_variable_indices, start=1):
data_path = variables[variable_index].data_path
if data_path is None:
continue
series_lists[data_path] = []
saved_paths.append((column, data_path))
for _row_index in range(point_count):
row = results_file.read(row_byte_count)
if len(row) != row_byte_count:
raise AmesimResultsError("AMESim results matrix is truncated.")
time_s = struct.unpack_from("<d", row, 0)[0]
times.append(time_s)
for column, data_path in saved_paths:
series_lists[data_path].append(
struct.unpack_from("<d", row, column * 8)[0]
)
# Keep one real sample after the requested stop so endpoint finite
# differences do not silently fall back to a backward-only slope.
if time_s > time_stop_s + 1.0e-12:
break
return AmesimResults(
times=tuple(times),
variables=variables,
saved_variable_indices=tuple(saved_variable_indices),
series_by_data_path={
data_path: tuple(values) for data_path, values in series_lists.items()
},
final_values_by_data_path={},
)
def parse_amesim_results_bytes(
results_data: bytes,
variables: tuple[AmesimVariable, ...],
) -> AmesimResults:
if len(results_data) < 8:
raise AmesimResultsError("AMESim results data is too small.")
point_count, encoded_saved_variable_count = struct.unpack_from("<2i", results_data, 0)
saved_variable_count = abs(encoded_saved_variable_count)
if point_count <= 0 or saved_variable_count <= 0:
raise AmesimResultsError("Invalid AMESim results header.")
mapping_offset = 8
mapping_size = saved_variable_count * 4
data_offset = mapping_offset + mapping_size
saved_variable_indices = struct.unpack_from(
f"<{saved_variable_count}i",
results_data,
mapping_offset,
)
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
raise AmesimResultsError(
"AMESim results variable mapping references unknown .var rows."
)
row_length = 1 + saved_variable_count
main_value_count = point_count * row_length
main_byte_count = main_value_count * 8
main_end = data_offset + main_byte_count
if main_end > len(results_data):
raise AmesimResultsError("AMESim results matrix is truncated.")
main_values = struct.unpack_from(f"<{main_value_count}d", results_data, data_offset)
times = tuple(main_values[row * row_length] for row in range(point_count))
series_by_data_path: dict[str, tuple[float, ...]] = {}
for column, variable_index in enumerate(saved_variable_indices, start=1):
variable = variables[variable_index]
if variable.data_path is None:
continue
series_by_data_path[variable.data_path] = tuple(
main_values[row * row_length + column]
for row in range(point_count)
)
final_values_by_data_path: dict[str, float] = {}
trailing_bytes = len(results_data) - main_end
expected_final_bytes = (1 + len(variables)) * 8
if trailing_bytes >= expected_final_bytes:
final_values = struct.unpack_from(f"<{1 + len(variables)}d", results_data, main_end)
for variable, value in zip(variables, final_values[1:]):
if variable.data_path is not None:
final_values_by_data_path[variable.data_path] = value
return AmesimResults(
times=times,
variables=variables,
saved_variable_indices=tuple(saved_variable_indices),
series_by_data_path=series_by_data_path,
final_values_by_data_path=final_values_by_data_path,
)
def _parse_variable_line(index: int, line: str) -> AmesimVariable:
data_path_match = _DATA_PATH_RE.search(line)
param_id_match = _PARAM_ID_RE.search(line)
label = line
if data_path_match is not None:
label = line[: data_path_match.start()].strip()
return AmesimVariable(
index=index,
label=label,
data_path=data_path_match.group(1) if data_path_match else None,
param_id=int(param_id_match.group(1)) if param_id_match else None,
hidden="HIDDEN" in line,
)
+56
View File
@@ -0,0 +1,56 @@
"""Backend-neutral simulation result and preparation error contracts."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from app.simulation.native_codegen.transport import NativeSeriesJson
from app.simulation.core.metadata import ResultVariableMetadata
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
@dataclass(frozen=True)
class SimulationPreparationIssue:
code: str
message: str
def as_dict(self) -> dict[str, str]:
return {"code": self.code, "message": self.message}
class SimulationPreparationError(ValueError):
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
super().__init__("The compiled model is not ready for simulation.")
self.issues = issues
@dataclass(frozen=True)
class GenericSimulationResult:
success: bool
status: SimulationRunStatus
message: str
simulated_until: float
requested_stop_time: float
variables: tuple[ResultVariableMetadata, ...]
series: dict[str, list[float]] | NativeSeriesJson
final: dict[str, float]
diagnostics: dict[str, object]
def as_dict(self, *, raw_series: bool = False) -> dict[str, object]:
from app.simulation.native_codegen.transport import NativeSeriesJson
series = self.series
if isinstance(series, NativeSeriesJson) and not raw_series:
series = series.materialize()
return {
"success": self.success,
"status": self.status,
"partial": self.status != "completed",
"message": self.message,
"simulatedUntil": self.simulated_until,
"requestedStopTime": self.requested_stop_time,
"variables": [variable.as_dict() for variable in self.variables],
"series": series,
"final": self.final,
"diagnostics": self.diagnostics,
}
+101
View File
@@ -0,0 +1,101 @@
"""Validate the requested output time grid before starting the C worker."""
from __future__ import annotations
from math import floor, isfinite
from sys import maxsize
from app.simulation.config import SolveIVPConfig
class SimulationSampleTimeError(ValueError):
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def simulation_sample_times(
config: SolveIVPConfig,
step: float,
) -> list[float]:
t_start = float(config.t_start)
t_stop = float(config.t_stop)
if not isfinite(t_start) or not isfinite(t_stop):
raise SimulationSampleTimeError(
"SIMULATION_VALUE_NOT_FINITE",
"Simulation start and stop times must be finite.",
)
if step <= 0.0 or not isfinite(step):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_STEP_INVALID",
"Simulation sample step must be finite and greater than zero.",
)
duration = t_stop - t_start
if not isfinite(duration):
raise SimulationSampleTimeError(
"SIMULATION_TIME_SPAN_NOT_FINITE",
"Simulation time span must be finite.",
)
if duration <= 0.0:
raise SimulationSampleTimeError(
"SIMULATION_TIME_RANGE_INVALID",
"Simulation stop time must be greater than start time.",
)
ratio = duration / step
if not isfinite(ratio):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
"Simulation sample count cannot be represented by this runtime; "
"increase sampleStep.",
)
interval_count = floor(ratio)
# There is no product-level point cap. Still reject a collection that the
# Python runtime cannot index before multiplying by the potentially huge
# interval count or allocating the output grid.
if interval_count > maxsize - 2:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
"Simulation sample count cannot be represented by this runtime; "
"increase sampleStep.",
)
last_regular_time = t_start + interval_count * step
append_stop = last_regular_time < t_stop
requested_point_count = interval_count + 1 + int(append_stop)
if requested_point_count > maxsize:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
"Simulation sample count cannot be represented by this runtime; "
"increase sampleStep.",
)
times = [t_start]
for index in range(1, interval_count + 1):
candidate = t_start + index * step
if not isfinite(candidate):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sampleStep cannot be represented over the requested "
"absolute time range.",
)
if candidate >= t_stop:
candidate = t_stop
if candidate <= times[-1]:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sampleStep is too small to advance floating-point "
"time over the requested absolute time range.",
)
times.append(candidate)
if candidate == t_stop:
break
if times[-1] < t_stop:
times.append(t_stop)
if len(times) < 2 or any(
current >= following
for current, following in zip(times, times[1:])
):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sample times must contain at least two strictly "
"increasing values.",
)
return times
File renamed without changes.
+314
View File
@@ -0,0 +1,314 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.equations import EquationDefinition
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.core.ports import PortState
from app.simulation.core.port_computation import (
PortSupplyError, port_supply_issue, reference_supply_issues,
)
@dataclass(frozen=True)
class Endpoint:
component: str
port: str
@property
def key(self) -> tuple[str, str]:
return self.component, self.port
def __str__(self) -> str:
return f"{self.component}.{self.port}"
@dataclass(frozen=True)
class Connection:
id: str
kind: str
domain: str
endpoint_a: Endpoint
endpoint_b: Endpoint
@property
def endpoints(self) -> tuple[Endpoint, Endpoint]:
return self.endpoint_a, self.endpoint_b
@property
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
first, second = sorted((self.endpoint_a.key, self.endpoint_b.key))
return first, second
# Compatibility accessors for existing reports. They do not imply physical flow.
@property
def source_component(self) -> str:
return self.endpoint_a.component
@property
def source_port(self) -> str:
return self.endpoint_a.port
@property
def target_component(self) -> str:
return self.endpoint_b.component
@property
def target_port(self) -> str:
return self.endpoint_b.port
def as_interface_dict(self) -> dict[str, object]:
return {
"id": self.id,
"kind": self.kind,
"domain": self.domain,
"endpoints": [
{"component": endpoint.component, "port": endpoint.port}
for endpoint in self.endpoints
],
}
class SimulationNetwork:
"""Container for components, topology, and state-vector bookkeeping."""
def __init__(self, name: str) -> None:
self.name = name
self.components: dict[str, Component] = {}
self.connections: list[Connection] = []
def add_component(self, component: Component) -> None:
if component.name in self.components:
raise ValueError(f"Duplicate component name: {component.name}")
self.components[component.name] = component
def connect(
self,
endpoint_a_component: str,
endpoint_a_port: str,
endpoint_b_component: str,
endpoint_b_port: str,
*,
connection_id: str | None = None,
) -> Connection:
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
if endpoint_a.component == endpoint_b.component:
raise ValueError(
f"Cannot connect component {endpoint_a.component} to itself."
)
first_port = self._active_port_for(endpoint_a)
second_port = self._active_port_for(endpoint_b)
first_definition = first_port.definition
second_definition = second_port.definition
if first_definition is None or second_definition is None:
raise ValueError("Connected ports must expose interface definitions.")
if first_definition.kind != second_definition.kind:
raise ValueError(f"Connection mixes physical and signal ports: {endpoint_a}, {endpoint_b}.")
if first_definition.domain != second_definition.domain:
raise ValueError(f"Connection domains do not match: {endpoint_a}, {endpoint_b}.")
if first_definition.variables != second_definition.variables:
raise ValueError(
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
)
supply_issue = port_supply_issue(first_definition, second_definition,
str(endpoint_a), str(endpoint_b))
if supply_issue:
raise PortSupplyError(supply_issue)
if first_definition.kind == "signal" and {
first_definition.nominal_role,
second_definition.nominal_role,
} != {"input", "output"}:
raise ValueError("A signal connection must contain one output and one input.")
occupied_endpoints = {
endpoint
for item in self.connections
for endpoint in item.endpoints
}
if first_definition.kind == "physical":
occupied = [
str(endpoint)
for endpoint in (endpoint_a, endpoint_b)
if endpoint in occupied_endpoints
]
if occupied:
raise ValueError(
"Physical ports accept one connection; already connected: "
+ ", ".join(occupied)
+ ". Use a junction component for branching."
)
else:
signal_input = (
endpoint_a
if first_definition.nominal_role == "input"
else endpoint_b
)
if signal_input in occupied_endpoints:
raise ValueError(
f"Signal input {signal_input} already has a driver."
)
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
endpoint_a, endpoint_b = endpoint_b, endpoint_a
connection = Connection(
id=connection_id or f"connection_{len(self.connections) + 1}",
kind=first_definition.kind,
domain=first_definition.domain,
endpoint_a=endpoint_a,
endpoint_b=endpoint_b,
)
if any(item.undirected_key == connection.undirected_key for item in self.connections):
raise ValueError(f"Duplicate connection between {endpoint_a} and {endpoint_b}.")
if any(item.id == connection.id for item in self.connections):
raise ValueError(f"Duplicate connection id: {connection.id}.")
self.connections.append(connection)
return connection
def validate_port_supplies(self) -> None:
"""Validate again at compile time, including manually assembled networks."""
ports = {(c.name, p.name): p for c in self.components.values()
for p in c.active_port_definitions}
adjacency = {}
for edge in self.connections:
a, b = (endpoint.key for endpoint in edge.endpoints)
issue = port_supply_issue(ports[a], ports[b], '.'.join(a), '.'.join(b))
if issue:
raise PortSupplyError(issue)
if edge.kind == 'physical':
adjacency[a], adjacency[b] = b, a
issues = reference_supply_issues(ports, adjacency)
if issues:
raise PortSupplyError(issues[0])
def _port_for(self, endpoint: Endpoint) -> PortState:
try:
component = self.components[endpoint.component]
except KeyError as exc:
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
return component.get_port(endpoint.port)
def _active_port_for(self, endpoint: Endpoint) -> PortState:
try:
component = self.components[endpoint.component]
except KeyError as exc:
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
active_names = {
definition.name for definition in component.active_port_definitions
}
if endpoint.port not in active_names:
raise ValueError(
f"Port {endpoint} is not active for the component's current parameters."
)
return component.get_port(endpoint.port)
def connection_equation_definitions(self) -> tuple[EquationDefinition, ...]:
equations=[]
for connection in self.connections:
if connection.kind!='physical':continue
definition=self._port_for(connection.endpoint_a).definition
for variable in definition.variables:
if variable.connection_rule not in ('equal','sumToZero'):continue
equations.append(EquationDefinition(id=f'{connection.id}:{variable.name}',owner='connection',owner_id=connection.id,relation=variable.connection_rule,variables=(f'{connection.endpoint_a}.{variable.name}',f'{connection.endpoint_b}.{variable.name}'),role=variable.role))
return tuple(equations)
def equation_definitions(self) -> tuple[EquationDefinition, ...]:
"""Evaluate the complete algebraic pressure-flow equation subsystem."""
component_residuals = tuple(
residual
for component in self.components.values()
for residual in component.equation_definitions()
)
return component_residuals + self.connection_equation_definitions()
def pressure_flow_unknowns(self) -> tuple[str, ...]:
return tuple(
f"{component.name}.{definition.name}.{variable.name}"
for component in self.components.values()
for definition in component.active_port_definitions
if definition.kind == "physical"
for variable in definition.variables
if variable.role in {"effort", "flow"}
)
def pressure_flow_structure_dict(self) -> dict[str, object]:
unknowns = self.pressure_flow_unknowns()
equations = self.equation_definitions()
return {
"unknownCount": len(unknowns),
"equationCount": len(equations),
"isSquare": len(unknowns) == len(equations),
"unknowns": list(unknowns),
"equations": [
equation.as_definition_dict() for equation in equations
],
}
def dynamic_components(self) -> list[DynamicComponent]:
return [
component
for component in self.components.values()
if isinstance(component, DynamicComponent)
]
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
return tuple(
variable
for component in self.components.values()
for variable in component.result_variable_metadata()
)
def summary(self) -> str:
lines = [f"Network: {self.name}", "Components:"]
for name, component in self.components.items():
lines.append(f" - {name}: {component.__class__.__name__}")
lines.append("Connections:")
for conn in self.connections:
connector = "<->" if conn.kind == "physical" else "->"
lines.append(
f" - {conn.endpoint_a} {connector} {conn.endpoint_b}"
)
return "\n".join(lines)
def as_interface_dict(self) -> dict[str, object]:
connected_endpoints = {
endpoint.key
for connection in self.connections
for endpoint in connection.endpoints
}
return {
"name": self.name,
"components": [
{
"id": component.name,
"type": component.model_type,
"parameters": component.parameter_interface_dicts(),
"ports": [
definition.as_interface_dict()
for definition in component.active_port_definitions
],
"resultVariables": [
variable.as_dict()
for variable in component.result_variable_metadata()
],
}
for component in self.components.values()
],
"connections": [
connection.as_interface_dict() for connection in self.connections
],
"pressureFlowSystem": self.pressure_flow_structure_dict(),
"unconnectedPorts": [
{"component": component.name, "port": definition.name}
for component in self.components.values()
for definition in component.active_port_definitions
if (component.name, definition.name) not in connected_endpoints
],
}
+139
View File
@@ -0,0 +1,139 @@
"""Nonblocking startup diagnostics; editor availability is independent of GCC."""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime, timezone
import logging
import os
from threading import Event, Lock, Thread
from time import perf_counter
from uuid import uuid4
from .native_codegen.self_test import CheckCancelled, CheckFailure, check_native_runtime
LOGGER = logging.getLogger(__name__)
MAX_RETRIES = 5
RETRY_DELAY_SECONDS = 0.5
def simulation_warmup_enabled() -> bool:
value = os.getenv("SIMULATIONAPP_WARMUP", "on").strip().lower()
if value in {"", "1", "true", "yes", "on"}:
return True
if value in {"0", "false", "no", "off"}:
return False
raise ValueError("SIMULATIONAPP_WARMUP must be one of: on, off, true, false, 1, 0.")
class SimulationRuntimeCheck:
"""One bounded background check per application lifespan, with readable stages."""
def __init__(self):
self._lock = Lock()
self._stop = Event()
self._thread: Thread | None = None
self._history: list[dict] = []
self._started = perf_counter()
self._report = {"checkId": uuid4().hex, "status": "pending", "stage": "queued",
"message": "等待仿真环境启动自检", "durationMs": 0.0,
"attempt": 0, "maxRetries": MAX_RETRIES, "maxAttempts": MAX_RETRIES + 1,
"retryDelaySeconds": RETRY_DELAY_SECONDS, "startedAt": None, "finishedAt": None,
"compiler": None, "compilerVersion": None, "sundialsRoot": None,
"error": None, "failure": None}
def snapshot(self) -> dict:
with self._lock:
report = deepcopy(self._report)
if report["status"] in {"running", "retrying"}:
report["durationMs"] = (perf_counter() - self._started) * 1000
elif report["status"] in {"completed", "failed", "disabled", "cancelled"}:
# Preserve first-failure evidence without surfacing details mid-retry.
report["attemptHistory"] = deepcopy(self._history)
return report
def _update(self, **values):
with self._lock:
if values.get("status") in {"completed", "failed", "disabled", "cancelled"}:
values["durationMs"] = (perf_counter() - self._started) * 1000
values["finishedAt"] = datetime.now(timezone.utc).isoformat()
self._report.update(values)
def start(self):
with self._lock:
if self._thread is not None:
return
self._started = perf_counter()
self._report.update(status="running", message="正在进行仿真环境首次启动自检",
startedAt=datetime.now(timezone.utc).isoformat())
self._thread = Thread(target=self._run, name="simulation-runtime-check", daemon=True)
self._thread.start()
def _run(self):
for attempt in range(1, MAX_RETRIES + 2):
if self._stop.is_set():
self._cancel()
return
attempt_started = perf_counter()
self._update(status="running", attempt=attempt, stage="queued", error=None, failure=None,
compiler=None, compilerVersion=None, sundialsRoot=None)
def progress(**values):
if attempt > 1 and "message" in values:
values["message"] = f"正在重试(第 {attempt - 1}/{MAX_RETRIES} 轮):{values['message']}"
self._update(**values)
try:
if not simulation_warmup_enabled():
self._update(status="disabled", message="仿真环境启动自检已由服务器配置关闭")
return
check_native_runtime(progress, self._stop)
if self._stop.is_set():
raise CheckCancelled()
except CheckCancelled:
self._cancel()
return
except Exception as exc:
failure = deepcopy(exc.details) if isinstance(exc, CheckFailure) else {
"command": [], "cwd": None, "exitCode": None, "stdout": "", "stderr": "",
"errorType": type(exc).__name__,
}
with self._lock:
self._history.append({"attempt": attempt, "status": "failed", "stage": self._report["stage"],
"durationMs": (perf_counter() - attempt_started) * 1000,
"error": str(exc), "failure": failure})
if self._stop.is_set():
self._cancel()
return
if attempt <= MAX_RETRIES:
previous = "首次检测" if attempt == 1 else f"第 {attempt - 1} 轮重试"
message = (f"{previous}未通过,正在准备第 {attempt}/{MAX_RETRIES} 轮重试"
f"(间隔 {RETRY_DELAY_SECONDS:g} 秒);编辑器可正常使用")
self._update(status="retrying", stage="retry-wait", message=message)
LOGGER.warning("%s", message)
# Interruptible on both Windows and Linux; never sleep on the API thread.
if self._stop.wait(RETRY_DELAY_SECONDS):
self._cancel()
return
continue
self._update(status="failed", error=str(exc), failure=failure,
message=f"本次启动自检未通过:首次检测及 {MAX_RETRIES} 轮重试均失败;编辑器仍可使用")
LOGGER.error("Simulation startup self-check exhausted retries at %s; attempt history: %s",
self.snapshot()["stage"], self._history, exc_info=True)
return
else:
with self._lock:
self._history.append({"attempt": attempt, "status": "completed", "stage": "complete",
"durationMs": (perf_counter() - attempt_started) * 1000,
"error": None, "failure": None})
recovery = f"(第 {attempt - 1} 轮重试成功)" if attempt > 1 else ""
self._update(status="completed", stage="complete", error=None, failure=None,
message=f"本次启动自检通过,仿真环境可用{recovery}")
return
def _cancel(self):
self._update(status="cancelled", message="后端正在关闭,启动自检及重试已取消")
def close(self):
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=1)
Loaded 100 of 648 files, more files were not shown because too many files have changed in this diff. Show more