This commit is contained in:
fmq 2026-07-23 19:06:45 +08:00
parent c53fc99c1d
commit 92d9c515c9
20 changed files with 2400 additions and 328 deletions

194
cno_grid/DIST.md Normal file
View File

@ -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=NoneLTE 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.json432 点 pending本地已 converged 标 done
2. 每台 worker 一个管理线程,并发跑:
循环:
a. SSH 查 worker 正在跑的进程数 → 空闲槽 = nproc - running
b. 每个空闲槽:本地 flock 领一个 pending→runningSSH 派 run_one.pynohup
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 回 master2Mbps≈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/<name>.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 用密钥免密,不用密码

View File

@ -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=10tests/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
### 错误 4NFREAD=50
- **来源**:从 hhe35lt纯H+He抄来
- **影响**:展开成 77695 频率点,慢 15 倍且不稳定
- **纠正**:用 NFREAD=20005088 点
- **纠正**:用 NFREAD=200075443 点,实测
- **教训**NFREAD 的展开行为反直觉(小→多),必须实测确认
### 错误 5ORELAX=0.5(部分有效但非通用解)
@ -231,17 +233,23 @@ IELCOR=-1
- **教训**:先核实输入参数的物理含义和量级,再调试数值方法。对比用户成功配置时
要逐行精确对比(用户用 abn=0 太阳丰度,我用 abn=1.0 绝对比值)。
### 错误 8ICRSW 是死代码(本次会话发现)
### 错误 8ICRSW 是死代码(本次会话发现 —— 后已修复并测试
- **来源**:边界测试发现 80K + He-poor + logCNO=-1 即使种子步进也发散,
尝试用 ICRSWHummer & 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 的尖峰。
### 错误 8ICRSW 是死代码(关键发现)
### 错误 8ICRSW 是死代码(关键发现 —— 后已修复并测试
源码分析后发现 `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.6iter 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.0line 1812
`CRSW(ID)=UN`),所有乘法都是无效操作。
结果:`CRSW(ID)` 永远保持默认值 `UN`=1.0line 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 22RESOLV 之后
被重置为 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来自
RATES1SWITCH(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
**验证 1ICRSW=0 时与原版逐字节相同**
在 H+He NLTE20000/5.0/-1模型上patched exe 与原 exe 产生的
fort.7(大气)和 fort.9(收敛日志)**完全相同**`cmp` 通过、md5 相同)。
证明修复对 ICRSW=0 的所有现有运行零影响。
**验证 2ICRSW=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=1patched| 压低倍数 |
|------|---------------|-------------------|---------|
| 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/521043% | 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.3e5CRSW 太小导致线性化
过度校正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 checkpatched exe 在正常区间仍工作)
为了排除"修复破坏了正常路径"的可能,在已验证的 40K + logg 5.5 + cno-1
模型上跑 patched exeICRSW=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 checkICRSW=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 <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/f90gfortran 行为可能不同)
### 替代方案:网格层面规避
如果不想改源码,**现有种子步进方案已覆盖大部分情况**
- 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。

View File

@ -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
│ 冷启动发散且有干净邻居种子?
▼ → 移失败结果到 <model>.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. ncNLTE 连续谱)| tlusty | `F F` + `ilvlin=0`,收敛电离平衡(无线跃迁)| 1-10 分钟 |
| 3. nlNLTE 含线)| 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. ncNLTE 连续谱)| tlusty | `F F` + `ilvlin=0`,收敛电离平衡(无线跃迁)| **10** | 1-5 分钟 |
| 3. nlNLTE 含线)| 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 次迭代)。
- **阶段4synspec**:用 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/<model>.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 CNONITER=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` 指向 `<model>.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":"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/<seed-model>/<seed-model>.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`
把失败结果备份到 `<model>.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含中间文件走种子步进的点还会留
`<model>.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 从未被 CALLCRSW≡1.0),不要依赖它稳定化;
即便源码修复启用后实测对难点也无帮助。详见 EXPERIENCE.md §5Y。

View File

@ -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 一个稳定种子。
# 跳过 ncgrey -> 完整 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.4min35000K 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

View File

@ -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 = 1280vs 原 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

58
cno_grid/dist_config.yaml Normal file
View File

@ -0,0 +1,58 @@
# 分布式网格计算配置dist_master.py 用)。
# 架构master 单向 SSH 推/拉(只需 master 能 SSH 进 workerworker 不回连 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@hostSSH 目标。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

1
cno_grid/results Symbolic link
View File

@ -0,0 +1 @@
/mnt/e/fmq/grid

View File

@ -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

View File

@ -1,45 +0,0 @@
#!/bin/bash
# 修正丰度范围logCNO = -4..-1后的边界测试。
# 之前的"高温发散"实际上是因为 abn=1.0logX=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"

View File

@ -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=$!"
}
# S280K 贫 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
# S380K 富 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
# S160K 贫 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"

109
cno_grid/src/claim_task.py Normal file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""master 端原子任务领取worker 经 SSH 调用)。
tasks.json 结构
{
"grid": "config.yaml 路径或标识",
"created": <unix秒>,
"tasks": {
"<model_name>": {
"params": {"teff":..,"logg":..,"loghe":..,"logc":..,"logn":..,"logo":..},
"status": "pending|running|done|failed",
"worker": <worker_id null>,
"claimed_at": <unix秒>,
"finished_at": <unix秒>,
"attempts": <int>,
"max_relc": <float>,
"seed_step_used": <bool>,
"note": <str>
},
...
}
}
并发安全 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()

View File

@ -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/<name>/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()

733
cno_grid/src/dist_master.py Normal file
View File

@ -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 领一个 pendingrunning
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 flockmaster 多线程也安全
- 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 → pendingworker 崩溃/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 从本地/七牛拉到种子 .7scp worker再调
seed_step.pyLTGRAY=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/<name>/;这里指定 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/<name>/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
# 标记 runningattempts++),重新派 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):
"""为某点找种子 .7master 本地 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(
"<tlusty_root>/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()

View File

@ -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()

263
cno_grid/src/qiniu_store.py Normal file
View File

@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""七牛云对象存储的轻量封装(零 SDK 依赖)。
仅用 Python 标准库hmac/hashlib/base64/json+ curl 实现七牛云的上传/
下载/列举worker 端无需 pip 装任何东西
七牛上传签名机制
PutPolicy = {"scope":"<bucket>:<key>", "deadline":<unix秒>}
编码后用 AccessKey/SecretKey HMAC-SHA1 签名得到 uploadToken
用途本分布式网格
- 七牛云当"种子库"每个收敛模型的 .7 大气393KB上传为
<seed_prefix>/<model_name>.7供异地 worker seed_step 回退时拉取
- master 持有 AK/SK 生成 uptokenworker 只拿 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)
# ---- 上传 / 下载(通过 curlworker 无需 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/<model>.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):
"""返回七牛种子库中所有 <model_name>.7 的模型名列表。
调用七牛 RS API: GET /list?bucket=<b>&prefix=<seed_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)):<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/<model>.7 -> <model>
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 "(未配置)")

278
cno_grid/src/repair_grid.py Normal file
View File

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""网格修补脚本:对未收敛的点用"最佳种子 + 种子步进"重试。
策略分层推进从易到难
1. 对每个失败点在全局种子库中找 CNO 距离最近的已收敛模型作种子
2. SEED_STEP_CHAINLTGREY=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()

104
cno_grid/src/report_task.py Normal file
View File

@ -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_relcfloat")
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()

View File

@ -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 内并行跑Poolwave 之间串行(等前一批完成)。
# 这样前一个 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,8 +473,12 @@ def main():
len(status_log), n_total, res["name"], res["status"]),
flush=True)
else:
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, worker_args):
for res in pool.imap_unordered(_worker, wave):
status_log.append(res)
print(" [{}/{}] {} -> {}".format(
len(status_log), n_total, res["name"], res["status"]),

View File

@ -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 CNONITER=50 浪费 2.2×
#
# 阶段 1LTE 灰度大气T T。NITER=0。
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0,
"require_converged": False, "niter": 0},
# 阶段 2NLTE 连续谱F F, ilvlin=0。默认 CHMAX=0.001 强制真正
# 收敛。NITER=50 给足迭代余量。
# 阶段 2NLTE 连续谱F F, ilvlin=0NITER=10 已足够给 nl 好种子
# (见上)。默认 CHMAX=0.001
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0,
"require_converged": False, "niter": 50},
# 阶段 3含谱线的完整 NLTEF F, ilvlin=100。从已收敛的 nc 种子出
# 发,约 1 次迭代即可收敛。默认 CHMAX=0.001。
"require_converged": False, "niter": 10},
# 阶段 3含谱线的完整 NLTEF 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

View File

@ -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.mdnl 会自修正,无需多跑。
{"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},