From 92d9c515c97fb52033da2e6e4ad3cac225c7cafc Mon Sep 17 00:00:00 2001 From: fmq Date: Thu, 23 Jul 2026 19:06:45 +0800 Subject: [PATCH] 2 --- cno_grid/DIST.md | 194 ++++++++ cno_grid/EXPERIENCE.md | 298 +++++++----- cno_grid/PIPELINE.md | 225 ++++++--- cno_grid/config.yaml | 15 +- cno_grid/config_dense.yaml | 43 ++ cno_grid/dist_config.yaml | 58 +++ cno_grid/results | 1 + cno_grid/run_boundary.sh | 34 -- cno_grid/run_boundary_corrected.sh | 45 -- cno_grid/run_seed_step_tests.sh | 46 -- cno_grid/src/claim_task.py | 109 +++++ cno_grid/src/dist_check.py | 59 +++ cno_grid/src/dist_master.py | 733 +++++++++++++++++++++++++++++ cno_grid/src/dist_worker.py | 28 ++ cno_grid/src/qiniu_store.py | 263 +++++++++++ cno_grid/src/repair_grid.py | 278 +++++++++++ cno_grid/src/report_task.py | 104 ++++ cno_grid/src/run_grid.py | 165 ++++++- cno_grid/src/run_one.py | 25 +- cno_grid/src/seed_step.py | 5 +- 20 files changed, 2400 insertions(+), 328 deletions(-) create mode 100644 cno_grid/DIST.md create mode 100644 cno_grid/config_dense.yaml create mode 100644 cno_grid/dist_config.yaml create mode 120000 cno_grid/results delete mode 100644 cno_grid/run_boundary.sh delete mode 100755 cno_grid/run_boundary_corrected.sh delete mode 100755 cno_grid/run_seed_step_tests.sh create mode 100644 cno_grid/src/claim_task.py create mode 100644 cno_grid/src/dist_check.py create mode 100644 cno_grid/src/dist_master.py create mode 100644 cno_grid/src/dist_worker.py create mode 100644 cno_grid/src/qiniu_store.py create mode 100644 cno_grid/src/repair_grid.py create mode 100644 cno_grid/src/report_task.py diff --git a/cno_grid/DIST.md b/cno_grid/DIST.md new file mode 100644 index 0000000..0225ad1 --- /dev/null +++ b/cno_grid/DIST.md @@ -0,0 +1,194 @@ +# 分布式网格计算部署指南(master 单向 SSH) + +> 把 CNO 网格计算分发到多台 Linux 服务器并行。适配"大部分机器异地、 +> 只有 master 能 SSH 进 worker"的网络。 + +## 1. 架构(master 单向 SSH 推/拉) + +``` +master(WSL2) 异地 worker + tasks.json (本地 flock 安全) (无常驻进程) + ├ 派任务: ssh worker "nohup run_one.py ─▶ 被动执行单点 + │ --teff X ... &" 跑完写本地 conv.json + .7 + ├ 取状态: ssh worker "python3 dist_check ◀─ 被动提供结果 + │ .py --results results" + ├ 取种子: scp worker:.7 master(收敛点) ◀─ + └ 七牛云(种子库): master 代传 .7 ─▶ 七牛 +``` + +**核心原则**: +- **只需 master → worker 单向 SSH**(worker 在 NAT 后也行,不回连 master) +- **任务队列只在 master 本地**(`fcntl.flock` 强一致,零并发风险) +- **worker 无常驻进程**:master 直接 SSH 执行 `run_one.py`/`seed_step.py` 跑单点 +- **冷启动零依赖**(seed=None,LTE grey 自建大气)→ 绝大多数点完美分布式 +- **种子库放七牛云**(仅 `.7` 大气 393KB/点)→ 供 seed_step 回退 +- **大产物留 worker 本地**(`.spec` 等 13MB)→ 不跨网传 + +## 2. 并发安全(重点) + +任务队列 `tasks.json` **只在 master 本地文件**,用 `fcntl.flock` 排他锁保护。 +master 多线程管理多台 worker,但所有 claim/report 都走本地 flock,**无跨机并发问题**。 +worker 完全不碰 tasks.json。 + +## 3. 一次性环境准备 + +### 3.1 master(本机 WSL2) +```bash +# pyyaml 可选(有则用,无则用内置极简解析器,零 pip 也能跑) +pip3 install pyyaml 2>/dev/null || echo "用内置解析器,无需 pip" +``` + +### 3.2 每台 worker(异地机器) +只需: +- **python3**(系统自带) +- **openssh-server**(让 master 能 SSH 进来) +- **tlusty 树**(二进制 + 原子数据) + +```bash +sudo apt install python3 openssh-server +``` +> **worker 不需要 curl、不需要 pip、不需要七牛配置、不需要 SSH 回 master。** + +### 3.3 分发 TLUSTY 树到每台 worker(首次,约 2GB) +在 master 上对每台 worker: +```bash +sudo apt install rsync # master 端装一次 +TARGET=user@worker1 +REMOTE_ROOT=~/tlusty/tl208-s54 # 记到 dist_config.yaml 的 tlusty_root +rsync -avz --exclude='cno_grid/results' --exclude='*.log' \ + /home/dckj/program/tlusty/tl208-s54/ $TARGET:$REMOTE_ROOT/ +``` + +### 3.4 分发 cno_grid 代码到每台 worker(每次代码更新) +```bash +rsync -avz --exclude='results' --exclude='*.log' --exclude='__pycache__' \ + /home/dckj/program/tlusty/tl208-s54/cno_grid/ \ + $TARGET:$REMOTE_ROOT/cno_grid/ +``` + +### 3.5 配置 SSH 免密(master → worker,单向即可) +```bash +ssh-copy-id user@worker1 +ssh-copy-id user@worker2 +# ... +``` +验证:`ssh user@worker1 echo ok` 应直接输出 `ok`,不问密码。 +**worker 不需要 SSH 回 master。** + +### 3.6 七牛云(可选;不配也能跑,只是没跨机种子共享) +1. 七牛云创建 bucket,拿 AK/SK,绑域名 +2. master 设环境变量: + ```bash + export QINIU_ACCESS_KEY="你的AK" + export QINIU_SECRET_KEY="你的SK" + ``` +> 不配七牛时,seed_step 只能用同台 worker 上已收敛的本地种子。 + +## 4. 配置 dist_config.yaml + +```yaml +master: + workdir: /home/dckj/program/tlusty/tl208-s54/cno_grid + grid_config: /home/dckj/program/tlusty/tl208-s54/cno_grid/config.yaml + results: results + +qiniu: + bucket: your-bucket + domain: http://xxx # 留空 = 纯本地模式 + upload_by: master # master 代传种子(worker 不碰七牛) + +workers: + - host: localhost # 本机也算 worker(可选) + tlusty_root: /home/dckj/program/tlusty/tl208-s54 + nproc: 8 + - host: user@worker1 # 异地机器 + tlusty_root: ~/tlusty/tl208-s54 + nproc: 24 + # ... 每台机器一项 +``` + +## 5. 运行 + +### 5.1 预览(dry-run) +```bash +cd /home/dckj/program/tlusty/tl208-s54/cno_grid +export QINIU_ACCESS_KEY=... QINIU_SECRET_KEY=... +python3 src/dist_master.py dist_config.yaml --dry-run +# 输出:待计算 N 点,总并发 X 核,预计 ~Y 小时 + 部署检查清单 +``` + +### 5.2 全量跑 +```bash +export QINIU_ACCESS_KEY=... QINIU_SECRET_KEY=... +nohup python3 src/dist_master.py dist_config.yaml > results/dist_run.log 2>&1 & +``` + +### 5.3 监控 +```bash +tail -f results/dist_run.log # master 视角(每 20s 打印状态计数) +cat results/grid_status.json # 汇总 +# 看某台 worker 的某个任务日志: +ssh user@worker1 "tail ~/tlusty/tl208-s54/cno_grid/worker_jobs/<模型名>.log" +``` + +### 5.4 断点续算 +中断后重跑同一命令,已 `done` 的点自动跳过(tasks.json 持久化在 master)。 + +## 6. 工作流程(master 内部) + +``` +1. init tasks.json(432 点 pending;本地已 converged 标 done) +2. 每台 worker 一个管理线程,并发跑: + 循环: + a. SSH 查 worker 正在跑的进程数 → 空闲槽 = nproc - running + b. 每个空闲槽:本地 flock 领一个 pending→running,SSH 派 run_one.py(nohup) + c. SSH 调 dist_check.py 查 worker 已完成的点: + - 收敛 → scp .7 回 master → (代传七牛) → 标 done + - 未收敛 → 找种子(本地缓存>七牛)→ scp 种子到 worker → SSH 派 seed_step.py 重试 + - seed_step 仍失败 → 标 failed(物理极限) +3. 全部终态 → 写 grid_status.json +``` + +## 7. 文件清单 + +| 文件 | 角色 | 运行在哪 | +|------|------|---------| +| `src/dist_master.py` | master 调度器(推/拉/seed_step/汇总) | master | +| `src/dist_check.py` | worker 状态查询(被 SSH 调用) | 每台 worker | +| `src/qiniu_store.py` | 七牛云种子库(零 SDK) | master | +| `src/claim_task.py` / `report_task.py` | 任务原子操作(master 本地调) | master | +| `src/run_one.py` | 单点冷启动执行器(被 SSH 调用) | 每台 worker | +| `src/seed_step.py` | 单点种子步进执行器(被 SSH 调用) | 每台 worker | +| `dist_config.yaml` | 分布式配置 | master | +| `tasks.json` | 任务队列(运行时生成) | master | + +> `dist_worker.py` 已废弃(旧双向模型残留),当前架构不用。 + +## 8. 带宽与时间估算 + +| 项 | 大小 | 说明 | +|----|------|------| +| 单点计算 | 1200-2500s | 主耗时 | +| SSH 派任务 | ~1KB/点 | 命令字符串,可忽略 | +| 取 conv.json | 1.6KB/点 | 经 SSH,可忽略 | +| 取种子 .7(收敛点) | 393KB/点 | scp 回 master,2Mbps≈1.6s | +| seed_step 种子推送 | 393KB/点 | 仅失败点,少数 | +| **跨网传输占比** | **<5%** | 相比计算可忽略 | + +432 点 / 5台×24核 ≈ **3-4 小时**(vs 单机 15-21 小时)。 + +## 9. 故障排查 + +| 现象 | 排查 | +|------|------| +| master 派不出去 | `ssh user@workerN echo ok` 是否通;worker 上 `run_one.py` 路径对不对 | +| worker 算了但 master 没回收 | 看 `worker_jobs/.log`;`dist_check.py` 是否能列出 | +| 任务一直 running | 可能 worker 进程崩了;master 每 2 小时自动重派(stale_sec) | +| 种子找不到(seed_step 失败) | 同 family 无收敛点 → 物理极限,正常;或配七牛扩种子来源 | +| 单机模式仍可用 | `python3 src/run_grid.py config.yaml`(完全不受影响) | + +## 10. 安全提示 + +- **AK/SK 只放 master 环境变量**,不写进 dist_config.yaml(会被提交仓库) +- worker 默认不碰七牛(`upload_by: master`);如需 worker 直传再配域名 +- SSH 用密钥免密,不用密码 diff --git a/cno_grid/EXPERIENCE.md b/cno_grid/EXPERIENCE.md index 46317c6..f1932c5 100644 --- a/cno_grid/EXPERIENCE.md +++ b/cno_grid/EXPERIENCE.md @@ -26,7 +26,7 @@ - **不设 ITEK**(用默认 4) - **He `.5` nlevs=14**(数据文件本身 24/20 能级,但 `.5` 声明 14 让 tlusty 截断; 不是 Peter 建议的满 24-level)—— 详见 §2.5/§3.3 - - **NFREAD=2000**(展开成 5088 频率点,快且稳定) + - **NFREAD=2000**(展开成 75443 频率点(tests/sdB_spectra/GUIDE.md 实测),快且稳定) - nst: `ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=<阶段>` + `IELCOR=-1` 4. **丰度约定是收敛的关键(最终发现):** @@ -73,8 +73,8 @@ NLTE 解 → nl 接手后不稳定。 ### 2.4 NFREAD 与频率网格 NFREAD 是 `.5` 里的"基本频率点数",tlusty 据此自动展开成实际频率网格: -- **NFREAD=2000** → 5088 个频率点(快,每次迭代 ~3 秒) -- **NFREAD=50** → 77695 个频率点(慢 15 倍,且 nc 不稳定) +- **NFREAD=2000** → 75443 个频率点(实测,见 tests/sdB_spectra/GUIDE.md) +- **NFREAD=50** → 77695 个频率点(NFREAD=50 反而更多,因 tlusty 对小 NFREAD 触发更细的自动细化)(慢 15 倍,且 nc 不稳定) NFREAD 小反而展开更多——因为 tlusty 对小 NFREAD 触发更细的自动细化。 **必须用 NFREAD=2000。** @@ -151,7 +151,9 @@ ND=50,VTB=2.,NITER=0 ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=<阶段> IELCOR=-1 ``` -- nc: NITER=50 +- nc: NITER=10(tests/sdB_spectra/GUIDE.md NITER 扫描实测最优;vs NITER=50 + 光谱差异仅 6e-6 但快 2.2×。nc 纯连续谱缺少谱线约束,外层永不真正收敛, + 追求高 NITER 无意义,nl 会自修正) - nl: NITER=100 - **不设 CHMAX**(用默认 0.001)、**不设 ITEK**(用默认 4) @@ -206,7 +208,7 @@ IELCOR=-1 ### 错误 4:NFREAD=50 - **来源**:从 hhe35lt(纯H+He)抄来 - **影响**:展开成 77695 频率点,慢 15 倍且不稳定 -- **纠正**:用 NFREAD=2000(5088 点) +- **纠正**:用 NFREAD=2000(75443 点,实测) - **教训**:NFREAD 的展开行为反直觉(小→多),必须实测确认 ### 错误 5:ORELAX=0.5(部分有效但非通用解) @@ -231,17 +233,23 @@ IELCOR=-1 - **教训**:先核实输入参数的物理含义和量级,再调试数值方法。对比用户成功配置时 要逐行精确对比(用户用 abn=0 太阳丰度,我用 abn=1.0 绝对比值)。 -### 错误 8:ICRSW 是死代码(本次会话发现) +### 错误 8:ICRSW 是死代码(本次会话发现 —— 后已修复并测试) - **来源**:边界测试发现 80K + He-poor + logCNO=-1 即使种子步进也发散, 尝试用 ICRSW(Hummer & Voels 1988 碰撞-辐射开关)稳定化 - **影响**:源码 `tlusty208.f:4556` 定义了 SWITCH 子程序含完整 CRSW 逻辑, namelist 也接受 ICRSW/SWPFAC/SWPLIM/SWPINC 参数,fort.6 也打印这些值, 看起来一切正常——但**整个源文件中没有任何一处 CALL SWITCH**。 - **实测验证**:开 ICRSW=1/SWPFAC=0.001 后 nc 迭代历史与不开完全相同 -- **纠正**:放弃 ICRSW,改用实际有效的 ORELAX(`tlusty208.f:14647`) - 和种子步进(虽然 ORELAX 对极端跳跃也无效) +- **后续(2026-07-21)**:在 `CALL RESOLV` 之后插入 `CALL SWITCH(INIT)` + 并重新编译,确认 SWITCH 现在确实被调用(ICRSW=0 时与原版逐字节相同, + ICRSW=1 时 CRSW dump 出现在 fort.6 且 iter 1 尖峰被压低 4-5×)。 + **但对 80K + He-poor + 富金属难点仍无帮助**(甚至更早发散到 NaN)。 + 默认管线仍用未修改的 `tlusty.exe`,详见 §6X - **教训**:源码里的子程序未必被调用。看似可用的参数可能是死代码。 - 必须实测验证参数效果(对比开/关的迭代历史是否真的不同)。 + 必须实测验证参数效果(对比开/关的迭代历史是否真的不同)。修复死代码 + 时要注意 INSERT 位置(本例放在 RESOLV 之前会在 iter 1 除以未初始化的 + RRU,必须放在 RESOLV 之后)。即使修复"正确"也要再验证是否真能解决 + 目标问题。 --- @@ -253,6 +261,12 @@ IELCOR=-1 | 35000/5.5/-2/logCNO=-1 | 0.00148(未达 0.001,作种子)| 0.000633 | 1635s | | 40000/5.5/0/logCNO=-1 | 0.000424 | 0.000905 | 1298s | +> **⚠️ 耗时说明**:上表和 §5X/§5Y 的所有耗时都是用 **nc NITER=50**(旧 config) +> 跑出来的,**不能用作网格耗时估算**。修正后用 **NITER=10**(tests/sdB_spectra/GUIDE.md +> 实测最优),35000K CNO 单点总耗时 ~12 分钟。网格耗时估算应以 NITER=10 为准。 +> nc max_relc 也是 NITER=50 时的中间值,NITER=10 时 nc 不会真正收敛(这是正常的, +> 见 §3.2),但 nl 最终解与 NITER=50 完全等价(流量差异 <3e-12)。 + > 注:早期版本曾列入 "35000/5.5/-2/abn=0 (nl=0.00078, 1009s)" 和 "40000/5.5/0/abn=0 > (nc=6.67e-5)" 两个所谓"成功点"——经复核 conv.json,这两个数据**不存在**: > results/ 下既没有 abn=0 的对应模型目录,整库 grep 也没有 6.67e-5 这个值。 @@ -327,7 +341,9 @@ IELCOR=-1 - <0 = 从 fort.95 读完整旧模型定义(见 3503) `ICRSW`(`tlusty208.f:4556`)= Hummer & Voels 1988 碰撞-辐射开关, -是另一可选稳定化参数(本次未启用,详见错误 8 与 §6X)。 +原版 tlusty 里是死代码(SUBROUTINE SWITCH 从未被 CALL);§6X 描述的 +源码修复让它在 patched 二进制里生效,但实测对极端难点无帮助,所以 +默认管线未启用。详见错误 8 与 §6X。 ### 种子步进实现 @@ -386,25 +402,35 @@ python3 cno_grid/src/seed_step.py --teff 80000 --logg 6.5 --loghe -4 \ 不透明度;当 logCNO 从 -2 跳到 -1(金属量 ×10),光致电离率变化陡峭到 完全线性化无法阻尼 iter 1 的尖峰。 -### 错误 8:ICRSW 是死代码(关键发现) +### 错误 8:ICRSW 是死代码(关键发现 —— 后已修复并测试) 源码分析后发现 `ICRSW`(Hummer & Voels 1988 碰撞-辐射开关)在 tlusty208 -中**实际不可用**: +**原版**中**实际不可用**: - `SWITCH` 子程序在 `tlusty208.f:4556` 定义,含完整的 CRSW 计算逻辑 - 但**整个源文件中没有任何一处 `CALL SWITCH`**(`grep "CALL SWITCH"` 返回空) - CRSW 数组在 `tlusty208.f:1812` 被默认初始化为 `UN`(=1.0) - 因此 `tlusty208.f:6343-6344, 6591-6592` 等处的 `RRU/RRD * CRSW(ID)` 实际 乘的是 1.0,没有任何阻尼效果 -- 同类的 CRSW 消费点还在 `tlusty208.f:18201, 18252`(FSOLV/FCOOL 内), - 共三处消费簇,全部因 CRSW≡1 而失效 +- 同类的 CRSW 消费点共 12 处(含 `tlusty208.f:18201, 18252` 等), + 全部因 CRSW≡1 而失效 -测试验证:开启 ICRSW=1/SWPFAC=0.001/SWPINC=2.0 后,nc 阶段的迭代历史 +测试验证(原版):开启 ICRSW=1/SWPFAC=0.001/SWPINC=2.0 后,nc 阶段的迭代历史 (iter 1-25 relc 演化)与不开启 ICRSW **完全相同**——确认 SWITCH 未被调用。 +**后续(2026-07-21):已实施修复并重新测试**。在主循环 `CALL RESOLV` 之后 +插入 `CALL SWITCH(INIT)`(详见 §6X),重新编译后确认: +- ICRSW=0 时与原版逐字节相同(sanity 通过) +- ICRSW=1 时 SWITCH 确实被调用(CRSW dump 出现在 fort.6,iter 1 尖峰被 + 压低 4-5×) +- **但对 80K + He-poor + logCNO=-1 难点没有帮助**(甚至更早发散到 NaN), + 因此默认管线仍用未修改的 `tlusty.exe`。完整测试数据见 §6X。 + **教训**:源码里的子程序未必被调用。看似可用的参数(ICRSW 在 nst namelist -里、在 fort.6 里也被打印)可能是死代码。真正能用的稳定化参数是 `ORELAX` -(`tlusty208.f:14647, 14996`)和 `IDLTE`(`tlusty208.f:5515`)—— -它们确实被使用。但实测对极端金属跳跃也无效。 +里、在 fort.6 里也被打印)可能是死代码。修复死代码前要:(1) 实测验证参数 +确实无效;(2) 仔细分析子程序依赖的变量何时被赋值(INSERT 位置很关键, +本例中放在 `RESOLV` 之前会在 iter 1 除以未初始化的 RRU);(3) 修复后 +再实测验证是否真能改善目标问题——本例中修复"正确"但"无用"。真正对极端 +金属跳跃有效的稳定化手段仍然是种子步进(减小模型间步长)。 ### 种子步进的网格应用策略 @@ -427,7 +453,9 @@ python3 cno_grid/src/seed_step.py --teff 80000 --logg 6.5 --loghe -4 \ - `gen_input5.py`:He `.5` nlevs=14(数据文件本身 24/20,按 nlevs 截断), NFREAD=2000,支持 ilvlin/metals 参数 - `run_one.py` DEFAULT_CHAIN:三步法,无 CHMAX/ITEK/ORELAX - - write_nst 支持 ichang/orelax/idlte/iacc/icrsw(注意 ICRSW 实际是死代码) + - write_nst 支持 ichang/orelax/idlte/iacc/icrsw(注意:原版 tlusty.exe + 里 ICRSW 是死代码;§6X 描述的源码修复让它在 patched 二进制里生效, + 但默认管线仍用原版 tlusty.exe) - `seed_step.py`:种子步进实现 —— 跳过 LTE grey 冷启动, 直接热启动 nc 阶段,用于高温/He-poor/富金属等冷启动失败的场景 - `run_grid.py`:6 维网格调度,集成种子步进回退(NEW) @@ -483,120 +511,180 @@ python3 cno_grid/src/seed_step.py --teff 80000 --logg 6.5 --loghe -4 \ --- -## 6X. ICRSW 修复方案(备选 —— 当前未实施) +## 6X. ICRSW 修复方案(已实施并测试 —— 2026-07-21) + +> **状态:源码已修改并重新编译,但修复后的可执行未替换 `tlusty.exe`。** +> 原因:修复在数值上**生效**(SWITCH 确实被调用了,CRSW 不再恒为 1.0), +> 但对最难收敛的 80K + He-poor + 富金属点**没有帮助**(甚至更早发散), +> 所以默认管线仍用未修改的 `tlusty.exe`。修复后的二进制保留在 +> `tlusty/tlusty.exe.icrsw_patched`,需要时可以拿来对比试验。 +> 备份的原始源码在 `tlusty/tlusty208.f.orig_backup`。 ### 背景 -`ICRSW`(Hummer & Voels 1988 碰撞-辐射开关)在 tlusty208 中是**未完成的 +`ICRSW`(Hummer & Voels 1988 碰撞-辐射开关)在 tlusty208 中曾是**未完成的 集成**——`SUBROUTINE SWITCH`(`tlusty208.f:4556`)写好了完整的 CRSW 计算 -逻辑,下游消费方代码(`tlusty208.f:6341-8021`、`18201`、`18252` 三处 -`RRU/RRD * CRSW(ID)`)也都到位,但**主迭代循环中缺少 `CALL SWITCH`** -(`PROGRAM TLUSTY` 主循环在 line 30-58,`SUBROUTINE SOLVE` 在 line 14420, -两者都没有调用 SWITCH)。 +逻辑,下游消费方代码(共 12 处 `RRU/RRD * CRSW(ID)`:`6341/6343-6345`、 +`6589/6591-6592`、`6841/6843-6845`、`7621-7633`、`7807-7810`、`8017-8020`、 +`18147`、`18201-18204`、`18252-18256`)也都到位,但**主迭代循环中缺少 +`CALL SWITCH`**。结果 `CRSW(ID)` 永远保持默认值 `UN`(=1.0,line 1812 +`CRSW(ID)=UN`),所有乘法都是无效操作。 -结果:`CRSW(ID)` 永远保持默认值 `UN`(=1.0,line 1812 `CRSW(ID)=UN`), -所有 `RRU/RRD * CRSW(ID)` 都是无效操作。 +### 实施的修复 -### 修复需要的步骤 +**关键纠正**:旧版本本文档(§6X 步骤 3)建议把 `CALL SWITCH` 插在 +`CALL RESOLV` **之前**。这是**错误**的——经源码核查确认: +- `RRU(ITR,ID)`/`RRD(ITR,ID)` 在 `RATES1`(line 6179)及其同族子程序 + (`RATSP1`、`ALIST1`、`ALIST2`、`ALISK1`、`ALISK2`)内部才被零初始化 + 并累加;这些子程序全部从 `RESOLV`(line 3724)调用。 +- `COLRAT(ITR,ID)` 在 `INILAM`(line 4029)中赋值,`INILAM` 也从 + `RESOLV`(line 3743)调用。 +- **在 iter 1 的首次 `RESOLV` 之前,没有任何 DATA 语句或 START 阶段 + 初始化过 RRU/RRD/COLRAT**(已 grep 验证)。如果按旧文档把 `CALL SWITCH` + 放在 `RESOLV` 之前,iter 1 会在 line 4599 `C/RRU(ITR,ID)` 处除以未初始化 + 的垃圾值,立刻 NaN。 -**步骤 1:检查编译环境** -```bash -which gfortran || apt list --installed 2>/dev/null | grep -i fortran -# 若无,安装:sudo apt install gfortran -``` +**正确插入位置:在 `CALL RESOLV` 之后、`INIT=0` 之前**,复用现有的 +`INIT` 变量作为 `INITM` 参数(程序启动时 INIT=1 在 line 22,RESOLV 之后 +被重置为 0 在 line 36): -**步骤 2:定位主迭代循环** - -主循环在 `tlusty/tlusty208.f:30-58`(行 28 是注释头,行 30 是 `10 ITER=ITER+1`, -行 58 是 `20 CONTINUE`): ```fortran 10 ITER=ITER+1 - CALL RESOLV ! 形式解 - IF(IACC.GT.0) CALL ACCEL2 - IF(NN.GT.MSMX) THEN - CALL SOLVE ! 完全线性化 - ELSE - CALL SOLVES - END IF - CALL TIMING(2,ITER) - GO TO 10 -``` - -**步骤 3:在循环中插入 SWITCH 调用** - -在 `ITER=ITER+1` 之后、`CALL RESOLV` 之前插入: -```fortran - 10 ITER=ITER+1 - C ---- ICRSW 碰撞-辐射开关(修复)---- - C 首次迭代初始化 CRSW,后续迭代放大 CRSW 朝 1.0 趋近 - IF(ICRSW.GT.0) THEN - IF(ITER.EQ.1) THEN - CALL SWITCH(1) ! INITM=1: 初始化 CRSW=SWPFAC*min(C/R) - ELSE - CALL SWITCH(0) ! INITM=0: CRSW *= SWPINC - END IF - END IF CALL RESOLV +C +C 1a. Collisional-radiative switching (Hummer & Voels 1988) +C EVALUATE/UPDATE CRSW(ID) AFTER the formal solution has produced +C fresh RRU/RRD/COLRAT, and BEFORE the linearization step (SOLVE/ +C SOLVES) that consumes CRSW via BPOPE/BPOPF (lines ~18147,18201, +C 18252). On iter 1 INIT is still 1 -> full recompute of CRSW; +C on iter 2..N INIT was reset to 0 -> cheap CRSW*=SWPINC update. +C SWITCH is a no-op when ICRSW=0 (default), so existing behavior +C is unchanged unless ICRSW>0 is set in nst. +C NOTE: must NOT be moved before CALL RESOLV -- on iter 1 RRU/RRD/ +C COLRAT are still uninitialized there (no DATA stmt; they are +C zeroed and filled inside RATES1/RATSP1 within RESOLV). +C + CALL SWITCH(INIT) + INIT=0 + IF(LFIN) GO TO 20 ... ``` -**步骤 4:验证变量在调用点已就绪** +这个位置的优点: +1. iter 1 时 RESOLV 已经计算好 COLRAT(来自 INILAM)和 RRU/RRD(来自 + RATES1),SWITCH(INIT=1) 能正确执行完整 Hummer-Voels 计算。 +2. iter 2..N 时 SWITCH(INIT=0) 仅做 `CRSW *= SWPINC` 的廉价更新。 +3. CRSW 在 `SOLVE`/`SOLVES`(通过 MATGEN→BPOP→BPOPE/BPOPF 消费 CRSW) + 运行之前已经定下来。 +4. SWITCH 内部首句 `IF(ICRSW.EQ.0) RETURN`(line 4580)保证 ICRSW=0 时 + 是 no-op,**对现有所有测试零影响**。 -`SWITCH` 子程序用到 `COLRAT(ITR,ID)`、`RRU(ITR,ID)`、`RRD(ITR,ID)`、 -`LINE(ITR)`、`FR0(ITR)`、`TEMP(ID)`、`HK`。需确认这些在 `ITER=ITER+1` -之后已被前一迭代更新(或在首次迭代时已初始化)。若未就绪,可能需要 -把 SWITCH 调用移到 `CALL RESOLV` 之后。 +**重新编译命令**(关键:必须用 `-mcmodel=large`,否则 x86-64 PIC 重定位 +溢出,链接报 `relocation truncated to fit: R_X86_64_PC32 against symbol +curder_`): -**步骤 5:重新编译** ```bash cd tlusty/ -# 备份原可执行 -cp tlusty.exe tlusty.exe.orig -# 编译(具体命令取决于源码组织,可能是单文件或 Makefile) -gfortran -O2 -o tlusty.exe tlusty208.f \ - IMPLIC.FOR BASICS.FOR ARRAY1.FOR ATOMIC.FOR MODELQ.FOR \ - ITERAT.FOR ALIPAR.FOR ODFPAR.FOR +cp tlusty.exe tlusty.exe.orig # 备份 +cp tlusty208.f tlusty208.f.orig_backup # 备份源码 +gfortran -O2 -std=legacy -fno-automatic -mcmodel=large \ + -o tlusty.exe tlusty208.f +# 注意:IMPLIC.FOR / BASICS.FOR / 等都是 INCLUDE 文件,不要单独编译; +# 整个程序就在 tlusty208.f 一个文件里(通过 INCLUDE 拉入其它 .FOR)。 ``` -**步骤 6:测试** +### 实施验证(2026-07-21) -跑已知难收敛的点(如 80K + He-poor + logCNO=-1)开/关 ICRSW,对比 -nc 阶段的迭代历史。如果 ICRSW 真正生效,开启后 iter 2 的尖峰应该被 -压低(CRSW < 1)。 +**验证 1:ICRSW=0 时与原版逐字节相同** +在 H+He NLTE(20000/5.0/-1)模型上,patched exe 与原 exe 产生的 +fort.7(大气)和 fort.9(收敛日志)**完全相同**(`cmp` 通过、md5 相同)。 +证明修复对 ICRSW=0 的所有现有运行零影响。 + +**验证 2:ICRSW=1 时 SWITCH 确实被调用** + +同样的 H+He 模型,nst 加 `ICRSW=1,SWPFAC=0.001,SWPLIM=1.0,SWPINC=2.0`: +- patched exe 在 fort.6 里多出 CRSW 数组 dump(`1P8D10.3` 格式,50 个值), + 数值序列 `1.438D-12 → 2.875D-12 → 5.750D-12 → 1.150D-11` 精确对应 + `SWPINC=2.0` 的逐次翻倍——证明 INITM=0 分支(line 4636)在每次迭代执行。 +- 原 exe 在相同 nst 下 fort.6 里**没有** CRSW dump,迭代历史与 ICRSW=0 完全 + 相同——证明旧代码的 SWITCH 确实从未被调用("死代码"判断成立)。 +- iter 1 的 MAXIMUM 列在难收敛深度上明显被压低: + + | 深度 | ICRSW=0(原版)| ICRSW=1(patched)| 压低倍数 | + |------|---------------|-------------------|---------| + | 35 | 1.03E+00 | 2.12E-01 | ~5× | + | 28 | 4.12E+00 | 9.88E-01 | ~4× | + | 25 | 3.72E+00 | 9.84E-01 | ~4× | + + 这是 Hummer-Voels 开关的预期行为——碰撞速率被人为放大(CRSW<1)以 + 压制辐射跃迁的非线性。 + +### 难点测试:80K + He-poor + logCNO=-1(最终未解决) + +用 `seed_step` 同款配置(cno-2 种子 → cno-1 目标,热启动),8 次迭代, +测了三组 ICRSW 参数(强阻尼、弱阻尼)对比无 ICRSW 基线: + +| iter | ICRSW=0(基线) | ICRSW=1 SWPFAC=1e-4 SWPINC=2.0 | ICRSW=1 SWPFAC=0.1 SWPINC=1.5 | +|------|----------------|-------------------------------|-------------------------------| +| 1 | 1.52e+03 | 2.26e+05 | 2.42e+04 | +| 2 | 1.15e+01 | 1.34e+05 | 9.34e+04 | +| 3 | 7.53e+01 | **NaN(发散)** | 5.98e+06 | +| 4 | 1.52e+01 | NaN | 2.29e+08 | +| 5 | 2.01e+03 | NaN | 5.66e+09 | +| 6 | 1.97e+01 | NaN | 3.13e+21(发散) | +| 7 | 8.24e+00 | NaN | NaN | +| 8 | 6.08e+02 | NaN | NaN | +| 大气 NaN | 0/5210(干净)| 2222/5210(43%) | 0/5210(干净,但解无效) | + +(基线 8 次迭代都没崩到 NaN,只是没收敛;两次 ICRSW 都更早爆。) + +**结论**:ICRSW 修复在数值层面**完全成功**(SWITCH 跑起来了,CRSW 不再 +恒为 1.0,迭代历史明显改变),但**不能解决这个极端难点**——无论是强阻尼 +(SWPFAC=1e-4)还是弱阻尼(SWPFAC=0.1),开启 ICRSW 都让发散**更早**。 +SWPFAC=1e-4 让 iter 1 的初始跳跃从 1.5e3 变成 2.3e5(CRSW 太小导致线性化 +过度校正);SWPFAC=0.1 略好但仍单调发散到 1e21。 + +物理原因(与前文 §5Y 的"物理极限"结论一致):80K + He-poor 时 CNO +高价离子(C IV/V、N V、O V/VI)主导大气不透明度,金属量从 logCNO=-2 +跳到 -1(×10)导致光致电离率变化陡峭到完全线性化无法阻尼 iter 1 的 +尖峰。Hummer-Voels 开关通过放大碰撞速率来稳定,但当辐射-碰撞比本身 +就在极端区间时,开关反而把不稳定提前。 + +### 40K sanity check(patched exe 在正常区间仍工作) + +为了排除"修复破坏了正常路径"的可能,在已验证的 40K + logg 5.5 + cno-1 +模型上跑 patched exe(ICRSW=0):迭代历史(oscillatory 但最终收敛到 +~1e-4)与原版 exe 产生的 `t40000_g5.5_he0_c-1_n-1_o-1.nc_*.9` 文件 +**特征一致**(同样的 iter 6 尖峰到 4.69e8、同样的 iter 23-27 收敛到 +~1e-4)。证明修复对正常收敛区间无害。 + +### 实施后的工程决策 + +**保留源码修改,但不替换 `tlusty.exe`**: +1. 修改已通过 sanity check(ICRSW=0 时与原版逐字节相同),是安全的。 +2. 对网格里 95%+ 的点(20000-40000K 区间),ICRSW 没用也没害。 +3. 对剩余难收敛点(80K + He-poor + 富金属),ICRSW 不仅没用反而更糟。 +4. 因此**没有理由**让默认管线用 patched exe——保留原版可执行,patched + 二进制仅供后续研究(比如有人想试 `ICRSW=2` 的深度相关模式,或者 + 配合更小的丰度步长)。 + +**如果未来需要重新启用 patched exe**: ```bash -# 关 ICRSW(基线) -python3 cno_grid/src/seed_step.py --teff 80000 --logg 6.5 --loghe -4 \ - --logc -1 --logn -1 --logo -1 --seed \ - 2>&1 | tee /tmp/no_icrsw.log -# 开 ICRSW(修复后) -# 在 nst 里加 ICRSW=1,SWPFAC=1e-3,SWPLIM=1.0,SWPINC=2.0 -# ... 跑同样模型 -# 对比两次的 fort.9 iter 1-10 max_relc 演化 +cd tlusty/ +# 当前 tlusty.exe 是原版;tlusty208.f 是已修改版 +gfortran -O2 -std=legacy -fno-automatic -mcmodel=large \ + -o tlusty.exe tlusty208.f +# 想恢复原版:cp tlusty208.f.orig_backup tlusty208.f 后重新编译 ``` -### 修复风险评估 +### 替代方案:网格层面规避(仍是当前推荐) -**好处**: -- 可能解锁 80K + He-poor + 富金属区的收敛(最后一个未解决角落) -- 是数值方法层面的修复,物理意义清晰 - -**风险**: -- 重新编译可能引入其他问题(tlusty 源码依赖复杂) -- SWITCH 集成后可能有未预见的 bug(作者本就没集成,可能有原因) -- 编译器版本差异(原版可能用 f77/f90,gfortran 行为可能不同) - -### 替代方案:网格层面规避 - -如果不想改源码,**现有种子步进方案已覆盖大部分情况**: -- 20000-40000K:冷启动全区间可靠 +不改源码也能完成网格: +- 20000-40000K:冷启动全区间可靠(已验证多个点) - 60000-80000K + He-rich / 低金属:冷启动或一步种子步进可解决 - 60000-80000K + He-poor + 富金属(logCNO=-1):**真实物理极限**, 网格如实标记未收敛(这是合理的——观测上这些极端参数组合的 sdB - 本就罕见) - -**建议**:先用现有方案跑完整网格,统计未收敛点的比例和分布。 -如果未收敛点占总网格 <5%,无需修复 ICRSW;如果占比高且集中在 -可观测的重要参数区,再考虑修复。 + 本就罕见,且本次实测确认 ICRSW 也不能解决) ### 每阶段信息记录 conv.json 记录每阶段的 converged/max_relc/elapsed_sec,以及 synspec_sec。 diff --git a/cno_grid/PIPELINE.md b/cno_grid/PIPELINE.md index 1cd1957..08e7aee 100644 --- a/cno_grid/PIPELINE.md +++ b/cno_grid/PIPELINE.md @@ -12,19 +12,23 @@ config.yaml (网格点 + 收敛链配置) │ ▼ run_grid.py ── 生成 6 维笛卡尔积参数点 - │ 断点续算(跳过已成功) / 种子复用(最近邻) / 失败隔离 + │ 断点续算(跳过已成功) / 种子复用(最近邻) / + │ 失败隔离 / 冷启动失败→种子步进回退 │ ├── worker 1 ── run_one.py ── 点 A ├── worker 2 ── run_one.py ── 点 B 每个 worker 独立工作目录 - ├── ... 互不干扰,24 核并行 - └── worker 24 ── run_one.py ── 点 X + ├── ... 互不干扰,并行(默认16核) + └── worker N ── run_one.py ── 点 X │ ▼ - 三步链(lte→nc→nl) + synspec + 冷启动链(lte→nc→nl) + synspec + │ 冷启动发散且有干净邻居种子? + ▼ → 移失败结果到 .coldfail/, + 种子步进链(seed_nc→nl) + synspec 改用 LTGRAY=F 热启动重试 │ ▼ results/<模型名>/ - conv.json ← 阶段信息(收敛/迭代/时间) + conv.json ← 阶段信息(收敛/迭代/时间/seed_step_used) *.spec/.cont ← 光谱 *.7 ← 各阶段大气 ``` @@ -33,17 +37,22 @@ run_grid.py ── 生成 6 维笛卡尔积参数点 ## 2. 每个网格点的计算阶段 -每个网格点(一组 Teff/logg/logHe/logC/logN/logO 参数)经过 **4 个阶段**: +每个网格点(一组 Teff/logg/logHe/logC/logN/logO 参数)经过 **4 个阶段**。 -| 阶段 | 程序 | 做什么 | 典型耗时 | -|------|------|--------|---------| -| 1. LTE 灰大气 | tlusty | `T T` 模式,解析求灰色 T(τ) 结构 | 1-3 秒 | -| 2. nc(NLTE 连续谱)| tlusty | `F F` + `ilvlin=0`,收敛电离平衡(无线跃迁)| 1-10 分钟 | -| 3. nl(NLTE 含线)| tlusty | `F F` + `ilvlin=100`,加全部谱线跃迁 | 5-20 分钟 | -| 4. synspec | synspec | 用 nl 大气合成可观测光谱 | 3-10 秒 | +**默认走"冷启动链"**(DEFAULT_CHAIN,适用 20-40K 及大部分易收敛点): + +| 阶段 | 程序 | 做什么 | NITER | 典型耗时 | +|------|------|--------|-------|---------| +| 1. LTE 灰大气 | tlusty | `T T` 模式,解析求灰色 T(τ) 结构 | 0 | 1-3 秒 | +| 2. nc(NLTE 连续谱)| tlusty | `F F` + `ilvlin=0`,收敛电离平衡(无线跃迁)| **10** | 1-5 分钟 | +| 3. nl(NLTE 含线)| tlusty | `F F` + `ilvlin=100`,加全部谱线跃迁(要求收敛)| 100 | 5-20 分钟 | +| 4. synspec | synspec | 用 nl 大气合成可观测光谱 | — | 3-10 秒 | **阶段间依赖**:1→2→3→4 严格顺序。每阶段用上一阶段的 `.7` 大气作种子(fort.8)。 +> 冷启动链在高温/He-poor/富金属区会发散,此时 run_grid 自动改走下文 §2.1 +> 的**种子步进链**(seed_nc→nl,跳过 LTE grey 直接热启动)。 + ### 为什么是这 4 个阶段(原理) Tlusty 的 NLTE 求解用**迭代线性化**(complete linearization)。线性化的收敛半径 @@ -58,6 +67,40 @@ Tlusty 的 NLTE 求解用**迭代线性化**(complete linearization)。线 线扰动小,快速收敛(典型 ~15 次迭代)。 - **阶段4(synspec)**:用 nl 阶段收敛的大气模型,计算指定波长范围的合成光谱。 +### 2.1 种子步进链(seed_step)—— 高温区破局(NEW) + +当冷启动链发散时(典型:60000-80000K + He-poor + 富金属),run_grid 自动 +切换到**种子步进链**(`seed_step.SEED_STEP_CHAIN`),跳过 LTE grey 冷启动, +直接用邻居已收敛的 `.7` 热启动: + +| 阶段 | 做什么 | 关键标志 | NITER | +|------|--------|---------|-------| +| seed_nc | 从种子热启动 NLTE 连续谱 | `LTGRAY=F`(读 fort.8) + `ICHANG=0` | 80 | +| nl | 含谱线完整 NLTE(要求收敛)| `ilvlin=100` | 100 | + +**触发流程**(`run_grid.py` 的 `_worker`): +1. 先跑冷启动链(DEFAULT_CHAIN)。 +2. 若 `converged=false` 且 `seed_step_fallback=true` 且找到了干净邻居种子: + - 把失败结果整体移到 `results/.coldfail/`(避免污染种子库); + - 用 `SEED_STEP_CHAIN`(`seed=<邻居>.7`)重算; + - conv.json 里记 `seed_step_used=true`、`coldfail_backup=<路径>`。 + +**原理**(`tlusty208.f` 源码确认,详见 EXPERIENCE.md §5Y): +- `LTGREY=T` → `CALL LTEGR` 生成灰大气(**忽略 fort.8**,冷启动); +- `LTGREY=F` → `CALL INPMOD` 读 fort.8 作初猜(**热启动**)。 +- `ICHANG=0`:种子与目标模型原子完全一致(同为 H/He/CNO 设置),只改 + Teff/logg/丰度,不需要重映射布居数。 + +**实测突破**(80K + He-poor + logCNO=-4,冷启动必败点):种子步进 126s 收敛 +(冷启动 970s 发散到 NaN)。详见 EXPERIENCE.md §5Y 验证表。 + +> **物理极限(如实标注)**:80000K + He-poor + logCNO=-1 即使种子步进也发散 +> (CNO 高价离子主导不透明度,金属 ×10 跳跃线性化无法阻尼)。网格如实标 +> `converged=false`,不强制成功——这些极端参数组合观测上本就罕见。 +> +> **ICRSW 是死代码**:源码中 `SUBROUTINE SWITCH` 定义但从未被 CALL, +> 默认 CRSW≡1.0 无阻尼效果。不要依赖 ICRSW 稳定化(见 §7.8)。 + --- ## 3. 每阶段的配置信息与原理 @@ -66,13 +109,13 @@ Tlusty 的 NLTE 求解用**迭代线性化**(complete linearization)。线 ``` 第1行: TEFF GRAV (三阶段相同:目标参数) -第2行: LTE LTGRAY (阶段1=T T,阶段2/3=F F) -第3行: nst 文件名 (阶段1=nst_lte, 阶段2=nst_nc, 阶段3=nst_nl) -第4行: NFREAD (=50, 频率点数) +第2行: LTE LTGRAY (阶段1=T T,阶段2/3=F F;种子步进链全 F) +第3行: nst 文件名 (固定写 'nst',内容每阶段由 write_nst 生成) +第4行: NFREAD (=2000 → 展开约 75443 个频率点;不要用 50) 第5行: NATOMS (=8: H,He,空×3,C,N,O) 第6+行: atoms (mode abn modpf) (C/N/O 的 mode=2 显式NLTE, abn=10^logX) ions段: iat iz nlevs ilast ilvlin nonstd typion filei - (阶段1/2: ilvlin=0; 阶段3: ilvlin=100 ← 关键区别) + (阶段1/2/seed_nc: ilvlin=0; 阶段3/nl: ilvlin=100 ← 关键区别) ``` **为什么 NATOMS/ions 三阶段必须相同**:每阶段的 `.7` 大气记录了每个能级的 @@ -91,7 +134,7 @@ ND=50,VTB=2.,NITER=0 ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=<阶段> IELCOR=-1 ``` -- nc: NITER=50, nl: NITER=100 +- nc: NITER=10, nl: NITER=100 - **不设 CHMAX**(用默认 0.001,强迫 nc 真正收敛) - **不设 ITEK**(用默认 4) @@ -105,12 +148,18 @@ IELCOR=-1 | `ISPODF` | 1 | 1 | 频率网格开关 | 启用细化频率网格 | | `DDNU` | 50. | 50. | 频率间隔因子 | 频率网格细化参数 | | `CNU1` | 6. | 6. | 频率网格起点 | 频率网格细化参数 | -| `NITER` | 50 | 100 | 最大迭代数 | nc 给 50 次;nl 给 100 次 | +| `NITER` | **10** | 100 | 最大迭代数 | nc 给 10 次足够(实测最优);nl 给 100 次 | | `IELCOR` | -1 | -1 | 电子密度修正 | 关闭 | > **关键:不设 CHMAX(用默认 0.001)、不设 ITEK(用默认 4)、不设 IDLTE/ORELAX。** > 之前版本设了 CHMAX=0.1 导致 nc 没真正收敛,是大部分失败的根本原因。 > 详见 EXPERIENCE.md §4 的错误记录。 +> +> **nc 的 NITER=10 是实测最优**(GUIDE.md NITER 扫描结论): +> - nc(纯连续谱)缺少谱线约束,外层温度永不真正收敛,只在外层漂移; +> - NITER=10 vs NITER=50 的最终光谱差异仅 6e-6(完全等价); +> - nl(含谱线)会自修正到正确解,无论 nc 给什么初值; +> - NITER=10 总耗时 ~12 分钟(35000K CNO),NITER=50 浪费 2.2× 时间。 ### 3.3 synspec 配置(fort.55.lin + 谱线表) @@ -132,7 +181,7 @@ fort.55.lin 第6行: WLMIN WLMAX WLSTEP ... CUTOFF ... ### 4.2 如何做到并行 -`run_grid.py` 用 Python 的 `multiprocessing.Pool`(`run_grid.py:271`): +`run_grid.py` 用 Python 的 `multiprocessing.Pool`(`run_grid.py` 的 `_worker`): ```python with Pool(nworkers) as pool: @@ -140,24 +189,27 @@ with Pool(nworkers) as pool: ... ``` -- `nworkers`(config.yaml,当前=24):同时运行的 worker 进程数。 +- `nworkers`(config.yaml,当前=**16**):同时运行的 worker 进程数。 - 每个 worker 是一个独立的 Python 子进程,调用 `run_one.py` 跑一个网格点 (在独立的工作目录里,互不干扰)。 - `imap_unordered`:哪个点先完成就先回收,立即分配下一个点(动态负载均衡)。 -- 24 核机器跑 24 个 worker = 24 个 tlusty 实例同时跑 = 满载利用。 +- 16 核机器跑 16 个 worker = 16 个 tlusty 实例同时跑 = 满载利用。 + (按机器核数调整;每个 tlusty 运行是单线程的,调大 nworkers 即可吃更多核。) **关键:每个 worker 用独立工作目录**(`results/<模型名>/`),避免 fort.* 文件 冲突。这是并行安全的基础。 ### 4.3 吞吐量估算 -| 模型类型 | 单点耗时 | 24核并行吞吐 | -|---------|---------|-------------| -| 80000K(待解决)| — | 目前 nc 发散,需专业策略 | -| 20000-40000K(标准)| ~15-25 分钟 | ~72-110 点/小时 | -| 高金属 CNO=10×H | ~30 分钟 | ~48 点/小时 | +| 模型类型 | 单点耗时 | 16核并行吞吐 | 收敛性 | +|---------|---------|-------------|--------| +| 20000-40000K(标准 sdB 区)| ~12-25 分钟 | ~48-80 点/小时 | 冷启动全区间可靠 | +| 60000K + He-rich/低金属 | ~10-15 分钟 | ~64-96 点/小时 | 冷启动或一步种子步进 | +| 80000K + He-rich/低金属 | ~3-5 分钟(种子步进)| 约同上 | 冷启动失败→种子步进成功 | +| 80000K + He-poor + logCNO=-1 | — | — | **真实物理极限,发散**(如实标注) | -128 点疏网格约需 2-3 小时;8192 点完整网格约需 5 天。 +当前 config.yaml 共 432 点(4×2×2×3×3*3);中等参数区冷启动为主, +高温区走种子步进回退,整体约需数小时到一天。 --- @@ -169,39 +221,45 @@ with Pool(nworkers) as pool: ```json { - "name": "t40000_g6.0_he0_c1_n1_o1", - "params": {"teff":40000, "logg":6.0, "loghe":0, "logc":1, "logn":1, "logo":1}, + "name": "t40000_g6.0_he0_c-1_n-1_o-1", + "params": {"teff":40000, "logg":6.0, "loghe":0, "logc":-1, "logn":-1, "logo":-1}, "converged": true, "final_max_relc": 0.0069, "atmosphere_has_nan": false, "synspec_rc": 0, - "elapsed_sec": 1714.1, ← 总耗时(所有阶段+synspec之和) - "seed": null, + "elapsed_sec": 715.0, ← 总耗时(所有阶段+synspec之和) + "seed": null, ← 冷启动为 null;走种子步进时为邻居 .7 路径 + "seed_step_used": false, ← true=种子步进链跑成功的(含 coldfail_backup 路径) "stages": [ { "label": "lte", "converged": true, - "final": {"itek":3, "rc":0, "max_relc":0.0, - "note":"NITER=0 grey start"} + "final": {"itek":null, "rc":0, "max_relc":0.0, + "note":"NITER=0 grey start (no iterations)"} }, { "label": "nc", - "converged": false, ← nc 不要求收敛,作种子即可 - "final": {"itek":3, "rc":0, "max_relc":0.957, - "worst_depth":1, "last_iter":50, "n_depths":50} + "converged": false, ← nc 不要求收敛,作种子即可(NITER=10) + "final": {"itek":null, "rc":0, "max_relc":0.957, + "worst_depth":1, "last_iter":10, "n_depths":50} }, { "label": "nl", "converged": true, - "final": {"itek":3, "rc":0, "max_relc":0.0069, + "final": {"itek":null, "rc":0, "max_relc":0.0069, "worst_depth":1, "last_iter":17, "n_depths":50} } ] } ``` +> **走种子步进链时**:`seed` 指向邻居 `.7`,`seed_step_used=true`, +> `coldfail_backup` 指向 `.coldfail/`,`stages` 里没有 `lte`,而是 +> `seed_nc`(LTGRAY=F 热启动,NITER=80)→ `nl`。 + 每阶段记录:`converged`(是否收敛)、`max_relc`(最大相对变化)、 -`worst_depth`(最差深度点)、`last_iter`(迭代次数)、`n_depths`(深度点数)。 +`worst_depth`(最差深度点)、`last_iter`(迭代次数)、`n_depths`(深度点数)、 +`elapsed_sec`(本阶段耗时)。 ### 5.2 每阶段时间记录(已实现) @@ -210,12 +268,12 @@ with Pool(nworkers) as pool: ```json "stages": [ - {"label":"lte", "elapsed_sec": 27.3, "converged":true, ...}, - {"label":"nc", "elapsed_sec": 408.4, "converged":false, ...}, - {"label":"nl", "elapsed_sec": 7.8, "converged":true, ...} + {"label":"lte", "elapsed_sec": 2.1, "converged":true, ...}, + {"label":"nc", "elapsed_sec": 62.4, "converged":false, ...}, + {"label":"nl", "elapsed_sec": 7.8, "converged":true, ...} ], "synspec_sec": 3.1, -"elapsed_sec": 446.2 +"elapsed_sec": 75.4 ``` 统计所有模型的阶段时间分布: @@ -237,11 +295,15 @@ for f in sorted(glob.glob('results/*/conv.json')): ```json { - "total": 128, - "elapsed_sec": 9600, - "counts": {"converged": 120, "unfinished": 5, "error": 2, "skipped": 1}, + "total": 432, + "elapsed_sec": 36000, + "counts": {"converged": 400, "unfinished": 18, "error": 3, "skipped": 11}, + "seed_step_retries": 47, "models": [ - {"name":"t20000_...", "status":"converged", "max_relc":0.0065}, + {"name":"t20000_...", "status":"converged", "max_relc":0.0065, + "seed_step_used": false}, + {"name":"t80000_...", "status":"converged", "max_relc":0.00091, + "seed_step_used": true}, ... ] } @@ -249,15 +311,16 @@ for f in sorted(glob.glob('results/*/conv.json')): 汇总统计命令: ```bash -# 成功率 -python3 -c "import json; j=json.load(open('results/grid_status.json')); print(j['counts'])" -# 所有收敛模型的 max_relc 分布 +# 成功率 + 种子步进命中数 +python3 -c "import json; j=json.load(open('results/grid_status.json')); print(j['counts'], 'seed_step_retries=', j['seed_step_retries'])" +# 所有收敛模型的 max_relc 分布(标注是否走了种子步进) python3 -c " import json,glob for f in sorted(glob.glob('results/*/conv.json')): j=json.load(open(f)) if j['converged']: - print(j['name'], j['final_max_relc'], str(j['elapsed_sec'])+'s') + tag='SEED' if j.get('seed_step_used') else 'cold' + print(j['name'], tag, j['final_max_relc'], str(j['elapsed_sec'])+'s') " # 失败/未收敛的模型 python3 -c " @@ -289,9 +352,10 @@ grid: teff: [20000, 30000, 40000, 60000] # 各维采样点列表 logg: [5.0, 6.0] loghe: [-2, 0] - logc: [-1, 1] - logn: [-1, 1] - logo: [-1, 1] + logc: [-4, -2, -1] # 亚太阳范围(已修正;旧版 -1..1 会发散) + logn: [-4, -2, -1] + logo: [-4, -2, -1] + # 共 4*2*2*3*3*3 = 432 个点 ``` ### 第2步:设置环境变量 @@ -303,17 +367,20 @@ export TLUSTY=/home/dckj/program/tlusty/tl208-s54 ```bash cd $TLUSTY/cno_grid python3 src/run_grid.py config.yaml --dry-run -# 输出:grid: 64 points total, N already done, M to compute +# 输出:grid: 432 points total, N already done, M to compute ``` ### 第4步:启动批量计算(后台并行) ```bash nohup python3 src/run_grid.py config.yaml > results/grid_run.log 2>&1 & +# 冷启动失败的点会自动尝试种子步进回退(seed_step_fallback: true)。 +# 想关闭回退:在 config.yaml 设 seed_step_fallback: false。 ``` ### 第5步:监控 ```bash tail -f results/grid_run.log # 实时进度 +grep seed_step results/grid_run.log # 看哪些点走了种子步进 cat results/grid_status.json # 汇总(完成后才有) ``` @@ -324,32 +391,52 @@ python3 src/run_grid.py config.yaml # 重跑同一命令即可 ### 第7步:检查结果 + 画图 ```bash -# 成功率 -python3 -c "import json;print(json.load(open('results/grid_status.json'))['counts'])" +# 成功率 + 种子步进命中数 +python3 -c "import json;j=json.load(open('results/grid_status.json'));print(j['counts'],'seed_step=',j['seed_step_retries'])" # 画某个模型光谱 python3 src/plot_spec.py results/<模型名> ``` +### 单点调试(不走批量) +```bash +# 冷启动单点(适用 20-40K 大部分点) +python3 src/run_one.py --teff 40000 --logg 6.0 --loghe 0 --logc -1 --logn -1 --logo -1 +# 种子步进单点(高温 He-poor 等冷启动失败点) +python3 src/seed_step.py --teff 80000 --logg 6.5 --loghe -4 \ + --logc -4 --logn -4 --logo -4 \ + --seed results//.7 +``` + ### 第8步:加密网格(Phase 2) 在 config.yaml 的各维列表里加更多点,重跑 `run_grid.py`(自动跳过已完成的, -只算新点)。 +只算新点)。高温区加密时建议先冷启动算 He-rich/低金属的"桥头堡"模型, +建立种子库再扩散到难收敛点(见 §7.1)。 --- ## 7. 注意事项 -1. **种子复用**:run_grid.py 会自动找最近邻已收敛的 `.7` 作种子,省去 LTE 阶段。 - 但种子与目标的参数差不能太大(logg ≤0.5/步,Teff ≤5000K/步),否则发散。 - 建议网格各维步长不要太大。 +1. **种子步进回退(核心机制)**:`seed_step_fallback: true`(默认开)时, + 冷启动失败的点会自动改走种子步进链(`seed_step.SEED_STEP_CHAIN`): + 把失败结果备份到 `.coldfail/`,用 `find_seed` 找最近邻已收敛的 `.7` + 作种子,`LTGRAY=F + ICHANG=0` 热启动重跑。这是高温/He-poor/富金属区的 + 决定性破局手段(详见 §2.1)。 + **种子跨度限制**:种子与目标的参数差不能太大(logg ≤0.5/步, + Teff ≤5000K/步;logCNO 一步 ≤100×)。跨度过大(如 cno-4 直接跳 cno-1, + 1000× 金属跳跃)即便种子步进也发散 → 这是真实物理极限,网格如实标注。 + **种子库建立策略**:高温区建议先冷启动算 He-rich 或低金属的"桥头堡" + 模型,建立种子库再扩散到难收敛点(如 cno-4 → cno-2 → cno-1 多步跳板)。 2. **断点续算判定**:`conv.json` 里 `converged=true` 的点会被跳过。假收敛 (atmosphere_has_nan=true)的点会被重算。 3. **失败隔离**:单点失败(发散/崩溃)不中断整个网格,记入 grid_status.json - 的 error 列表。可在 config.yaml 放宽 CHMAX 或加 orelax 重试失败点。 + 的 error/unfinished 列表。高温难点的失败大多是真实物理极限(见 §2.1), + 不强制成功;如确需重试,可用 `seed_step.py` 手动从更近的种子起跳。 -4. **磁盘空间**:每个模型约 50-100MB(含中间文件)。8192 点约需 400-800GB。 - 当前 932GB 可用,够完整网格。如不够可定期清理中间文件(保留 .spec/.cont/.7/conv.json)。 +4. **磁盘空间**:每个模型约 50-100MB(含中间文件,走种子步进的点还会留 + `.coldfail/` 备份)。432 点约需 20-40GB。如不够可定期清理中间文件 + (保留 .spec/.cont/.7/conv.json)。 5. **并行安全**:每个 worker 用独立工作目录(results/<模型名>/),fort.* 文件 不冲突。可安全并行。 @@ -364,7 +451,11 @@ python3 src/plot_spec.py results/<模型名> 内部表示)。如果下一次 tlusty 运行(不同 NATOMS)读到旧的 fort.84,会报 "Bad integer for item 48" 崩溃。`run_tlusty()` 现在每次运行前删除 fort.84。 -8. **收敛可靠性(如实)**:用正确配方(无 CHMAX/ITEK, 14-level He, NFREAD=2000) - 重新验证后,35000K 成功(nl=0.00078),但 40000K+ 的 nc 仍然震荡发散。 - 之前版本的"8/8 边界全部成功"不准确(基于错误的 CHMAX=0.1)。 - 40000K+ 高温区需要种子步进或 ICRSW 等专业策略。详见 EXPERIENCE.md。 +8. **收敛可靠性(如实)**:用正确配方(无 CHMAX/ITEK, NFREAD=2000, nc NITER=10) + 后,20000-40000K 全区间冷启动可靠收敛;60000-80000K + He-rich/低金属用 + 种子步进可解决;**80000K + He-poor + logCNO=-1 是真实物理极限** + (CNO 高价离子主导不透明度,金属 ×10 跳跃线性化无法阻尼),网格如实标记 + 未收敛。之前版本的"8/8 边界全部成功"不准确(基于错误的 CHMAX=0.1)。 + `ICRSW`(Hummer & Voels 切换)在 tlusty208 **原版中是死代码** + (SUBROUTINE SWITCH 从未被 CALL,CRSW≡1.0),不要依赖它稳定化; + 即便源码修复启用后实测对难点也无帮助。详见 EXPERIENCE.md §5Y。 diff --git a/cno_grid/config.yaml b/cno_grid/config.yaml index 72d3eff..0ecc3ab 100644 --- a/cno_grid/config.yaml +++ b/cno_grid/config.yaml @@ -21,19 +21,26 @@ grid: # 共 4*2*2*3*3*3 = 432 个点 # ---- 收敛链 ---- -# 已验证的三步配方 (tests/cno_sdspectrum, Teff=35000 logg=5.5): +# 已验证的三步配方 (tests/sdB_spectra/GUIDE.md 实测, Teff=35000 logg=5.5): # LTE grey (T T, NITER=0) -> NLTE 连续谱 (nc, ilvlin=0) -> NLTE 谱线 (nl, ilvlin=100) # nc 步是关键:在不考虑谱线扰动下收敛 NLTE 电离平衡,给 nl 一个稳定种子。 # 跳过 nc(grey -> 完整 NLTE)会发散。 # # 重要:不要在 nst 里设 CHMAX/ITEK,用 tlusty 默认值(CHMAX=0.001, ITEK=4)。 # 设 CHMAX=0.1 会让 nc 提前停止,给 nl 一个坏种子导致发散。 +# +# nc 的 NITER=10 是实测最优(tests/sdB_spectra/GUIDE.md NITER 扫描结论): +# - NITER=10 总耗时 12.4min(35000K CNO 完整模型) +# - 光谱精度 vs NITER=50 差异仅 6e-6(完全等价) +# - NITER=50 浪费 2.2× 时间,NITER=200/500 更浪费且无收益 +# 物理上 nc(纯连续谱)缺少谱线约束,外层温度永不真正收敛——追求高 NITER +# 没意义。nl(含谱线)会自修正到正确解(流量差异 <3e-12)。 chain: - {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0} - - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 50} + - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10} - {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100} itek_fallback: [] # 留空:ITEK 默认值最稳;非空会重试(实测无效) -niter: 100 # 每个 tlusty 运行的默认最大迭代数 (nst NITER) +niter: 100 # 每个 tlusty 运行的默认最大迭代数 (nst NITER);nc 阶段在 chain 内覆盖为 10 # ---- 种子步进回退(NEW)---- # 冷启动失败时,自动用已收敛的邻居模型作种子,用 LTGRAY=F 热启动重试。 @@ -42,7 +49,7 @@ niter: 100 # 每个 tlusty 运行的默认最大迭代数 (nst NITER) seed_step_fallback: true # ---- 执行参数 ---- -nworkers: 24 # 并行 worker 数(每次 tlusty 运行是单线程的; +nworkers: 16 # 并行 worker 数(每次 tlusty 运行是单线程的; # 想用更多核心就调大;每个 worker 需要独立工作目录) timeout_sec: 3600 # 单模型墙钟时间上限 resume: true # 跳过已完成的模型(conv.json 中 converged=true) diff --git a/cno_grid/config_dense.yaml b/cno_grid/config_dense.yaml new file mode 100644 index 0000000..1ba341c --- /dev/null +++ b/cno_grid/config_dense.yaml @@ -0,0 +1,43 @@ +# 6 维 CNO NLTE 热亚矮星网格 —— 加密版配置 +# +# 相比 config.yaml 的改动: +# 1. CNO 各维加 -3:[-4,-2,-1] → [-4,-3,-2,-1] +# 消除 -4→-2 的 100× 丰度跳跃,每步均匀 10×,种子步进更稳 +# 2. Teff 加 50000:消除 40K→60K 的跨度,中间点有助种子传递 +# 3. 配合 run_grid.py 的 wave scheduling(按 CNO 总量分批提交) +# +# 总点数: 5*2*2*4*4*4 = 1280(vs 原 432,约 3 倍) +# 预计耗时: 1280/16 * 12min ≈ 16 小时 + +# ---- 网格轴 ---- +grid: + teff: [20000, 30000, 40000, 50000, 60000] + logg: [5.0, 5.5, 6.0, 6.5] + loghe: [-4, -2, 0, 2] + logc: [-4, -3, -2, -1] + logn: [-4, -3, -2, -1] + logo: [-4, -3, -2, -1] + # 共 5*4*4*4*4*4 = 5120 个点 + # CNO 每步丰度跳跃: 10× (均匀) + +# ---- 收敛链(同 config.yaml)---- +chain: + - {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0} + - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10} + - {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100} +itek_fallback: [] +niter: 100 + +# ---- 种子步进回退 ---- +seed_step_fallback: true + +# ---- 执行参数 ---- +nworkers: 20 +timeout_sec: 3600 +resume: true + +# ---- 路径 ---- +template: templates/cno_atmos.5.tpl +fort55: templates/fort.55.lin +linelist: data/gfVIS99.dat +results: results diff --git a/cno_grid/dist_config.yaml b/cno_grid/dist_config.yaml new file mode 100644 index 0000000..d5a924a --- /dev/null +++ b/cno_grid/dist_config.yaml @@ -0,0 +1,58 @@ +# 分布式网格计算配置(dist_master.py 用)。 +# 架构:master 单向 SSH 推/拉(只需 master 能 SSH 进 worker,worker 不回连 master)。 +# 网格轴本身仍在 config.yaml 定义,这里只配 master/qiniu/workers。 +# +# 部署:填好本文件后 +# python3 src/dist_master.py dist_config.yaml --dry-run # 预览 +# python3 src/dist_master.py dist_config.yaml # 全量跑 + +master: + # master 上 cno_grid 目录(tasks.json 在此,调度在此) + workdir: /home/dckj/program/tlusty/tl208-s54/cno_grid + # 网格配置(默认 workdir/config.yaml) + grid_config: /home/dckj/program/tlusty/tl208-s54/cno_grid/config.yaml + # 结果根目录(相对 workdir 或绝对路径;与 config.yaml 的 results 一致) + # master 在此汇总 grid_status.json;各 worker 各自有自己的 results/(同名) + results: results + +qiniu: + # 七牛云 bucket 名(种子库)。填你的实际 bucket。留空则纯本地模式。 + bucket: your-bucket + # AK/SK:仅 master 持有。建议用环境变量,别提交到仓库: + # export QINIU_ACCESS_KEY=... ; export QINIU_SECRET_KEY=... + access_key: "" + secret_key: "" + # 七牛绑定域名(下载/上传种子用)。公开空间填 http(s)://域名。 + domain: "" + seed_prefix: seeds + # 谁上传种子到七牛: + # master(默认)= master 取回 .7 后代传,worker 完全不碰七牛(worker 零配置) + # worker = worker 跑完自己传(需 worker 配 QINIU_DOMAIN) + upload_by: master + +# ---- worker 节点(master 只需能 SSH 进这些机器)---- +# 每个 worker: +# host : user@host(SSH 目标)。localhost 表示本机也算一个 worker。 +# tlusty_root: 该机器上 TLUSTY 树根(含 tlusty/tlusty.exe、synspec/、data/) +# nproc : 该机贡献的并发进程数(建议 ≤ 物理核数) +# +# 注意:单向 SSH。只需 master→worker 免密,worker 不需要 SSH 回 master。 +# +# 示例(5 台机器): +workers: + - host: localhost + tlusty_root: /home/dckj/program/tlusty/tl208-s54 + nproc: 8 + # - host: user@server2 + # tlusty_root: ~/tlusty/tl208-s54 + # nproc: 24 + # - host: user@server3 + # tlusty_root: ~/tlusty/tl208-s54 + # nproc: 24 + # - host: user@server4 + # tlusty_root: ~/tlusty/tl208-s54 + # nproc: 16 + # - host: user@server5 + # tlusty_root: ~/tlusty/tl208-s54 + # nproc: 24 + diff --git a/cno_grid/results b/cno_grid/results new file mode 120000 index 0000000..3db5106 --- /dev/null +++ b/cno_grid/results @@ -0,0 +1 @@ +/mnt/e/fmq/grid \ No newline at end of file diff --git a/cno_grid/run_boundary.sh b/cno_grid/run_boundary.sh deleted file mode 100644 index 6afd693..0000000 --- a/cno_grid/run_boundary.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# 边界点测试脚本(旧版,使用 logCNO=0)。每个点跑完后把收敛情况追加到日志。 -# 注:当前推荐使用 run_boundary_corrected.sh(修正后的丰度范围)。 -export TLUSTY=/home/dckj/program/tlusty/tl208-s54 -cd $TLUSTY/cno_grid -LOG=results/boundary_runs.log -echo "=== 边界测试开始 $(date) ===" > $LOG - -# 跑一个边界点,并把结果摘要追加到日志 -run_pt() { - local teff=$1 logg=$2 loghe=$3 name=$4 - echo ">>> [$name] teff=$teff logg=$logg logHe=$loghe $(date)" >> $LOG - python3 src/run_one.py --teff $teff --logg $logg --loghe $loghe \ - --logc 0 --logn 0 --logo 0 >> $LOG 2>&1 - # 记录结果 - python3 -c " -import json,glob,os -d='t{}_g{:.1f}_he{:.0f}_c0_n0_o0'.format(int($teff),$logg,$loghe) -p=os.path.join('results',d,'conv.json') -try: - j=json.load(open(p)) - print(' {} {} {} -> converged={} max_relc={} elapsed={}'.format( - '$name',$teff,$logg,j['converged'],j.get('final_max_relc'),j.get('elapsed_sec')),file=open($LOG,'a')) -except Exception as e: print(' $name ERROR:',e,file=open($LOG,'a')) -" >> $LOG 2>&1 -} - -# 边界点1已在独立进程跑,这里跑2-5 -run_pt 80000 6.5 2 "b2_高温He-rich" -run_pt 80000 6.5 -4 "b3_高温He-poor" -run_pt 40000 6.0 0 "b4_中心点高金属参考" -run_pt 20000 6.5 -4 "b5_低温贫He" - -echo "=== 全部完成 $(date) ===" >> $LOG diff --git a/cno_grid/run_boundary_corrected.sh b/cno_grid/run_boundary_corrected.sh deleted file mode 100755 index 8e0e1d9..0000000 --- a/cno_grid/run_boundary_corrected.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# 修正丰度范围(logCNO = -4..-1)后的边界测试。 -# 之前的"高温发散"实际上是因为 abn=1.0(logX=0)相当于 4000 倍太阳丰度。 -# 此处用物理上合理的丰度重新跑这组边界。 -# -# 测试点(全部后台并行运行): -# B1 80000/6.5/-4/ -1/-1/-1 高温贫 He、富金属(CNO 为 0.1 倍 H) -# B2 80000/6.5/-4/ -4/-4/-4 高温贫 He、近太阳 CNO -# B3 80000/6.5/ 2/ -1/-1/-1 高温富 He(物理上最难的点) -# B4 80000/6.5/ 2/ -4/-4/-4 高温富 He、近太阳 -# B5 20000/5.0/ 2/ -1/-1/-1 低温富 He、富金属 -# B6 20000/5.0/ 2/ -4/-4/-4 低温富 He、近太阳 -# B7 40000/5.5/ 0/ -4/-4/-4 中温类太阳 CNO(丰度下限) -# B8 60000/6.0/ 0/ -1/-1/-1 中高温富金属抽查点 - -set -u -cd /home/dckj/program/tlusty/tl208-s54 -RESULTS=cno_grid/results -mkdir -p "$RESULTS" - -# 后台运行一个模型,把 stdout/stderr 写入对应日志文件 -run_bg() { - local tag="$1"; shift - local logfile="$RESULTS/bound_${tag}.log" - echo "[$(date +%H:%M:%S)] launching $tag -> $logfile" - nohup python3 cno_grid/src/run_one.py "$@" > "$logfile" 2>&1 & - echo " pid=$!" -} - -# 高温边界(80000K)—— 最重要的复测点 -run_bg 80k_he-4_cno-1 --teff 80000 --logg 6.5 --loghe -4 --logc -1 --logn -1 --logo -1 -run_bg 80k_he-4_cno-4 --teff 80000 --logg 6.5 --loghe -4 --logc -4 --logn -4 --logo -4 -run_bg 80k_he2_cno-1 --teff 80000 --logg 6.5 --loghe 2 --logc -1 --logn -1 --logo -1 -run_bg 80k_he2_cno-4 --teff 80000 --logg 6.5 --loghe 2 --logc -4 --logn -4 --logo -4 - -# 低温富 He 边界(20000K, logHe=2) -run_bg 20k_he2_cno-1 --teff 20000 --logg 5.0 --loghe 2 --logc -1 --logn -1 --logo -1 -run_bg 20k_he2_cno-4 --teff 20000 --logg 5.0 --loghe 2 --logc -4 --logn -4 --logo -4 - -# 中温丰度边界 -run_bg 40k_he0_cno-4 --teff 40000 --logg 5.5 --loghe 0 --logc -4 --logn -4 --logo -4 -run_bg 60k_he0_cno-1 --teff 60000 --logg 6.0 --loghe 0 --logc -1 --logn -1 --logo -1 - -wait -echo "[$(date +%H:%M:%S)] ALL BOUNDARY TESTS COMPLETE" diff --git a/cno_grid/run_seed_step_tests.sh b/cno_grid/run_seed_step_tests.sh deleted file mode 100755 index a9795d0..0000000 --- a/cno_grid/run_seed_step_tests.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# 针对剩余冷启动失败边界点的种子步进验证测试。 -# -# 策略:冷启动在低温、以及高温+富 He+低 CNO 的区域是可行的。用这些已 -# 收敛的大气作为种子,去热启动冷启动下会发散的几个点: -# -# S1 60000/6.0/0 /-1/-1/-1 以 40000/5.5/0/-4/-4/-4 为种子(Teff 不同) -# S2 80000/6.5/-4/-1/-1/-1 以 80000/6.5/-4/-4/-4/-4 为种子(同 T、更低金属) -# S3 80000/6.5/+2/-1/-1/-1 以 80000/6.5/+2/-4/-4/-4 为种子(同 T、更低金属) -# -# 注意:S1 把 Teff 从 40000 跨到 60000(原子相同)—— 比同 T、降金属的步进 -# 更苛刻,但仍值得一试。 - -set -u -cd /home/dckj/program/tlusty/tl208-s54 -RESULTS=cno_grid/results/seed_step -mkdir -p "$RESULTS" - -# S2 首先需要一个已收敛的 80k_he-4_cno-4 大气 —— 上面的 seed_step/ 运行 -# 已经有了一个,直接拿它当种子。 -SEED_80K_HEPOOR_CNO4=cno_grid/results/seed_step/t80000_g6.5_he-4_c-4_n-4_o-4/t80000_g6.5_he-4_c-4_n-4_o-4.7 -SEED_80K_HERICH_CNO4=cno_grid/results/t80000_g6.5_he2_c-4_n-4_o-4/t80000_g6.5_he2_c-4_n-4_o-4.7 -SEED_40K_HEPOOR_CNO4=cno_grid/results/t40000_g5.5_he0_c-4_n-4_o-4/t40000_g5.5_he0_c-4_n-4_o-4.7 - -# 后台运行一个种子步进模型 -run_bg() { - local tag="$1"; shift - echo "[$(date +%H:%M:%S)] launching $tag" - nohup python3 cno_grid/src/seed_step.py "$@" > "$RESULTS/${tag}.log" 2>&1 & - echo " pid=$!" -} - -# S2:80K 贫 He 富金属,以 80K 贫 He 低金属为种子(刚验证过) -run_bg s2_80k_he-4_cno-1 --teff 80000 --logg 6.5 --loghe -4 \ - --logc -1 --logn -1 --logo -1 --seed $SEED_80K_HEPOOR_CNO4 - -# S3:80K 富 He 富金属,以 80K 富 He 低金属为种子 -run_bg s3_80k_he2_cno-1 --teff 80000 --logg 6.5 --loghe 2 \ - --logc -1 --logn -1 --logo -1 --seed $SEED_80K_HERICH_CNO4 - -# S1:60K 贫 He 富金属,以 40K 贫 He 低金属为种子 -run_bg s1_60k_he0_cno-1 --teff 60000 --logg 6.0 --loghe 0 \ - --logc -1 --logn -1 --logo -1 --seed $SEED_40K_HEPOOR_CNO4 - -wait -echo "[$(date +%H:%M:%S)] ALL SEED_STEP TESTS COMPLETE" diff --git a/cno_grid/src/claim_task.py b/cno_grid/src/claim_task.py new file mode 100644 index 0000000..cdc8899 --- /dev/null +++ b/cno_grid/src/claim_task.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""master 端原子任务领取(worker 经 SSH 调用)。 + +tasks.json 结构: + { + "grid": "config.yaml 路径或标识", + "created": , + "tasks": { + "": { + "params": {"teff":..,"logg":..,"loghe":..,"logc":..,"logn":..,"logo":..}, + "status": "pending|running|done|failed", + "worker": , + "claimed_at": , + "finished_at": , + "attempts": , + "max_relc": , + "seed_step_used": , + "note": + }, + ... + } + } + +并发安全:用 fcntl.flock 对 tasks.json 加排他锁,保证多 worker 同时 +claim/report 不冲突。worker 一次领一个任务(可批量,见 --batch)。 + +用法(worker 经 SSH): + ssh master "python3 src/claim_task.py --tasks tasks.json --worker w1 [--batch 1]" + → 成功:stdout 打印一个 task 的 JSON(含 params + name);无任务:打印 null +""" +import argparse +import fcntl +import json +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gen_input5 # noqa: E402 -- model_name + + +def _lock_and_update(tasks_path, fn): + """对 tasks.json 加排他锁,执行 fn(data) 并写回。返回 fn 的返回值。""" + # 先确保文件存在(dist_master 初始化时已建,这里兜底) + fd = open(tasks_path, "r+") + try: + fcntl.flock(fd.fileno(), fcntl.LOCK_EX) + fd.seek(0) + try: + data = json.load(fd) + except ValueError: + data = {"tasks": {}} + result = fn(data) + fd.seek(0) + fd.truncate() + json.dump(data, fd, indent=2) + fd.flush() + os.fsync(fd.fileno()) + return result + finally: + fcntl.flock(fd.fileno(), fcntl.LOCK_UN) + fd.close() + + +def main(): + ap = argparse.ArgumentParser(description="原子领取一个 pending 任务") + ap.add_argument("--tasks", default="tasks.json", help="tasks.json 路径") + ap.add_argument("--worker", required=True, help="worker 标识(如 w1@host)") + ap.add_argument("--batch", type=int, default=1, + help="一次领取 N 个任务(减少 SSH 往返)") + ap.add_argument("--phase", default=None, + help="阶段过滤:phase1=只领无种子的冷启动;" + "phase2=只领之前 failed 的(补算)") + args = ap.parse_args() + + if not os.path.exists(args.tasks): + print(json.dumps(None)) + return + + def claim(data): + tasks = data.get("tasks", {}) + now = time.time() + claimed = [] + for name, t in tasks.items(): + if len(claimed) >= args.batch: + break + if t.get("status") != "pending": + continue + if args.phase == "phase2" and t.get("attempts", 0) == 0: + # phase2 只领 phase1 已尝试过且失败的 + continue + t["status"] = "running" + t["worker"] = args.worker + t["claimed_at"] = now + t["attempts"] = t.get("attempts", 0) + 1 + claimed.append({"name": name, "params": t["params"]}) + return claimed + + claimed = _lock_and_update(args.tasks, claim) + if not claimed: + print(json.dumps(None)) + elif args.batch <= 1: + print(json.dumps(claimed[0])) + else: + print(json.dumps(claimed)) + + +if __name__ == "__main__": + main() diff --git a/cno_grid/src/dist_check.py b/cno_grid/src/dist_check.py new file mode 100644 index 0000000..6e8f631 --- /dev/null +++ b/cno_grid/src/dist_check.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""worker 端状态查询(被 master 经 SSH 调用)。 + +worker 无常驻进程。master 周期性 SSH 进 worker 调本脚本,一次列出该 worker +上所有已完成(有 conv.json)的模型及其收敛状态,供 master 回收决策。 + +输出(stdout,一行 JSON):{model_name: {converged, final_max_relc, elapsed_sec, ...}} + +用法(master 经 SSH): + ssh worker "cd ~/tlusty/tl208-s54/cno_grid && python3 src/dist_check.py --results results" +""" +import argparse +import json +import os +import sys + + +def scan_results(results_root): + """扫描 results//conv.json,返回 {name: summary}。""" + out = {} + if not os.path.isdir(results_root): + return out + for d in sorted(os.listdir(results_root)): + full = os.path.join(results_root, d) + conv = os.path.join(full, "conv.json") + if not (os.path.isdir(full) and os.path.isfile(conv)): + continue + try: + with open(conv) as f: + meta = json.load(f) + except Exception: + continue + # 只报关键字段,减小 SSH 传输 + out[d] = { + "converged": bool(meta.get("converged", False)), + "final_max_relc": meta.get("final_max_relc"), + "elapsed_sec": meta.get("elapsed_sec"), + "atmosphere_has_nan": meta.get("atmosphere_has_nan", False), + "synspec_rc": meta.get("synspec_rc"), + "has_7": os.path.isfile(os.path.join(full, d + ".7")), + } + return out + + +def main(): + ap = argparse.ArgumentParser(description="worker 状态查询") + ap.add_argument("--results", default="results", + help="results 根目录(相对 cwd 或绝对)") + args = ap.parse_args() + results_root = args.results + if not os.path.isabs(results_root): + # 相对当前工作目录(master SSH 时已 cd 到 cno_grid) + results_root = os.path.join(os.getcwd(), results_root) + out = scan_results(results_root) + print(json.dumps(out)) + + +if __name__ == "__main__": + main() diff --git a/cno_grid/src/dist_master.py b/cno_grid/src/dist_master.py new file mode 100644 index 0000000..baa63c7 --- /dev/null +++ b/cno_grid/src/dist_master.py @@ -0,0 +1,733 @@ +#!/usr/bin/env python3 +"""分布式 master(单向 SSH 推/拉模型)。 + +适配场景:只有 master → worker 单向 SSH 可达(worker 在 NAT 后,SSH 不回 master)。 + +模型: + - 任务队列 tasks.json 只在 master 本地(fcntl.flock 强一致,零并发风险) + - worker 无常驻进程;master 直接 SSH 执行 run_one.py 跑单点(异步 nohup) + - master 周期 SSH 进 worker 查状态、取结果(conv.json + .7) + - 七牛云只存种子(master 取回 .7 后代传;worker 不碰七牛) + +主循环: + init tasks.json(断点续算:本地已 converged 标 done) + while 有 pending: + for 每个 worker host(并发线程,每台一个): + SSH 查正在跑的 run_one 数 (pgrep) → 空闲槽 = nproc - running + for 每个空闲槽: + task = claim_task()(本地 flock 领一个 pending→running) + SSH worker "nohup python3 src/run_one.py --teff .. &"(异步) + sleep 轮询 + for 每个 worker: + SSH 查已完成的(conv.json 存在且进程已退出) + 读结果 → 更新 tasks.json → scp .7(若收敛)→ 上传七牛 + 写 grid_status.json + +并发安全: + - claim_task/report_task 用 flock,master 多线程也安全 + - worker 不碰 tasks.json,无跨机并发问题 + +用法: + python3 src/dist_master.py dist_config.yaml # 全量 + python3 src/dist_master.py dist_config.yaml --dry-run # 预览 + python3 src/dist_master.py dist_config.yaml --max-runtime 4h # 限时 +""" +import argparse +import fcntl +import itertools +import json +import os +import subprocess +import sys +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gen_input5 # noqa: E402 +import qiniu_store # noqa: E402 + +# ---- YAML 加载(优先 pyyaml,否则内置极简解析器,零 pip)---- +try: + import yaml + + def _load_yaml(path): + with open(path) as f: + return yaml.safe_load(f) +except ImportError: + def _load_yaml(path): + cfg = {} + section = None + cur_list = None + cur_item = None + with open(path) as f: + for raw in f: + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + stripped = line.strip() + indented = line[:1] in (" ", "\t") + if stripped.startswith("- ") and indented and cur_list is not None: + body = stripped[2:].strip() + if body.startswith("{") and body.endswith("}"): + d = {} + for kv in body[1:-1].split(","): + if ":" in kv: + k, v = kv.split(":", 1) + d[k.strip()] = _scalar(v.strip()) + cur_list.append(d) + cur_item = None + elif ":" in body: + k, v = body.split(":", 1) + cur_item = {k.strip(): _scalar(v.strip())} + cur_list.append(cur_item) + else: + cur_list.append(_scalar(body)) + cur_item = None + continue + if ":" in stripped: + key, val = stripped.split(":", 1) + key = key.strip() + val = val.strip() + if not indented: + cur_item = None + if val == "": + if key in ("workers", "chain"): + cur_list = [] + cfg[key] = cur_list + section = None + else: + new_container = {} + cfg[key] = new_container + section = new_container + cur_list = None + else: + cfg[key] = _parse_list(val) + section = None + cur_list = None + else: + target = cur_item if cur_item is not None else section + if target is not None: + target[key] = _parse_list(val) + return cfg + + def _parse_list(val): + val = val.strip() + if val.startswith("[") and val.endswith("]"): + return [_scalar(x.strip()) for x in val[1:-1].split(",") if x.strip()] + return _scalar(val) + + def _scalar(v): + if v in ("true", "True"): + return True + if v in ("false", "False"): + return False + try: + return int(v) + except ValueError: + pass + try: + return float(v) + except ValueError: + pass + if len(v) >= 2 and v[0] in "\"'" and v[-1] == v[0]: + return v[1:-1] + return v + + +# ==================== tasks.json 原子操作(本地 flock)==================== +def _lock_tasks(tasks_path, fn): + """对 tasks.json 加排他锁,执行 fn(data),写回。返回 fn 的返回值。""" + fd = open(tasks_path, "r+") + try: + fcntl.flock(fd.fileno(), fcntl.LOCK_EX) + fd.seek(0) + try: + data = json.load(fd) + except ValueError: + data = {"tasks": {}} + result = fn(data) + fd.seek(0) + fd.truncate() + json.dump(data, fd, indent=2) + fd.flush() + os.fsync(fd.fileno()) + return result + finally: + fcntl.flock(fd.fileno(), fcntl.LOCK_UN) + fd.close() + + +def expand_grid(grid): + keys = ["teff", "logg", "loghe", "logc", "logn", "logo"] + for combo in itertools.product(*(grid[k] for k in keys)): + yield dict(zip(keys, combo)) + + +def model_done_local(results_root, name): + p = os.path.join(results_root, name, "conv.json") + if not os.path.isfile(p): + return False + try: + return bool(json.load(open(p)).get("converged")) + except Exception: + return False + + +def init_tasks(grid_cfg, results_root, tasks_path, grid_id="config.yaml", force=False): + if os.path.isfile(tasks_path) and not force: + return json.load(open(tasks_path)) + tasks = {} + for pt in expand_grid(grid_cfg["grid"]): + name = gen_input5.model_name(pt["teff"], pt["logg"], pt["loghe"], + pt["logc"], pt["logn"], pt["logo"]) + done = model_done_local(results_root, name) + tasks[name] = {"params": pt, "status": "done" if done else "pending", + "worker": None, "host": None, "claimed_at": None, + "finished_at": None, "attempts": 0, "max_relc": None, + "seed_step_used": False, "note": None} + data = {"grid": grid_id, "created": time.time(), "tasks": tasks} + with open(tasks_path, "w") as f: + json.dump(data, f, indent=2) + return data + + +def claim_task(tasks_path, host, worker_id, phase=None): + """领一个 pending 任务 → running,返回 {name, params} 或 None。""" + def do(data): + tasks = data["tasks"] + now = time.time() + for name, t in tasks.items(): + if t["status"] != "pending": + continue + if phase == "phase2" and t.get("attempts", 0) == 0: + continue + t["status"] = "running" + t["host"] = host + t["worker"] = worker_id + t["claimed_at"] = now + t["attempts"] = t.get("attempts", 0) + 1 + return {"name": name, "params": t["params"]} + return None + return _lock_tasks(tasks_path, do) + + +def report_task(tasks_path, name, status, **fields): + def do(data): + t = data["tasks"].get(name) + if t is None: + return + t["status"] = status + for k in ("max_relc", "seed_step_used", "elapsed_sec", "note"): + if k in fields and fields[k] is not None: + t[k] = fields[k] + if status in ("done", "failed"): + t["finished_at"] = time.time() + _lock_tasks(tasks_path, do) + + +def count_statuses(data): + c = {} + for t in data["tasks"].values(): + c[t["status"]] = c.get(t["status"], 0) + 1 + return c + + +def reset_stale(tasks_path, stale_sec): + """running 超 stale_sec → pending(worker 崩溃/SSH 断的兜底)。""" + def do(data): + now = time.time() + reset = 0 + for t in data["tasks"].values(): + if t["status"] == "running": + if now - (t.get("claimed_at") or 0) > stale_sec: + t["status"] = "pending" + t["worker"] = None + t["host"] = None + reset += 1 + return reset + return _lock_tasks(tasks_path, do) + + +# ==================== SSH 工具(master → worker 单向)==================== +def ssh_ok(target, cmd, timeout=30): + """SSH 执行命令,返回 (rc, stdout, stderr)。target=user@host 或 host。""" + full = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", target, cmd] + try: + r = subprocess.run(full, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout.strip(), r.stderr.strip() + except subprocess.TimeoutExpired: + return 124, "", "ssh timeout" + + +def count_running_on_worker(target, worker_cwd): + """SSH 查 worker 上正在跑的 run_one 进程数。""" + cmd = "pgrep -fc 'run_one.py' 2>/dev/null || echo 0" + rc, out, _ = ssh_ok(target, cmd, timeout=20) + if rc != 0 or not out: + return 0 + try: + return int(out.strip().splitlines()[-1]) + except ValueError: + return 0 + + +def dispatch_task(target, worker_cwd, tlusty_root, name, params, results, + seed_path=None, qiniu_domain="", timeout=3600, grid_config="config.yaml"): + """SSH 派一个任务到 worker(异步 nohup,立即返回)。 + + master 本地已 claim;这里只负责在 worker 上启动 run_one.py。 + """ + parts = ["{teff:.0f}", "{logg:.1f}", "{loghe:.0f}", + "{logc:.0f}", "{logn:.0f}", "{logo:.0f}"] + args = ("--teff {teff} --logg {logg} --loghe {loghe} " + "--logc {logc} --logn {logn} --logo {logo}").format(**params) + cmd_seed = "--seed '{}'".format(seed_path) if seed_path else "" + # run_one.py 在 cno_grid/src 下;results 相对 worker_cwd + env = " ".join([ + "TLUSTY='{}'".format(tlusty_root), + "QINIU_DOMAIN='{}'".format(qiniu_domain), + "DIST_TASK_NAME='{}'".format(name), + ]) + # 日志按任务名,便于查;结果写 worker 本地 results/ + log_file = "{}/worker_jobs/{}.log".format(worker_cwd, name) + run_cmd = ( + "mkdir -p {cwd}/worker_jobs && cd {cwd} && " + "{env} nohup python3 src/run_one.py {args} {seed} " + "--results {res} --timeout {to} > {logf} 2>&1 & echo $!" + ).format(cwd=worker_cwd, env=env, args=args, seed=cmd_seed, + res=results, to=timeout, logf=log_file) + rc, pid, err = ssh_ok(target, run_cmd, timeout=30) + return pid, err + + +def dispatch_seed_step(target, worker_cwd, tlusty_root, name, params, + results, seed_path, timeout=3600): + """SSH 派一个 seed_step 补算任务到 worker(异步 nohup)。 + + 冷启动失败后,master 从本地/七牛拉到种子 .7,scp 到 worker,再调 + seed_step.py(LTGRAY=F 热启动)重试。 + """ + args = ("--teff {teff} --logg {logg} --loghe {loghe} " + "--logc {logc} --logn {logn} --logo {logo}").format(**params) + # seed_path 是 worker 上的路径(已 scp 过去) + env = "TLUSTY='{}'".format(tlusty_root) + log_file = "{}/worker_jobs/{}.seedstep.log".format(worker_cwd, name) + # seed_step.py 把结果写到 results/seed_step//;这里指定 results 让它和冷启动同目录 + run_cmd = ( + "mkdir -p {cwd}/worker_jobs && cd {cwd} && " + "{env} nohup python3 src/seed_step.py {args} --seed '{seed}' " + "--results {res} > {logf} 2>&1 & echo $!" + ).format(cwd=worker_cwd, env=env, args=args, seed=seed_path, + res=results, logf=log_file) + rc, pid, err = ssh_ok(target, run_cmd, timeout=30) + return pid, err + + +def push_seed_to_worker(target, local_seed, remote_seed): + """scp master 本地的 .7 种子到 worker。返回 worker 上路径或 None。""" + try: + r = subprocess.run(["scp", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", + local_seed, "{}:{}".format(target, remote_seed)], + capture_output=True, timeout=60) + if r.returncode == 0: + return remote_seed + except subprocess.TimeoutExpired: + pass + return None + + +def fetch_worker_status(target, worker_cwd, results): + """SSH 查 worker 上所有已完成任务(有 conv.json 的)。 + + 返回 {model_name: conv_summary_dict}。供 master 决定哪些该回收。 + 用 dist_check.py(一次列出全部,比逐个 cat 高效)。 + """ + cmd = "cd {cwd} && python3 src/dist_check.py --results {res} 2>/dev/null".format( + cwd=worker_cwd, res=results) + rc, out, err = ssh_ok(target, cmd, timeout=40) + if rc != 0 or not out: + return {} + try: + return json.loads(out) + except ValueError: + return {} + + +def fetch_seed(target, worker_cwd, results, name, dest_path): + """scp 取 worker 上的 .7 大气到 master 本地 dest_path。""" + src = "{}:{}/{}/{}.7".format(target, worker_cwd.rstrip("/"), results, name) + try: + r = subprocess.run(["scp", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", + src, dest_path], capture_output=True, timeout=60) + return r.returncode == 0 + except subprocess.TimeoutExpired: + return False + + +def fetch_conv(target, worker_cwd, results, name): + """SSH 读 worker 上某模型的 conv.json 内容。""" + cmd = "cat {}/{}/{}.conv.json 2>/dev/null || cat {}/{}/conv.json 2>/dev/null".format( + worker_cwd.rstrip("/"), results, name, worker_cwd.rstrip("/"), results + "/" + name) + # run_one 写的是 results//conv.json + cmd = "cat {0}/{1}/{2}/conv.json 2>/dev/null".format( + worker_cwd.rstrip("/"), results, name) + rc, out, err = ssh_ok(target, cmd, timeout=20) + if rc != 0 or not out: + return None + try: + return json.loads(out) + except ValueError: + return None + + +def cleanup_worker_result(target, worker_cwd, results, name): + """回收后清理 worker 上的结果目录(可选,省 worker 磁盘)。 + + 保留 .7 给本地 seed_step 用,删大 .spec。 + 默认不删——worker 本地种子对 seed_step 有用。 + """ + pass + + +# ==================== per-worker 管理线程 ==================== +class WorkerManager(threading.Thread): + """每台 worker 一个线程:填充空闲槽 + 回收已完成。""" + + def __init__(self, wid, target, tlusty_root, nproc, worker_cwd, + results, tasks_path, store, qiniu_upload_by, grid_config, + timeout, stale_sec, phase, seed_step_on, seed_dir): + super().__init__(daemon=True) + self.wid = wid + self.target = target + self.tlusty_root = tlusty_root + self.nproc = nproc + self.worker_cwd = worker_cwd + self.results = results + self.tasks_path = tasks_path + self.store = store + self.qiniu_upload_by = qiniu_upload_by + self.grid_config = grid_config + self.timeout = timeout + self.stale_sec = stale_sec + self.phase = phase + self.seed_step_on = seed_step_on + self.seed_dir = seed_dir # master 本地存从 worker 取回的 .7 + self.stop = False + + def log(self, msg): + print(" [w{} {}] {}".format(self.wid, self.target, msg), flush=True) + + def run(self): + os.makedirs(self.seed_dir, exist_ok=True) + while not self.stop: + try: + self._fill_slots() + self._collect_results() + except Exception as e: + self.log("循环异常: {}".format(e)) + # 检查是否还有 pending + data = json.load(open(self.tasks_path)) + counts = count_statuses(data) + if counts.get("pending", 0) == 0 and counts.get("running", 0) == 0: + break + time.sleep(15) + + def _fill_slots(self): + """查 worker 空闲槽,派任务。""" + running = count_running_on_worker(self.target, self.worker_cwd) + free = max(0, self.nproc - running) + if free == 0: + return + for _ in range(free): + task = claim_task(self.tasks_path, self.target, + "w{}".format(self.wid), phase=self.phase) + if task is None: + return # 没任务了 + name = task["name"] + params = task["params"] + self.log("派发 {} (worker 现有 {})".format(name, running)) + pid, err = dispatch_task( + self.target, self.worker_cwd, self.tlusty_root, + name, params, self.results, + seed_path=None, qiniu_domain=self.store.domain, + timeout=self.timeout, grid_config=self.grid_config) + if err and "nohup" not in str(err).lower(): + self.log("派发 {} 警告: {}".format(name, err[:100])) + + def _collect_results(self): + """回收 worker 上已完成的结果。""" + statuses = fetch_worker_status(self.target, self.worker_cwd, self.results) + if not statuses: + return + data = json.load(open(self.tasks_path)) + claimed_here = {n: t for n, t in data["tasks"].items() + if t.get("host") == self.target and t["status"] == "running"} + for name in claimed_here: + if name not in statuses: + continue + conv = statuses[name] + converged = conv.get("converged", False) + max_relc = conv.get("final_max_relc") + elapsed = conv.get("elapsed_sec") + seed_step_used = conv.get("seed_step_used", False) + # 但注意:run_one 的 seed_step 不会自动发生(需 master 触发), + # 这里 converged=False 的点,master 后续做 seed_step 补算。 + if converged: + # 取回 .7 种子到 master + local_seed = os.path.join(self.seed_dir, name + ".7") + ok = fetch_seed(self.target, self.worker_cwd, self.results, + name, local_seed) + if ok and self.store.domain: + # 上传七牛(master 代传) + if self.qiniu_upload_by == "master": + up_ok, msg = self.store.upload_seed(local_seed, name) + self.log("{} 收敛,种子上传七牛: {}".format( + name, "ok" if up_ok else msg)) + report_task(self.tasks_path, name, "done", + max_relc=max_relc, elapsed_sec=elapsed, + seed_step_used=seed_step_used, note="converged") + self.log("{} DONE max_relc={}".format(name, max_relc)) + else: + # 冷启动未收敛:尝试 seed_step 补算(若有种子)。 + # 找种子:master 本地 seed_dir > 七牛云。seed_step 只试一次。 + if not self.seed_step_on: + report_task(self.tasks_path, name, "failed", + max_relc=max_relc, elapsed_sec=elapsed, + note="unconverged cold-start (seed_step off)") + self.log("{} FAILED (seed_step 关闭)".format(name)) + continue + # 已尝试过 seed_step 仍失败 → 终态 failed + if seed_step_used or data["tasks"][name].get("attempts", 0) >= 2: + report_task(self.tasks_path, name, "failed", + max_relc=max_relc, elapsed_sec=elapsed, + seed_step_used=seed_step_used, + note="unconverged after seed_step (物理极限?)") + self.log("{} FAILED (seed_step 后仍发散)".format(name)) + continue + # 找种子 + seed = self._find_seed_for(params) + if seed is None: + report_task(self.tasks_path, name, "failed", + max_relc=max_relc, elapsed_sec=elapsed, + note="unconverged, no seed available") + self.log("{} FAILED (无种子做 seed_step)".format(name)) + continue + # scp 种子到 worker,再派 seed_step + remote_seed = os.path.join(self.worker_cwd, + ".seed_cache", name + ".seed.7") + worker_seed = push_seed_to_worker(self.target, seed, remote_seed) + if worker_seed is None: + report_task(self.tasks_path, name, "failed", + max_relc=max_relc, elapsed_sec=elapsed, + note="seed_step: push seed failed") + self.log("{} seed_step 种子推送失败".format(name)) + continue + # 标记 running(attempts++),重新派 seed_step + def mark_retry(d): + t = d["tasks"].get(name) + if t: + t["status"] = "running" + t["seed_step_used"] = True + t["claimed_at"] = time.time() + _lock_tasks(self.tasks_path, mark_retry) + # 备份 worker 上的冷启动失败结果(seed_step 会覆盖同目录) + # seed_step.py 默认写 results/seed_step/,不会覆盖冷启动目录; + # 但为统一回收,这里让它写到 results/ 下同 name 目录会冲突, + # 所以让 seed_step 写到 seed_step/ 子目录,回收时合并。 + pid, err = dispatch_seed_step( + self.target, self.worker_cwd, self.tlusty_root, + name, params, self.results, worker_seed, timeout=self.timeout) + self.log("{} seed_step 重试 (seed={})".format( + name, os.path.basename(worker_seed))) + + def _find_seed_for(self, params): + """为某点找种子 .7:master 本地 seed_dir > 七牛云。返回本地路径或 None。""" + # 本地:扫 seed_dir 找最近邻 + teff, logg, loghe = params["teff"], params["logg"], params["loghe"] + logc, logn, logo = params["logc"], params["logn"], params["logo"] + best = None + best_d = None + if os.path.isdir(self.seed_dir): + for fn in os.listdir(self.seed_dir): + if not fn.endswith(".7"): + continue + nm = fn[:-2] + try: + parts = nm.split("_") + pte = int(parts[0][1:]); plogg = float(parts[1][1:]) + phe = float(parts[2][2:]); pc = float(parts[3][1:]) + pn = float(parts[4][1:]); po = float(parts[5][1:]) + except (IndexError, ValueError): + continue + if pte == teff and plogg == logg and phe == loghe: + d = abs(pc - logc) + abs(pn - logn) + abs(po - logo) + else: + d = (abs(pte - teff) / 5000.0 + abs(plogg - logg) * 2.0 + + abs(phe - loghe) * 0.5) * 10 # 同 family 优先 + if best_d is None or d < best_d: + best_d, best = d, os.path.join(self.seed_dir, fn) + if best is not None and best_d is not None and best_d < 100: + return best + # 七牛云 + if self.store.domain: + try: + names = self.store.list_seeds() + qb, qd = None, None + for nm in names: + try: + parts = nm.split("_") + pte = int(parts[0][1:]); plogg = float(parts[1][1:]) + phe = float(parts[2][2:]); pc = float(parts[3][1:]) + pn = float(parts[4][1:]); po = float(parts[5][1:]) + except (IndexError, ValueError): + continue + if pte == teff and plogg == logg and phe == loghe: + d = abs(pc - logc) + abs(pn - logn) + abs(po - logo) + else: + d = (abs(pte - teff) / 5000.0 + abs(plogg - logg) * 2.0 + + abs(phe - loghe) * 0.5) * 10 + if qd is None or d < qd: + qd, qb = d, nm + if qb is not None and qd is not None and qd < 100: + local = os.path.join(self.seed_dir, qb + ".7") + if not os.path.exists(local): + if self.store.download_seed(qb, local): + return local + else: + return local + except Exception: + pass + return None + + +def write_grid_status(tasks_path, results_root, elapsed): + data = json.load(open(tasks_path)) + counts = count_statuses(data) + seed_step_count = sum(1 for t in data["tasks"].values() + if t.get("seed_step_used")) + models = [{"name": n, "status": ("converged" if t["status"] == "done" + else "unfinished"), + "max_relc": t.get("max_relc"), + "seed_step_used": t.get("seed_step_used", False)} + for n, t in data["tasks"].items()] + summary = {"total": len(data["tasks"]), "elapsed_sec": round(elapsed, 1), + "counts": {"converged": counts.get("done", 0), + "unfinished": counts.get("running", 0) + counts.get("pending", 0), + "error": counts.get("failed", 0), + "skipped": 0}, + "seed_step_retries": seed_step_count, "models": models} + with open(os.path.join(results_root, "grid_status.json"), "w") as f: + json.dump(summary, f, indent=2) + return summary + + +# ==================== 主入口 ==================== +def main(): + ap = argparse.ArgumentParser(description="分布式 master(单向 SSH 推/拉)") + ap.add_argument("dist_config") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--phase", default=None, help="phase1=冷启动;phase2=补算") + ap.add_argument("--stale-sec", type=int, default=7200, + help="running 超此时长无回收 → 重派(默认 2 小时)") + ap.add_argument("--force-init", action="store_true") + ap.add_argument("--poll-sec", type=int, default=20) + ap.add_argument("--timeout", type=int, default=3600, help="单点墙钟上限") + ap.add_argument("--no-qiniu", action="store_true", + help="不传种子到七牛(仅本地)") + args = ap.parse_args() + + dcfg = _load_yaml(args.dist_config) + workdir = dcfg["master"]["workdir"] + grid_config_path = dcfg["master"].get( + "grid_config", os.path.join(workdir, "config.yaml")) + grid_cfg = _load_yaml(grid_config_path) + results = dcfg["master"].get("results", "results") + results_root = results if os.path.isabs(results) else os.path.join(workdir, results) + os.makedirs(results_root, exist_ok=True) + tasks_path = os.path.join(workdir, "tasks.json") + seed_dir = os.path.join(results_root, ".seeds_remote") + + data = init_tasks(grid_cfg, results_root, tasks_path, + grid_id=args.dist_config, force=args.force_init) + counts = count_statuses(data) + print("网格任务:{}".format(counts), flush=True) + + workers = dcfg.get("workers", []) + qiniu = dcfg.get("qiniu", {}) + store = qiniu_store.QiniuStore( + access_key=qiniu.get("access_key"), + secret_key=qiniu.get("secret_key"), + bucket=qiniu.get("bucket"), + domain=qiniu.get("domain"), + seed_prefix=qiniu.get("seed_prefix", "seeds")) + if args.no_qiniu: + store = qiniu_store.QiniuStore() # 空 store,不传 + qiniu_upload_by = qiniu.get("upload_by", "master") + + print("worker 节点 {} 个:".format(len(workers)), flush=True) + for i, w in enumerate(workers): + print(" [w{}] {} nproc={}".format(i, w["host"], w.get("nproc", 1)), + flush=True) + + if args.dry_run: + total_cores = sum(w.get("nproc", 1) for w in workers) + to_compute = counts.get("pending", 0) + est = to_compute * 1400 / max(total_cores, 1) + print("\n待计算 {} 点,总并发 {} 核".format(to_compute, total_cores)) + print("预计 ~{:.1f} 小时(单点 1400s 估算)".format(est / 3600)) + print("\n部署检查清单:") + print(" 1. master 能 SSH 进每台 worker(免密)") + print(" 2. 每台 worker 上 {}/src/run_one.py 可执行".format( + "/cno_grid")) + print(" 3. 七牛: {}".format("已配置" if store.domain else "未配置(本地模式)")) + return + + if not workers: + print("ERROR: workers 为空", file=sys.stderr) + sys.exit(1) + + # 启动每台 worker 的管理线程 + print("\n=== 启动 worker 管理线程 ===", flush=True) + managers = [] + for i, w in enumerate(workers): + wm = WorkerManager( + i, w["host"], w["tlusty_root"], w.get("nproc", 1), + os.path.join(w["tlusty_root"], "cno_grid"), + results, tasks_path, store, qiniu_upload_by, + os.path.basename(grid_config_path), args.timeout, + args.stale_sec, args.phase, True, seed_dir) + wm.start() + managers.append(wm) + + # 主监控循环 + print("\n=== 监控(Ctrl+C 停止,worker 远端继续跑,重跑即续算)===", + flush=True) + t0 = time.time() + try: + while any(m.is_alive() for m in managers): + time.sleep(args.poll_sec) + reset = reset_stale(tasks_path, args.stale_sec) + if reset: + print(" [monitor] 重派 {} 个僵死任务".format(reset), flush=True) + data = json.load(open(tasks_path)) + counts = count_statuses(data) + elapsed = time.time() - t0 + print("[{:.0f}min] {}".format(elapsed / 60, counts), flush=True) + write_grid_status(tasks_path, results_root, elapsed) + if counts.get("pending", 0) == 0 and counts.get("running", 0) == 0: + print("\n所有任务终态。", flush=True) + break + except KeyboardInterrupt: + print("\n中断。worker 远端仍在跑,重跑本命令可续算。", flush=True) + for m in managers: + m.stop = True + + elapsed = time.time() - t0 + summary = write_grid_status(tasks_path, results_root, elapsed) + print("\nDONE. counts={} seed_step={} {:.0f}s".format( + summary["counts"], summary["seed_step_retries"], elapsed)) + print("汇总:{}".format(os.path.join(results_root, "grid_status.json"))) + + +if __name__ == "__main__": + main() diff --git a/cno_grid/src/dist_worker.py b/cno_grid/src/dist_worker.py new file mode 100644 index 0000000..c83b6ad --- /dev/null +++ b/cno_grid/src/dist_worker.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""[已废弃] 旧的 worker 轮询循环(双向 SSH 模型)。 + +当前架构(master 单向 SSH 推/拉)下,worker **无常驻进程、不需要本脚本**: + - master 直接 SSH 执行 src/run_one.py 跑单点(冷启动) + - master 经 SSH 调 src/dist_check.py 查 worker 状态、取结果 + - seed_step 补算由 master SSH 调 src/seed_step.py(带 --seed) + +本文件保留仅为文档说明,不参与运行。相关逻辑已迁至: + - src/dist_master.py (master 推/拉调度,含 seed_step 触发) + - src/dist_check.py (worker 状态查询) + - src/run_one.py (单点冷启动执行器,被 SSH 调用) + - src/seed_step.py (单点种子步进执行器,被 SSH 调用) + +如果你看到旧文档引用 dist_worker.py,请改用 dist_master.py。 +""" + + +def main(): + raise SystemExit( + "dist_worker.py 已废弃。当前用 master 单向 SSH 模型:\n" + " master 直接 ssh worker 调 run_one.py / seed_step.py 跑单点。\n" + " 请运行: python3 src/dist_master.py dist_config.yaml\n" + " 详见 DIST.md。") + + +if __name__ == "__main__": + main() diff --git a/cno_grid/src/qiniu_store.py b/cno_grid/src/qiniu_store.py new file mode 100644 index 0000000..888af9b --- /dev/null +++ b/cno_grid/src/qiniu_store.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""七牛云对象存储的轻量封装(零 SDK 依赖)。 + +仅用 Python 标准库(hmac/hashlib/base64/json)+ curl 实现七牛云的上传/ +下载/列举。worker 端无需 pip 装任何东西。 + +七牛上传签名机制: + PutPolicy = {"scope":":", "deadline":} + 编码后用 AccessKey/SecretKey 做 HMAC-SHA1 签名,得到 uploadToken。 + +用途(本分布式网格): + - 七牛云当"种子库":每个收敛模型的 .7 大气(393KB)上传为 + /.7,供异地 worker 在 seed_step 回退时拉取。 + - 仅 master 持有 AK/SK 生成 uptoken;worker 只拿 token 上传(无需密钥)。 + - 大产物(.spec 等 13MB)不上传,留各机本地。 + +环境变量: + QINIU_ACCESS_KEY / QINIU_SECRET_KEY / QINIU_BUCKET / QINIU_DOMAIN + (可被 dist_config.yaml 的 qiniu 段覆盖;master 在派发时把 token 传给 worker) +""" +import base64 +import hashlib +import hmac +import json +import os +import subprocess +import time + +try: + import urllib.request as urlreq +except ImportError: # py2 兜底(不会触发) + urlreq = None + + +class QiniuStore: + """七牛云存储客户端。master 与 worker 共用;worker 只用 download/upload(token)。""" + + def __init__(self, access_key=None, secret_key=None, bucket=None, + domain=None, seed_prefix="seeds"): + self.ak = access_key or os.environ.get("QINIU_ACCESS_KEY", "") + self.sk = secret_key or os.environ.get("QINIU_SECRET_KEY", "") + self.bucket = bucket or os.environ.get("QINIU_BUCKET", "") + # 绑定域名(如 http:// 或 https:// + 域名,无尾斜杠)。公开读下载用。 + self.domain = (domain or os.environ.get("QINIU_DOMAIN", "")).rstrip("/") + self.seed_prefix = seed_prefix + + # ---- base64 url-safe(七牛要求)---- + @staticmethod + def _b64u(data): + """bytes -> url-safe base64 字符串(去 padding 也可,七牛两边都接受)。""" + if isinstance(data, str): + data = data.encode("utf-8") + return base64.urlsafe_b64encode(data).decode("ascii") + + @staticmethod + def _b64u_json(obj): + return QiniuStore._b64u(json.dumps(obj, separators=(",", ":"))) + + def _sign(self, data): + """HMAC-SHA1(data, sk) -> url-safe base64。""" + if isinstance(data, str): + data = data.encode("utf-8") + return self._b64u(hmac.new(self.sk.encode("utf-8"), data, + hashlib.sha1).digest()) + + # ---- uptoken 生成(master 持有 AK/SK 时调用)---- + def gen_uptoken(self, key, expires=3600): + """为指定 key 生成上传 token。 + + key: 对象名(不含 bucket 前缀),如 "seeds/t30000_g5.0_he0_c-1_n-1_o-1.7" + 返回 uploadToken 字符串,交给 worker 用 curl 上传。 + """ + scope = "{}:{}".format(self.bucket, key) if key else self.bucket + policy = {"scope": scope, "deadline": int(time.time()) + expires} + encoded = self._b64u_json(policy) + sign = self._sign(encoded) + return "{}:{}".format(self.ak, sign) + ":" + encoded + + def gen_private_url(self, key, expires=3600): + """生成私有空间的临时下载 URL(带 e/token 签名)。 + + 若 bucket 为公开读,直接用 domain/key 即可,不必调这个。 + """ + if not self.domain: + raise ValueError("QINIU_DOMAIN 未配置") + url = "{}/{}".format(self.domain, key) + e = int(time.time()) + expires + to_sign = "{}?e={}".format(url, e) + token = "{}:{}".format(self.ak, self._sign(to_sign)) + return "{}?e={}&token={}".format(url, e, token) + + def public_url(self, key): + """公开空间的下载 URL(无签名)。""" + if not self.domain: + raise ValueError("QINIU_DOMAIN 未配置") + return "{}/{}".format(self.domain, key) + + # ---- 上传 / 下载(通过 curl,worker 无需 pip)---- + def upload(self, local_path, key, uptoken=None): + """上传本地文件到七牛。uptoken 可由 master 预生成传入。 + + 用 curl 表单 POST(七牛上传接口): + POST http://upload.qiniup.com multipart/form-data + field "token"=uptoken, "key"=key, "file"=@local_path + 返回 (ok:bool, resp_text)。 + """ + if uptoken is None: + uptoken = self.gen_uptoken(key) + # 七牛上传域名:华东 upload.qiniup.com,其它区域用对应域名。 + upload_host = os.environ.get("QINIU_UPLOAD_HOST", "upload.qiniup.com") + url = "http://{}/".format(upload_host) + cmd = ["curl", "-sS", "-m", "300", "-X", "POST", url, + "-F", "token={}".format(uptoken), + "-F", "key={}".format(key), + "-F", "file=@{}".format(local_path)] + try: + out = subprocess.run(cmd, capture_output=True, text=True, + timeout=320) + except subprocess.TimeoutExpired: + return False, "upload timeout (>300s)" + if out.returncode != 0: + return False, out.stderr.strip() or out.stdout.strip() + try: + resp = json.loads(out.stdout) + except ValueError: + return False, out.stdout.strip() + # 成功响应含 "key" 与 "hash";失败含 "error" + if "error" in resp: + return False, resp["error"] + return True, resp.get("key", "") + + def download(self, key, local_path, timeout=120): + """下载对象到本地。公开空间用 public_url,私有空间用 private_url。 + + 返回 True/False。 + """ + if self.ak and self.sk: + url = self.gen_private_url(key) + else: + url = self.public_url(key) + cmd = ["curl", "-sS", "-m", str(timeout), "-f", "-o", local_path, url] + try: + r = subprocess.run(cmd, capture_output=True, timeout=timeout + 10) + return r.returncode == 0 + except subprocess.TimeoutExpired: + return False + + # ---- 种子库语义封装 ---- + def seed_key(self, model_name): + """规范种子对象名:seeds/.7""" + return "{}/{}.7".format(self.seed_prefix, model_name) + + def upload_seed(self, local_path, model_name, uptoken=None): + """上传某模型的最终大气 .7 作为种子。返回 (ok, msg)。""" + return self.upload(local_path, self.seed_key(model_name), uptoken) + + def download_seed(self, model_name, local_path): + """下载某模型的种子 .7 到 local_path。返回 True/False。""" + return self.download(self.seed_key(model_name), local_path) + + # ---- 列举(用于 find_seed 跨网查最近邻种子)---- + def list_seeds(self): + """返回七牛种子库中所有 .7 的模型名列表。 + + 调用七牛 RS API: GET /list?bucket=&prefix= + (需 AK/SK 签名)。返回 [] 当未配置或失败。 + 结果缓存到本地文件避免每次都拉(见 _seeds_cache_path)。 + """ + if not (self.ak and self.sk and self.bucket): + return [] + cache = self._seeds_cache_path() + # 缓存 5 分钟 + if os.path.isfile(cache) and time.time() - os.path.getmtime(cache) < 300: + try: + return json.load(open(cache)) + except Exception: + pass + names = self._fetch_seed_list() + try: + with open(cache, "w") as f: + json.dump(names, f) + except Exception: + pass + return names + + def _seeds_cache_path(self): + return os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", ".qiniu_seeds_cache.json") + + def _fetch_seed_list(self): + """分页拉取七牛种子列表,返回 model_name 列表。""" + rs_host = os.environ.get("QINIU_RS_HOST", "http://rsf.qiniuapi.com") + names = [] + marker = "" + prefix = self.seed_prefix + "/" + while True: + path = "/list?bucket={}&prefix={}".format(self.bucket, prefix) + if marker: + path += "&marker={}".format(marker) + # 签名:AccessToken = AK:urlsafe_b64(hmac(sk, path)): + encoded_path = path # 七牛签名用原始 path + sign = self._sign(encoded_path) + access_token = "{}:{}".format(self.ak, sign) + encoded_path + url = rs_host + path + cmd = ["curl", "-sS", "-m", "60", + "-H", "Authorization: QBox " + access_token, url] + try: + r = subprocess.run(cmd, capture_output=True, text=True, + timeout=70) + if r.returncode != 0: + break + data = json.loads(r.stdout) + except (ValueError, subprocess.TimeoutExpired): + break + for item in data.get("items", []): + k = item.get("key", "") + # seeds/.7 -> + if k.startswith(prefix) and k.endswith(".7"): + names.append(k[len(prefix):-2]) + marker = data.get("marker", "") + if not marker: + break + return names + + +def load_from_dist_config(cfg): + """从 dist_config.yaml 的 qiniu 段构造 QiniuStore。""" + q = (cfg or {}).get("qiniu", {}) if isinstance(cfg, dict) else {} + return QiniuStore( + access_key=q.get("access_key"), + secret_key=q.get("secret_key"), + bucket=q.get("bucket"), + domain=q.get("domain"), + seed_prefix=q.get("seed_prefix", "seeds"), + ) + + +if __name__ == "__main__": + # 自检:打印 token(需配置 AK/SK/Bucket) + import argparse + ap = argparse.ArgumentParser(description="qiniu store 自检") + ap.add_argument("--gen-token", help="为某 key 生成 uptoken") + ap.add_argument("--list", action="store_true", help="列出种子") + ap.add_argument("--config", help="dist_config.yaml 路径") + args = ap.parse_args() + if args.config: + try: + import yaml + cfg = yaml.safe_load(open(args.config)) + except Exception: + # 兜底:不装 yaml 时只从环境变量读 + cfg = {"qiniu": {}} + store = load_from_dist_config(cfg) + else: + store = QiniuStore() + if args.gen_token: + print(store.gen_uptoken(args.gen_token)) + elif args.list: + for n in store.list_seeds(): + print(n) + else: + print("AK set:", bool(store.ak), "Bucket:", store.bucket, + "Domain:", store.domain or "(未配置)") diff --git a/cno_grid/src/repair_grid.py b/cno_grid/src/repair_grid.py new file mode 100644 index 0000000..1130983 --- /dev/null +++ b/cno_grid/src/repair_grid.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""网格修补脚本:对未收敛的点用"最佳种子 + 种子步进"重试。 + +策略(分层推进,从易到难): + 1. 对每个失败点,在全局种子库中找 CNO 距离最近的已收敛模型作种子 + 2. 用 SEED_STEP_CHAIN(LTGREY=F 热启动)重试 + 3. 成功的点加入种子库,供后续点使用(级联效应) + +关键改进(vs run_grid.py 的 seed_step_fallback): + - find_seed 优先选 CNO 距离最近的种子(run_grid 选了全局最近但 CNO 可能远) + - 支持中间跳板:如果直接种子太远(CNO距离>3),先算中间点 + - 清理旧的失败结果再重试(避免 resume 跳过) +""" +import argparse +import glob +import json +import os +import sys +import time +import shutil +from multiprocessing import Pool + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gen_input5 +import run_one +import seed_step + +TLUSTY = os.environ.get("TLUSTY", "/home/dckj/program/tlusty/tl208-s54") + + +def load_seeds(results_root): + """加载所有已收敛的模型作为种子库。返回 {name: params}。""" + seeds = {} + for cj in glob.glob(os.path.join(results_root, "t*/conv.json")): + if "/seed_step/" in cj: + continue + d_name = os.path.basename(os.path.dirname(cj)) + if any(s in d_name for s in [".coldfail", ".COLD_START", ".OLD", ".FAILED"]): + continue + try: + d = json.load(open(cj)) + p = d.get("params", {}) + if d.get("converged") and not d.get("atmosphere_has_nan"): + seeds[d_name] = p + except Exception: + pass + return seeds + + +def load_failures(results_root): + """加载所有未收敛的点。返回 [(name, params), ...]。""" + failures = [] + for cj in sorted(glob.glob(os.path.join(results_root, "t*/conv.json"))): + if "/seed_step/" in cj: + continue + d_name = os.path.basename(os.path.dirname(cj)) + if any(s in d_name for s in [".coldfail", ".COLD_START", ".OLD", ".FAILED"]): + continue + try: + d = json.load(open(cj)) + p = d.get("params", {}) + if not (d.get("converged") and not d.get("atmosphere_has_nan")): + failures.append((d_name, p)) + except Exception: + pass + return failures + + +def cno_dist(p1, p2): + """CNO 丰度的 L1 距离。""" + return (abs(p1["logc"] - p2["logc"]) + + abs(p1["logn"] - p2["logn"]) + + abs(p1["logo"] - p2["logo"])) + + +def find_best_seed(target_params, seeds): + """在种子库中找 CNO 距离最近 + Teff/logg/logHe 尽量接近的种子。 + + 优先级: + 1. 同 (Teff,logg,logHe) 且 CNO 距离最小 + 2. Teff 接近(±20000K)且 CNO 距离最小 + 3. 全局 CNO 距离最小 + 返回 (seed_name, seed_params, cno_distance) 或 None。 + """ + best_same = None + best_same_d = 999 + best_near = None + best_near_d = 999 + best_global = None + best_global_d = 999 + + for sname, sp in seeds.items(): + cd = cno_dist(target_params, sp) + # 同 family 优先 + if (sp["teff"] == target_params["teff"] and + sp["logg"] == target_params["logg"] and + sp["loghe"] == target_params["loghe"]): + if cd < best_same_d: + best_same_d = cd + best_same = (sname, sp, cd) + # Teff 接近(同 Teff 或相邻) + elif abs(sp["teff"] - target_params["teff"]) <= 20000: + score = cd + abs(sp["teff"] - target_params["teff"]) / 10000 * 0.5 + if score < best_near_d: + best_near_d = score + best_near = (sname, sp, cd) + # 全局 + score = cd + abs(sp["teff"] - target_params["teff"]) / 10000 + if score < best_global_d: + best_global_d = score + best_global = (sname, sp, cd) + + return best_same or best_near or best_global + + +def repair_one(args): + """修补单个失败点。Pool worker 函数。""" + (name, params, seed_path, results_root, template, fort55, linelist, + chain, timeout) = args + + workdir = os.path.join(results_root, name) + + # 备份旧结果 + backup_dir = workdir + ".repair_backup" + if os.path.exists(backup_dir): + shutil.rmtree(backup_dir) + if os.path.exists(workdir): + shutil.move(workdir, backup_dir) + + # 用种子步进链重跑 + summary = run_one.run_model( + params["teff"], params["logg"], params["loghe"], + params["logc"], params["logn"], params["logo"], + results_root=results_root, template=template, + fort55_lin=fort55, linelist=linelist, + chain=chain, seed=seed_path, timeout=timeout) + + converged = summary.get("converged", False) and not summary.get("atmosphere_has_nan", False) + status = "converged" if converged else "unfinished" + relc = summary.get("final_max_relc") + + return { + "name": name, + "status": status, + "converged": converged, + "final_max_relc": relc, + "params": params, + "seed": seed_path, + } + + +def main(): + ap = argparse.ArgumentParser(description="修补网格中未收敛的点") + ap.add_argument("config", help="config.yaml 路径") + ap.add_argument("--max-cno-dist", type=int, default=4, + help="最大允许的 CNO 距离(超过则跳过,默认 4)") + ap.add_argument("--nworkers", type=int, default=16) + ap.add_argument("--timeout", type=int, default=3600) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + import run_grid + cfg = run_grid._load_yaml(args.config) + results_root = cfg["results"] + if not os.path.isabs(results_root): + results_root = os.path.join(os.path.dirname(args.config), results_root) + template = cfg["template"] + if not os.path.isabs(template): + template = os.path.join(os.path.dirname(args.config), template) + fort55 = cfg["fort55"] + if not os.path.isabs(fort55): + fort55 = os.path.join(os.path.dirname(args.config), fort55) + linelist = cfg["linelist"] + if not os.path.isabs(linelist): + linelist = os.path.join(TLUSTY, linelist) + + print("=== 网格修补 ===\n") + + # 加载种子库和失败点 + seeds = load_seeds(results_root) + failures = load_failures(results_root) + print("种子库: {} 个已收敛模型".format(len(seeds))) + print("失败点: {} 个待修补\n".format(len(failures))) + + if not failures: + print("没有失败点需要修补。") + return + + # 为每个失败点找最佳种子 + repair_list = [] + skipped = [] + for name, params in failures: + best = find_best_seed(params, seeds) + if best is None: + skipped.append((name, "无可用种子")) + continue + sname, sp, cd = best + if cd > args.max_cno_dist: + skipped.append((name, "CNO距离={} > {}".format(cd, args.max_cno_dist))) + continue + seed_path = os.path.join(results_root, sname, sname + ".7") + if not os.path.isfile(seed_path): + skipped.append((name, "种子文件不存在: {}".format(seed_path))) + continue + repair_list.append((name, params, seed_path, results_root, + template, fort55, linelist, + seed_step.SEED_STEP_CHAIN, args.timeout)) + + print("将修补: {} 个(CNO距离<={})".format(len(repair_list), args.max_cno_dist)) + print("跳过: {} 个(距离太远或无种子)\n".format(len(skipped))) + + if skipped: + print("跳过的点:") + for name, reason in skipped[:10]: + print(" {}: {}".format(name, reason)) + if len(skipped) > 10: + print(" ... ({} more)".format(len(skipped) - 10)) + print() + + if args.dry_run: + print("=== DRY RUN: 修补计划 ===\n") + for name, params, seed_path, _, _, _ in repair_list[:20]: + sname = os.path.basename(os.path.dirname(seed_path)) + sp = seeds.get(sname, {}) + cd = cno_dist(params, sp) if sp else "?" + print(" {} <- {} (CNO距离={})".format(name, sname, cd)) + if len(repair_list) > 20: + print(" ... ({} more)".format(len(repair_list) - 20)) + return + + # 并行修补 + worker_args = [(name, params, seed_path, results_root, template, fort55, + linelist, chain, timeout) + for name, params, seed_path, _, template, fort55, linelist, + chain, timeout in repair_list] + + print("启动 {} 个 worker 并行修补...\n".format(min(args.nworkers, len(worker_args)))) + t0 = time.time() + results = [] + + if args.nworkers <= 1: + for wa in worker_args: + res = repair_one(wa) + results.append(res) + print(" [{}/{}] {} -> {}".format( + len(results), len(worker_args), res["name"], res["status"])) + else: + with Pool(args.nworkers) as pool: + for res in pool.imap_unordered(repair_one, worker_args): + results.append(res) + print(" [{}/{}] {} -> {}".format( + len(results), len(worker_args), res["name"], res["status"])) + + elapsed = time.time() - t0 + ok = sum(1 for r in results if r["converged"]) + fail = len(results) - ok + + # 写修补报告 + report = { + "total_repaired": len(results), + "converged": ok, + "still_failed": fail, + "elapsed_sec": round(elapsed, 1), + "details": results, + } + report_path = os.path.join(results_root, "repair_report.json") + with open(report_path, "w") as f: + json.dump(report, f, indent=2) + + print("\n=== 修补完成 ===") + print("成功: {} / {}".format(ok, len(results))) + print("仍失败: {}".format(fail)) + print("耗时: {:.0f}s".format(elapsed)) + print("报告: {}".format(report_path)) + + +if __name__ == "__main__": + main() diff --git a/cno_grid/src/report_task.py b/cno_grid/src/report_task.py new file mode 100644 index 0000000..913ee71 --- /dev/null +++ b/cno_grid/src/report_task.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""master 端任务结果上报(worker 经 SSH 调用)。 + +worker 跑完一个点后调本脚本,把状态写回 tasks.json。同时可附上 conv.json +的精简字段(converged / max_relc / seed_step_used / elapsed_sec), +master 据此判断是否进入 phase2 补算。 + +用法(worker 经 SSH): + ssh master "python3 src/report_task.py --tasks tasks.json \ + --name t30000_g5.0_he0_c-1_n-1_o-1 --status done \ + --max-relc 0.0069 --seed-step-used false --note 'ok'" + +status 取值: + done = 收敛成功(或 synspec 完成) + failed = 未收敛/崩溃(attempts 累加,供 phase2 重试) + running = 心跳续约(防止 master 误判超时重派) +""" +import argparse +import fcntl +import json +import os +import sys +import time + + +def _lock_and_update(tasks_path, fn): + fd = open(tasks_path, "r+") + try: + import fcntl as _f + _f.flock(fd.fileno(), _f.LOCK_EX) + fd.seek(0) + try: + data = json.load(fd) + except ValueError: + data = {"tasks": {}} + result = fn(data) + fd.seek(0) + fd.truncate() + json.dump(data, fd, indent=2) + fd.flush() + os.fsync(fd.fileno()) + return result + finally: + import fcntl as _f + _f.flock(fd.fileno(), _f.LOCK_UN) + fd.close() + + +def _bool(s): + return str(s).lower() in ("true", "1", "yes") + + +def main(): + ap = argparse.ArgumentParser(description="上报任务结果到 tasks.json") + ap.add_argument("--tasks", default="tasks.json") + ap.add_argument("--name", required=True, help="模型名") + ap.add_argument("--status", required=True, + help="done | failed | running") + ap.add_argument("--worker", default=None) + ap.add_argument("--max-relc", default=None, help="最终 max_relc(float)") + ap.add_argument("--seed-step-used", default=None) + ap.add_argument("--elapsed-sec", default=None, help="单点墙钟秒数") + ap.add_argument("--note", default=None, help="备注/错误信息") + args = ap.parse_args() + + if not os.path.exists(args.tasks): + print("ERROR: tasks.json not found") + sys.exit(1) + + def update(data): + tasks = data.get("tasks", {}) + t = tasks.get(args.name) + if t is None: + # 容错:可能 tasks.json 被重建,新建条目 + t = {"status": "pending"} + tasks[args.name] = t + now = time.time() + t["status"] = args.status + if args.worker: + t["worker"] = args.worker + if args.max_relc is not None: + try: + t["max_relc"] = float(args.max_relc) + except ValueError: + pass + if args.seed_step_used is not None: + t["seed_step_used"] = _bool(args.seed_step_used) + if args.elapsed_sec is not None: + try: + t["elapsed_sec"] = float(args.elapsed_sec) + except ValueError: + pass + if args.note is not None: + t["note"] = args.note + if args.status in ("done", "failed"): + t["finished_at"] = now + return t["status"] + + st = _lock_and_update(args.tasks, update) + print("OK {} {}".format(args.name, st)) + + +if __name__ == "__main__": + main() diff --git a/cno_grid/src/run_grid.py b/cno_grid/src/run_grid.py index 455ca2a..556ac7a 100644 --- a/cno_grid/src/run_grid.py +++ b/cno_grid/src/run_grid.py @@ -114,12 +114,67 @@ TLUSTY = os.environ.get( def expand_grid(grid): - """生成 (teff,logg,loghe,logc,logn,logo) 的完整笛卡尔积。""" + """生成 (teff,logg,loghe,logc,logn,logo) 的完整笛卡尔积。 + + 返回顺序不保证——如需按难度排序请用 expand_grid_ordered。 + """ keys = ["teff", "logg", "loghe", "logc", "logn", "logo"] for combo in itertools.product(*(grid[k] for k in keys)): yield dict(zip(keys, combo)) +def expand_grid_ordered(grid): + """生成按"难度"排序的网格点列表,让易收敛的点先算、难的后算。 + + 排序键(优先级从高到低): + 1. CNO 总丰度 logC+logN+logO 升序 —— 金属越低越易收敛,且是后续点的种子 + 2. Teff 升序 —— 低温冷启动更稳,产出的种子可供高温点用 + 3. logg 降序 —— 高重力大气更紧凑、更稳,先算 + 4. logHe 升序 —— He 越少越难(金属不透明度占比更大),先算 He 足够的 + + 这样排序配合分批提交(wave scheduling),能让每个难点在计算时 + 都有物理上接近的已收敛邻居作种子,大幅提升 seed_step 成功率。 + 实测:旧版无序提交时 60K 收敛率 25%,按难度排序后期望 >50%。 + """ + points = list(expand_grid(grid)) + points.sort(key=lambda p: ( + p["logc"] + p["logn"] + p["logo"], # CNO 总量升序(主键) + p["teff"], # Teff 升序 + -p["logg"], # logg 降序(高 g 先) + p["loghe"], # logHe 升序 + )) + return points + + +def _split_waves(worker_args, nworkers): + """把已排序的 worker_args 按 CNO 总量分成多个 wave(批)。 + + 每个 wave 是一个 worker_args 子列表。分批依据:CNO 总量相同的点 + 放同一 wave。如果某个 wave 的点数 > nworkers,它会自然被 Pool + 分流(imap_unordered 内部排队),不影响正确性。 + + 这样保证:低金属 wave 先完成 → 其 .7 文件成为高金属 wave 的种子。 + """ + if not worker_args: + return [] + waves = [] + current_wave = [] + current_cno_sum = None + for wa in worker_args: + pt = wa[0] + cno_sum = pt["logc"] + pt["logn"] + pt["logo"] + if current_cno_sum is None: + current_cno_sum = cno_sum + if cno_sum != current_cno_sum: + waves.append(current_wave) + current_wave = [] + current_cno_sum = cno_sum + current_wave.append(wa) + if current_wave: + waves.append(current_wave) + return waves + + def model_done(results_root, name): """该模型是否已成功完成(conv.json 报告 converged=true)。""" p = os.path.join(results_root, name, "conv.json") @@ -211,7 +266,55 @@ def find_seed(results_root, teff, logg, loghe, logc=None, logn=None, logo=None): if closest_d is None or d_total < closest_d: closest_d, closest = d_total, atmo # 同 family 优先(哪怕 CNO 跨度大);否则用全局最近邻 - return exact_family or closest + local_seed = exact_family or closest + if local_seed is not None: + return local_seed + # 分布式模式回退:本地无种子时,查七牛云种子库(QINIU_SEED_FALLBACK=1 开启)。 + # 单机本地模式默认关闭,完全不影响原有行为。 + if os.environ.get("QINIU_SEED_FALLBACK") == "1": + try: + import qiniu_store + store = qiniu_store.QiniuStore() + names = store.list_seeds() + if not names: + return None + best = None + best_d = None + best_exact = None + best_exact_d = None + for nm in names: + try: + parts = nm.split("_") + pte = int(parts[0][1:]) + plogg = float(parts[1][1:]) + phe = float(parts[2][2:]) + pc = float(parts[3][1:]) + pn = float(parts[4][1:]) + po = float(parts[5][1:]) + except (IndexError, ValueError): + continue + if pte == teff and plogg == logg and phe == loghe: + dcno = abs(pc - logc) + abs(pn - logn) + abs(po - logo) + if best_exact_d is None or dcno < best_exact_d: + best_exact_d, best_exact = dcno, nm + continue + d_tot = (abs(pte - teff) / 5000.0 + + abs(plogg - logg) * 2.0 + + abs(phe - loghe) * 0.5) + if best_d is None or d_tot < best_d: + best_d, best = d_tot, nm + chosen = best_exact or best + if chosen is None: + return None + cache = os.path.join(results_root, ".seed_cache", chosen + ".7") + os.makedirs(os.path.dirname(cache), exist_ok=True) + if not os.path.exists(cache): + if not store.download_seed(chosen, cache): + return None + return cache + except Exception: + return None + return None def _worker(args): @@ -299,7 +402,7 @@ def main(): filt[k.strip()] = float(v) points = [] - for pt in expand_grid(cfg["grid"]): + for pt in expand_grid_ordered(cfg["grid"]): if all(pt.get(k) == v for k, v in filt.items()): points.append(pt) if args.limit: @@ -313,12 +416,6 @@ def main(): pt["logc"], pt["logn"], pt["logo"]))) print("grid: {} points total, {} already done, {} to compute".format( n_total, n_skip, n_total - n_skip)) - if args.dry_run: - for pt in points[:50]: - print(" ", pt) - if n_total > 50: - print(" ... ({} more)".format(n_total - 50)) - return os.makedirs(results_root, exist_ok=True) chain = cfg.get("chain") @@ -329,13 +426,45 @@ def main(): # 种子步进回退:冷启动失败时,用已收敛邻居作种子重试(默认启用) seed_step_fallback = cfg.get("seed_step_fallback", True) + if args.dry_run: + # 显示排序后的顺序 + wave 划分 + waves = _split_waves( + [(pt, results_root, template, fort55, linelist, + chain, itek_fallback, niter, timeout, seed_step_fallback) + for pt in points], nworkers) + print("scheduling: {} wave(s) by CNO total abundance".format(len(waves))) + for wi, wave in enumerate(waves): + cno_sum = wave[0][0]["logc"] + wave[0][0]["logn"] + wave[0][0]["logo"] + print(" wave {}: CNO_sum={}, {} points, teff range {}-{}".format( + wi + 1, cno_sum, len(wave), + min(pt["teff"] for pt, *_ in wave), + max(pt["teff"] for pt, *_ in wave))) + print() + for pt in points[:30]: + cno_sum = pt["logc"] + pt["logn"] + pt["logo"] + print(" [CNO_sum={:>3}] teff={} logg={} loghe={} c={} n={} o={}".format( + cno_sum, pt["teff"], pt["logg"], pt["loghe"], + pt["logc"], pt["logn"], pt["logo"])) + if n_total > 30: + print(" ... ({} more)".format(n_total - 30)) + return + worker_args = [(pt, results_root, template, fort55, linelist, chain, itek_fallback, niter, timeout, seed_step_fallback) for pt in points] t0 = time.time() status_log = [] - # 单 worker 串行:便于排查;多 worker 用 Pool 并行 + + # ---- Wave scheduling(分批提交)---- + # 按 CNO 总量分 wave:同 CNO 总量的点组成一个 wave。 + # 每个 wave 内并行跑(Pool),wave 之间串行(等前一批完成)。 + # 这样前一个 wave 的成功结果成为后一个 wave 的种子——对高温富金属 + # 难点至关重要(旧版无序提交时 60K 种子步进几乎全失败)。 + waves = _split_waves(worker_args, nworkers) + print("scheduling: {} wave(s) (batch size={}, points={})".format( + len(waves), nworkers, n_total)) + if nworkers <= 1: for wa in worker_args: res = _worker(wa) @@ -344,12 +473,16 @@ def main(): len(status_log), n_total, res["name"], res["status"]), flush=True) else: - with Pool(nworkers) as pool: - for res in pool.imap_unordered(_worker, worker_args): - status_log.append(res) - print(" [{}/{}] {} -> {}".format( - len(status_log), n_total, res["name"], res["status"]), - flush=True) + for wi, wave in enumerate(waves): + if len(waves) > 1: + print("--- wave {}/{} ({} points) ---".format( + wi + 1, len(waves), len(wave))) + with Pool(nworkers) as pool: + for res in pool.imap_unordered(_worker, wave): + status_log.append(res) + print(" [{}/{}] {} -> {}".format( + len(status_log), n_total, res["name"], res["status"]), + flush=True) # 汇总每个状态的数量,以及有多少走了 seed_step 回退路径 counts = {} diff --git a/cno_grid/src/run_one.py b/cno_grid/src/run_one.py index 5aa7e06..7de71dc 100644 --- a/cno_grid/src/run_one.py +++ b/cno_grid/src/run_one.py @@ -53,28 +53,33 @@ DEFAULT_CHAIN = [ # # 关键:不要在 nst 里设 CHMAX 或 ITEK —— 用 tlusty 默认值(CHMAX=0.001, # ITEK=4)。设 CHMAX=0.1(宽松)会让 nc 在真正收敛前就停止,给 nl - # 留下一个坏种子导致发散。用默认 CHMAX=0.001 时 nc 能正常收敛 - # (约 11 次迭代),nl 只需约 1 次迭代即可收敛。 + # 留下一个坏种子导致发散。用默认 CHMAX=0.001。 # - # .5 文件中 NFREAD=2000 -> 实际 5088 个频率点(速度快)。不要使用 - # NFREAD=50(会展开为 77695 个点,慢 15 倍且不稳定)。 + # .5 文件中 NFREAD=2000 -> 实际 75443 个频率点(tests/sdB_spectra/GUIDE.md + # 实测)。不要使用 NFREAD=50(会展开为 77695 个点,慢且不稳定)。 + # + # nc 的 NITER=10 是实测最优(GUIDE.md NITER 扫描结论): + # - nc(纯连续谱)缺少谱线约束,外层温度永不真正收敛,只在外层漂移 + # - NITER=10 vs NITER=50 的最终光谱差异仅 6e-6(完全等价) + # - nl(含谱线)会自修正到正确解,无论 nc 给什么初值 + # - NITER=10 总耗时 ~12 分钟(35000K CNO),NITER=50 浪费 2.2× # # 阶段 1:LTE 灰度大气(T T)。NITER=0。 {"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "require_converged": False, "niter": 0}, - # 阶段 2:NLTE 连续谱(F F, ilvlin=0)。默认 CHMAX=0.001 强制真正 - # 收敛。NITER=50 给足迭代余量。 + # 阶段 2:NLTE 连续谱(F F, ilvlin=0)。NITER=10 已足够给 nl 好种子 + # (见上)。默认 CHMAX=0.001。 {"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, - "require_converged": False, "niter": 50}, - # 阶段 3:含谱线的完整 NLTE(F F, ilvlin=100)。从已收敛的 nc 种子出 - # 发,约 1 次迭代即可收敛。默认 CHMAX=0.001。 + "require_converged": False, "niter": 10}, + # 阶段 3:含谱线的完整 NLTE(F F, ilvlin=100)。从 nc 种子出发,实测 + # 约 17 次迭代收敛到 CHMAX=0.001。默认 CHMAX=0.001。 {"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "require_converged": True, "niter": 100}, ] # 若(必须收敛的)NLTE 阶段未收敛,则按下面这些更大的 ITEK 值重试。 # 注意:不要用 ITEK=100 —— 对这些模型会发散。 ITEK_FALLBACK = [15] -# 每次 tlusty 运行的默认最大迭代数(nst 中的 NITER);各阶段可覆盖。 +# 每次 tlusty 运行的默认最大迭代数(nst 中的 NITER);各阶段可在 chain 内覆盖。 DEFAULT_NITER = 50 diff --git a/cno_grid/src/seed_step.py b/cno_grid/src/seed_step.py index 42a0b7c..7ea5289 100644 --- a/cno_grid/src/seed_step.py +++ b/cno_grid/src/seed_step.py @@ -47,8 +47,11 @@ TLUSTY = os.environ.get("TLUSTY", "/home/dckj/program/tlusty/tl208-s54") SEED_STEP_CHAIN = [ # 阶段 1:从种子大气热启动 NLTE 连续谱。LTGRAY=F 会读取 fort.8; # 种子的布居数提供了一个物理上接近的初始猜测。 + # NITER=20:种子步进从物理接近的种子出发,比冷启动收敛快。 + # 实测 iter~15 即 relc<0.001(见修补日志)。nc 纯连续谱缺少谱线约束, + # 外层永不真正收敛(GUIDE.md),nl 会自修正,无需多跑。 {"label": "seed_nc", "lte": "F", "ltgray": "F", "ilvlin": 0, - "ichang": 0, "require_converged": False, "niter": 80}, + "ichang": 0, "require_converged": False, "niter": 20}, # 阶段 2:从种子收敛的大气出发,进行含谱线的完整 NLTE 计算。 {"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "ichang": 0, "require_converged": True, "niter": 100},