feat(all): 物理正确性五重硬门槛、输入文件结构化与 fort.55 错位修复、conv 诊断 DB 化与阶段归因修复、ORELAX 收敛修复与导入工具下线
物理正确性校验体系(common/conv_check.rs +494 行) - 新增 5 类硬门槛:能量守恒(.6)、温度结构(.7)、emflux 积分校验(.emflux,含全 NaN 判失败)、假收敛排查(itek 轨迹首末比)、b 因子合理性(.bfac) - runner 在 TLUSTY 阶段结束后执行全部校验,任一失败判 final_converged=false - GridConfig 新增 8 个可配阈值,经 scheduler→executor→runner 全链路透传 输入文件配置结构化重构(config.rs +1453 行) - TlustyInput 拆为 dot5/nst 分层结构,字段名严格映射 tlusty208.f READ 语句;SynspecInput 重构为 9 个 Fort55Line 子结构体 - 移除 ChainStep.metals 字段,元素集改由 dot5.atoms/ions 显式声明(gen_input5/nst_writer 同步重写为三源融合 / 分层覆盖) - fort.55 修复行结构 bug:补全分子表行(7→9 行),IDSTD 50→0 错位修正(影响全部光谱线强归一化,需重算 SYNSPEC 阶段) conv 诊断 DB 化与阶段归因修复(server) - 单点详情 conv 面板从磁盘 conv.json 改读 DB grid_points.summary_json;grid_points 新增 summary_json/last_elapsed_sec 两列(旧库幂等 ALTER) - record_task_report 阶段归因列加 CASE 守卫 + clear_synspec 对称处理,修复 synspec-only/TLUSTY-only 重跑污染统计 - 新增 summary_merge.rs 点级增量合并,避免重跑覆盖诊断字段 收敛性 ORELAX 修复与 seed_chain 可配(sdB_cno.yaml + node) - nl 阶段加 orelax=0.5、seed_nc 加 orelax=0.3,阻尼中温区 relc 振荡发散 - seed_chain 块可配,executor 优先采用用户配置而非内置默认链 导入工具下线 - 删除 import_results 客户端工具及 Windows 推送脚本;移除 /admin/import_seed 端点 - 改为服务端临时 migrate_conv 端点(扫 conv.json 增量合并入库,迁移后可删) 文档与分析 - 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档 - spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错
This commit is contained in:
@@ -10,7 +10,7 @@ DCTS is a Rust-based distributed computing scheduler for stellar atmosphere mode
|
||||
|
||||
```bash
|
||||
# Build / check
|
||||
cargo build --release # binaries: server, node, import_results
|
||||
cargo build --release # binaries: server, node
|
||||
cargo check --workspace --all-targets
|
||||
|
||||
# Tests (per crate)
|
||||
@@ -43,7 +43,7 @@ All config is via environment variables prefixed `DCTS_` (see `.env.example`): `
|
||||
|
||||
## Architecture
|
||||
|
||||
Workspace crates (`Cargo.toml`): `common` (lib), `server` (bin), `node` (bin), `mq` (lib), `tools/import_results` (bin), plus `dashboard/` (web UI).
|
||||
Workspace crates (`Cargo.toml`): `common` (lib), `server` (bin), `node` (bin), `mq` (lib), plus `dashboard/` (web UI).
|
||||
|
||||
### Master-Worker topology (pull-based)
|
||||
|
||||
|
||||
Generated
-16
@@ -897,22 +897,6 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "import_results"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"common",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
|
||||
@@ -5,7 +5,6 @@ members = [
|
||||
"crates/mq",
|
||||
"crates/server",
|
||||
"crates/node",
|
||||
"tools/import_results",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -20,7 +20,6 @@ cargo build --release
|
||||
编译产物位于 `target/release/`:
|
||||
- `server`:调度与 API 主服务端
|
||||
- `node`:计算节点后台进程
|
||||
- `import_results`:历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树)
|
||||
|
||||
### 2. 构建前端与启动服务端 (Master & Web Dashboard)
|
||||
首先编译前端全域实时网格可视图看班:
|
||||
@@ -113,7 +112,7 @@ DCTS_ADMIN_TOKEN=<强随机值>
|
||||
| [`node`](crates/node/README.md) | Binary | Worker 节点 Daemon 进程,负责任务抢占、自适应环境预热、计算链执行与产物汇报 | [README](crates/node/README.md) |
|
||||
| [`mq`](crates/mq/README.md) | Library | 基于 SQLite 构建的高可靠事务型分布式任务队列引擎 | [README](crates/mq/README.md) |
|
||||
| [`dashboard`](dashboard/index.html) | Web UI | 基于 Vite 与原生高交互前端语系创写的分层式恒星网格任务可观测可视化控表空间 | [说明详情](dashboard/package.json) |
|
||||
| [`import_results`](tools/import_results/README.md) | Tool CLI | 历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树) | [README](tools/import_results/README.md) |
|
||||
| [`import_db`](tools/import_db/README.md) | Tool CLI | 离线 DB 导入工具(扫结果目录 conv.json → 直写本地 db 文件) | [README](tools/import_db/README.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1407
-74
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
use crate::models::{ConvCheckResult, IterCheck};
|
||||
use crate::models::{
|
||||
BfacCheckResult, ConvergenceTraceCheckResult, ConvCheckResult, EmfluxCheckResult,
|
||||
EnergyCheckResult, IterCheck, TempStructCheckResult,
|
||||
};
|
||||
use regex::Regex;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
@@ -334,6 +337,494 @@ pub fn spec_is_valid(path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 Fortran 数值字符串为 f64,兼容 **D 记数法**(如 `1.00106D+00`)。
|
||||
///
|
||||
/// `.6` 能量守恒表的 `TOTAL SURFACE FLUX` 行用 D 记数法(双精度标记),数据行用 E 记数法。
|
||||
/// `parse_fortran_float`(见上)覆盖标准/E/无-E 记数法,但不处理 D。本函数在 `parse_fortran_float`
|
||||
/// 之前把 D/d 归一化为 E,复用其无-E 与溢出处理逻辑。仅用于 `.6` 解析(不改动公共函数行为,
|
||||
/// 避免 fort.9/fort.7 解析受影响)。
|
||||
fn parse_fortran_d_float(s: &str) -> Option<f64> {
|
||||
let trimmed = s.trim();
|
||||
// 快路径:不含 D/d 直接走 parse_fortran_float
|
||||
if !trimmed.to_ascii_lowercase().contains('d') {
|
||||
return parse_fortran_float(trimmed);
|
||||
}
|
||||
let normalized = trimmed.replacen(['D', 'd'], "E", 1);
|
||||
parse_fortran_float(&normalized)
|
||||
}
|
||||
|
||||
/// 校验 `.6`(TLUSTY stdout)中的能量守恒诊断 `(RAD+CON)/TOT` 列。
|
||||
///
|
||||
/// # 物理不变量
|
||||
/// TLUSTY 在 LFIN(最终迭代)时由 `OUTPRI`(`tlusty208.f:3889`)无条件写出逐深度通量表:
|
||||
/// 每深度 `(RAD+CON)/TOT = (辐射通量 + 对流通量) / σTeff⁴`,**应 ≈ 1.0**(能量守恒)。
|
||||
/// Hubeny 官方判据:偏离 1 ≤1% 为收敛良好;>5% 明显发散(见
|
||||
/// `docs/spectrum_correctness_analysis.md` §1.1/§3.1)。任一深度超 `tolerance` 即判失败。
|
||||
///
|
||||
/// # 返回值
|
||||
/// - `Some(EnergyCheckResult)`:找到能量守恒表并完成校验(含 valid 判定)。
|
||||
/// - `None`:`.6` 缺失、无能量守恒表、或无有效数据行——**跳过**(不误判失败)。
|
||||
/// 与 `atmosphere_has_nan` 对缺失文件返回 false(语义"无 NaN")的保守策略一致:
|
||||
/// 非常规配置(TLUSTY-only、极简 niter)可能不产出此表,不应因此判失败。
|
||||
///
|
||||
/// # 解析策略
|
||||
/// 表格由 `TOTAL SURFACE FLUX`(唯一,LFIN 时)标记,其后含 `(RAD+CON)/TOT` 表头行,
|
||||
/// 随后是逐深度数据行(ID + 10 列,最后一列即校验目标)。用表头行而非行号定位,
|
||||
/// 对不同 ND(如 50/99)自适应。
|
||||
pub fn check_energy_conservation(path: &Path, tolerance: f64) -> Option<EnergyCheckResult> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
|
||||
|
||||
// 状态机:找到表头后开始收集数据行。
|
||||
let mut found_header = false;
|
||||
let mut values: Vec<(i32, f64)> = Vec::new(); // (depth_id, rad_con_tot)
|
||||
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if !found_header {
|
||||
// 定位含 (RAD+CON)/TOT 的表头行(OUTPRI 写出,固定文本)。
|
||||
if line.contains("(RAD+CON)/TOT") {
|
||||
found_header = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 表头之后的数据行:首个 token 是深度 ID(整数),末 token 是 (RAD+CON)/TOT。
|
||||
// 遇到非数据行(如分隔线、下一段标题)即结束当前表收集。
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.is_empty() {
|
||||
continue; // 空行容忍(表后常见)
|
||||
}
|
||||
// 深度 ID:正整数。非整数 → 表已结束(如下一段 "MODEL QUANTITIES" 标题)。
|
||||
let depth_id: i32 = match tokens[0].parse() {
|
||||
Ok(id) if id >= 1 => id,
|
||||
_ => break,
|
||||
};
|
||||
// NaN/溢出行:标记但不计入(避免误把发散值当有效偏离)。
|
||||
if nan_re.is_match(&line) {
|
||||
continue;
|
||||
}
|
||||
// 末 token = (RAD+CON)/TOT。支持 E/D 记数法。
|
||||
let val = match parse_fortran_d_float(tokens.last().unwrap()) {
|
||||
Some(v) if v.is_finite() => v,
|
||||
_ => continue,
|
||||
};
|
||||
values.push((depth_id, val));
|
||||
}
|
||||
|
||||
if !found_header || values.is_empty() {
|
||||
return None; // 无能量守恒表或无有效数据 → 跳过
|
||||
}
|
||||
|
||||
// 找偏离最大的深度点。
|
||||
let (worst_depth, max_dev) = values
|
||||
.iter()
|
||||
.map(|(id, v)| (*id, (v - 1.0).abs()))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap(); // values 非空,safe
|
||||
|
||||
let valid = max_dev <= tolerance;
|
||||
Some(EnergyCheckResult {
|
||||
valid,
|
||||
max_deviation: max_dev,
|
||||
worst_depth: Some(worst_depth),
|
||||
n_depths_checked: values.len(),
|
||||
tolerance,
|
||||
error: if valid {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"能量守恒校验失败:(RAD+CON)/TOT 在深度 {} 偏离 1 达 {:.4}(> 阈值 {:.4})",
|
||||
worst_depth, max_dev, tolerance
|
||||
))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验 `.7` 大气文件的温度结构边界(见 docs/spectrum_correctness_analysis.md §3.1/§4)。
|
||||
///
|
||||
/// # 物理不变量
|
||||
/// T(τ) 应落在物理合理范围:表层(ID=1)T ≲ 2-3×Teff;总体 10¹-10⁸ K。
|
||||
/// 表层 T >>Teff(如 >3×Teff)或任一深度越界 → T(τ) 非物理(红旗 §4)。
|
||||
///
|
||||
/// # `.7` 文件格式
|
||||
/// TLUSTY `OUTPUT` 子程序(`tlusty208.f:14066-14108`)写出:
|
||||
/// - 行 1:`ND NUMPAR`(深度数、每深度参数数 = NLEVEL+NUMLT)
|
||||
/// - 行 2..ceil(ND/6)+1:DM 列质量深度数组(FORMAT 502,每行 6 个)
|
||||
/// - 之后 ND 个深度块,每块 ceil(NUMPAR/5) 行(FORMAT 503,每行 5 个),
|
||||
/// **每块第一个值即温度 TEMP(ID)**(`tlusty208.f:14079`,INPMOD `X(1)=TEMP`)。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `teff`:有效温度(K),来自网格点参数。
|
||||
/// - `max_factor`:表层 T / Teff 上限(默认 3.0)。
|
||||
/// - `temp_floor` / `temp_ceiling`:全层温度绝对边界(默认 10 / 1e8 K)。
|
||||
///
|
||||
/// # 返回值
|
||||
/// `Some(TempStructCheckResult)`:完成校验。`None`:`.7` 缺失/格式不可解析 → 跳过。
|
||||
pub fn check_temperature_structure(
|
||||
path: &Path,
|
||||
teff: f64,
|
||||
max_factor: f64,
|
||||
temp_floor: f64,
|
||||
temp_ceiling: f64,
|
||||
) -> Option<TempStructCheckResult> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = reader.lines().map_while(Result::ok);
|
||||
|
||||
// 行 1:ND NUMPAR
|
||||
let header = lines.next()?;
|
||||
let mut hdr = header.split_whitespace();
|
||||
let nd: usize = hdr.next()?.parse().ok()?;
|
||||
let numpar: usize = hdr.next()?.parse().ok()?;
|
||||
if nd == 0 || numpar == 0 {
|
||||
return None;
|
||||
}
|
||||
let dm_lines = (nd + 5) / 6; // FORMAT 502 每行 6 个
|
||||
let block_lines = (numpar + 4) / 5; // FORMAT 503 每行 5 个
|
||||
|
||||
// 跳过 DM 数组。检查截断:若行不足说明文件残缺,无法可靠定位温度块 → 跳过。
|
||||
for _ in 0..dm_lines {
|
||||
lines.next()?;
|
||||
}
|
||||
|
||||
// 逐深度块:每块取首个数值 token = TEMP(ID)
|
||||
let mut temps: Vec<f64> = Vec::with_capacity(nd);
|
||||
for _ in 0..nd {
|
||||
// 块的第一行第一个 token 即温度(FORMAT 503 行首)
|
||||
let first_line = lines.next()?;
|
||||
let first_token = first_line.split_whitespace().next()?;
|
||||
let t = parse_fortran_float(first_token)?;
|
||||
temps.push(t);
|
||||
// 跳过该块剩余行(截断时 next()? 同样提前返回 None)
|
||||
for _ in 1..block_lines {
|
||||
lines.next()?;
|
||||
}
|
||||
}
|
||||
|
||||
if temps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let surface_temp = temps[0];
|
||||
let bottom_temp = temps[temps.len() - 1];
|
||||
let surface_ratio = if teff > 0.0 { surface_temp / teff } else { f64::INFINITY };
|
||||
|
||||
// 判定:表层超 max_factor×Teff,或任一深度越界 [floor, ceiling]。
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
if surface_ratio > max_factor {
|
||||
violations.push(format!(
|
||||
"表层 T={:.0}K = {:.2}×Teff 超上限 {:.1}×Teff",
|
||||
surface_temp, surface_ratio, max_factor
|
||||
));
|
||||
}
|
||||
for (i, &t) in temps.iter().enumerate() {
|
||||
if !t.is_finite() || t < temp_floor || t > temp_ceiling {
|
||||
violations.push(format!(
|
||||
"深度 {} T={:.3e}K 越界 [{:.0}, {:.0e}]",
|
||||
i + 1,
|
||||
t,
|
||||
temp_floor,
|
||||
temp_ceiling
|
||||
));
|
||||
break; // 报第一个越界即可
|
||||
}
|
||||
}
|
||||
|
||||
let valid = violations.is_empty();
|
||||
Some(TempStructCheckResult {
|
||||
valid,
|
||||
teff,
|
||||
surface_temp,
|
||||
bottom_temp,
|
||||
surface_ratio,
|
||||
n_depths_checked: temps.len(),
|
||||
max_factor,
|
||||
temp_floor,
|
||||
temp_ceiling,
|
||||
error: if valid {
|
||||
None
|
||||
} else {
|
||||
Some(format!("温度结构校验失败:{}", violations.join("; ")))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Stefan-Boltzmann 常量(cgs:erg cm⁻² s⁻¹ K⁻⁴)。
|
||||
/// TLUSTY 用 `SIG4P = σ_SB/4π`(`BASICS.FOR:51`);此处用完整 σ_SB 做 bolometric 比对。
|
||||
const SIGMA_SB: f64 = 5.670374419e-5;
|
||||
|
||||
/// 校验 `.emflux` 的 bolometric 通量守恒(见 docs/spectrum_correctness_analysis.md §3.2/§4)。
|
||||
///
|
||||
/// # 物理不变量
|
||||
/// ∫Fλ dλ(出射谱全波长积分)≈ σ_SB × Teff⁴。偏离 >2% → 能量不守恒(红旗 §4)。
|
||||
///
|
||||
/// # `.emflux` 格式
|
||||
/// SYNSPEC 写出的 fort.14 快照(runner `snapshot_tlusty_outputs`),两列:波长 λ(Å) / Fλ。
|
||||
/// 部分样本全 NaN(大气收敛失败导致辐射转移求解发散)—— NaN 占比过高(>50%)直接判失败。
|
||||
/// 详见 `docs/fort14_nan_analysis.md`:fort.14 全 NaN 是大气结构发散的充分(且几乎必要的)标志。
|
||||
///
|
||||
/// # 算法
|
||||
/// 跳过 NaN/不可解析行,对剩余 (λ, Fλ) 按波长排序后梯形积分。NaN 占比 >0.5 → 失败。
|
||||
/// 否则比较 integrated / (σTeff⁴),偏离 1 超 `tolerance` → 失败。
|
||||
///
|
||||
/// # 返回值
|
||||
/// `Some(EmfluxCheckResult)`:完成校验(含全 NaN 判失败)。`None`:文件缺失 → 跳过。
|
||||
pub fn check_emflux_bolometric(
|
||||
path: &Path,
|
||||
teff: f64,
|
||||
tolerance: f64,
|
||||
) -> Option<EmfluxCheckResult> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
|
||||
|
||||
let mut total = 0usize;
|
||||
let mut points: Vec<(f64, f64)> = Vec::new(); // (lambda, flux)
|
||||
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if nan_re.is_match(&line) {
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if tokens.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let (lam, flux) = match (
|
||||
parse_fortran_d_float(tokens[0]),
|
||||
parse_fortran_d_float(tokens[1]),
|
||||
) {
|
||||
(Some(l), Some(f)) if l.is_finite() && f.is_finite() => (l, f),
|
||||
_ => continue,
|
||||
};
|
||||
points.push((lam, flux));
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return None; // 空文件 → 跳过
|
||||
}
|
||||
let nan_ratio = 1.0 - (points.len() as f64 / total as f64);
|
||||
let sigma_teff4 = SIGMA_SB * teff.powi(4);
|
||||
|
||||
// NaN 占比过高 → 大气收敛失败(辐射转移求解发散),直接判失败(不积分)。
|
||||
// 源码机制:大气 TEMP/POPUL 发散 → OPACF1 中 HK/TEMP→Inf、POPUL 乘积→Inf →
|
||||
// RTEFR1 源函数 EMIS1/AB0=Inf/Inf=NaN → 整列 FLUX 全 NaN → OUTPRI 写进 fort.14。
|
||||
// 全 NaN 只能由大气发散引起,不存在"大气好但辐射转移单独失败"的路径。
|
||||
if nan_ratio > 0.5 {
|
||||
return Some(EmfluxCheckResult {
|
||||
valid: false,
|
||||
integrated_flux: 0.0,
|
||||
sigma_teff4,
|
||||
ratio: 0.0,
|
||||
nan_ratio,
|
||||
n_points: points.len(),
|
||||
tolerance,
|
||||
error: Some(format!(
|
||||
"emflux NaN/不可解析占比 {:.0}% 过高(>{:.0}%),大气收敛失败(辐射转移求解发散)",
|
||||
nan_ratio * 100.0,
|
||||
50.0
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
// 梯形积分:按波长排序后 ∫Fλ dλ。
|
||||
points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mut integral = 0.0;
|
||||
for w in points.windows(2) {
|
||||
let (lam0, f0) = w[0];
|
||||
let (lam1, f1) = w[1];
|
||||
integral += 0.5 * (f0 + f1) * (lam1 - lam0);
|
||||
}
|
||||
|
||||
let ratio = if sigma_teff4 > 0.0 {
|
||||
integral / sigma_teff4
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
let valid = (ratio - 1.0).abs() <= tolerance;
|
||||
Some(EmfluxCheckResult {
|
||||
valid,
|
||||
integrated_flux: integral,
|
||||
sigma_teff4,
|
||||
ratio,
|
||||
nan_ratio,
|
||||
n_points: points.len(),
|
||||
tolerance,
|
||||
error: if valid {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"emflux bolometric 校验失败:∫Fλdλ / σTeff⁴ = {:.4} 偏离 1 超 {:.2}",
|
||||
ratio, tolerance
|
||||
))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 收敛轨迹(假收敛)排查(见 docs/spectrum_correctness_analysis.md §2.1/§3.1)。
|
||||
///
|
||||
/// # 物理不变量
|
||||
/// 真收敛的 max_relc 轨迹应单调下降 ≥3 个量级(首/末比 ≥10³)。Ng/Kantorovich 加速
|
||||
/// 可人为压低 max_relc 造成"假收敛"——数值达标(< chmax)但统计平衡未真正达成。
|
||||
///
|
||||
/// # 判定
|
||||
/// - 轨迹 <3 拍:无法判定 → 返回 None(跳过,避免短链误杀)。
|
||||
/// - 首/末比 < `min_ratio`(默认 1000):可疑假收敛 → 失败。
|
||||
/// - 含 NaN/非正值轨迹也判失败。
|
||||
///
|
||||
/// # 调用时机
|
||||
/// 仅在 `converged=true` 的 stage 上做(发散 stage 不需排查假收敛)。
|
||||
/// 数据来自 `check_fort9` 已解析的 `itek_history`,无需重读文件。
|
||||
pub fn check_convergence_trace(
|
||||
itek: &[IterCheck],
|
||||
min_ratio: f64,
|
||||
) -> Option<ConvergenceTraceCheckResult> {
|
||||
if itek.len() < 3 {
|
||||
return None; // 轨迹太短,无法判定
|
||||
}
|
||||
let valid_values: Vec<f64> = itek
|
||||
.iter()
|
||||
.map(|c| c.max_relc)
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.collect();
|
||||
if valid_values.len() < 2 {
|
||||
return Some(ConvergenceTraceCheckResult {
|
||||
valid: false,
|
||||
first_max_relc: itek[0].max_relc,
|
||||
last_max_relc: itek.last().map(|c| c.max_relc).unwrap_or(0.0),
|
||||
ratio: 0.0,
|
||||
monotonic: false,
|
||||
n_iters: itek.len(),
|
||||
min_ratio,
|
||||
error: Some("收敛轨迹含 NaN/非正值,无法判定收敛质量".to_string()),
|
||||
});
|
||||
}
|
||||
let first = valid_values[0];
|
||||
let last = *valid_values.last().unwrap();
|
||||
let ratio = first / last;
|
||||
let monotonic = valid_values
|
||||
.windows(2)
|
||||
.all(|w| w[0] >= w[1] * 0.5); // 容忍 Ng 加速的局部反弹(×2 内)
|
||||
|
||||
let valid = ratio >= min_ratio;
|
||||
Some(ConvergenceTraceCheckResult {
|
||||
valid,
|
||||
first_max_relc: first,
|
||||
last_max_relc: last,
|
||||
ratio,
|
||||
monotonic,
|
||||
n_iters: itek.len(),
|
||||
min_ratio,
|
||||
error: if valid {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"假收敛排查失败:首末 max_relc 比 {:.1e} < {:.0e}(可能 Ng 加速伪收敛)",
|
||||
ratio, min_ratio
|
||||
))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验 `.bfac` b 因子合理性(见 docs/spectrum_correctness_analysis.md §3.1/§6 #19)。
|
||||
///
|
||||
/// # 物理不变量
|
||||
/// NLTE 偏离因子 b(=n_NLTE/n_LTE)对关键能级应落在 [10⁻³, 10³];b=1.0 表示 LTE。
|
||||
/// 极端值(>1e3 或 <1e-3)比例过高 → 统计平衡未达成或布居数错误。
|
||||
///
|
||||
/// # `.bfac` 格式
|
||||
/// 与 `.7` 同构(`tlusty208.f:14344-14350` WRITBFAC):首行 `ND NUMPAR`,DM 数组,
|
||||
/// 逐深度块(T/ne/dens + NLTE 布居偏离因子)。表层 grey start 块全 1.0(LTE);
|
||||
/// 某些深度块开头有 NaN(布居为零的能级),属正常。
|
||||
///
|
||||
/// # 判定
|
||||
/// 扫描所有数值 token,排除 NaN/1.0(LTE 占位),统计有效 b 因子中极端值占比。
|
||||
/// 占比 > 10% → 失败。
|
||||
///
|
||||
/// # 返回值
|
||||
/// `Some(BfacCheckResult)`:完成校验。`None`:文件缺失/无有效 b 因子 → 跳过。
|
||||
pub fn check_bfactor(path: &Path, max_value: f64, min_value: f64) -> Option<BfacCheckResult> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
|
||||
|
||||
let mut total_tokens = 0usize;
|
||||
let mut nan_count = 0usize;
|
||||
let mut b_values: Vec<f64> = Vec::new();
|
||||
let mut in_data = false; // 跳过首行 + DM 数组
|
||||
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if !in_data {
|
||||
// 检测首行(两个整数 ND NUMPAR),之后进入数据区
|
||||
if tokens.len() == 2
|
||||
&& tokens[0].parse::<usize>().is_ok()
|
||||
&& tokens[1].parse::<usize>().is_ok()
|
||||
{
|
||||
in_data = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for tok in &tokens {
|
||||
total_tokens += 1;
|
||||
if nan_re.is_match(tok) {
|
||||
nan_count += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = parse_fortran_float(tok) {
|
||||
// 跳过 LTE 占位 b=1.0(grey start 表层);正值有限数才计入
|
||||
if v.is_finite() && v > 0.0 && (v - 1.0).abs() > 1e-10 {
|
||||
b_values.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if b_values.is_empty() {
|
||||
return None; // 无有效 b 因子(纯 LTE 模型?)→ 跳过
|
||||
}
|
||||
let nan_ratio = if total_tokens > 0 {
|
||||
nan_count as f64 / total_tokens as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let extreme_count = b_values
|
||||
.iter()
|
||||
.filter(|&&b| b > max_value || b < min_value)
|
||||
.count();
|
||||
let extreme_ratio = extreme_count as f64 / b_values.len() as f64;
|
||||
let max_b = b_values.iter().cloned().fold(0.0f64, f64::max);
|
||||
let min_b = b_values.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let tolerance = 0.1; // 极端值占比上限 10%
|
||||
let valid = extreme_ratio <= tolerance;
|
||||
|
||||
Some(BfacCheckResult {
|
||||
valid,
|
||||
n_values: b_values.len(),
|
||||
extreme_ratio,
|
||||
max_b,
|
||||
min_b,
|
||||
nan_ratio,
|
||||
max_value,
|
||||
min_value,
|
||||
error: if valid {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"b 因子校验失败:极端值(>{:.0e} 或 <{:.0e})占比 {:.0}% > {:.0}%",
|
||||
max_value, min_value, extreme_ratio * 100.0, tolerance * 100.0
|
||||
))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 fort.6(TLUSTY stdout 日志)提取失败诊断提示。
|
||||
///
|
||||
/// # 背景(漏洞 5,P2)
|
||||
@@ -741,4 +1232,296 @@ mod tests {
|
||||
assert!((res.itek_history[1].max_relc - 5.0e-4).abs() < 1e-15);
|
||||
assert_eq!(res.itek_history[1].n_depths, 2);
|
||||
}
|
||||
|
||||
/// 构造一段 `.6` 能量守恒表片段(LFIN 时 OUTPRI 写出)。
|
||||
/// 每行:ID + 10 列,最后一列是 `(RAD+CON)/TOT`。
|
||||
fn make_energy_table(rad_con_tot_values: &[f64]) -> String {
|
||||
let header = " TOTAL SURFACE FLUX 6.77712339D+12\n\n\
|
||||
----------------------\n\
|
||||
FINAL MODEL ATMOSPHERE\n\
|
||||
----------------------\n\
|
||||
ID MASS TAUROSS TEMP NE DENS P_gas LOG(G_rad) RAD/TOT CON/TOT (RAD+CON)/TOT\n";
|
||||
let mut body = String::new();
|
||||
for (i, v) in rad_con_tot_values.iter().enumerate() {
|
||||
body.push_str(&format!(
|
||||
" {:>2} 2.917E-07 5.001E-08 32890.1 3.011E+08 5.840E-16 2.632E-03 2.989E+00 1.001E+00 0.000E+00 {:e}\n",
|
||||
i + 1,
|
||||
v
|
||||
));
|
||||
}
|
||||
format!("{}{}", header, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_conservation_pass() {
|
||||
// hhe35nl 真实偏离量级:~0.001(收敛良好),阈值 0.01 应通过。
|
||||
// 深度 2 偏离略大(模拟真实:不同深度偏离略有差异)。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.6");
|
||||
std::fs::write(
|
||||
&file_path,
|
||||
make_energy_table(&[1.0005e0, 1.00106e0, 0.9998e0]),
|
||||
)
|
||||
.unwrap();
|
||||
let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表");
|
||||
assert!(res.valid, "偏离 0.00106 < 阈值 0.01 应通过");
|
||||
assert!((res.max_deviation - 1.06e-3).abs() < 1e-10);
|
||||
assert_eq!(res.n_depths_checked, 3);
|
||||
assert_eq!(res.worst_depth, Some(2)); // 深度 2 偏离最大
|
||||
assert!(res.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_conservation_fail() {
|
||||
// 深度 2 偏离 0.15(>5%,明显发散),阈值 0.01 应失败。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.6");
|
||||
std::fs::write(
|
||||
&file_path,
|
||||
make_energy_table(&[1.001e0, 1.15e0, 0.98e0]),
|
||||
)
|
||||
.unwrap();
|
||||
let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表");
|
||||
assert!(!res.valid, "偏离 0.15 > 阈值 0.01 应失败");
|
||||
assert!((res.max_deviation - 0.15).abs() < 1e-10);
|
||||
assert_eq!(res.worst_depth, Some(2));
|
||||
assert!(res.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_conservation_missing_table() {
|
||||
// `.6` 无能量守恒表(如 TLUSTY 未到 LFIN)→ None(跳过,不误判失败)。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.6");
|
||||
std::fs::write(&file_path, "some TLUSTY log without energy table\n").unwrap();
|
||||
assert!(check_energy_conservation(&file_path, 0.01).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_conservation_missing_file() {
|
||||
// `.6` 文件缺失 → None(跳过)。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("nonexistent.6");
|
||||
assert!(check_energy_conservation(&file_path, 0.01).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_conservation_d_notation() {
|
||||
// `TOTAL SURFACE FLUX` 行用 D 记数法;数据行也可能用 D。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.6");
|
||||
let content = " TOTAL SURFACE FLUX 6.77712339D+12\n\n\
|
||||
----------------------\n\
|
||||
FINAL MODEL ATMOSPHERE\n\
|
||||
----------------------\n\
|
||||
ID MASS (RAD+CON)/TOT\n\
|
||||
1 2.917E-07 1.001D+00\n\
|
||||
2 3.974E-07 9.99D-01\n";
|
||||
std::fs::write(&file_path, content).unwrap();
|
||||
let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表");
|
||||
assert!(res.valid, "D 记数法偏离 ≤0.001 应通过");
|
||||
assert!((res.max_deviation - 1.0e-3).abs() < 1e-10);
|
||||
}
|
||||
|
||||
/// 构造一个最小 `.7` 大气文件(ND=3 深度,NUMPAR=3 即仅 T/ne/dens 无 NLTE 布居)。
|
||||
/// 格式:行1 `ND NUMPAR`;DM 数组 `ceil(ND/6)=1` 行;每深度块 `ceil(NUMPAR/5)=1` 行。
|
||||
/// 每深度块首值即温度 T。
|
||||
fn make_dot7(temps: &[f64]) -> String {
|
||||
let nd = temps.len();
|
||||
let numpar = 3;
|
||||
let mut s = format!(" {} {}\n", nd, numpar);
|
||||
// DM 数组:每行 6 个(FORMAT 502)。ND≤6 时一行。
|
||||
let dm: Vec<String> = (0..nd).map(|i| format!("1.0E-0{}", i + 1)).collect();
|
||||
s.push_str(&dm.join(" "));
|
||||
s.push('\n');
|
||||
// 逐深度块:每块 ceil(3/5)=1 行,首值=T。
|
||||
for t in temps {
|
||||
s.push_str(&format!(" {:.5} 1.0E+10 1.0E-15\n", t));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_structure_pass() {
|
||||
// Teff=50000K,表层 30000K(0.6×Teff)、底层 200000K(4×Teff)——物理合理。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.7");
|
||||
std::fs::write(&file_path, make_dot7(&[3.0e4, 1.0e5, 2.0e5])).unwrap();
|
||||
let res =
|
||||
check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7");
|
||||
assert!(res.valid, "表层 0.6×Teff、全层 [3e4,2e5] 应通过");
|
||||
assert!((res.surface_temp - 3.0e4).abs() < 1.0);
|
||||
assert!((res.bottom_temp - 2.0e5).abs() < 1.0);
|
||||
assert!((res.surface_ratio - 0.6).abs() < 1e-4);
|
||||
assert_eq!(res.n_depths_checked, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_structure_surface_too_hot() {
|
||||
// Teff=50000K,表层 200000K = 4×Teff > 3.0 上限 → 失败。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.7");
|
||||
std::fs::write(&file_path, make_dot7(&[2.0e5, 2.5e5, 3.0e5])).unwrap();
|
||||
let res =
|
||||
check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7");
|
||||
assert!(!res.valid, "表层 4×Teff > 3.0 上限应失败");
|
||||
assert!(res.surface_ratio > 3.0);
|
||||
assert!(res.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_structure_out_of_range() {
|
||||
// 某深度温度 1e9 K > ceiling 1e8 → 失败。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.7");
|
||||
std::fs::write(&file_path, make_dot7(&[3.0e4, 1.0e9, 2.0e5])).unwrap();
|
||||
let res =
|
||||
check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7");
|
||||
assert!(!res.valid, "深度2 T=1e9 越界 [10,1e8] 应失败");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_structure_missing_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("nonexistent.7");
|
||||
assert!(check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1e8).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_structure_truncated_file() {
|
||||
// DM 数组被截断(ND=3 需 1 行 DM,但此处只给首行头无 DM 行)。
|
||||
// 修复前:静默跳过 → 读到后续行当作温度(可能误判)。
|
||||
// 修复后:next()? 返回 None → 跳过(不误报)。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("truncated.7");
|
||||
std::fs::write(&file_path, " 50 517\n").unwrap(); // 只有首行,无 DM/深度块
|
||||
assert!(check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1e8).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emflux_bolometric_pass() {
|
||||
// 构造一个 ∫Fλdλ ≈ σTeff⁴ 的简化谱:Teff=50000K。
|
||||
// σ×50000⁴ = 5.670374419e-5 × 6.25e18 = 3.544e14 erg/cm²/s。
|
||||
// 用梯形:λ∈[100,1100]Å 共 1001 点,均匀 Fλ 使积分 = 3.544e14。
|
||||
// 积分 = Fλ × (1100-100) = Fλ × 1000。故 Fλ = 3.544e11。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.emflux");
|
||||
let mut content = String::new();
|
||||
let sigma_teff4 = 5.670374419e-5 * 50000.0f64.powi(4);
|
||||
let flux = sigma_teff4 / 1000.0; // 均匀谱,积分=Fλ×1000
|
||||
for i in 0..1001 {
|
||||
let lam = 100.0 + i as f64;
|
||||
content.push_str(&format!("{:>15.3} {:e}\n", lam, flux));
|
||||
}
|
||||
std::fs::write(&file_path, content).unwrap();
|
||||
let res = check_emflux_bolometric(&file_path, 50000.0, 0.02).expect("应解析 emflux");
|
||||
assert!(res.valid, "均匀谱积分 = σTeff⁴ 应通过");
|
||||
assert!((res.ratio - 1.0).abs() < 0.01, "比值应≈1.0,实际 {}", res.ratio);
|
||||
assert_eq!(res.nan_ratio, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emflux_bolometric_all_nan() {
|
||||
// 全 NaN → 大气收敛失败(辐射转移求解发散),应判失败(而非跳过)。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.emflux");
|
||||
let content: String = (0..100).map(|i| format!("{:>12.3} NaN\n", 100.0 + i as f64)).collect();
|
||||
std::fs::write(&file_path, content).unwrap();
|
||||
let res = check_emflux_bolometric(&file_path, 50000.0, 0.02).expect("应解析");
|
||||
assert!(!res.valid, "全 NaN 应判失败");
|
||||
assert!(res.nan_ratio > 0.5);
|
||||
assert!(res.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emflux_missing_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("nonexistent.emflux");
|
||||
assert!(check_emflux_bolometric(&file_path, 50000.0, 0.02).is_none());
|
||||
}
|
||||
|
||||
// ===== 假收敛排查(#8)测试 =====
|
||||
|
||||
#[test]
|
||||
fn test_convergence_trace_pass() {
|
||||
// 真收敛:首=100, 逐拍除10, 10拍末=1e-8, 比值 1e10 > 1000。单调。
|
||||
let itek: Vec<IterCheck> = (0..10)
|
||||
.map(|i| IterCheck {
|
||||
iter: i + 1,
|
||||
max_relc: 100.0 / 10.0_f64.powi(i),
|
||||
n_depths: 50,
|
||||
})
|
||||
.collect();
|
||||
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
|
||||
assert!(res.valid, "比值远 > 1000 应通过");
|
||||
assert!(res.ratio >= 1000.0);
|
||||
assert!(res.monotonic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convergence_trace_suspect() {
|
||||
// 假收敛嫌疑:首=10, 末=0.005, 比值 2000 但刚过阈值 → 通过;
|
||||
// 改为 首=10, 末=0.05, 比值 200 < 1000 → 失败。
|
||||
let itek: Vec<IterCheck> = (0..6)
|
||||
.map(|i| IterCheck {
|
||||
iter: i + 1,
|
||||
max_relc: 10.0 - i as f64 * 1.65, // 10→1.75 近线性下降
|
||||
n_depths: 50,
|
||||
})
|
||||
.collect();
|
||||
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
|
||||
assert!(!res.valid, "比值 <1000 应判假收敛嫌疑");
|
||||
assert!(res.ratio < 1000.0);
|
||||
assert!(res.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convergence_trace_too_short() {
|
||||
// 2拍:无法判定 → None
|
||||
let itek = vec![
|
||||
IterCheck { iter: 1, max_relc: 10.0, n_depths: 50 },
|
||||
IterCheck { iter: 2, max_relc: 0.001, n_depths: 50 },
|
||||
];
|
||||
assert!(check_convergence_trace(&itek, 1000.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convergence_trace_ng_bounce() {
|
||||
// Ng 加速局部反弹但整体收敛:10→0.5→2→0.001。比值 1e4,monotonic 允许 ×2 反弹。
|
||||
let itek = vec![
|
||||
IterCheck { iter: 1, max_relc: 10.0, n_depths: 50 },
|
||||
IterCheck { iter: 2, max_relc: 0.5, n_depths: 50 },
|
||||
IterCheck { iter: 3, max_relc: 2.0, n_depths: 50 }, // 反弹 ×4 > ×2 → 非单调
|
||||
IterCheck { iter: 4, max_relc: 0.001, n_depths: 50 },
|
||||
];
|
||||
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
|
||||
assert!(res.valid, "比值 1e4 > 1000 应通过");
|
||||
assert!(!res.monotonic, "反弹 ×4 > ×2 应判非单调");
|
||||
}
|
||||
|
||||
// ===== b 因子合理性(#19)测试 =====
|
||||
|
||||
#[test]
|
||||
fn test_bfactor_pass() {
|
||||
// 正常 b 因子:多数在 [1e-3, 1e3],少量极端。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("model.bfac");
|
||||
let content = " 50 3\n\
|
||||
1.0E-07 2.0E-07 3.0E-07 4.0E-07 5.0E-07 6.0E-07\n\
|
||||
1.0E+04 5.0E+01 2.0E+00 1.0E+00 5.0E-01\n\
|
||||
3.0E+02 1.5E+00 8.0E-01 1.0E+00 2.0E+00\n";
|
||||
std::fs::write(&file_path, content).unwrap();
|
||||
let res = check_bfactor(&file_path, 1.0e3, 1.0e-3).expect("应解析 bfac");
|
||||
// 1.0E+04 是极端值(>1e3),1个/约8个有效值 ≈12.5%。
|
||||
// 这个测试验证极端值检测;具体 valid 取决于阈值。
|
||||
assert!(res.n_values > 0);
|
||||
assert!(res.max_b > 1.0e3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bfactor_missing_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("nonexistent.bfac");
|
||||
assert!(check_bfactor(&file_path, 1.0e3, 1.0e-3).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,339 @@
|
||||
//! SYNSPEC fort.55 控制卡生成器。
|
||||
//!
|
||||
//! `generate_fort55_content` 把 [`SynspecInput`](按行分组的子结构体)序列化为 fort.55 文本。
|
||||
//! 每行字段顺序严格对应 `synspec54.f` 的 `READ(55,*)` 语句,字段名一一映射——详见各
|
||||
//! `Fort55Line*` 结构体的文档注释(附 `synspec54.f` 行号)。
|
||||
|
||||
use crate::config::SynspecInput;
|
||||
|
||||
/// Dynamic generator for SYNSPEC fort.55 parameter control file
|
||||
/// 把 [`SynspecInput`] 序列化为 SYNSPEC fort.55 控制卡文本(9 行)。
|
||||
///
|
||||
/// 每行对应 `synspec54.f` 的一个 `READ(55,*)` 语句,行内字段顺序与 READ 语句完全一致:
|
||||
/// - 行1 (`:253`): `IMODE IDSTD IPRIN`
|
||||
/// - 行2 (`:254`): `INMOD INTRPL ICHANG ICHEMC`
|
||||
/// - 行3 (`:255`): `IOPHLI nunalp nunbet nungam nunbal`
|
||||
/// - 行4 (`:2146`): `IFREQ INLTE ICONTL INLIST IFHE2`
|
||||
/// - 行5 (`:2148`): `IHYDPR IHE1PR IHE2PR`
|
||||
/// - 行6 (`:2149`): `ALAM0 ALAST CUTOF0 CUTOFS RELOP SPACE`
|
||||
/// - 行7 (`:2208`, IMODE∈[-3,1] 条件读取): `nmlist [iunitm...]`
|
||||
/// - 行8 (`:2251`): `VTB`
|
||||
/// - 行9 (`:2301`, IFWIN≤0): `NMU0 ANG0 IFLUX`
|
||||
///
|
||||
/// # 行7(分子表)的必要性
|
||||
/// SYNSPEC 在 IMODE∈[-3,1](默认 IMODE=0 满足)时会**额外读一行分子表**(`:2208`)。
|
||||
/// gfortran list-directed READ 满足变量列表后会消费整行剩余,故行7必须独立成行,
|
||||
/// 否则 VTB 行会被分子表 READ 吞掉,导致 vturb 被错误覆盖。见 `Fort55LineMol` 文档。
|
||||
pub fn generate_fort55_content(cfg: &SynspecInput) -> String {
|
||||
let line1 = format!(" {} {} {}", cfg.imode, cfg.idrv, cfg.ifreq);
|
||||
let line2 = " 1 0 0 0";
|
||||
let line3 = " 0 0 0 0 0";
|
||||
let line4 = " 1 1 0 0 0";
|
||||
let line5 = " 0 0 0";
|
||||
let line6 = format!(
|
||||
" {:.1} {:.1} 10 0 {} {}",
|
||||
cfg.wstart, cfg.wend, cfg.rel_cutoff, cfg.abs_cutoff
|
||||
);
|
||||
let line7 = " 0 0";
|
||||
let l1 = &cfg.line1;
|
||||
let l2 = &cfg.line2;
|
||||
let l3 = &cfg.line3;
|
||||
let l4 = &cfg.line4;
|
||||
let l5 = &cfg.line5;
|
||||
let l6 = &cfg.line6;
|
||||
let lm = &cfg.line_mol;
|
||||
let lv = &cfg.line_vtb;
|
||||
let la = &cfg.line_ang;
|
||||
|
||||
// SYNSPEC 用 list-directed READ(`READ(55,*)`)读取,对空白分隔的数值格式宽容,
|
||||
// 不要求固定列宽,只需 token 间有空白分隔即可。波长用 .1(与官方样本一致),
|
||||
// 其余浮点用通用 Display。
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
|
||||
line1, line2, line3, line4, line5, line6, line7
|
||||
// 行1: IMODE, IDSTD, IPRIN
|
||||
"{imode} {idstd} {iprin}\n\
|
||||
{inmod} {intrpl} {ichang} {ichemc}\n\
|
||||
{iophli} {nunalp} {nunbet} {nungam} {nunbal}\n\
|
||||
{ifreq} {inlte} {icontl} {inlist} {ifhe2}\n\
|
||||
{ihydpr} {ihe1pr} {ihe2pr}\n\
|
||||
{alam0:.1} {alast:.1} {cutoff0} {cutoffs} {relop} {space}\n\
|
||||
{nmlist}\n\
|
||||
{vtb}\n\
|
||||
{nmu0} {ang0} {iflux}\n",
|
||||
// 行1
|
||||
imode = l1.imode,
|
||||
idstd = l1.idstd,
|
||||
iprin = l1.iprin,
|
||||
// 行2
|
||||
inmod = l2.inmod,
|
||||
intrpl = l2.intrpl,
|
||||
ichang = l2.ichang,
|
||||
ichemc = l2.ichemc,
|
||||
// 行3
|
||||
iophli = l3.iophli,
|
||||
nunalp = l3.nunalp,
|
||||
nunbet = l3.nunbet,
|
||||
nungam = l3.nungam,
|
||||
nunbal = l3.nunbal,
|
||||
// 行4
|
||||
ifreq = l4.ifreq,
|
||||
inlte = l4.inlte,
|
||||
icontl = l4.icontl,
|
||||
inlist = l4.inlist,
|
||||
ifhe2 = l4.ifhe2,
|
||||
// 行5
|
||||
ihydpr = l5.ihydpr,
|
||||
ihe1pr = l5.ihe1pr,
|
||||
ihe2pr = l5.ihe2pr,
|
||||
// 行6
|
||||
alam0 = l6.alam0,
|
||||
alast = l6.alast,
|
||||
cutoff0 = l6.cutoff0,
|
||||
cutoffs = l6.cutoffs,
|
||||
relop = l6.relop,
|
||||
space = l6.space,
|
||||
// 行7 分子表(nmlist=0 时仅写出 nmlist,list-directed READ 消费整行)
|
||||
nmlist = lm.nmlist,
|
||||
// 行8 VTB
|
||||
vtb = lv.vtb,
|
||||
// 行9 角度
|
||||
nmu0 = la.nmu0,
|
||||
ang0 = la.ang0,
|
||||
iflux = la.iflux,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{
|
||||
Fort55Line1, Fort55Line2, Fort55Line3, Fort55Line4, Fort55Line5, Fort55Line6,
|
||||
Fort55LineAng, Fort55LineMol, Fort55LineVtb,
|
||||
};
|
||||
|
||||
/// 基本:自定义配置的波长值出现在生成的文本中。
|
||||
#[test]
|
||||
fn test_fort55_generation() {
|
||||
let cfg = SynspecInput {
|
||||
wstart: 3000.0,
|
||||
wend: 7000.0,
|
||||
imode: 0,
|
||||
idrv: 50,
|
||||
ifreq: 1,
|
||||
rel_cutoff: 0.0001,
|
||||
abs_cutoff: 0.01,
|
||||
line6: Fort55Line6 {
|
||||
alam0: 3000.0,
|
||||
alast: 7000.0,
|
||||
..Fort55Line6::default()
|
||||
},
|
||||
..SynspecInput::default()
|
||||
};
|
||||
let content = generate_fort55_content(&cfg);
|
||||
assert!(content.contains("3000.0"));
|
||||
assert!(content.contains("7000.0"));
|
||||
assert!(content.contains("3000.0"), "应含起始波长 3000.0");
|
||||
assert!(content.contains("7000.0"), "应含终止波长 7000.0");
|
||||
}
|
||||
|
||||
/// fort.55 必须恰好 9 行(含行7 分子表 + 行8 VTB + 行9 角度)。
|
||||
#[test]
|
||||
fn test_fort55_line_count() {
|
||||
let cfg = SynspecInput::default();
|
||||
let content = generate_fort55_content(&cfg);
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
9,
|
||||
"fort.55 应为 9 行(行7 分子表 + 行8 VTB + 行9 角度均显式生成),实际 {} 行:\n{}",
|
||||
lines.len(),
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
/// 核心回归:字段位置与 synspec54.f READ 语句严格对齐,含行7 分子表。
|
||||
/// 重点防护历史上 idrv→IDSTD、ifreq→IPRIN、abs_cutoff→SPACE 的错位 bug,
|
||||
/// 以及行7 分子表缺失导致 VTB 被吞的问题。
|
||||
#[test]
|
||||
fn test_fort55_field_alignment() {
|
||||
let cfg = SynspecInput {
|
||||
line1: Fort55Line1 {
|
||||
imode: 0,
|
||||
idstd: 0,
|
||||
iprin: 1,
|
||||
},
|
||||
line4: Fort55Line4 {
|
||||
ifreq: 7, // 真正的 IFREQ,应在行4第1列
|
||||
inlte: 1,
|
||||
icontl: 0,
|
||||
inlist: 0,
|
||||
ifhe2: 0,
|
||||
},
|
||||
line6: Fort55Line6 {
|
||||
alam0: 3000.0,
|
||||
alast: 7000.0,
|
||||
cutoff0: 10.0,
|
||||
cutoffs: 0.0,
|
||||
relop: 0.0001,
|
||||
space: 0.01,
|
||||
},
|
||||
line_vtb: Fort55LineVtb { vtb: 5.0 },
|
||||
..SynspecInput::default()
|
||||
};
|
||||
let content = generate_fort55_content(&cfg);
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
// 行1: IMODE(0) IDSTD(0) IPRIN(1)
|
||||
let l1: Vec<&str> = lines[0].split_whitespace().collect();
|
||||
assert_eq!(l1[0], "0", "行1第1列应为 IMODE=0");
|
||||
assert_eq!(l1[1], "0", "行1第2列应为 IDSTD=0(不再是旧的错位 idrv=50)");
|
||||
assert_eq!(l1[2], "1", "行1第3列应为 IPRIN=1");
|
||||
|
||||
// 行4: IFREQ(7) INLTE(1) ICONTL(0) INLIST(0) IFHE2(0)
|
||||
let l4: Vec<&str> = lines[3].split_whitespace().collect();
|
||||
assert_eq!(l4.len(), 5, "行4应有 5 个字段");
|
||||
assert_eq!(l4[0], "7", "行4第1列应为真正的 IFREQ=7(不再错位到行1)");
|
||||
|
||||
// 行6: ALAM0 ALAST CUTOF0 CUTOFS RELOP SPACE
|
||||
let l6: Vec<&str> = lines[5].split_whitespace().collect();
|
||||
assert_eq!(l6.len(), 6, "行6应有 6 个字段");
|
||||
assert_eq!(l6[0], "3000.0", "行6第1列 ALAM0");
|
||||
assert_eq!(l6[1], "7000.0", "行6第2列 ALAST");
|
||||
assert_eq!(l6[2], "10", "行6第3列 CUTOF0");
|
||||
assert_eq!(l6[5], "0.01", "行6第6列 SPACE");
|
||||
|
||||
// 行7 分子表:nmlist=0(单字段)
|
||||
let l7: Vec<&str> = lines[6].split_whitespace().collect();
|
||||
assert_eq!(l7.len(), 1, "行7 分子表默认 nmlist=0 应为单字段");
|
||||
assert_eq!(l7[0], "0", "行7 nmlist=0");
|
||||
|
||||
// 行8 VTB:自定义值 5.0
|
||||
let l8: Vec<&str> = lines[7].split_whitespace().collect();
|
||||
assert_eq!(l8.len(), 1, "行8 应为单字段 VTB");
|
||||
assert_eq!(
|
||||
l8[0], "5",
|
||||
"行8 VTB=5(自定义值,证明 VTB 行未被分子表吞掉)"
|
||||
);
|
||||
|
||||
// 行9 角度:NMU0 ANG0 IFLUX
|
||||
let l9: Vec<&str> = lines[8].split_whitespace().collect();
|
||||
assert_eq!(l9.len(), 3, "行9 应有 3 个字段 NMU0/ANG0/IFLUX");
|
||||
}
|
||||
|
||||
/// 关键回归:VTB 默认值必须是负值(-1.0),否则 SYNSPEC 会把 vturb 覆盖为 0。
|
||||
///
|
||||
/// 背景(审查 Critical-1):SYNSPEC `:2253` `if(vtb.ge.0.)` 为真时会用 `vtb²·1e10`
|
||||
/// 覆盖所有深度的 vturb。默认 VTB=0 会把大气自带的 vturb=2 km/s 覆盖为 0,导致
|
||||
/// 谱线多普勒宽度错误。VTB=-1 触发"不覆盖"分支,保留大气 vturb。
|
||||
#[test]
|
||||
fn test_fort55_default_vtb_is_negative() {
|
||||
let cfg = SynspecInput::default();
|
||||
assert!(
|
||||
cfg.line_vtb.vtb < 0.0,
|
||||
"默认 VTB 必须为负值(实际 {}),否则 SYNSPEC 会覆盖 vturb 为 0",
|
||||
cfg.line_vtb.vtb
|
||||
);
|
||||
let content = generate_fort55_content(&cfg);
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let vtb_line = lines[7];
|
||||
assert!(
|
||||
vtb_line.trim_start().starts_with('-'),
|
||||
"行8 VTB 应以负号开头(实际 '{}'),确保不覆盖大气 vturb",
|
||||
vtb_line
|
||||
);
|
||||
}
|
||||
|
||||
/// 默认值生成的 fort.55 关键字段应与官方样本 `hotsd/synspec/fort.55.lin` 对齐。
|
||||
/// 注意:官方样本是 7 行(靠 EOF 跳过 VTB/角度),本生成器是 9 行(显式生成),
|
||||
/// 行数不同但 line1-6 的值与样本一致。唯一差异是 IDSTD(样本 50,默认现 0)和
|
||||
/// VTB(样本无独立行,默认现 -1)。
|
||||
#[test]
|
||||
fn test_fort55_default_key_fields() {
|
||||
let cfg = SynspecInput::default();
|
||||
let content = generate_fort55_content(&cfg);
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 9, "应为 9 行");
|
||||
|
||||
// 行1: 官方样本 "0 50 1";默认 "0 0 1"(idstd 修正为 0)
|
||||
let l1: Vec<&str> = lines[0].split_whitespace().collect();
|
||||
assert_eq!(l1[0], "0", "IMODE=0 与样本一致");
|
||||
assert_eq!(l1[1], "0", "IDSTD=0(样本是 50,这是本次修复的行为变化)");
|
||||
assert_eq!(l1[2], "1", "IPRIN=1 与样本一致");
|
||||
|
||||
// 行2: "1 0 0 0" 与样本一致
|
||||
let l2: Vec<&str> = lines[1].split_whitespace().collect();
|
||||
assert_eq!(l2, vec!["1", "0", "0", "0"], "行2 与样本一致");
|
||||
|
||||
// 行4: "1 1 0 0 0" 与样本一致
|
||||
let l4: Vec<&str> = lines[3].split_whitespace().collect();
|
||||
assert_eq!(l4, vec!["1", "1", "0", "0", "0"], "行4 与样本一致");
|
||||
|
||||
// 行6: 默认波长 1400/1410、CUTOF0=10、CUTOFS=0、RELOP=0.0001、SPACE=0.01
|
||||
let l6: Vec<&str> = lines[5].split_whitespace().collect();
|
||||
assert_eq!(l6[0], "1400.0", "ALAM0=1400.0 与样本一致");
|
||||
assert_eq!(l6[1], "1410.0", "ALAST=1410.0 与样本一致");
|
||||
assert_eq!(l6[2], "10", "CUTOF0=10 与样本一致");
|
||||
assert_eq!(l6[3], "0", "CUTOFS=0 与样本一致");
|
||||
assert_eq!(l6[4], "0.0001", "RELOP=0.0001 与样本一致");
|
||||
assert_eq!(l6[5], "0.01", "SPACE=0.01 与样本一致");
|
||||
|
||||
// 行7 分子表: nmlist=0(样本行7第一个值也是 0)
|
||||
let l7: Vec<&str> = lines[6].split_whitespace().collect();
|
||||
assert_eq!(l7[0], "0", "nmlist=0 与样本一致");
|
||||
|
||||
// 行8 VTB: -1(样本无独立 VTB 行,靠 EOF 跳过;本生成器用 -1 显式不覆盖)
|
||||
// 行9 角度: NMU0=0(样本无,靠 EOF 跳过;本生成器显式 0 不算比强度)
|
||||
}
|
||||
|
||||
/// 全自定义:所有字段设非默认值,验证无字段丢失或错位。
|
||||
#[test]
|
||||
fn test_fort55_full_custom() {
|
||||
let cfg = SynspecInput {
|
||||
line1: Fort55Line1 {
|
||||
imode: 1,
|
||||
idstd: -5,
|
||||
iprin: 3,
|
||||
},
|
||||
line2: Fort55Line2 {
|
||||
inmod: 0,
|
||||
intrpl: 0, // 注意:intrpl>0+inmod>0 会触发 DM 数组条件读取,生成器不支持
|
||||
ichang: 1,
|
||||
ichemc: 1,
|
||||
},
|
||||
line3: Fort55Line3 {
|
||||
iophli: 1,
|
||||
nunalp: 2,
|
||||
nunbet: 3,
|
||||
nungam: 4,
|
||||
nunbal: 5,
|
||||
},
|
||||
line4: Fort55Line4 {
|
||||
ifreq: 2,
|
||||
inlte: 25,
|
||||
icontl: 1,
|
||||
inlist: 1,
|
||||
ifhe2: 1,
|
||||
},
|
||||
line5: Fort55Line5 {
|
||||
ihydpr: 2,
|
||||
ihe1pr: 1,
|
||||
ihe2pr: 1,
|
||||
},
|
||||
line6: Fort55Line6 {
|
||||
alam0: 900.0,
|
||||
alast: 1100.0,
|
||||
cutoff0: 5.0,
|
||||
cutoffs: 3.0,
|
||||
relop: 0.001,
|
||||
space: 0.05,
|
||||
},
|
||||
line_mol: Fort55LineMol { nmlist: 0 },
|
||||
line_vtb: Fort55LineVtb { vtb: 5.0 },
|
||||
line_ang: Fort55LineAng {
|
||||
nmu0: 5,
|
||||
ang0: 0.1,
|
||||
iflux: 1,
|
||||
},
|
||||
};
|
||||
let content = generate_fort55_content(&cfg);
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 9);
|
||||
|
||||
// 抽查几个易错位的关键值
|
||||
assert!(
|
||||
lines[0].split_whitespace().nth(1) == Some("-5"),
|
||||
"行1第2列 IDSTD=-5 应正确写出负数"
|
||||
);
|
||||
assert!(
|
||||
lines[3].split_whitespace().next() == Some("2"),
|
||||
"行4第1列 真正的 IFREQ=2"
|
||||
);
|
||||
assert!(
|
||||
lines[7].split_whitespace().next() == Some("5"),
|
||||
"行8 VTB=5(VTB 行独立存在,未被分子表吞掉)"
|
||||
);
|
||||
assert!(
|
||||
lines[8].split_whitespace().next() == Some("5"),
|
||||
"行9 NMU0=5"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+337
-504
@@ -1,398 +1,181 @@
|
||||
use crate::config::{AtomConfig, IonConfig, TlustyInput};
|
||||
//! TLUSTY .5 输入文件生成器。
|
||||
//!
|
||||
//! [`make_input5`] 把 [`TlustyInput`](全局模板)+ grid(网格点)+ chain(阶段)三源融合,
|
||||
//! 渲染出 .5 文件文本。grid/chain 覆写 global 模板的对应字段后统一渲染。
|
||||
|
||||
use crate::config::{ChainStep, Dot5Input, Geometry, IonRow, NonstdExtra, TlustyInput};
|
||||
use crate::models::GridPointParams;
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct IonDef {
|
||||
iat: i32,
|
||||
iz: i32,
|
||||
nlevs: i32,
|
||||
typion: &'static str,
|
||||
filei: &'static str,
|
||||
}
|
||||
|
||||
const IONS_H: &[IonDef] = &[
|
||||
IonDef {
|
||||
iat: 1,
|
||||
iz: 0,
|
||||
nlevs: 9,
|
||||
typion: " H 1",
|
||||
filei: "data/h1.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 1,
|
||||
iz: 1,
|
||||
nlevs: 1,
|
||||
typion: " H 2",
|
||||
filei: " ",
|
||||
},
|
||||
];
|
||||
|
||||
const IONS_HE: &[IonDef] = &[
|
||||
IonDef {
|
||||
iat: 2,
|
||||
iz: 0,
|
||||
nlevs: 14,
|
||||
typion: "He 1",
|
||||
filei: "data/he1.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 2,
|
||||
iz: 1,
|
||||
nlevs: 14,
|
||||
typion: "He 2",
|
||||
filei: "data/he2.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 2,
|
||||
iz: 2,
|
||||
nlevs: 1,
|
||||
typion: "He 3",
|
||||
filei: " ",
|
||||
},
|
||||
];
|
||||
|
||||
const IONS_C: &[IonDef] = &[
|
||||
IonDef {
|
||||
iat: 6,
|
||||
iz: 0,
|
||||
nlevs: 40,
|
||||
typion: " C 1",
|
||||
filei: "data/c1.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 6,
|
||||
iz: 1,
|
||||
nlevs: 22,
|
||||
typion: " C 2",
|
||||
filei: "data/c2.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 6,
|
||||
iz: 2,
|
||||
nlevs: 46,
|
||||
typion: " C 3",
|
||||
filei: "data/c3_34+12lev.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 6,
|
||||
iz: 3,
|
||||
nlevs: 25,
|
||||
typion: " C 4",
|
||||
filei: "data/c4.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 6,
|
||||
iz: 4,
|
||||
nlevs: 1,
|
||||
typion: " C 5",
|
||||
filei: " ",
|
||||
},
|
||||
];
|
||||
|
||||
const IONS_N: &[IonDef] = &[
|
||||
IonDef {
|
||||
iat: 7,
|
||||
iz: 0,
|
||||
nlevs: 34,
|
||||
typion: " N 1",
|
||||
filei: "data/n1.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 7,
|
||||
iz: 1,
|
||||
nlevs: 42,
|
||||
typion: " N 2",
|
||||
filei: "data/n2_32+10lev.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 7,
|
||||
iz: 2,
|
||||
nlevs: 32,
|
||||
typion: " N 3",
|
||||
filei: "data/n3.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 7,
|
||||
iz: 3,
|
||||
nlevs: 48,
|
||||
typion: " N 4",
|
||||
filei: "data/n4_34+14lev.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 7,
|
||||
iz: 4,
|
||||
nlevs: 16,
|
||||
typion: " N 5",
|
||||
filei: "data/n5.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 7,
|
||||
iz: 5,
|
||||
nlevs: 1,
|
||||
typion: " N 6",
|
||||
filei: " ",
|
||||
},
|
||||
];
|
||||
|
||||
const IONS_O: &[IonDef] = &[
|
||||
IonDef {
|
||||
iat: 8,
|
||||
iz: 0,
|
||||
nlevs: 33,
|
||||
typion: " O 1",
|
||||
filei: "data/o1_23+10lev.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 8,
|
||||
iz: 1,
|
||||
nlevs: 48,
|
||||
typion: " O 2",
|
||||
filei: "data/o2_36+12lev.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 8,
|
||||
iz: 2,
|
||||
nlevs: 41,
|
||||
typion: " O 3",
|
||||
filei: "data/o3_28+13lev.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 8,
|
||||
iz: 3,
|
||||
nlevs: 39,
|
||||
typion: " O 4",
|
||||
filei: "data/o4.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 8,
|
||||
iz: 4,
|
||||
nlevs: 6,
|
||||
typion: " O 5",
|
||||
filei: "data/o5.dat",
|
||||
},
|
||||
IonDef {
|
||||
iat: 8,
|
||||
iz: 5,
|
||||
nlevs: 1,
|
||||
typion: " O 6",
|
||||
filei: " ",
|
||||
},
|
||||
];
|
||||
|
||||
/// 把对数丰度 logx 格式化为 10^logx 的科学计数法字符串。
|
||||
///
|
||||
/// 审查修复 #N3:深度防御 powf 溢出——config.validate 已把丰度轴限制在 [-20, 10],
|
||||
/// 但 fmt_abn 也可能被 tlusty_chain 里的元素丰度等其它路径调用。若 logx 超出约 [-308, 308],
|
||||
/// `10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5 输入文件导致 TLUSTY 行为未定义。
|
||||
/// 配置层校验在前,此处仅作最终防线:溢出时 saturate 到 f64 可表示范围并按原格式输出。
|
||||
/// 深度防御:config.validate 已把丰度轴限制在 [-20, 10],但此处也作最终防线——
|
||||
/// 若 logx 超出约 [-300, 300],`10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5。
|
||||
fn fmt_abn(logx: f64) -> String {
|
||||
let val = if logx.abs() < 300.0 {
|
||||
10.0f64.powf(logx)
|
||||
} else if logx < 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
// 超出安全范围:clamp 到 0(丰度对数极小)或 f64::MAX(丰度对数极大),
|
||||
// 避免写出 inf。配置层应已拦截,这里不会成为正常路径。
|
||||
if logx < 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
f64::MAX
|
||||
}
|
||||
f64::MAX
|
||||
};
|
||||
format!("{:.4E}", val)
|
||||
}
|
||||
|
||||
/// 元素符号首字母大写归一("h"→"H", "he"→"He", "HE"→"He")。
|
||||
/// 容忍用户在 YAML atoms 块用小写/全大写写元素符号。
|
||||
fn capitalize_first(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str().to_lowercase().as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
/// 用 grid + chain 覆写 global 模板,生成渲染用的 Dot5Input。
|
||||
fn merge(global: &Dot5Input, grid: &GridPointParams, chain: &ChainStep) -> Dot5Input {
|
||||
let mut m = global.clone();
|
||||
|
||||
/// 默认 ions 能级数据表(与历史 IONS_* 常量等价)。
|
||||
/// 用户未在 `tlusty_input.ions` 配置时使用此表,按 metals 筛选参与元素。
|
||||
fn default_ions(metals: &str) -> Vec<IonConfig> {
|
||||
let mt = metals.to_lowercase();
|
||||
let has_c = mt.contains('c');
|
||||
let has_n = mt.contains('n');
|
||||
let has_o = mt.contains('o');
|
||||
let mut ions: Vec<IonConfig> = Vec::new();
|
||||
// H(恒在)
|
||||
for d in IONS_H {
|
||||
ions.push(IonConfig {
|
||||
iat: d.iat,
|
||||
iz: d.iz,
|
||||
nlevs: d.nlevs,
|
||||
typion: d.typion.to_string(),
|
||||
filei: d.filei.to_string(),
|
||||
});
|
||||
// grid → geometry.teff/grav(恒星大气路径)
|
||||
if let Geometry::Stellar { teff, logg } = &mut m.geometry {
|
||||
*teff = grid.teff.value();
|
||||
*logg = grid.logg.value();
|
||||
}
|
||||
// He(恒在)
|
||||
for d in IONS_HE {
|
||||
ions.push(IonConfig {
|
||||
iat: d.iat,
|
||||
iz: d.iz,
|
||||
nlevs: d.nlevs,
|
||||
typion: d.typion.to_string(),
|
||||
filei: d.filei.to_string(),
|
||||
});
|
||||
}
|
||||
let extend = |ions: &mut Vec<IonConfig>, table: &[IonDef]| {
|
||||
for d in table {
|
||||
ions.push(IonConfig {
|
||||
iat: d.iat,
|
||||
iz: d.iz,
|
||||
nlevs: d.nlevs,
|
||||
typion: d.typion.to_string(),
|
||||
filei: d.filei.to_string(),
|
||||
});
|
||||
|
||||
// chain → lte/ltgray
|
||||
m.lte = chain.lte == "T";
|
||||
m.ltgray = chain.ltgray == "T";
|
||||
|
||||
// grid → atoms 块 He/C/N/O 的 abn 列
|
||||
for row in &mut m.atoms {
|
||||
match row.iat {
|
||||
2 => row.abn = Some(fmt_abn(grid.loghe.value())),
|
||||
6 => row.abn = Some(fmt_abn(grid.logc.value())),
|
||||
7 => row.abn = Some(fmt_abn(grid.logn.value())),
|
||||
8 => row.abn = Some(fmt_abn(grid.logo.value())),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
if has_c {
|
||||
extend(&mut ions, IONS_C);
|
||||
}
|
||||
if has_n {
|
||||
extend(&mut ions, IONS_N);
|
||||
|
||||
// chain → ions 每行 ilvlin(nlevs==1 裸核强制 0)
|
||||
for ion in &mut m.ions {
|
||||
let ilv = if ion.nlevs == 1 { 0 } else { chain.ilvlin };
|
||||
ion.ilvlin = Some(ilv);
|
||||
}
|
||||
if has_o {
|
||||
extend(&mut ions, IONS_O);
|
||||
}
|
||||
ions
|
||||
|
||||
m
|
||||
}
|
||||
|
||||
/// Constructs the complete text of a `.5` input file for TLUSTY
|
||||
///
|
||||
/// `input_cfg`:全局输入参数(NFREAD / atoms.mode / ions 表)。
|
||||
/// None → 走代码内硬编码默认(与改动前行为完全一致,向后兼容)。
|
||||
pub fn make_input5(
|
||||
params: &GridPointParams,
|
||||
lte: &str,
|
||||
ltgray: &str,
|
||||
metals: &str,
|
||||
ilvlin: i32,
|
||||
input_cfg: Option<&TlustyInput>,
|
||||
) -> String {
|
||||
let mt = metals.to_lowercase();
|
||||
let has_c = mt.contains('c');
|
||||
let has_n = mt.contains('n');
|
||||
let has_o = mt.contains('o');
|
||||
/// 渲染 atoms 块。直接渲染传入的完整列表(元素集由 dot5.atoms 决定,不再用 metals filter);
|
||||
/// abn 缺省用 "0."(太阳默认)。iabset=Some 时写出 `natoms iabset`(行5 两列),
|
||||
/// 否则只写 `natoms`(iostat 兜底默认 0)。
|
||||
fn render_atoms(atoms: &[crate::config::AtomRow], iabset: Option<i32>) -> String {
|
||||
let header = match iabset {
|
||||
Some(i) => format!(" {} {}\n* mode abn modpf\n", atoms.len(), i),
|
||||
None => format!(" {}\n* mode abn modpf\n", atoms.len()),
|
||||
};
|
||||
let mut out = header;
|
||||
for r in atoms {
|
||||
let abn = r.abn.clone().unwrap_or_else(|| "0.".to_string());
|
||||
out.push_str(&format!(" {} {} {}\n", r.mode, abn, r.modpf));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// 元素符号 → (mode, abn)。mode 优先取 input_cfg.atoms 覆写,否则走默认。
|
||||
// abn(丰度)永远由 GridPointParams 计算——每网格点不同,不可全局配置。
|
||||
// 查找时对 key 做首字母大写归一(H/He/Li/Be/B/C/N/O),容忍用户写小写 "h"/"he"。
|
||||
//
|
||||
// M1 修复:config.rs 的 `atoms` 是 Serde 反序列化的 `HashMap<String, AtomConfig>`,
|
||||
// 键按 YAML 原样存储(无规范化)。若用户写 `he:`/`HE:`,原实现只归一探针、不归一存储键,
|
||||
// 查不到规范键 "He" → mode 覆写静默回落默认(错误物理:想要的 LTE-only 变统计平衡)。
|
||||
// 这里把用户键也按同一 capitalize_first 规则归一一次,保证任意大小写都能命中。
|
||||
let atoms_normalized: Option<HashMap<String, &AtomConfig>> = input_cfg.map(|c| {
|
||||
let mut m = HashMap::with_capacity(c.atoms.len());
|
||||
for (k, v) in &c.atoms {
|
||||
let canon = capitalize_first(k);
|
||||
// 归一撞键检测:用户同时写 "C" 与 "c"(或 "He"/"HE")等大小写变体时,两键归一为
|
||||
// 同一规范键。原 collect() 静默覆盖(迭代序非确定 → 取哪个任选)。现显式提示
|
||||
// 让歧义可见,避免 mode 覆写悄悄取到任意一个变体。
|
||||
if let Some(_prev) = m.insert(canon.clone(), v) {
|
||||
tracing::warn!(
|
||||
"atoms 键 {:?} 与已有键(归一后同为 {:?})冲突:取最后读取项,请改用单一规范键",
|
||||
k,
|
||||
canon
|
||||
);
|
||||
/// 渲染 ions 块。直接渲染传入的完整列表(元素集由 dot5.ions 决定,不再用 metals filter);
|
||||
/// ilast 由 nlevs 推导;nonstd≠0 渲染附加行。
|
||||
/// 列宽固定右对齐,与真实 tlusty 输入(tests/tlusty/hhe/fort.5)逐字节一致:
|
||||
/// iat→col4, iz→col10, nlevs→col16, ilast→col23, ilvl→col30, nonstd→col37。
|
||||
fn render_ions(ions: &[IonRow]) -> String {
|
||||
let mut out = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string();
|
||||
for ion in ions {
|
||||
let ilast = ion.ilast.unwrap_or(if ion.nlevs == 1 { 1 } else { 0 });
|
||||
let ilvl = ion.ilvlin.unwrap_or(0);
|
||||
out.push_str(&format!(
|
||||
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
|
||||
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, ion.nonstd, ion.typion, ion.filei
|
||||
));
|
||||
// NONSTD≠0 附加行
|
||||
if let Some(extra) = &ion.nonstd_extra {
|
||||
match extra {
|
||||
NonstdExtra::Positive {
|
||||
iupsum,
|
||||
icup,
|
||||
modeff,
|
||||
nff,
|
||||
} => {
|
||||
out.push_str(&format!(
|
||||
" {} {} {} {}\n",
|
||||
iupsum, icup, modeff, nff
|
||||
));
|
||||
}
|
||||
NonstdExtra::Negative {
|
||||
inodf1,
|
||||
inodf2,
|
||||
fiodf1,
|
||||
fiodf2,
|
||||
fibfcs,
|
||||
} => {
|
||||
out.push_str(&format!(
|
||||
" {} {} '{}' '{}' '{}'\n",
|
||||
inodf1, inodf2, fiodf1, fiodf2, fibfcs
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
let atom_mode = |sym: &str, default_mode: i32| -> i32 {
|
||||
atoms_normalized
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&capitalize_first(sym)))
|
||||
.and_then(|a| a.mode)
|
||||
.unwrap_or(default_mode)
|
||||
};
|
||||
let atom_modpf = |sym: &str| -> i32 {
|
||||
atoms_normalized
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&capitalize_first(sym)))
|
||||
.and_then(|a| a.modpf)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
// Atoms block:H/He 恒在;Li/Be/B 恒在(mode=0 不参与);C/N/O 按 metals。
|
||||
let mut atom_rows: Vec<(i32, String, i32)> = vec![
|
||||
(atom_mode("H", 2), "0.".to_string(), atom_modpf("H")), // 1 H
|
||||
(atom_mode("He", 2), fmt_abn(*params.loghe), atom_modpf("He")), // 2 He
|
||||
(atom_mode("Li", 0), "0.".to_string(), atom_modpf("Li")), // 3 Li
|
||||
(atom_mode("Be", 0), "0.".to_string(), atom_modpf("Be")), // 4 Be
|
||||
(atom_mode("B", 0), "0.".to_string(), atom_modpf("B")), // 5 B
|
||||
];
|
||||
if has_c {
|
||||
atom_rows.push((atom_mode("C", 2), fmt_abn(*params.logc), atom_modpf("C")));
|
||||
// 6 C
|
||||
}
|
||||
if has_n {
|
||||
atom_rows.push((atom_mode("N", 2), fmt_abn(*params.logn), atom_modpf("N")));
|
||||
// 7 N
|
||||
}
|
||||
if has_o {
|
||||
atom_rows.push((atom_mode("O", 2), fmt_abn(*params.logo), atom_modpf("O")));
|
||||
// 8 O
|
||||
}
|
||||
let natoms = atom_rows.len() as i32;
|
||||
|
||||
let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms);
|
||||
for (mode, abn, modpf) in &atom_rows {
|
||||
atoms_block.push_str(&format!(" {} {} {}\n", mode, abn, modpf));
|
||||
}
|
||||
|
||||
// Ions block:用户配置非空时完全替换默认表,仍按 metals 筛选 iat。
|
||||
let ions: Vec<IonConfig> = if let Some(cfg) = input_cfg {
|
||||
if cfg.ions.is_empty() {
|
||||
default_ions(metals)
|
||||
} else {
|
||||
// 用户自定义表——按 metals 筛选参与元素(H/He 恒在,C/N/O 按 metals)。
|
||||
cfg.ions
|
||||
.iter()
|
||||
.filter(|ion| match ion.iat {
|
||||
1 | 2 => true, // H, He 恒在
|
||||
6 => has_c,
|
||||
7 => has_n,
|
||||
8 => has_o,
|
||||
_ => true, // 用户自定义元素不筛
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
default_ions(metals)
|
||||
};
|
||||
|
||||
let mut ions_block = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string();
|
||||
for ion in &ions {
|
||||
let ilast = if ion.nlevs == 1 { 1 } else { 0 };
|
||||
let ilvl = if ion.nlevs == 1 { 0 } else { ilvlin };
|
||||
// ions 行字段右对齐到固定列,与真实 tlusty 输入文件
|
||||
// (tests/tlusty/hhe/fort.5) 逐字节一致:
|
||||
// iat 结束于 col4(|iat|=4), iz col10(+6), nlevs col16(+6),
|
||||
// ilast col23(+7), ilvl col30(+7), nonstd col37(+7)。
|
||||
ions_block.push_str(&format!(
|
||||
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
|
||||
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, 0, ion.typion, ion.filei
|
||||
));
|
||||
}
|
||||
// 终止行:(iat,iz,nlevs,ilast,ilvl,nonstd)=(0,0,0,-1,0,0),typion/filei 全空。
|
||||
ions_block.push_str(&format!(
|
||||
// 终止行
|
||||
out.push_str(&format!(
|
||||
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
|
||||
0, 0, 0, -1, 0, 0, " ", " "
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
// NFREAD:从配置读,None → 默认 2000。
|
||||
let nfread = input_cfg.map(|c| c.nfread).unwrap_or(2000);
|
||||
/// 渲染 CHANGE 块(ICHANG<0 时,每能级行)。
|
||||
fn render_change(change: &[crate::config::ChangeRow]) -> String {
|
||||
let mut out = String::new();
|
||||
for r in change {
|
||||
out.push_str(&format!(
|
||||
" {} {} {} {} {} {} {}\n",
|
||||
r.iold, r.mode, r.nxtold, r.isinew, r.isiold, r.nxtsio, r.rel
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
format!(
|
||||
"{:.1} {:.1} ! TEFF, GRAV\n \
|
||||
/// 生成完整的 .5 文件文本。
|
||||
///
|
||||
/// 三源融合:global(`TlustyInput`,完整模板)+ grid(网格点,覆写 teff/logg/丰度)
|
||||
/// + chain(阶段,覆写 lte/ltgray/ilvlin)。其余字段直接从 global 取。
|
||||
pub fn make_input5(grid: &GridPointParams, chain: &ChainStep, global: &TlustyInput) -> String {
|
||||
let mut m = merge(&global.dot5, grid, chain);
|
||||
|
||||
// 空兜底:YAML 的 dot5 块未列出 ions 时,serde default 给空 Vec(而非 default_ions())。
|
||||
// 空 ions 会产出只有终止行的损坏 .5。此处恢复兜底语义:空 → 用默认 22 行表。
|
||||
// 注意 merge 已注入了 ilvlin,故此处兜底后需重新注入一次。
|
||||
if m.ions.is_empty() {
|
||||
let mut default = crate::config::default_ions();
|
||||
for ion in &mut default {
|
||||
let ilv = if ion.nlevs == 1 { 0 } else { chain.ilvlin };
|
||||
ion.ilvlin = Some(ilv);
|
||||
}
|
||||
m.ions = default;
|
||||
}
|
||||
|
||||
// 行1:geometry
|
||||
let line1 = match &m.geometry {
|
||||
Geometry::Stellar { teff, logg } => format!("{:.1} {:.1} ! TEFF, GRAV", teff, logg),
|
||||
Geometry::Disk {
|
||||
xmstar,
|
||||
xmdot,
|
||||
rstar,
|
||||
reldst,
|
||||
} => format!(
|
||||
"{} {} {} {} ! XMSTAR, XMDOT, RSTAR, RELDST",
|
||||
xmstar, xmdot, rstar, reldst
|
||||
),
|
||||
};
|
||||
|
||||
let lte_str = if m.lte { "T" } else { "F" };
|
||||
let ltgray_str = if m.ltgray { "T" } else { "F" };
|
||||
|
||||
let atoms_block = render_atoms(&m.atoms, m.iabset);
|
||||
let ions_block = render_ions(&m.ions);
|
||||
|
||||
let mut out = format!(
|
||||
"{}\n \
|
||||
{} {} ! LTE, LTGRAY\n \
|
||||
'nst' ! name of file containing non-standard flags\n\
|
||||
'{}' ! name of file containing non-standard flags\n\
|
||||
*-----------------------------------------------------------------\n\
|
||||
* frequencies\n \
|
||||
{} ! NFREAD\n\
|
||||
@@ -401,55 +184,136 @@ pub fn make_input5(
|
||||
{}\
|
||||
*-----------------------------------------------------------------\n\
|
||||
* data for ions\n*\n\
|
||||
{}\
|
||||
*\n* end\n",
|
||||
params.teff, params.logg, lte, ltgray, nfread, atoms_block, ions_block
|
||||
)
|
||||
{}",
|
||||
line1, lte_str, ltgray_str, m.finstd, m.nfread, atoms_block, ions_block
|
||||
);
|
||||
|
||||
// CHANGE 块(ICHANG<0 时)
|
||||
if chain.ichang.map(|i| i < 0).unwrap_or(false) {
|
||||
if let Some(change) = &m.change {
|
||||
out.push_str("*-----------------------------------------------------------------\n");
|
||||
out.push_str("* change block (ICHANG<0)\n");
|
||||
out.push_str(&render_change(change));
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("*\n* end\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::TlustyInput;
|
||||
use crate::models::GridPointParams;
|
||||
|
||||
#[test]
|
||||
fn test_make_input5() {
|
||||
let params = GridPointParams {
|
||||
fn grid() -> GridPointParams {
|
||||
GridPointParams {
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.5.into(),
|
||||
loghe: (-1.0).into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let input5 = make_input5(¶ms, "F", "F", "cno", 100, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn chain_cno() -> ChainStep {
|
||||
ChainStep {
|
||||
label: "nl".into(),
|
||||
lte: "F".into(),
|
||||
ltgray: "F".into(),
|
||||
ilvlin: 100,
|
||||
require_converged: true,
|
||||
niter: 100,
|
||||
chmax: Some(0.001),
|
||||
itek: None,
|
||||
ichang: None,
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
orelax: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_input5_basic() {
|
||||
let input5 = make_input5(&grid(), &chain_cno(), &TlustyInput::default());
|
||||
assert!(input5.contains("35000.0 5.5"));
|
||||
assert!(input5.contains("data/h1.dat"));
|
||||
assert!(input5.contains("data/c1.dat"));
|
||||
assert!(input5.contains("data/n1.dat"));
|
||||
assert!(input5.contains("data/o1_23+10lev.dat"));
|
||||
assert!(input5.contains("2000"), "NFREAD 默认 2000");
|
||||
}
|
||||
|
||||
/// ions 行必须与真实 tlusty 输入文件 (tests/tlusty/hhe/fort.5) 的宽列宽逐字节一致。
|
||||
/// 字段右对齐到固定列:iat→col4, iz→col10, nlevs→col16, ilast→col23, ilvl→col30, nonstd→col37。
|
||||
/// 一旦此处失败,说明 ions 格式被改动且偏离了真实 tlusty 输入惯例(虽不影响 list-directed
|
||||
/// 解析结果,但破坏与历史参考文件的可 diff 性)。
|
||||
/// ions 行数值列宽必须与真实 fort.5 逐字节一致(回归守护)。
|
||||
/// 用仅含 H+He 的自定义 ions 列表(模拟旧版 metals="" 的效果)。
|
||||
#[test]
|
||||
fn test_ions_block_byte_perfect_with_real_fort5() {
|
||||
// H+He 仅(metals="")对应真实 hhe fort.5 的 ions 集。
|
||||
let params = GridPointParams {
|
||||
teff: 35000.0.into(),
|
||||
logg: 4.0.into(),
|
||||
loghe: (-2.0).into(),
|
||||
logc: (-1.0).into(),
|
||||
logn: (-1.0).into(),
|
||||
logo: (-1.0).into(),
|
||||
};
|
||||
let input5 = make_input5(¶ms, "T", "T", "", 100, None);
|
||||
let lines: Vec<&str> = input5.lines().collect();
|
||||
|
||||
// 真实 fort.5 的 ions 数据行(数值部分 + typion/filei)。
|
||||
// 注:filei 真实用 './data/...',Rust/Python 生成用 'data/...'(DCTS 靠 data 软链解析),
|
||||
// 这是既有独立差异,本测试只校验数值列宽 + typion。
|
||||
let mut chain = chain_cno();
|
||||
chain.lte = "T".into();
|
||||
chain.ltgray = "T".into();
|
||||
chain.ilvlin = 100;
|
||||
// 构造仅含 H+He 的 global(替代旧版 metals="" filter)
|
||||
let mut global = TlustyInput::default();
|
||||
global.dot5.ions = vec![
|
||||
IonRow {
|
||||
iat: 1,
|
||||
iz: 0,
|
||||
nlevs: 9,
|
||||
ilast: None,
|
||||
ilvlin: None,
|
||||
nonstd: 0,
|
||||
typion: " H 1".into(),
|
||||
filei: "data/h1.dat".into(),
|
||||
nonstd_extra: None,
|
||||
},
|
||||
IonRow {
|
||||
iat: 1,
|
||||
iz: 1,
|
||||
nlevs: 1,
|
||||
ilast: None,
|
||||
ilvlin: None,
|
||||
nonstd: 0,
|
||||
typion: " H 2".into(),
|
||||
filei: " ".into(),
|
||||
nonstd_extra: None,
|
||||
},
|
||||
IonRow {
|
||||
iat: 2,
|
||||
iz: 0,
|
||||
nlevs: 14,
|
||||
ilast: None,
|
||||
ilvlin: None,
|
||||
nonstd: 0,
|
||||
typion: "He 1".into(),
|
||||
filei: "data/he1.dat".into(),
|
||||
nonstd_extra: None,
|
||||
},
|
||||
IonRow {
|
||||
iat: 2,
|
||||
iz: 1,
|
||||
nlevs: 14,
|
||||
ilast: None,
|
||||
ilvlin: None,
|
||||
nonstd: 0,
|
||||
typion: "He 2".into(),
|
||||
filei: "data/he2.dat".into(),
|
||||
nonstd_extra: None,
|
||||
},
|
||||
IonRow {
|
||||
iat: 2,
|
||||
iz: 2,
|
||||
nlevs: 1,
|
||||
ilast: None,
|
||||
ilvlin: None,
|
||||
nonstd: 0,
|
||||
typion: "He 3".into(),
|
||||
filei: " ".into(),
|
||||
nonstd_extra: None,
|
||||
},
|
||||
];
|
||||
let input5 = make_input5(&grid(), &chain, &global);
|
||||
let expected_ions_num: &[&str] = &[
|
||||
" 1 0 9 0 100 0",
|
||||
" 1 1 1 1 0 0",
|
||||
@@ -458,11 +322,9 @@ mod tests {
|
||||
" 2 2 1 1 0 0",
|
||||
" 0 0 0 -1 0 0",
|
||||
];
|
||||
// 从生成结果中提取 ions 数据行的数值部分(引号前)。
|
||||
let gen_ions_num: Vec<String> = lines
|
||||
.iter()
|
||||
let gen_ions_num: Vec<String> = input5
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
// ions 数据行:含引号且首 token 是整数
|
||||
l.contains('\'')
|
||||
&& l.split_whitespace()
|
||||
.next()
|
||||
@@ -471,132 +333,103 @@ mod tests {
|
||||
})
|
||||
.map(|l| l.split('\'').next().unwrap().trim_end().to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
gen_ions_num.as_slice(),
|
||||
expected_ions_num,
|
||||
"ions 行数值部分必须与真实 fort.5 逐字节一致(宽列宽)"
|
||||
);
|
||||
assert_eq!(gen_ions_num.as_slice(), expected_ions_num);
|
||||
}
|
||||
|
||||
/// 回归守护:None 配置时 NFREAD=2000、atoms.mode 走默认(H/He=2,Li/Be/B=0)。
|
||||
/// grid 注入:teff/logg 与丰度正确覆写。
|
||||
#[test]
|
||||
fn test_make_input5_none_cfg_defaults() {
|
||||
let params = GridPointParams {
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.5.into(),
|
||||
loghe: (-1.0).into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let input5 = make_input5(¶ms, "F", "F", "cno", 100, None);
|
||||
// NFREAD 默认 2000
|
||||
assert!(input5.contains("2000"), "None 配置时 NFREAD 应为默认 2000");
|
||||
// H 的 mode=2(显式)
|
||||
assert!(input5.contains(" 2 0. 0"), "H 的 mode 应为默认 2");
|
||||
fn test_grid_injection() {
|
||||
let input5 = make_input5(&grid(), &chain_cno(), &TlustyInput::default());
|
||||
assert!(input5.contains("35000.0 5.5"));
|
||||
// He 丰度 = 10^-1
|
||||
let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0));
|
||||
assert!(input5.contains(&he_abn), "He abn 应为 {}", he_abn);
|
||||
// C 丰度 = 10^-2
|
||||
let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0));
|
||||
assert!(input5.contains(&c_abn));
|
||||
}
|
||||
|
||||
/// 用户配置生效:nfread 覆写、atoms.mode 覆写、ions 自定义表。
|
||||
/// chain 注入:lte/ltgray 与 ilvlin 正确覆写。
|
||||
#[test]
|
||||
fn test_make_input5_user_cfg_override() {
|
||||
let params = GridPointParams {
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.5.into(),
|
||||
loghe: (-1.0).into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let mut atoms = std::collections::HashMap::new();
|
||||
atoms.insert(
|
||||
"C".to_string(),
|
||||
crate::config::AtomConfig {
|
||||
mode: Some(1),
|
||||
modpf: None,
|
||||
},
|
||||
);
|
||||
let cfg = TlustyInput {
|
||||
nfread: 500,
|
||||
atoms,
|
||||
ions: vec![],
|
||||
nst_extra_keys: vec![],
|
||||
};
|
||||
let input5 = make_input5(¶ms, "F", "F", "cno", 100, Some(&cfg));
|
||||
// NFREAD 被覆写为 500
|
||||
fn test_chain_injection() {
|
||||
let mut chain = chain_cno();
|
||||
chain.lte = "T".into();
|
||||
chain.ltgray = "T".into();
|
||||
let input5 = make_input5(&grid(), &chain, &TlustyInput::default());
|
||||
assert!(input5.contains("T T"), "lte/ltgray 应为 T T");
|
||||
}
|
||||
|
||||
/// 字节格式回归:验证 .5 前几行的续行空格与归档样本一致(审查 Major-1)。
|
||||
/// 旧版用 `\n `(空格续行)保留前导空格,新版不得丢失。归档样本见
|
||||
/// data/result/.../*.lte.5(L2/L3/L6 有前导空格)。
|
||||
#[test]
|
||||
fn test_dot5_leading_spaces_byte_compat() {
|
||||
let mut chain = chain_cno();
|
||||
chain.lte = "T".into();
|
||||
chain.ltgray = "T".into();
|
||||
let input5 = make_input5(&grid(), &chain, &TlustyInput::default());
|
||||
let lines: Vec<&str> = input5.lines().collect();
|
||||
// L2: " T T ! LTE, LTGRAY"(前导空格)
|
||||
assert!(
|
||||
input5.contains("500") && !input5.contains("2000"),
|
||||
"NFREAD 应被用户配置覆写为 500"
|
||||
lines[1].starts_with(' '),
|
||||
"L2 应有前导空格(旧版续行保留),实际: {:?}",
|
||||
lines[1]
|
||||
);
|
||||
// C 的 mode 被覆写为 1(隐式),而非默认 2
|
||||
// atoms 行格式 " {mode} {abn} {modpf}",C 的 abn 是 fmt_abn(logc)
|
||||
let c_line = input5
|
||||
.lines()
|
||||
.find(|l| l.contains(&format!("{:.4E}", 10.0f64.powf(-2.0))))
|
||||
.expect("应找到 C 的 atoms 行");
|
||||
assert!(lines[1].contains("T T"), "L2 应含 T T");
|
||||
// L3: " 'nst' ! ..."(前导空格)
|
||||
assert!(
|
||||
c_line.trim_start().starts_with("1"),
|
||||
"C 的 mode 应被覆写为 1(隐式),实际: {}",
|
||||
c_line
|
||||
lines[2].starts_with(' '),
|
||||
"L3 应有前导空格,实际: {:?}",
|
||||
lines[2]
|
||||
);
|
||||
assert!(lines[2].contains("'nst'"), "L3 应含 'nst'");
|
||||
// L6: NFREAD 行(前导空格)
|
||||
assert!(
|
||||
lines[5].starts_with(' '),
|
||||
"L6 应有前导空格,实际: {:?}",
|
||||
lines[5]
|
||||
);
|
||||
assert!(lines[5].contains("2000"), "L6 应含 NFREAD=2000");
|
||||
}
|
||||
|
||||
/// M1 回归:`atoms` 键大小写不敏感查找。用户写小写/全大写元素键(`c:`/`HE:`)时,
|
||||
/// 与规范键(`C`/`He`)必须同样命中覆写,否则 mode 覆写会静默回落默认(错误物理)。
|
||||
/// atoms.mode 覆写:用户自定义 atoms 表的 mode 生效。
|
||||
#[test]
|
||||
fn test_atoms_key_case_insensitive() {
|
||||
let params = GridPointParams {
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.5.into(),
|
||||
loghe: (-1.0).into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
// 用户用小写 "c" 与全大写 "HE" 覆写 mode。
|
||||
let mut atoms = std::collections::HashMap::new();
|
||||
atoms.insert(
|
||||
"c".to_string(),
|
||||
crate::config::AtomConfig {
|
||||
mode: Some(1),
|
||||
modpf: None,
|
||||
},
|
||||
);
|
||||
atoms.insert(
|
||||
"HE".to_string(),
|
||||
crate::config::AtomConfig {
|
||||
mode: Some(0),
|
||||
modpf: None,
|
||||
},
|
||||
);
|
||||
let cfg = TlustyInput {
|
||||
nfread: 2000,
|
||||
atoms,
|
||||
ions: vec![],
|
||||
nst_extra_keys: vec![],
|
||||
};
|
||||
let input5 = make_input5(¶ms, "F", "F", "cno", 100, Some(&cfg));
|
||||
// C(键 "c" 小写)应命中得到 mode=1,而非默认 2。
|
||||
fn test_atoms_mode_override() {
|
||||
let mut global = TlustyInput::default();
|
||||
// 把 C 的 mode 改为 1(隐式)
|
||||
for r in &mut global.dot5.atoms {
|
||||
if r.iat == 6 {
|
||||
r.mode = 1;
|
||||
}
|
||||
}
|
||||
let input5 = make_input5(&grid(), &chain_cno(), &global);
|
||||
let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0));
|
||||
let c_line = input5
|
||||
.lines()
|
||||
.find(|l| l.contains(&c_abn))
|
||||
.expect("应找到 C 的 atoms 行");
|
||||
.expect("应找到 C 行");
|
||||
assert!(
|
||||
c_line.trim_start().starts_with("1"),
|
||||
"小写键 c 应覆写 C 的 mode 为 1,实际: {}",
|
||||
"C mode 应为 1,实际: {}",
|
||||
c_line
|
||||
);
|
||||
// He(键 "HE" 全大写)应命中得到 mode=0,而非默认 2。
|
||||
let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0));
|
||||
let he_line = input5
|
||||
.lines()
|
||||
.find(|l| l.contains(&he_abn))
|
||||
.expect("应找到 He 的 atoms 行");
|
||||
assert!(
|
||||
he_line.trim_start().starts_with("0"),
|
||||
"全大写键 HE 应覆写 He 的 mode 为 0,实际: {}",
|
||||
he_line
|
||||
);
|
||||
}
|
||||
|
||||
/// NFREAD 可配。
|
||||
#[test]
|
||||
fn test_nfread_configurable() {
|
||||
let mut global = TlustyInput::default();
|
||||
global.dot5.nfread = 500;
|
||||
let input5 = make_input5(&grid(), &chain_cno(), &global);
|
||||
assert!(input5.contains("500") && !input5.contains("2000"));
|
||||
}
|
||||
|
||||
/// finstd 可配(空串=不读 nst)。
|
||||
#[test]
|
||||
fn test_finstd_configurable() {
|
||||
let mut global = TlustyInput::default();
|
||||
global.dot5.finstd = "".to_string();
|
||||
let input5 = make_input5(&grid(), &chain_cno(), &global);
|
||||
assert!(input5.contains("''"), "空 finstd 应渲染为 ''");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ pub mod models;
|
||||
pub mod nst_writer;
|
||||
pub mod result_filter;
|
||||
pub mod runner;
|
||||
pub mod summary_merge;
|
||||
pub mod seed_finder;
|
||||
|
||||
+192
-3
@@ -430,6 +430,11 @@ pub struct TaskSpec {
|
||||
/// 旧 payload 反序列化时缺省为 None(旧节点本就用 default 链,无回归)。
|
||||
#[serde(default)]
|
||||
pub tlusty_chain_params: Option<serde_json::Value>,
|
||||
/// 种子热启动链参数(`config::GridConfig.seed_chain` → `Vec<ChainStep>`)。
|
||||
/// 仅 seed_step 策略下由 executor 读取并透传给 runner。
|
||||
/// None/空 → executor 用 `default_seed_chain()` 兜底。
|
||||
#[serde(default)]
|
||||
pub seed_chain_params: Option<serde_json::Value>,
|
||||
/// TLUSTY 输入文件(.5 + nst)的全局物理参数(`config::TlustyInput`)。
|
||||
/// NFREAD 频率网格、ions 能级表、nst extra_keys 等不随阶段变化的参数。
|
||||
/// None → runner 用代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量)。
|
||||
@@ -439,6 +444,31 @@ pub struct TaskSpec {
|
||||
/// 仅 SYNSPEC-only 场景(TLUSTY 关闭)拉取大气用:显式关联大气网格点名。
|
||||
#[serde(default)]
|
||||
pub atmosphere_ref: Option<String>,
|
||||
/// 能量守恒硬门槛(`(RAD+CON)/TOT` 偏离 1 的容许绝对值)。源自工作流 YAML
|
||||
/// `energy_tolerance`(默认 0.01);None → 跳过校验。旧 payload 缺省为 None(用默认)。
|
||||
#[serde(default)]
|
||||
pub energy_tolerance: Option<f64>,
|
||||
/// 温度结构:表层 T / Teff 的上限因子(默认 3.0,文档§4 红旗)。
|
||||
#[serde(default)]
|
||||
pub temp_max_factor: Option<f64>,
|
||||
/// 温度结构:全层温度绝对下限(K,默认 10)。
|
||||
#[serde(default)]
|
||||
pub temp_floor: Option<f64>,
|
||||
/// 温度结构:全层温度绝对上限(K,默认 1e8)。
|
||||
#[serde(default)]
|
||||
pub temp_ceiling: Option<f64>,
|
||||
/// emflux bolometric:∫Fλdλ 偏离 σTeff⁴ 的容许绝对值(默认 0.02 = 2%)。
|
||||
#[serde(default)]
|
||||
pub emflux_tolerance: Option<f64>,
|
||||
/// 假收敛排查:收敛轨迹首末 max_relc 比值的最小阈值(默认 1000,<此值可疑)。
|
||||
#[serde(default)]
|
||||
pub convergence_min_ratio: Option<f64>,
|
||||
/// b 因子合理性:极端值上限(默认 1e3)。
|
||||
#[serde(default)]
|
||||
pub bfac_max: Option<f64>,
|
||||
/// b 因子合理性:极端值下限(默认 1e-3)。
|
||||
#[serde(default)]
|
||||
pub bfac_min: Option<f64>,
|
||||
}
|
||||
|
||||
/// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、
|
||||
@@ -464,8 +494,17 @@ impl Default for TaskSpec {
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
synspec_params: None,
|
||||
tlusty_chain_params: None,
|
||||
seed_chain_params: None,
|
||||
tlusty_input_params: None,
|
||||
atmosphere_ref: None,
|
||||
energy_tolerance: None,
|
||||
temp_max_factor: None,
|
||||
temp_floor: None,
|
||||
temp_ceiling: None,
|
||||
emflux_tolerance: None,
|
||||
convergence_min_ratio: None,
|
||||
bfac_max: None,
|
||||
bfac_min: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,7 +624,139 @@ pub struct IterCheck {
|
||||
pub n_depths: usize,
|
||||
}
|
||||
|
||||
/// Convergence stage summary recorded in conv.json
|
||||
/// `.6` 能量守恒诊断(LFIN 时 OUTPRI 写出的 `(RAD+CON)/TOT` 列偏离度)。
|
||||
///
|
||||
/// 物理不变量:每深度 πF(辐射+对流) = σTeff⁴,即 `(RAD+CON)/TOT` 应 ≈1.0。
|
||||
/// Hubeny 官方判据:偏离 1 ≤1% 为收敛良好(见 docs/spectrum_correctness_analysis.md §1.1)。
|
||||
/// 任一深度超阈值即判失败(硬门槛),与 `atmosphere_has_nan` 同级。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnergyCheckResult {
|
||||
/// 是否通过(max_deviation ≤ tolerance)。
|
||||
pub valid: bool,
|
||||
/// 逐深度 `(RAD+CON)/TOT` 偏离 1 的最大绝对值。
|
||||
pub max_deviation: f64,
|
||||
/// 偏离最大的深度点编号(1-based,表 ID 列);无法定位时为 None。
|
||||
#[serde(default)]
|
||||
pub worst_depth: Option<i32>,
|
||||
/// 实际校验的深度点行数(应为大气 ND,如 50)。
|
||||
pub n_depths_checked: usize,
|
||||
/// 使用的容差阈值。
|
||||
pub tolerance: f64,
|
||||
/// 失败原因(valid=false 时有值)。
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// `.7` 大气温度结构边界诊断(见 docs/spectrum_correctness_analysis.md §3.1/§4)。
|
||||
///
|
||||
/// 物理不变量:T(τ) 应落在物理合理范围——表层 T ≲ 2-3×Teff,总体 10¹-10⁸ K。
|
||||
/// 表层 T >>Teff(如 >3×Teff)或任一深度越界 → T(τ) 结构非物理(红旗)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TempStructCheckResult {
|
||||
/// 是否通过(所有深度温度在合理范围)。
|
||||
pub valid: bool,
|
||||
/// Teff(K),来自网格点参数,用于表层倍数判定。
|
||||
pub teff: f64,
|
||||
/// 实测表层(深度 1)温度(K)。
|
||||
pub surface_temp: f64,
|
||||
/// 实测最深层(深度 ND)温度(K)。
|
||||
pub bottom_temp: f64,
|
||||
/// 表层温度 / Teff 比值(红线:>max_factor 即判失败)。
|
||||
pub surface_ratio: f64,
|
||||
/// 实际校验的深度点数。
|
||||
pub n_depths_checked: usize,
|
||||
/// 表层温度上限因子(如 3.0 → 表层 T ≤ 3×Teff)。
|
||||
pub max_factor: f64,
|
||||
/// 温度绝对下限(K,默认 10)。
|
||||
pub temp_floor: f64,
|
||||
/// 温度绝对上限(K,默认 1e8)。
|
||||
pub temp_ceiling: f64,
|
||||
/// 失败原因(valid=false 时有值)。
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// `.emflux` bolometric 通量守恒诊断(见 docs/spectrum_correctness_analysis.md §3.2/§4)。
|
||||
///
|
||||
/// 物理不变量:∫Fλ dλ(出射谱全波长积分)≈ σ_SB × Teff⁴(bolometric 通量)。
|
||||
/// 偏离 >2% → 能量不守恒(红旗)。`.emflux` 全 NaN 也判失败(大气收敛失败导致
|
||||
/// 辐射转移求解发散,详见 `docs/fort14_nan_analysis.md`)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmfluxCheckResult {
|
||||
/// 是否通过(积分通量偏离 σTeff⁴ ≤ tolerance 且 NaN 占比可接受)。
|
||||
pub valid: bool,
|
||||
/// 梯形积分 ∫Fλ dλ(erg/cm²/s)。
|
||||
pub integrated_flux: f64,
|
||||
/// 预期 σ_SB × Teff⁴(erg/cm²/s)。
|
||||
pub sigma_teff4: f64,
|
||||
/// integrated_flux / sigma_teff4(应 ≈1.0)。
|
||||
pub ratio: f64,
|
||||
/// `.emflux` 中 NaN/不可解析行的占比(0.0-1.0)。
|
||||
pub nan_ratio: f64,
|
||||
/// 实际参与积分的有效行数。
|
||||
pub n_points: usize,
|
||||
/// 容差(偏离 1.0 的绝对值上限,默认 0.02)。
|
||||
pub tolerance: f64,
|
||||
/// 失败原因(valid=false 时有值)。
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 收敛轨迹(假收敛排查)诊断(见 docs/spectrum_correctness_analysis.md §2.1/§3.1)。
|
||||
///
|
||||
/// 物理不变量:真收敛的 max_relc 轨迹应单调下降 ≥3 个量级(首/末比 ≥10³)。
|
||||
/// Ng/Kantorovich 加速可人为压低 max_relc 造成"假收敛"——数值达标但统计平衡未达成。
|
||||
/// 本校验仅在 `converged=true` 的 stage 上做(发散 stage 不需排查假收敛)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConvergenceTraceCheckResult {
|
||||
/// 是否通过(首末比 ≥ min_ratio 或轨迹太短无法判定)。
|
||||
pub valid: bool,
|
||||
/// 首次迭代 max_relc。
|
||||
pub first_max_relc: f64,
|
||||
/// 末次迭代 max_relc。
|
||||
pub last_max_relc: f64,
|
||||
/// 首/末比值(≥10³ 为真收敛特征)。
|
||||
pub ratio: f64,
|
||||
/// 轨迹是否单调递减。
|
||||
pub monotonic: bool,
|
||||
/// 参与判定的迭代拍数。
|
||||
pub n_iters: usize,
|
||||
/// 判定的最小首末比阈值(默认 1000)。
|
||||
pub min_ratio: f64,
|
||||
/// 失败原因(valid=false 时有值)。
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// `.bfac` b 因子合理性诊断(见 docs/spectrum_correctness_analysis.md §3.1/§6 #19)。
|
||||
///
|
||||
/// 物理不变量:NLTE 偏离因子 b(=n_NLTE/n_LTE)对关键能级应落在 [10⁻³, 10³];
|
||||
/// b=1.0 表示 LTE。表层(深度 1)的布居可能为 NaN(LTE grey start),属正常。
|
||||
/// 本校验检查:有效 b 因子不含 Inf、极端值比例不超阈值。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BfacCheckResult {
|
||||
/// 是否通过(极端值占比 ≤ tolerance)。
|
||||
pub valid: bool,
|
||||
/// 实际解析的有效 b 因子值数(排除 NaN/LTE 占位)。
|
||||
pub n_values: usize,
|
||||
/// b 因子中 >max_value 或 <min_value 的极端值比例。
|
||||
pub extreme_ratio: f64,
|
||||
/// b 因子中的最大值。
|
||||
pub max_b: f64,
|
||||
/// b 因子中的最小值(排除 0/NaN)。
|
||||
pub min_b: f64,
|
||||
/// NaN 行占比(表层 LTE 占位正常)。
|
||||
pub nan_ratio: f64,
|
||||
/// 极端值上限(默认 1e3)。
|
||||
pub max_value: f64,
|
||||
/// 极端值下限(默认 1e-3)。
|
||||
pub min_value: f64,
|
||||
/// 失败原因(valid=false 时有值)。
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StepSummary {
|
||||
pub label: String,
|
||||
@@ -609,6 +780,9 @@ pub struct StepSummary {
|
||||
/// 同源于 runner 的 StepSummary,故完整收敛轨迹随 summary_json 落库,不再仅存磁盘。
|
||||
#[serde(default)]
|
||||
pub itek_history: Vec<IterCheck>,
|
||||
/// 收敛轨迹(假收敛排查)校验结果。仅 converged=true 的 stage 有值。
|
||||
#[serde(default)]
|
||||
pub conv_trace_check: Option<ConvergenceTraceCheckResult>,
|
||||
}
|
||||
|
||||
/// Full execution summary for a grid point
|
||||
@@ -637,6 +811,20 @@ pub struct ModelSummary {
|
||||
/// 极旧版 conv.json 可能缺此字段,default 0.0 兜底(展示层把 ≤0 视为"无数据")。
|
||||
#[serde(default)]
|
||||
pub elapsed_sec: f64,
|
||||
/// `.6` 能量守恒校验结果(`(RAD+CON)/TOT` 偏离度,硬门槛)。
|
||||
/// None = 未做校验(TLUSTY 未启用 / `.6` 无能量守恒表 / 配置跳过)。
|
||||
/// 旧版 conv.json 无此字段,`#[serde(default)]` 反序列化为 None。
|
||||
#[serde(default)]
|
||||
pub energy_check: Option<EnergyCheckResult>,
|
||||
/// `.7` 大气温度结构边界校验结果。None = 未做校验。
|
||||
#[serde(default)]
|
||||
pub temp_check: Option<TempStructCheckResult>,
|
||||
/// `.emflux` bolometric 通量守恒校验结果。None = 未做校验。
|
||||
#[serde(default)]
|
||||
pub emflux_check: Option<EmfluxCheckResult>,
|
||||
/// `.bfac` b 因子合理性校验结果。None = 未做校验。
|
||||
#[serde(default)]
|
||||
pub bfac_check: Option<BfacCheckResult>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
@@ -674,6 +862,7 @@ mod tests {
|
||||
max_relc: 0.5,
|
||||
n_depths: 50,
|
||||
}],
|
||||
conv_trace_check: None,
|
||||
};
|
||||
let round: StepSummary =
|
||||
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
|
||||
@@ -683,7 +872,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 旧版 Python run_one.py 写出的 conv.json 必须能完整解析为 ModelSummary
|
||||
/// (历史结果迁移链路 import_results → /api/admin/import_seed 的兼容性命门)。
|
||||
/// (历史结果迁移链路的兼容性命门:旧 conv.json 用 `converged` 键)。
|
||||
///
|
||||
/// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套
|
||||
/// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。
|
||||
@@ -743,7 +932,7 @@ mod tests {
|
||||
assert_eq!(s.stages[2].best_max_relc, Some(0.0069));
|
||||
assert_eq!(s.stages[2].elapsed_sec, 650.2);
|
||||
// 旧版把迭代诊断嵌在 final/itek_attempts 里(非扁平字段)→ 扁平字段为 None,
|
||||
// 但原始诊断仍随 conv.json 原文落盘(import_seed 存原始 summary_json),无数据丢失。
|
||||
// 但原始诊断仍随 conv.json 原文落盘,无数据丢失。
|
||||
assert_eq!(s.stages[2].last_iter, None);
|
||||
assert_eq!(s.stages[2].worst_depth, None);
|
||||
}
|
||||
|
||||
+248
-128
@@ -1,174 +1,294 @@
|
||||
//! TLUSTY nst(非标准标志)文件生成器。
|
||||
//!
|
||||
//! [`generate_nst_content`] 分层覆盖:ChainStep(阶段级)> `TlustyInput.nst`(全局结构化)
|
||||
//! > `nst_extra_keys`(逃逸口,后写覆盖前写,warn 冲突)。
|
||||
//!
|
||||
//! # 向后兼容
|
||||
//! 默认配置生成的 nst **前 2 行**与旧实现字节级一致(line1 含
|
||||
//! ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 + ChainStep;line2 含 IELCOR + ChainStep)。
|
||||
//! line3 显式写出 26 个 TLUSTY 默认关键字(NMU/ISPLIN/CNU2 等)——旧实现不写这些
|
||||
//! (走 TLUSTY 内置默认),新版显式写出使配置透明化。对 sdB 工作流物理等价
|
||||
//! (TLUSTY 解析后参数值与旧实现一致)。频率截断 FRCMAX/MIN/FRLMAX/MIN 仅在用户
|
||||
//! 配非零值时才写出(0=TLUSTY 默认,不显式覆盖,避免 FRLMIN=0 偏差)。
|
||||
|
||||
use crate::config::{ChainStep, TlustyInput};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// 生成 TLUSTY 的 nst(非标准标志)文件内容。
|
||||
/// 把 f64 格式化为 TLUSTY nst 值字符串。整数值带小数点(如 2.0 → "2."),
|
||||
/// 与旧实现硬编码 `"VTB=2."` 字节一致;非整数原样输出(如 0.001 → "0.001")。
|
||||
/// -0.0 归一为 "0."(避免 "-0.")。
|
||||
fn fmt_real(v: f64) -> String {
|
||||
if v == 0.0 {
|
||||
"0.".to_string()
|
||||
} else if v.fract() == 0.0 {
|
||||
format!("{}.", v)
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 nst 文件内容。
|
||||
///
|
||||
/// nst 文件由 `KEY=VALUE` 对组成,TLUSTY 的 NSTPAR 子程序逐行解析(无行数限制)。
|
||||
/// 内容来源(优先级从高到低):
|
||||
/// 1. `input_cfg.nst_extra_keys`:用户自由传入的任意 KEY=VALUE(逃逸口),追加到末尾。
|
||||
/// 2. `stage`(ChainStep):CHMAX/ITEK/NITER/ORELAX/IDLTE/IACC/ICHANG 等阶段差异参数。
|
||||
/// 3. 硬编码默认:ND/NLAMBD/VTB/ISPODF/DDNU/CNU1/IELCOR(input_cfg 无对应字段时)。
|
||||
///
|
||||
/// `input_cfg` 为 None 时走全默认(与改动前行为完全一致,向后兼容)。
|
||||
pub fn generate_nst_content(stage: &ChainStep, input_cfg: Option<&TlustyInput>) -> String {
|
||||
// 第 1 行:深度点数/角度数/湍速/ODF 等物理网格参数 + 收敛控制。
|
||||
// ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 当前无结构化字段,保留硬编码(如需覆写用 extra_keys)。
|
||||
let mut line1_parts = vec![
|
||||
"ND=50".to_string(),
|
||||
"NLAMBD=3".to_string(),
|
||||
"VTB=2.".to_string(),
|
||||
"ISPODF=1".to_string(),
|
||||
"DDNU=50.".to_string(),
|
||||
"CNU1=6.".to_string(),
|
||||
/// 分层覆盖(优先级从高到低):
|
||||
/// 1. **ChainStep**(阶段级):NITER/CHMAX/ITEK/ORELAX/IDLTE/IACC/ICHANG——若 Some 则写出。
|
||||
/// 2. **`global.nst`**(全局结构化):~34 个高频关键字,Default 为 sdB 调优值。
|
||||
/// 3. **`global.nst_extra_keys`**(逃逸口):追加末尾,若与已写 key 冲突则 warn。
|
||||
pub fn generate_nst_content(chain: &ChainStep, global: &TlustyInput) -> String {
|
||||
let n = &global.nst;
|
||||
let mut written_keys: HashSet<String> = HashSet::new();
|
||||
let mut out = String::new();
|
||||
|
||||
// ── 第 1 行:深度/频率/物理网格参数 + 收敛控制(与旧实现字节一致)──
|
||||
let mut line1: Vec<String> = vec![
|
||||
format!("ND={}", n.depth.nd),
|
||||
format!("NLAMBD={}", n.depth.nlambd),
|
||||
format!("VTB={}", fmt_real(n.physics.vtb)),
|
||||
format!("ISPODF={}", n.frequency.ispodf),
|
||||
format!("DDNU={}", fmt_real(n.frequency.ddnu)),
|
||||
format!("CNU1={}", fmt_real(n.frequency.cnu1)),
|
||||
];
|
||||
for k in &line1 {
|
||||
written_keys.insert(k.split('=').next().unwrap().to_string());
|
||||
}
|
||||
// ChainStep 阶段级覆盖
|
||||
if let Some(chmax) = chain.chmax {
|
||||
line1.push(format!("CHMAX={}", chmax));
|
||||
written_keys.insert("CHMAX".to_string());
|
||||
}
|
||||
if let Some(itek) = chain.itek {
|
||||
line1.push(format!("ITEK={}", itek));
|
||||
written_keys.insert("ITEK".to_string());
|
||||
}
|
||||
line1.push(format!("NITER={}", chain.niter));
|
||||
written_keys.insert("NITER".to_string());
|
||||
out.push_str(&line1.join(","));
|
||||
out.push('\n');
|
||||
|
||||
if let Some(chmax) = stage.chmax {
|
||||
line1_parts.push(format!("CHMAX={}", chmax));
|
||||
// ── 第 2 行:加速/收敛控制开关 + IELCOR(恒输出,与旧实现一致)──
|
||||
let mut line2: Vec<String> = Vec::new();
|
||||
if let Some(orelax) = chain.orelax {
|
||||
line2.push(format!("ORELAX={}", orelax));
|
||||
written_keys.insert("ORELAX".to_string());
|
||||
}
|
||||
if let Some(itek) = stage.itek {
|
||||
line1_parts.push(format!("ITEK={}", itek));
|
||||
if let Some(idlte) = chain.idlte {
|
||||
line2.push(format!("IDLTE={}", idlte));
|
||||
written_keys.insert("IDLTE".to_string());
|
||||
}
|
||||
line1_parts.push(format!("NITER={}", stage.niter));
|
||||
if let Some(iacc) = chain.iacc {
|
||||
line2.push(format!("IACC={}", iacc));
|
||||
written_keys.insert("IACC".to_string());
|
||||
}
|
||||
if let Some(ichang) = chain.ichang {
|
||||
line2.push(format!("ICHANG={}", ichang));
|
||||
written_keys.insert("ICHANG".to_string());
|
||||
}
|
||||
line2.push(format!("IELCOR={}", n.physics.ielcor));
|
||||
written_keys.insert("IELCOR".to_string());
|
||||
out.push_str(&line2.join(","));
|
||||
out.push('\n');
|
||||
|
||||
// 第 2 行:加速/收敛控制开关。
|
||||
let mut line2_parts = Vec::new();
|
||||
if let Some(orelax) = stage.orelax {
|
||||
line2_parts.push(format!("ORELAX={}", orelax));
|
||||
// ── 第 3 行起:global.nst 高频关键字(非默认 ChainStep 覆盖的)──
|
||||
// 所有值统一格式化为 String,避免 i32/f64 混合数组的类型问题。浮点用 fmt_real 带小数点。
|
||||
let entries: Vec<(&str, String)> = vec![
|
||||
("NMU", n.depth.nmu.to_string()),
|
||||
("ISPLIN", n.depth.isplin.to_string()),
|
||||
("CNU2", fmt_real(n.frequency.cnu2)),
|
||||
("NFTAIL", n.frequency.nftail.to_string()),
|
||||
("DFTAIL", fmt_real(n.frequency.dftail)),
|
||||
];
|
||||
write_nst_group(&mut out, &mut written_keys, &entries);
|
||||
// 频率截断 4 字段:仅当用户显式配非零值时才写出(0=TLUSTY 内置默认,不显式覆盖)。
|
||||
// 这避免 FRLMIN=0 偏离 TLUSTY 默认(tlusty208.f:1933 frlmin<1e6→×1e13,但 0×1e13=0 无兜底)。
|
||||
let freq_cutoffs: Vec<(&str, String)> = [
|
||||
("FRCMAX", &n.frequency.frcmax),
|
||||
("FRCMIN", &n.frequency.frcmin),
|
||||
("FRLMAX", &n.frequency.frlmax),
|
||||
("FRLMIN", &n.frequency.frlmin),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, v)| **v != 0.0)
|
||||
.map(|(k, v)| (*k, fmt_real(**v)))
|
||||
.collect();
|
||||
if !freq_cutoffs.is_empty() {
|
||||
write_nst_group(&mut out, &mut written_keys, &freq_cutoffs);
|
||||
}
|
||||
if let Some(idlte) = stage.idlte {
|
||||
line2_parts.push(format!("IDLTE={}", idlte));
|
||||
}
|
||||
if let Some(iacc) = stage.iacc {
|
||||
line2_parts.push(format!("IACC={}", iacc));
|
||||
}
|
||||
if let Some(ichang) = stage.ichang {
|
||||
line2_parts.push(format!("ICHANG={}", ichang));
|
||||
}
|
||||
line2_parts.push("IELCOR=-1".to_string());
|
||||
let entries2: Vec<(&str, String)> = vec![
|
||||
("HMIX0", fmt_real(n.physics.hmix0)),
|
||||
("MLTYPE", n.physics.mltype.to_string()),
|
||||
("IPTURB", n.physics.ipturb.to_string()),
|
||||
("IBFINT", n.physics.ibfint.to_string()),
|
||||
("IHECOR", n.physics.ihecor.to_string()),
|
||||
("IRTE", n.radiative_transfer.irte.to_string()),
|
||||
("IBC", n.radiative_transfer.ibc.to_string()),
|
||||
("IFRYB", n.radiative_transfer.ifryb.to_string()),
|
||||
("IOPTAB", n.radiative_transfer.ioptab.to_string()),
|
||||
("IFMOL", n.radiative_transfer.ifmol.to_string()),
|
||||
("IFALI", n.nlte.ifali.to_string()),
|
||||
("IFPOPR", n.nlte.ifpopr.to_string()),
|
||||
("JALI", n.nlte.jali.to_string()),
|
||||
("TRAD", fmt_real(n.irradiation.trad)),
|
||||
("WDIL", fmt_real(n.irradiation.wdil)),
|
||||
("TFLOOR", fmt_real(n.temperature.tfloor)),
|
||||
("TDISK", fmt_real(n.temperature.tdisk)),
|
||||
("TMOLIM", fmt_real(n.temperature.tmolim)),
|
||||
];
|
||||
write_nst_group(&mut out, &mut written_keys, &entries2);
|
||||
|
||||
let mut out = format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(","));
|
||||
|
||||
// 第 3 行起:用户自由传入的额外 nst 关键字(逃逸口)。
|
||||
// 每行一个 KEY=VALUE,追加到末尾。用于暴露未结构化的 220+ nst 关键字
|
||||
// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。
|
||||
// 多行格式经 TLUSTY 源码验证(tlusty208.f:1819 NSTPAR 用 `READ(INPFI,500,END=70)`
|
||||
// + `GO TO 10` 逐行循环读至 EOF,每行用 GETWRD 解析 KEY=VALUE),追加行可被正确解析。
|
||||
// 安全校验:key/value 含逗号/换行/等号会破坏 nst 的逗号分隔或 KEY=VALUE 解析,
|
||||
// 跳过非法项并记 warn(避免生成损坏的 nst 导致 tlusty 行为异常)。
|
||||
// L1 修复:**key 与 value 都校验**——key 含 `=`/`,`/空白/换行会产出畸形 `KEY=VALUE`
|
||||
// 行,TLUSTY 的 GETWRD 解析器可能误读。key 必须是合法标识符(非空、无上述分隔符)。
|
||||
if let Some(cfg) = input_cfg {
|
||||
for (key, value) in &cfg.nst_extra_keys {
|
||||
let bad_key = key.is_empty()
|
||||
|| key.contains(',')
|
||||
|| key.contains('\n')
|
||||
|| key.contains('=')
|
||||
|| key.chars().any(char::is_whitespace);
|
||||
let bad_value = value.contains(',') || value.contains('\n') || value.contains('=');
|
||||
if bad_key || bad_value {
|
||||
tracing::warn!(
|
||||
"跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析",
|
||||
key,
|
||||
value
|
||||
);
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!("{}={}\n", key, value));
|
||||
// ── 末尾:nst_extra_keys 逃逸口(后写覆盖,warn 冲突)──
|
||||
for (key, value) in &global.nst_extra_keys {
|
||||
let bad_key = key.is_empty()
|
||||
|| key.contains(',')
|
||||
|| key.contains('\n')
|
||||
|| key.contains('=')
|
||||
|| key.chars().any(char::is_whitespace);
|
||||
let bad_value = value.contains(',') || value.contains('\n') || value.contains('=');
|
||||
if bad_key || bad_value {
|
||||
tracing::warn!(
|
||||
"跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析",
|
||||
key,
|
||||
value
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if written_keys.contains(key) {
|
||||
tracing::warn!(
|
||||
"nst_extra_keys {:?}={} 覆盖了已结构化的关键字(后写覆盖前写)",
|
||||
key,
|
||||
value
|
||||
);
|
||||
}
|
||||
out.push_str(&format!("{}={}\n", key, value));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// 写出一组 nst 关键字(同一行逗号分隔),跳过已写的 key。
|
||||
/// 用 HashSet::insert 返回值做单次遍历去重,避免 entries 内同键重复时两次都写出。
|
||||
fn write_nst_group(out: &mut String, written: &mut HashSet<String>, entries: &[(&str, String)]) {
|
||||
let parts: Vec<String> = entries
|
||||
.iter()
|
||||
.filter(|(k, _)| written.insert(k.to_string()))
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect();
|
||||
if !parts.is_empty() {
|
||||
out.push_str(&parts.join(","));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_nst_generation() {
|
||||
let stage = ChainStep {
|
||||
label: "nc".to_string(),
|
||||
lte: "F".to_string(),
|
||||
ltgray: "F".to_string(),
|
||||
fn chain_nc() -> ChainStep {
|
||||
ChainStep {
|
||||
label: "nc".into(),
|
||||
lte: "F".into(),
|
||||
ltgray: "F".into(),
|
||||
ilvlin: 0,
|
||||
require_converged: false,
|
||||
niter: 10,
|
||||
chmax: None,
|
||||
itek: None,
|
||||
metals: None,
|
||||
ichang: None,
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
orelax: None,
|
||||
};
|
||||
let content = generate_nst_content(&stage, None);
|
||||
assert!(content.contains("ND=50"));
|
||||
assert!(content.contains("NITER=10"));
|
||||
assert!(content.contains("IELCOR=-1"));
|
||||
}
|
||||
}
|
||||
|
||||
/// extra_keys 追加到 nst 末尾(每行一个 KEY=VALUE)。
|
||||
#[test]
|
||||
fn test_nst_extra_keys() {
|
||||
let stage = ChainStep {
|
||||
label: "nl".to_string(),
|
||||
lte: "F".to_string(),
|
||||
ltgray: "F".to_string(),
|
||||
ilvlin: 100,
|
||||
require_converged: true,
|
||||
niter: 100,
|
||||
chmax: Some(0.001),
|
||||
itek: None,
|
||||
metals: None,
|
||||
ichang: None,
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
orelax: None,
|
||||
};
|
||||
let cfg = TlustyInput {
|
||||
nfread: 2000,
|
||||
atoms: Default::default(),
|
||||
ions: vec![],
|
||||
fn test_nst_default_values() {
|
||||
let content = generate_nst_content(&chain_nc(), &TlustyInput::default());
|
||||
assert!(content.contains("ND=50"));
|
||||
assert!(content.contains("NLAMBD=3"));
|
||||
// 浮点值带小数点(与旧实现字节一致)
|
||||
assert!(content.contains("VTB=2."), "VTB 应带小数点");
|
||||
assert!(content.contains("ISPODF=1"));
|
||||
assert!(content.contains("DDNU=50."), "DDNU 应带小数点");
|
||||
assert!(content.contains("CNU1=6."), "CNU1 应带小数点");
|
||||
assert!(content.contains("IELCOR=-1"));
|
||||
assert!(content.contains("NITER=10"));
|
||||
// line3 高频关键字(entries2 修复后应出现)
|
||||
assert!(content.contains("HMIX0=-1."), "HMIX0 应在 line3");
|
||||
assert!(content.contains("IBFINT=1"), "IBFINT 应在 line3");
|
||||
assert!(content.contains("TFLOOR=8000."), "TFLOOR 应在 line3");
|
||||
assert!(content.contains("NMU=3"), "NMU 应在 line3");
|
||||
}
|
||||
|
||||
/// ChainStep 阶段级覆盖 nst 全局字段。
|
||||
#[test]
|
||||
fn test_chain_overrides() {
|
||||
let mut chain = chain_nc();
|
||||
chain.niter = 100;
|
||||
chain.chmax = Some(0.001);
|
||||
chain.itek = Some(4);
|
||||
let content = generate_nst_content(&chain, &TlustyInput::default());
|
||||
assert!(content.contains("NITER=100"));
|
||||
assert!(content.contains("CHMAX=0.001"));
|
||||
assert!(content.contains("ITEK=4"));
|
||||
}
|
||||
|
||||
/// nst_extra_keys 追加 + 冲突 warn。
|
||||
#[test]
|
||||
fn test_extra_keys_append() {
|
||||
let global = TlustyInput {
|
||||
nst_extra_keys: vec![
|
||||
("FRCMAX".to_string(), "0.01".to_string()),
|
||||
("CUTBAL".to_string(), "0.3".to_string()),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let content = generate_nst_content(&stage, Some(&cfg));
|
||||
assert!(content.contains("FRCMAX=0.01"), "extra_keys 应追加到 nst");
|
||||
let content = generate_nst_content(&chain_nc(), &global);
|
||||
assert!(content.contains("FRCMAX=0.01"));
|
||||
assert!(content.contains("CUTBAL=0.3"));
|
||||
// 原有内容仍存在
|
||||
assert!(content.contains("NITER=100"));
|
||||
assert!(content.contains("CHMAX=0.001"));
|
||||
}
|
||||
|
||||
/// None 配置时与改动前行为一致(无 extra_keys 行)。
|
||||
/// 非法 extra_keys 被跳过。
|
||||
#[test]
|
||||
fn test_nst_none_cfg_backward_compat() {
|
||||
let stage = ChainStep {
|
||||
label: "lte".to_string(),
|
||||
lte: "T".to_string(),
|
||||
ltgray: "T".to_string(),
|
||||
ilvlin: 0,
|
||||
require_converged: false,
|
||||
niter: 0,
|
||||
chmax: None,
|
||||
itek: None,
|
||||
metals: None,
|
||||
ichang: None,
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
orelax: None,
|
||||
fn test_extra_keys_validation() {
|
||||
let global = TlustyInput {
|
||||
nst_extra_keys: vec![
|
||||
("BAD KEY".to_string(), "1".to_string()),
|
||||
("OK".to_string(), "1".to_string()),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let content = generate_nst_content(&stage, None);
|
||||
// None 配置时只有 2 行(第1行 ND/NITER 等 + 第2行 IELCOR 等),无 extra_keys 追加行。
|
||||
let non_empty_lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
|
||||
let content = generate_nst_content(&chain_nc(), &global);
|
||||
assert!(!content.contains("BAD KEY"));
|
||||
assert!(content.contains("OK=1"));
|
||||
}
|
||||
|
||||
/// 向后兼容:默认配置生成的 nst 前 2 行与旧实现字节级一致。
|
||||
/// 旧实现 line1 = "ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=10"
|
||||
/// 旧实现 line2 = "IELCOR=-1"
|
||||
/// line3 显式写出 26 个 TLUSTY 默认关键字(物理等价)。
|
||||
/// 频率截断 FRCMAX/MIN/FRLMAX/MIN 不在默认输出中(0=不写出,走 TLUSTY 默认)。
|
||||
#[test]
|
||||
fn test_default_byte_compat_first_two_lines() {
|
||||
let content = generate_nst_content(&chain_nc(), &TlustyInput::default());
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.len() >= 2, "至少 2 行");
|
||||
assert_eq!(
|
||||
non_empty_lines.len(),
|
||||
2,
|
||||
"None 配置时 nst 应只有 2 行,实际 {} 行: {:?}",
|
||||
non_empty_lines.len(),
|
||||
non_empty_lines
|
||||
lines[0], "ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=10",
|
||||
"line1 应与旧实现字节一致"
|
||||
);
|
||||
assert!(!content.contains("FRCMAX"));
|
||||
assert_eq!(lines[1], "IELCOR=-1", "line2 应与旧实现字节一致");
|
||||
// 频率截断 4 字段默认不写出(值为 0)
|
||||
assert!(!content.contains("FRCMAX"), "FRCMAX=0 不应写出");
|
||||
assert!(!content.contains("FRCMIN"), "FRCMIN=0 不应写出");
|
||||
assert!(!content.contains("FRLMAX"), "FRLMAX=0 不应写出");
|
||||
assert!(!content.contains("FRLMIN"), "FRLMIN=0 不应写出");
|
||||
}
|
||||
|
||||
/// fmt_real 边界:-0.0 归一为 "0."(审查 Nit-1)。
|
||||
#[test]
|
||||
fn test_fmt_real_negative_zero() {
|
||||
assert_eq!(fmt_real(0.0), "0.");
|
||||
assert_eq!(fmt_real(-0.0), "0.", "-0.0 应归一为 0.");
|
||||
assert_eq!(fmt_real(2.0), "2.");
|
||||
assert_eq!(fmt_real(50.0), "50.");
|
||||
assert_eq!(fmt_real(0.25), "0.25");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 归档白名单:决定沙盒里的哪些文件值得进入持久归档目录(`data/result/<name>/`)。
|
||||
//!
|
||||
//! node 端实时结果归档(`executor::save_result_artifacts`)与离线迁移工具(`import_results`)
|
||||
//! 共用本模块的 [`is_result_worthy`],确保两条路径的结果归档口径一致、不会随时间漂移。
|
||||
//! node 端实时结果归档(`executor::save_result_artifacts`)共用本模块的
|
||||
//! [`is_result_worthy`],确保结果归档口径稳定。
|
||||
//!
|
||||
//! ## 设计原则
|
||||
//!
|
||||
|
||||
+174
-28
@@ -1,5 +1,9 @@
|
||||
use crate::config::{ChainStep, SynspecInput, TlustyInput};
|
||||
use crate::conv_check::{atmosphere_has_nan, check_fort9, extract_failure_hint, spec_is_valid};
|
||||
use crate::conv_check::{
|
||||
atmosphere_has_nan, check_bfactor, check_convergence_trace, check_emflux_bolometric,
|
||||
check_energy_conservation, check_fort9, check_temperature_structure, extract_failure_hint,
|
||||
spec_is_valid,
|
||||
};
|
||||
use crate::embedded::RuntimePaths;
|
||||
use crate::fort55_writer::generate_fort55_content;
|
||||
use crate::gen_input5::make_input5;
|
||||
@@ -24,7 +28,6 @@ pub fn default_cold_chain() -> Vec<ChainStep> {
|
||||
niter: 0,
|
||||
chmax: None,
|
||||
itek: None,
|
||||
metals: Some("cno".to_string()),
|
||||
ichang: None,
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
@@ -39,7 +42,6 @@ pub fn default_cold_chain() -> Vec<ChainStep> {
|
||||
niter: 10,
|
||||
chmax: None,
|
||||
itek: None,
|
||||
metals: Some("cno".to_string()),
|
||||
ichang: None,
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
@@ -54,7 +56,6 @@ pub fn default_cold_chain() -> Vec<ChainStep> {
|
||||
niter: 100,
|
||||
chmax: None,
|
||||
itek: None,
|
||||
metals: Some("cno".to_string()),
|
||||
ichang: None,
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
@@ -74,11 +75,10 @@ pub fn default_seed_chain() -> Vec<ChainStep> {
|
||||
niter: 20,
|
||||
chmax: None,
|
||||
itek: None,
|
||||
metals: Some("cno".to_string()),
|
||||
ichang: Some(0),
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
orelax: None,
|
||||
orelax: Some(0.3),
|
||||
},
|
||||
ChainStep {
|
||||
label: "nl".to_string(),
|
||||
@@ -89,11 +89,10 @@ pub fn default_seed_chain() -> Vec<ChainStep> {
|
||||
niter: 100,
|
||||
chmax: None,
|
||||
itek: None,
|
||||
metals: Some("cno".to_string()),
|
||||
ichang: Some(0),
|
||||
idlte: None,
|
||||
iacc: None,
|
||||
orelax: None,
|
||||
orelax: Some(0.5),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -222,6 +221,14 @@ impl<'a> ExecutionRunner<'a> {
|
||||
seed_atmos: Option<&Path>,
|
||||
synspec_cfg: Option<&SynspecInput>,
|
||||
tlusty_input: Option<&TlustyInput>,
|
||||
energy_tolerance: Option<f64>,
|
||||
temp_max_factor: Option<f64>,
|
||||
temp_floor: Option<f64>,
|
||||
temp_ceiling: Option<f64>,
|
||||
emflux_tolerance: Option<f64>,
|
||||
convergence_min_ratio: Option<f64>,
|
||||
bfac_max: Option<f64>,
|
||||
bfac_min: Option<f64>,
|
||||
) -> Result<ModelSummary> {
|
||||
self.run_model_with_timeout(
|
||||
params,
|
||||
@@ -235,6 +242,14 @@ impl<'a> ExecutionRunner<'a> {
|
||||
7200,
|
||||
None,
|
||||
tlusty_input,
|
||||
energy_tolerance,
|
||||
temp_max_factor,
|
||||
temp_floor,
|
||||
temp_ceiling,
|
||||
emflux_tolerance,
|
||||
convergence_min_ratio,
|
||||
bfac_max,
|
||||
bfac_min,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -262,6 +277,14 @@ impl<'a> ExecutionRunner<'a> {
|
||||
timeout_sec: u64,
|
||||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
tlusty_input: Option<&TlustyInput>,
|
||||
energy_tolerance: Option<f64>,
|
||||
temp_max_factor: Option<f64>,
|
||||
temp_floor: Option<f64>,
|
||||
temp_ceiling: Option<f64>,
|
||||
emflux_tolerance: Option<f64>,
|
||||
convergence_min_ratio: Option<f64>,
|
||||
bfac_max: Option<f64>,
|
||||
bfac_min: Option<f64>,
|
||||
) -> Result<ModelSummary> {
|
||||
// `name` 取自权威的 TaskSpec.point_name(DB 的 grid_points.name 列,源精度正确),
|
||||
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
|
||||
@@ -335,26 +358,22 @@ impl<'a> ExecutionRunner<'a> {
|
||||
}
|
||||
|
||||
let tlusty_skipped = !tlusty_enabled;
|
||||
// tlusty_input 为 None(YAML 未配 tlusty_input 块)时用默认值。
|
||||
// 默认值定义集中在 config.rs 的 TlustyInput::default(),此处不再重复维护。
|
||||
let default_input = TlustyInput::default();
|
||||
let input_cfg: &TlustyInput = tlusty_input.unwrap_or(&default_input);
|
||||
for stage_def in &chain {
|
||||
if tlusty_skipped {
|
||||
break;
|
||||
}
|
||||
let stage_t0 = Instant::now();
|
||||
let metals = stage_def.metals.as_deref().unwrap_or("cno");
|
||||
let input5_text = make_input5(
|
||||
params,
|
||||
&stage_def.lte,
|
||||
&stage_def.ltgray,
|
||||
metals,
|
||||
stage_def.ilvlin,
|
||||
tlusty_input,
|
||||
);
|
||||
let input5_text = make_input5(params, stage_def, input_cfg);
|
||||
|
||||
let input5_path = model_dir.join(format!("{}.5", name));
|
||||
tokio::fs::write(&input5_path, &input5_text).await?;
|
||||
|
||||
// Write nst file
|
||||
let nst_text = generate_nst_content(stage_def, tlusty_input);
|
||||
let nst_text = generate_nst_content(stage_def, input_cfg);
|
||||
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
|
||||
|
||||
// Prepare fort.8 for this stage
|
||||
@@ -417,6 +436,7 @@ impl<'a> ExecutionRunner<'a> {
|
||||
worst_depth: None,
|
||||
n_depths: None,
|
||||
itek_history: Vec::new(),
|
||||
conv_trace_check: None,
|
||||
};
|
||||
|
||||
if rc == 0 && fort7.is_file() {
|
||||
@@ -431,7 +451,26 @@ impl<'a> ExecutionRunner<'a> {
|
||||
stage_summary.worst_depth = Some(res.worst_depth);
|
||||
stage_summary.n_depths = Some(res.n_depths);
|
||||
// itek 全量保真(Phase 5b):逐次迭代诊断随 summary_json/conv.json 落库。
|
||||
stage_summary.itek_history = res.itek_history;
|
||||
stage_summary.itek_history = res.itek_history.clone();
|
||||
|
||||
// 假收敛排查(§2.1/§3.1):仅对 converged=true 的 stage 做。
|
||||
// Ng/Kantorovich 加速可压低 max_relc 造成数值达标但平衡未达成。
|
||||
if res.converged {
|
||||
if let Some(min_ratio) = convergence_min_ratio {
|
||||
if let Some(tc) =
|
||||
check_convergence_trace(&res.itek_history, min_ratio)
|
||||
{
|
||||
if !tc.valid {
|
||||
stage_summary.converged = false;
|
||||
stage_summary.note = Some(format!(
|
||||
"未收敛 [{}]",
|
||||
tc.error.as_deref().unwrap_or("假收敛排查失败")
|
||||
));
|
||||
}
|
||||
stage_summary.conv_trace_check = Some(tc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 漏洞5修复:发散时从 fort.6 提取求解器 STOP 行(SOLVE/SOLVES/RYBSOL)
|
||||
// 作为 note,提升归因质量。仅未收敛且无既有 note 时补(避免覆盖错误信息)。
|
||||
@@ -560,6 +599,87 @@ impl<'a> ExecutionRunner<'a> {
|
||||
final_converged = false;
|
||||
}
|
||||
|
||||
// 能量守恒硬门槛(docs/spectrum_correctness_analysis.md §1.1 第一层 #4):
|
||||
// 解析 `.6`(收敛链最后阶段 stdout,含 LFIN 时 OUTPRI 写出的能量守恒表)的
|
||||
// `(RAD+CON)/TOT` 列。任一深度偏离 1 超阈值即判失败(与 atmosphere_has_nan 同级)。
|
||||
// 此时 `<name>.6` 仍存在(行 688 的冗余清理在此之后),内容是最后阶段日志。
|
||||
// energy_tolerance=None → 跳过(非常规配置);`.6` 无能量守恒表 → 函数返回 None 跳过。
|
||||
let energy_check = if tlusty_enabled {
|
||||
energy_tolerance.and_then(|tol| {
|
||||
let fort6 = model_dir.join(format!("{}.6", name));
|
||||
check_energy_conservation(&fort6, tol)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut energy_failed = false;
|
||||
if let Some(ref ec) = energy_check {
|
||||
if !ec.valid {
|
||||
final_converged = false;
|
||||
energy_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 温度结构边界校验(docs/spectrum_correctness_analysis.md §3.1/§4):
|
||||
// 解析 `.7`(最终大气)逐深度温度 T,表层 >max_factor×Teff 或全层越界判失败。
|
||||
// 读 final_7(此时已就位);tlusty_enabled=false 时 final_7 来自外部种子,跳过。
|
||||
let teff = params.teff.value();
|
||||
let temp_check = if tlusty_enabled {
|
||||
temp_max_factor.and_then(|mf| {
|
||||
let floor = temp_floor.unwrap_or(10.0);
|
||||
let ceiling = temp_ceiling.unwrap_or(1.0e8);
|
||||
check_temperature_structure(&final_7, teff, mf, floor, ceiling)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut temp_failed = false;
|
||||
if let Some(ref tc) = temp_check {
|
||||
if !tc.valid {
|
||||
final_converged = false;
|
||||
temp_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// emflux bolometric 通量守恒校验(§3.2/§4):
|
||||
// 解析 `.emflux`(snapshot_tlusty_outputs 已快照),梯形积分 ∫Fλdλ 比 σTeff⁴。
|
||||
// 全 NaN(辐射转移失败)也判失败。文件缺失 → 跳过(非常规配置)。
|
||||
let emflux_check = if tlusty_enabled {
|
||||
emflux_tolerance.and_then(|tol| {
|
||||
let emflux_path = model_dir.join(format!("{}.emflux", name));
|
||||
check_emflux_bolometric(&emflux_path, teff, tol)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut emflux_failed = false;
|
||||
if let Some(ref ec) = emflux_check {
|
||||
if !ec.valid {
|
||||
final_converged = false;
|
||||
emflux_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// b 因子合理性校验(§3.1/§6 #19):解析 `.bfac`(snapshot_tlusty_outputs 已快照),
|
||||
// 检查 NLTE 偏离因子 b 无极端值(>1e3 或 <1e-3 占比 >10% → 失败)。
|
||||
// 文件缺失/无有效 b 因子 → 跳过(纯 LTE 模型或 grey start)。
|
||||
let bfac_check = if tlusty_enabled {
|
||||
bfac_max.and_then(|mx| {
|
||||
let mn = bfac_min.unwrap_or(1.0e-3);
|
||||
let bfac_path = model_dir.join(format!("{}.bfac", name));
|
||||
check_bfactor(&bfac_path, mx, mn)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut bfac_failed = false;
|
||||
if let Some(ref bc) = bfac_check {
|
||||
if !bc.valid {
|
||||
final_converged = false;
|
||||
bfac_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Run synspec if enabled and final .7 atmosphere exists
|
||||
let mut synspec_rc = None;
|
||||
let mut synspec_err = None;
|
||||
@@ -588,15 +708,9 @@ impl<'a> ExecutionRunner<'a> {
|
||||
let _ = tokio::fs::remove_file(&fort55_path).await;
|
||||
let _ = tokio::fs::remove_file(&fort19_path).await;
|
||||
|
||||
let default_cfg = SynspecInput {
|
||||
wstart: 1400.0,
|
||||
wend: 1410.0,
|
||||
imode: 0,
|
||||
idrv: 50,
|
||||
ifreq: 1,
|
||||
rel_cutoff: 0.0001,
|
||||
abs_cutoff: 0.01,
|
||||
};
|
||||
// synspec_cfg 为 None(工作流未配 synspec_input 块)时用默认值。
|
||||
// 默认值定义集中在 config.rs 的 SynspecInput::default(),此处不再重复维护。
|
||||
let default_cfg = SynspecInput::default();
|
||||
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
|
||||
if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await {
|
||||
warn!(
|
||||
@@ -725,6 +839,34 @@ impl<'a> ExecutionRunner<'a> {
|
||||
if atmo_has_nan {
|
||||
notes.push("Invalidated: atmosphere contains NaN/Inf lines".to_string());
|
||||
}
|
||||
if energy_failed {
|
||||
if let Some(ref ec) = energy_check {
|
||||
if let Some(ref err) = ec.error {
|
||||
notes.push(err.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if temp_failed {
|
||||
if let Some(ref tc) = temp_check {
|
||||
if let Some(ref err) = tc.error {
|
||||
notes.push(err.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if emflux_failed {
|
||||
if let Some(ref ec) = emflux_check {
|
||||
if let Some(ref err) = ec.error {
|
||||
notes.push(err.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if bfac_failed {
|
||||
if let Some(ref bc) = bfac_check {
|
||||
if let Some(ref err) = bc.error {
|
||||
notes.push(err.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref err) = synspec_err {
|
||||
notes.push(format!("synspec error: {}", err));
|
||||
} else if let Some(rc) = synspec_rc {
|
||||
@@ -752,6 +894,10 @@ impl<'a> ExecutionRunner<'a> {
|
||||
synspec_error: synspec_err,
|
||||
synspec_sec,
|
||||
elapsed_sec,
|
||||
energy_check,
|
||||
temp_check,
|
||||
emflux_check,
|
||||
bfac_check,
|
||||
note,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 点级 [`ModelSummary`] 的增量合并。
|
||||
//!
|
||||
//! # 背景
|
||||
//! 一个网格点可能经历多次执行:先是完整的 TLUSTY 收敛链(产出 `stages` 等大气诊断),
|
||||
//! 之后又跑了仅 SYNSPEC 的重算(`stages` 为空,仅刷新 `synspec_rc`/`synspec_sec` 等)。
|
||||
//! 直接用后者的 summary 覆盖前者会丢失 TLUSTY 诊断——本模块按"stages 是否为空"判定
|
||||
//! 本次执行是否含 TLUSTY 数据,据此选择整体替换或字段级合并。
|
||||
//!
|
||||
//! 合并函数供两条路径复用,保证逻辑一致:
|
||||
//! - server 端 `record_task_report`(在线 report 时合并写入 grid_points.summary_json)
|
||||
//! - 离线工具 `import_db`(扫 salvage 多节点 conv.json 时逐份合并)
|
||||
//!
|
||||
//! # 合并规则
|
||||
//! - incoming.stages 非空 且 incoming.synspec_rc 非空 → 整体替换(正常管线,TLUSTY+SYNSPEC 都跑了)。
|
||||
//! - incoming.stages 非空 但 incoming.synspec_rc 为空 → TLUSTY-only 重跑:以 incoming 为基底
|
||||
//! (含新 TLUSTY 诊断),保留 prior 的 synspec 字段(synspec_rc/error/sec)。新大气使旧光谱
|
||||
//! 失效,但 prior 的 synspec 数据仍有参考价值(记录了上次的光谱质量),且 DB 层已通过
|
||||
//! clear_synspec 清空了 synspec_success_method/synspec_status 列。
|
||||
//! - incoming.stages 为空 → synspec-only 重跑:以 prior 为基底,只覆盖 SYNSPEC/运行态字段,
|
||||
//! 保留 prior 的 TLUSTY 诊断(stages / final_max_relc / final_chmax / seed / 各 *_check)。
|
||||
//! - prior 为 None → 直接返回 incoming(首次写入,无历史可保留)。
|
||||
|
||||
use crate::models::ModelSummary;
|
||||
|
||||
/// 合并点级 [`ModelSummary`]。
|
||||
///
|
||||
/// 详见[模块文档][self]的规则说明。
|
||||
pub fn merge_point_summary(prior: Option<&ModelSummary>, incoming: &ModelSummary) -> ModelSummary {
|
||||
// 无历史数据 → 直接返回 incoming。
|
||||
let Some(prior) = prior else {
|
||||
return incoming.clone();
|
||||
};
|
||||
if !incoming.stages.is_empty() {
|
||||
// TLUSTY 链在本次执行中跑了。
|
||||
if incoming.synspec_rc.is_some() {
|
||||
// SYNSPEC 也跑了 → 正常管线,incoming 是最新完整摘要 → 整体替换。
|
||||
return incoming.clone();
|
||||
}
|
||||
// TLUSTY-only 重跑(synspec 禁用):以 incoming 为基底(新 TLUSTY 诊断 + 新运行态字段),
|
||||
// 保留 prior 的 synspec 字段(上次光谱的 rc/error/sec)。
|
||||
let mut merged = incoming.clone();
|
||||
merged.synspec_rc = prior.synspec_rc;
|
||||
merged.synspec_error = prior.synspec_error.clone();
|
||||
merged.synspec_sec = prior.synspec_sec;
|
||||
return merged;
|
||||
}
|
||||
|
||||
// synspec-only 重跑:以 prior 为基底,只覆盖 SYNSPEC / 运行态字段。
|
||||
let mut merged = prior.clone();
|
||||
merged.synspec_rc = incoming.synspec_rc;
|
||||
merged.synspec_error = incoming.synspec_error.clone();
|
||||
merged.synspec_sec = incoming.synspec_sec;
|
||||
merged.atmosphere_has_nan = incoming.atmosphere_has_nan;
|
||||
merged.elapsed_sec = incoming.elapsed_sec;
|
||||
merged.result_valid = incoming.result_valid;
|
||||
merged.note = incoming.note.clone();
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{GridAxisValue, GridPointParams, IterCheck, StepSummary};
|
||||
|
||||
fn mk_params() -> GridPointParams {
|
||||
GridPointParams {
|
||||
teff: GridAxisValue::from_value(25000.0),
|
||||
logg: GridAxisValue::from_value(5.0),
|
||||
loghe: GridAxisValue::from_value(2.0),
|
||||
logc: GridAxisValue::from_value(-2.0),
|
||||
logn: GridAxisValue::from_value(-2.0),
|
||||
logo: GridAxisValue::from_value(-2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个含完整 TLUSTY 链的 summary(stages 非空 + 各校验字段)。
|
||||
fn mk_tlusty_summary() -> ModelSummary {
|
||||
ModelSummary {
|
||||
name: "t25000_g5.0_he2_c-2_n-2_o-2".into(),
|
||||
params: mk_params(),
|
||||
stages: vec![StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.000756),
|
||||
elapsed_sec: 479.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![IterCheck {
|
||||
iter: 1,
|
||||
max_relc: 0.5,
|
||||
n_depths: 50,
|
||||
}],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.000756),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.24),
|
||||
elapsed_sec: 479.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个 synspec-only summary(stages 为空)。
|
||||
fn mk_synspec_only_summary() -> ModelSummary {
|
||||
let mut s = mk_tlusty_summary();
|
||||
s.stages = Vec::new();
|
||||
s.final_max_relc = None;
|
||||
s.final_chmax = None;
|
||||
// 模拟 synspec 重跑的结果
|
||||
s.synspec_rc = Some(1);
|
||||
s.synspec_error = Some("spec 含 NaN".into());
|
||||
s.synspec_sec = Some(0.5);
|
||||
s.atmosphere_has_nan = true;
|
||||
s.elapsed_sec = 0.5;
|
||||
s.result_valid = false;
|
||||
s.note = Some("synspec 失败".into());
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prior_none_returns_incoming() {
|
||||
let incoming = mk_tlusty_summary();
|
||||
let merged = merge_point_summary(None, &incoming);
|
||||
assert_eq!(merged.name, incoming.name);
|
||||
assert_eq!(merged.stages.len(), 1);
|
||||
assert_eq!(merged.final_max_relc, Some(0.000756));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_stages_nonempty_replaces_wholesale() {
|
||||
let prior = mk_tlusty_summary();
|
||||
let mut incoming = mk_tlusty_summary();
|
||||
incoming.final_max_relc = Some(0.0001);
|
||||
incoming.stages[0].best_max_relc = Some(0.0001);
|
||||
incoming.stages[0].label = "seed_nc".into();
|
||||
|
||||
let merged = merge_point_summary(Some(&prior), &incoming);
|
||||
// 整体替换:incoming 的值胜出
|
||||
assert_eq!(merged.final_max_relc, Some(0.0001));
|
||||
assert_eq!(merged.stages[0].label, "seed_nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synspec_only_preserves_tlusty_fields_overrides_synspec() {
|
||||
let prior = mk_tlusty_summary();
|
||||
let incoming = mk_synspec_only_summary();
|
||||
|
||||
let merged = merge_point_summary(Some(&prior), &incoming);
|
||||
|
||||
// TLUSTY 字段保留自 prior
|
||||
assert_eq!(merged.stages.len(), 1, "stages 应保留 prior 的 TLUSTY 链");
|
||||
assert_eq!(merged.stages[0].label, "nl");
|
||||
assert_eq!(merged.final_max_relc, Some(0.000756));
|
||||
assert_eq!(merged.final_chmax, Some(0.001));
|
||||
|
||||
// synspec/运行态字段用 incoming 覆盖
|
||||
assert_eq!(merged.synspec_rc, Some(1));
|
||||
assert_eq!(merged.synspec_error.as_deref(), Some("spec 含 NaN"));
|
||||
assert_eq!(merged.synspec_sec, Some(0.5));
|
||||
assert!(merged.atmosphere_has_nan);
|
||||
assert_eq!(merged.elapsed_sec, 0.5);
|
||||
assert!(!merged.result_valid);
|
||||
assert_eq!(merged.note.as_deref(), Some("synspec 失败"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synspec_only_with_no_prior_keeps_empty_stages() {
|
||||
// prior 为 None 且 incoming stages 为空(该点从未跑过 TLUSTY)
|
||||
let incoming = mk_synspec_only_summary();
|
||||
let merged = merge_point_summary(None, &incoming);
|
||||
assert!(merged.stages.is_empty(), "无历史时保留 incoming 的空 stages");
|
||||
assert_eq!(merged.synspec_rc, Some(1));
|
||||
}
|
||||
|
||||
/// 往返序列化保真:合并结果应能序列化再反序列化。
|
||||
#[test]
|
||||
fn merge_result_roundtrips() {
|
||||
let prior = mk_tlusty_summary();
|
||||
let incoming = mk_synspec_only_summary();
|
||||
let merged = merge_point_summary(Some(&prior), &incoming);
|
||||
|
||||
let json = serde_json::to_string(&merged).unwrap();
|
||||
let back: ModelSummary = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.stages.len(), 1);
|
||||
assert_eq!(back.synspec_rc, Some(1));
|
||||
}
|
||||
|
||||
/// 构造一个 TLUSTY-only summary(stages 非空,但 synspec_rc=None — synspec 未跑)。
|
||||
fn mk_tlusty_only_summary() -> ModelSummary {
|
||||
let mut s = mk_tlusty_summary();
|
||||
// 新的大气链结果(更优收敛)
|
||||
s.stages[0].best_max_relc = Some(0.0001);
|
||||
s.final_max_relc = Some(0.0001);
|
||||
// synspec 未运行 → 清空 synspec 字段
|
||||
s.synspec_rc = None;
|
||||
s.synspec_error = None;
|
||||
s.synspec_sec = None;
|
||||
s.elapsed_sec = 280.0; // TLUSTY-only 耗时
|
||||
s
|
||||
}
|
||||
|
||||
/// TLUSTY-only 重跑(stages 非空 + synspec_rc=None)保留 prior 的 synspec 字段,
|
||||
/// 使用 incoming 的 TLUSTY 诊断。
|
||||
#[test]
|
||||
fn tlusty_only_preserves_prior_synspec_fields() {
|
||||
let prior = mk_tlusty_summary(); // 含 synspec_rc=Some(0), synspec_sec=Some(0.24)
|
||||
let incoming = mk_tlusty_only_summary(); // stages 非空, synspec_rc=None
|
||||
|
||||
let merged = merge_point_summary(Some(&prior), &incoming);
|
||||
|
||||
// TLUSTY 字段来自 incoming(新大气诊断)
|
||||
assert_eq!(merged.stages.len(), 1, "stages 来自 incoming");
|
||||
assert_eq!(
|
||||
merged.final_max_relc,
|
||||
Some(0.0001),
|
||||
"final_max_relc 来自 incoming"
|
||||
);
|
||||
|
||||
// synspec 字段保留自 prior
|
||||
assert_eq!(
|
||||
merged.synspec_rc,
|
||||
Some(0),
|
||||
"synspec_rc 保留 prior 值(synspec 未跑但旧数据仍有参考价值)"
|
||||
);
|
||||
assert_eq!(merged.synspec_sec, Some(0.24), "synspec_sec 保留 prior 值");
|
||||
|
||||
// 运行态字段来自 incoming
|
||||
assert_eq!(merged.elapsed_sec, 280.0, "elapsed_sec 来自 incoming");
|
||||
}
|
||||
|
||||
/// TLUSTY-only 重跑 + 无 prior(首次执行就是 TLUSTY-only)→ 返回 incoming。
|
||||
#[test]
|
||||
fn tlusty_only_with_no_prior_keeps_empty_synspec() {
|
||||
let incoming = mk_tlusty_only_summary();
|
||||
let merged = merge_point_summary(None, &incoming);
|
||||
assert_eq!(merged.stages.len(), 1);
|
||||
assert_eq!(
|
||||
merged.synspec_rc,
|
||||
None,
|
||||
"无 prior 时 synspec_rc 为 incoming 的 None"
|
||||
);
|
||||
}
|
||||
|
||||
/// TLUSTY-only 合并结果往返序列化保真。
|
||||
#[test]
|
||||
fn tlusty_only_merge_roundtrips() {
|
||||
let prior = mk_tlusty_summary();
|
||||
let incoming = mk_tlusty_only_summary();
|
||||
let merged = merge_point_summary(Some(&prior), &incoming);
|
||||
|
||||
let json = serde_json::to_string(&merged).unwrap();
|
||||
let back: ModelSummary = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.stages.len(), 1);
|
||||
assert_eq!(back.final_max_relc, Some(0.0001));
|
||||
assert_eq!(back.synspec_rc, Some(0));
|
||||
}
|
||||
}
|
||||
+57
-14
@@ -166,7 +166,7 @@ pub async fn execute_task(
|
||||
// 执行链来源(优先级):
|
||||
// 1. TaskSpec.tlusty_chain_params(用户在 YAML `tlusty_chain:` 配置的多阶段 ChainStep
|
||||
// 数组,由 scheduler 序列化注入)——非空时优先使用,使用户能细粒度控制 niter/chmax/
|
||||
// metals 等阶段参数。
|
||||
// ilvlin 等阶段参数。
|
||||
// 2. default_chain_for_strategy(current_strategy) 兜底——按策略名(cold_run/seed_step)
|
||||
// 选预设默认链(runner.rs 的 default_cold_chain / default_seed_chain)。
|
||||
// 历史:Phase 6 起仅用 default 链(用户 config.chain 被忽略,是死字段);本次接通后
|
||||
@@ -175,6 +175,7 @@ pub async fn execute_task(
|
||||
let chain = resolve_execution_chain(
|
||||
current_strategy,
|
||||
&task.tlusty_chain_params,
|
||||
&task.seed_chain_params,
|
||||
&task.task_id.to_string(),
|
||||
);
|
||||
// TLUSTY 输入文件全局参数(NFREAD/ions 表/nst extra_keys 等)。
|
||||
@@ -208,6 +209,14 @@ pub async fn execute_task(
|
||||
task.timeout_sec,
|
||||
shutdown,
|
||||
tlusty_input.as_ref(),
|
||||
task.energy_tolerance,
|
||||
task.temp_max_factor,
|
||||
task.temp_floor,
|
||||
task.temp_ceiling,
|
||||
task.emflux_tolerance,
|
||||
task.convergence_min_ratio,
|
||||
task.bfac_max,
|
||||
task.bfac_min,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -450,21 +459,35 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
|
||||
|
||||
/// 解析任务要执行的大气链(冷启动链 / 种子热启动链)。
|
||||
///
|
||||
/// 优先级(修复回归):
|
||||
/// - `current_strategy == "seed_step"` → 强制 `default_seed_chain()`(seed_nc→nl)。
|
||||
/// 自定义 `tlusty_chain` 是冷启动链:首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的
|
||||
/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。scheduler 在派发与回退两条路径都注入
|
||||
/// 同一个 `tlusty_chain`,若 seed_step 也沿用自定义链,会把种子回退退化成本地冷启动,
|
||||
/// 丢失热启动语义。故种子链固定走内置默认,仅在 cold_run 等冷策略下信任用户自定义链。
|
||||
/// 优先级:
|
||||
/// - `current_strategy == "seed_step"` → 优先 TaskSpec.seed_chain_params(用户 YAML
|
||||
/// `seed_chain:` 配置),非空即用;为空/反序列化失败 → `default_seed_chain()` 兜底。
|
||||
/// 不复用 `tlusty_chain`:冷启动链首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的
|
||||
/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。seed_chain 专用于种子热启动,
|
||||
/// 首步 seed_nc 的 `ltgray=F` 保留 fort.8 种子。
|
||||
/// - 其余策略 → 优先 TaskSpec.tlusty_chain_params(用户 YAML `tlusty_chain:` 配置),非空即用;
|
||||
/// 为空/反序列化失败 → `default_chain_for_strategy(current_strategy)` 兜底。
|
||||
fn resolve_execution_chain(
|
||||
current_strategy: &str,
|
||||
tlusty_chain_params: &Option<serde_json::Value>,
|
||||
seed_chain_params: &Option<serde_json::Value>,
|
||||
task_id: &str,
|
||||
) -> Vec<ChainStep> {
|
||||
if current_strategy == "seed_step" {
|
||||
common::runner::default_seed_chain()
|
||||
seed_chain_params
|
||||
.as_ref()
|
||||
.and_then(|v| match serde_json::from_value::<Vec<ChainStep>>(v.clone()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"任务 {} 的 seed_chain_params 反序列化失败,回退 default_seed_chain: {}",
|
||||
task_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter(|c| !c.is_empty())
|
||||
.unwrap_or_else(common::runner::default_seed_chain)
|
||||
} else {
|
||||
tlusty_chain_params
|
||||
.as_ref()
|
||||
@@ -512,6 +535,10 @@ mod tests {
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(10.0),
|
||||
elapsed_sec: 120.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
@@ -520,22 +547,38 @@ mod tests {
|
||||
chain.iter().map(|s| s.label.clone()).collect()
|
||||
}
|
||||
|
||||
/// P1 回归防护:seed_step 即使注入自定义冷启动链,也必须强制走种子热启动默认链
|
||||
/// (seed_nc→nl),否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。
|
||||
/// P1 回归防护:seed_step 忽略 tlusty_chain_params(冷启动链),只认 seed_chain_params。
|
||||
/// 否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。
|
||||
#[test]
|
||||
fn seed_step_ignores_custom_cold_chain() {
|
||||
let custom = serde_json::json!([
|
||||
let custom_cold = serde_json::json!([
|
||||
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
|
||||
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
|
||||
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
|
||||
]);
|
||||
|
||||
let chain = resolve_execution_chain("seed_step", &Some(custom), "t1");
|
||||
// seed_chain_params=None → 走 default_seed_chain;tlusty_chain_params 被忽略。
|
||||
let chain = resolve_execution_chain("seed_step", &Some(custom_cold), &None, "t1");
|
||||
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
|
||||
// 首步必须是非灰 LTE(ltgray=F),否则会删 fort.8 丢弃种子。
|
||||
assert_eq!(chain[0].ltgray, "F");
|
||||
}
|
||||
|
||||
/// seed_step 使用 seed_chain_params(用户 YAML seed_chain 配置)时生效。
|
||||
#[test]
|
||||
fn seed_step_uses_custom_seed_chain_when_provided() {
|
||||
let custom_seed = serde_json::json!([
|
||||
{"label": "seed_nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 20, "orelax": 0.3},
|
||||
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100, "orelax": 0.5},
|
||||
]);
|
||||
|
||||
let chain = resolve_execution_chain("seed_step", &None, &Some(custom_seed), "t1b");
|
||||
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
|
||||
// 用户配置的 orelax 生效。
|
||||
assert_eq!(chain[0].orelax, Some(0.3));
|
||||
assert_eq!(chain[1].orelax, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_run_uses_custom_chain_when_provided() {
|
||||
let custom = serde_json::json!([
|
||||
@@ -544,13 +587,13 @@ mod tests {
|
||||
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
|
||||
]);
|
||||
|
||||
let chain = resolve_execution_chain("cold_run", &Some(custom), "t2");
|
||||
let chain = resolve_execution_chain("cold_run", &Some(custom), &None, "t2");
|
||||
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_run_falls_back_to_default_when_no_custom_chain() {
|
||||
let chain = resolve_execution_chain("cold_run", &None, "t3");
|
||||
let chain = resolve_execution_chain("cold_run", &None, &None, "t3");
|
||||
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,10 @@ mod tests {
|
||||
synspec_error: synspec_error.map(|s| s.to_string()),
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 10.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 一次性迁移端点:把 seeds_dir 下的 conv.json 批量导入 grid_points.summary_json。
|
||||
//!
|
||||
//! # 背景
|
||||
//! 旧版 conv.json 只存磁盘不进 DB。本端点扫 `seeds_dir/<name>/conv.json`,
|
||||
//! 解析为 ModelSummary 后经 `merge_point_summary` 增量合并写入 `grid_points.summary_json`,
|
||||
//! 让详情页 conv 诊断面板从 DB 读取(不再依赖磁盘文件)。
|
||||
//!
|
||||
//! # 快速删除
|
||||
//! 本模块是**临时迁移工具**,迁移完成后删除本文件 + main.rs 中路由注册即可。
|
||||
|
||||
use super::AppState;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use common::models::ModelSummary;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// `/admin/migrate_conv` 的查询参数。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MigrateConvQuery {
|
||||
/// 目标工作流名(必填)。
|
||||
pub workflow: String,
|
||||
/// 扫描根目录(缺省取 AppState.seeds_dir)。
|
||||
pub dir: Option<String>,
|
||||
}
|
||||
|
||||
/// 递归收集目录下所有 conv.json 路径。
|
||||
///
|
||||
/// 用 std::fs 而非 walkdir,避免给 server 引入临时依赖(本模块要快速删除)。
|
||||
fn collect_conv_jsons(dir: &Path, results: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_conv_jsons(&path, results);
|
||||
} else if path.file_name().map(|n| n == "conv.json").unwrap_or(false) {
|
||||
results.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/migrate_conv — 扫描 seeds_dir 的 conv.json → 增量合并写入 grid_points.summary_json。
|
||||
///
|
||||
/// - Admin 鉴权(与其他 /admin/* 端点一致)。
|
||||
/// - 幂等:`upsert_grid_point_named` 用 `ON CONFLICT DO NOTHING`,可重复运行。
|
||||
/// - 增量合并:synspec-only 的 conv.json 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。
|
||||
pub async fn migrate_conv(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<MigrateConvQuery>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
let scan_dir = query.dir.unwrap_or_else(|| state.seeds_dir.clone());
|
||||
let workflow = query.workflow;
|
||||
|
||||
// 1. 递归扫描 conv.json。
|
||||
let mut paths = Vec::new();
|
||||
collect_conv_jsons(Path::new(&scan_dir), &mut paths);
|
||||
|
||||
if paths.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"message": format!("目录 {} 下未找到 conv.json", scan_dir),
|
||||
"imported": 0,
|
||||
"skipped": 0,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
info!("迁移开始:扫到 {} 份 conv.json(dir={}, workflow={})", paths.len(), scan_dir, workflow);
|
||||
|
||||
// 2. 逐份解析 + upsert + 增量合并。
|
||||
let mut ok = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
|
||||
for path in &paths {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!("读取失败 {}: {}", path.display(), e);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// 少数"conv.json"实际是 node 端失败错误占位(`{"error": "..."}`,见 reporter 错误路径),
|
||||
// 非有效 ModelSummary → 反序列化失败,正确行为是跳过(这些点无有效收敛数据)。
|
||||
// name 缺失(ModelSummary.name 必填)也属此类,不应兜底导入伪造点。
|
||||
let summary: ModelSummary = match serde_json::from_str(&text) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("解析失败 {}: {}", path.display(), e);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 权威点名取 summary.name(源精度真名);空则回退目录名。
|
||||
let point_name = if !summary.name.is_empty() {
|
||||
summary.name.clone()
|
||||
} else {
|
||||
match path.parent().and_then(|p| p.file_name()) {
|
||||
Some(n) => n.to_string_lossy().to_string(),
|
||||
None => {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// method:stages 含 seed_nc → seed_step,否则 cold_run。
|
||||
let method = if summary.stages.iter().any(|s| s.label == "seed_nc") {
|
||||
"seed_step"
|
||||
} else {
|
||||
"cold_run"
|
||||
};
|
||||
|
||||
// upsert 建点(幂等)+ 增量合并写入 summary_json。
|
||||
let wave = state
|
||||
.db
|
||||
.compute_wave_for_cno_sum(&workflow, summary.params.cno_sum())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.upsert_grid_point_named(&point_name, &summary.params, wave, &workflow)
|
||||
.await
|
||||
{
|
||||
warn!("点 {} upsert 失败: {}", point_name, e);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.upsert_point_summary(&point_name, &workflow, &summary, method)
|
||||
.await
|
||||
{
|
||||
warn!("点 {} summary 写入失败: {}", point_name, e);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
ok += 1;
|
||||
}
|
||||
|
||||
info!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"message": format!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped),
|
||||
"imported": ok,
|
||||
"skipped": skipped,
|
||||
"total": ok + skipped,
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod data;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod node;
|
||||
pub mod rate_limit;
|
||||
pub mod seed;
|
||||
@@ -100,8 +101,8 @@ fn required_role(path: &str, method: &axum::http::Method) -> Option<Role> {
|
||||
if path == "/status" && method == Method::GET {
|
||||
return Some(Role::Admin);
|
||||
}
|
||||
// 管理 API(节点凭据查看/审批/重发/停用/启用、历史种子导入等)→ Admin
|
||||
// 注:所有 /admin/* 均需 Admin 鉴权(含 /admin/import_seed),统一在此判定即可,
|
||||
// 管理 API(节点凭据查看/审批/重发/停用/启用等)→ Admin
|
||||
// 注:所有 /admin/* 均需 Admin 鉴权,统一在此判定即可,
|
||||
// 无需为单个子路径重复列举(避免出现被前缀匹配遮蔽的不可达分支)。
|
||||
if path.starts_with("/admin/") {
|
||||
return Some(Role::Admin);
|
||||
|
||||
+35
-232
@@ -1,11 +1,10 @@
|
||||
use super::{AppState, AuthenticatedNode};
|
||||
use axum::{
|
||||
extract::{Extension, Multipart, Query, State},
|
||||
extract::{Extension, Multipart, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use common::models::{GridPointParams, ModelSummary, TaskReport, TaskStatus};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
@@ -268,12 +267,43 @@ pub async fn report_task(
|
||||
if fs::create_dir_all(&model_dir).await.is_ok() {
|
||||
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
|
||||
let conv_path = model_dir.join("conv.json");
|
||||
if fs::write(&conv_tmp, &report.summary_json).await.is_ok() {
|
||||
// conv.json 写入内容选择:
|
||||
// - 成功:从 DB 读取合并后的 summary_json(record_task_report 已通过
|
||||
// merge_point_summary 合并 prior TLUSTY 诊断 + incoming synspec 字段)。
|
||||
// synspec-only 重跑成功后 raw incoming 只有 synspec 字段(stages 为空),
|
||||
// 直接写 raw 会丢失 TLUSTY 诊断 → 离线 salvage 导入读 conv.json 时无法恢复。
|
||||
// - 失败:写 raw incoming(含错误路径 {"error":...},供排错),不读 DB(DB
|
||||
// 可能因 incoming 不可解析而保留 prior,丢失本次错误信息)。
|
||||
let conv_content = if report.result_valid
|
||||
&& !report.atmosphere_has_nan
|
||||
&& report.status == TaskStatus::Completed
|
||||
{
|
||||
state
|
||||
.db
|
||||
.get_point_summary_json(&workflow_name, &name)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| report.summary_json.clone())
|
||||
} else {
|
||||
report.summary_json.clone()
|
||||
};
|
||||
if fs::write(&conv_tmp, &conv_content).await.is_ok() {
|
||||
let _ = fs::rename(&conv_tmp, &conv_path).await;
|
||||
}
|
||||
|
||||
// Save seed file .7 using atomic temporary writing strategy
|
||||
if report.result_valid && !report.atmosphere_has_nan {
|
||||
// Save seed file .7 using atomic temporary writing strategy.
|
||||
// §7 修复:synspec-only 任务(tlusty_enabled=false)未重算大气,节点上传的 .7 是
|
||||
// 从服务端下载的原样字节 → 跳过写入,避免冗余 I/O 和潜在的误覆盖(如节点本地归档
|
||||
// 版本与服务端不一致)。查询失败时 fallback 为 true(保守,不阻断种子写入)。
|
||||
let tlusty_was_enabled = state
|
||||
.db
|
||||
.get_task_tlusty_enabled(&report.task_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(true);
|
||||
if report.result_valid && !report.atmosphere_has_nan && tlusty_was_enabled {
|
||||
if let Some(bytes) = seed_file_data {
|
||||
let seed_tmp =
|
||||
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
|
||||
@@ -341,230 +371,3 @@ fn extract_params(report: &TaskReport) -> Option<GridPointParams> {
|
||||
.ok()
|
||||
.map(|summary| summary.params)
|
||||
}
|
||||
|
||||
/// `/admin/import_seed` 的查询参数。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportSeedQuery {
|
||||
/// 目标工作流名(导入到此工作流的 grid_points)。缺省归入 `imported` 工作流。
|
||||
#[serde(default = "default_import_workflow")]
|
||||
pub workflow: String,
|
||||
}
|
||||
|
||||
fn default_import_workflow() -> String {
|
||||
"imported".to_string()
|
||||
}
|
||||
|
||||
/// 历史种子导入端点(Admin 鉴权)。
|
||||
///
|
||||
/// 供 `tools/import_results` 把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)
|
||||
/// 批量回灌进 DCTS。与 `/task/report` 的关键区别:
|
||||
/// - **跳过任务归属校验**(`verify_task_claim`):历史数据无领用语义,导入端点不经过
|
||||
/// claim/report 队列,直接幂等落库。
|
||||
/// - **`point_name` 取旧 `conv.json` 的 `name` 字段**(Python `gen_input5.model_name`
|
||||
/// 生成的源精度真名,如 `t20000_g5.0_...`),而非从数值重推——保证迁移逐字符保真。
|
||||
/// - **真实 `max_relc`** 取自 `summary.final_max_relc`(旧版已记录),不硬编码。
|
||||
///
|
||||
/// 幂等:`upsert_grid_point` 用 `ON CONFLICT DO NOTHING`,`.7`/`conv.json` 原子覆盖写,
|
||||
/// 可重复运行。
|
||||
pub async fn import_seed(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ImportSeedQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
let mut summary_json: Option<String> = None;
|
||||
let mut seed_file_data: Option<Vec<u8>> = None;
|
||||
// 大气收敛途径(cold_run/seed_step):由 import_results 工具依据旧 conv.json 的 stages 是否
|
||||
// 含 seed_nc 判定后透传。缺失或非法时兜底 cold_run(容错旧版工具 / 防注入)。
|
||||
// 语义为 TLUSTY 阶段策略,写入 grid_points.tlusty_success_method。
|
||||
let mut success_method: Option<String> = None;
|
||||
let mut multipart_error = false;
|
||||
|
||||
// 显式遍历全部字段,记录读取错误。旧实现 `while let Ok(...)` 在首字段出错时静默停止,
|
||||
// 可能丢失后续 seed_file/report 字段导致半截请求被处理。
|
||||
loop {
|
||||
match multipart.next_field().await {
|
||||
Ok(Some(field)) => {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
if field_name == "report" {
|
||||
match field.bytes().await {
|
||||
Ok(bytes) => {
|
||||
summary_json = Some(String::from_utf8_lossy(&bytes).to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("历史种子导入:读取 report 字段失败: {}", e);
|
||||
multipart_error = true;
|
||||
}
|
||||
}
|
||||
} else if field_name == "seed_file" {
|
||||
match field.bytes().await {
|
||||
Ok(bytes) => {
|
||||
seed_file_data = Some(bytes.to_vec());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("历史种子导入:读取 seed_file 字段失败: {}", e);
|
||||
multipart_error = true;
|
||||
}
|
||||
}
|
||||
} else if field_name == "tlusty_success_method" {
|
||||
match field.text().await {
|
||||
Ok(text) => {
|
||||
success_method = Some(text);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("历史种子导入:读取 tlusty_success_method 字段失败: {}", e);
|
||||
multipart_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
warn!("历史种子导入:解析 multipart 字段时出错: {}", e);
|
||||
multipart_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if multipart_error {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"multipart 请求体解析不完整(字段读取失败)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let summary_json = match summary_json {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"请求中缺少 report 字段(旧版 conv.json 内容)".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 解析旧版 conv.json(ModelSummary 结构)取 name / params / 收敛状态 / 真实 max_relc。
|
||||
let summary: ModelSummary = match serde_json::from_str(&summary_json) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("历史种子导入:conv.json 解析失败: {}", e);
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"conv.json 解析失败,非合法 ModelSummary".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// point_name 优先用旧 conv.json 的 name(源精度真名);回退到 params 规范名。
|
||||
let name = if !summary.name.is_empty() {
|
||||
summary.name.clone()
|
||||
} else {
|
||||
summary.params.model_name()
|
||||
};
|
||||
|
||||
// 名称合法性校验(防路径穿越),与 report_task 同口径。
|
||||
if !super::workflow::is_valid_point_name(&name) {
|
||||
warn!("历史种子导入:拒绝非法网格点名称: {}", name);
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"非法的网格点名称参数".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let workflow_name = query.workflow;
|
||||
let params = summary.params.clone();
|
||||
let converged = summary.result_valid && !summary.atmosphere_has_nan;
|
||||
let max_relc = summary.final_max_relc;
|
||||
|
||||
// 1. 幂等写入 grid_points(ON CONFLICT DO NOTHING):无需事先 start 工作流。
|
||||
// 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的
|
||||
// params 来自旧 JSON(无源文本,model_name() 会失真)。
|
||||
// wave 修复(2026-08-04):此前硬编码 wave=0,导入点全部被错误归入第一波,
|
||||
// 前端难度波次推进显示错误。现按 initialize_grid 同口径计算波次(该工作流内
|
||||
// cno_sum 严格小于本点的去重值个数),新导入点落库即归入正确波次。
|
||||
let wave = state
|
||||
.db
|
||||
.compute_wave_for_cno_sum(&workflow_name, params.cno_sum())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.upsert_grid_point_named(&name, ¶ms, wave, &workflow_name)
|
||||
.await
|
||||
{
|
||||
tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e);
|
||||
return Err(crate::api::AppError::Internal(e));
|
||||
}
|
||||
|
||||
// 2. 落地 conv.json(原子 tmp→rename)。
|
||||
let model_dir = Path::new(&state.seeds_dir).join(&name);
|
||||
if fs::create_dir_all(&model_dir).await.is_ok() {
|
||||
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
|
||||
let conv_path = model_dir.join("conv.json");
|
||||
if fs::write(&conv_tmp, &summary_json).await.is_ok() {
|
||||
let _ = fs::rename(&conv_tmp, &conv_path).await;
|
||||
}
|
||||
|
||||
// 3. 收敛且干净才写 .7 + 入种子库(与 report_task 同口径)。
|
||||
if converged {
|
||||
if let Some(bytes) = seed_file_data {
|
||||
let seed_tmp =
|
||||
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
|
||||
let seed_path = model_dir.join(format!("{}.7", name));
|
||||
if fs::write(&seed_tmp, bytes).await.is_ok()
|
||||
&& fs::rename(&seed_tmp, &seed_path).await.is_ok()
|
||||
{
|
||||
info!(
|
||||
"历史种子导入:网格点 {} 收敛种子已落地: {} (max_relc={:?})",
|
||||
name,
|
||||
seed_path.display(),
|
||||
max_relc
|
||||
);
|
||||
let _ = state
|
||||
.db
|
||||
.insert_seed_named(&name, ¶ms, &seed_path.to_string_lossy())
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"历史种子导入:网格点 {} 声称收敛但未上传 seed_file,跳过种子写入",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新 grid_points 状态:收敛→converged(大气归因 tlusty_success_method=工具判定的途径);否则维持 pending
|
||||
// 让正常调度处理(导入未收敛点无意义,但记录其尝试)。
|
||||
// 途径缺失或非法时兜底 cold_run(容错旧版工具 / 防注入),由 db 层再次白名单校验。
|
||||
if converged {
|
||||
// 归一化:仅接受 cold_run / seed_step,其余(含 None)一律兜底 cold_run。
|
||||
let method = match success_method.as_deref() {
|
||||
Some("seed_step") => "seed_step",
|
||||
_ => "cold_run",
|
||||
};
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.mark_grid_point_imported(&name, &workflow_name, Some(summary.elapsed_sec), method)
|
||||
.await
|
||||
{
|
||||
warn!("历史种子导入:标记 {} 为 converged 失败: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"历史种子导入完成:网格点 {} (workflow={}, converged={}, success_method={}, max_relc={:?})",
|
||||
name,
|
||||
workflow_name,
|
||||
converged,
|
||||
success_method.as_deref().unwrap_or("(default cold_run)"),
|
||||
max_relc
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"point_name": name,
|
||||
// 注:此 "converged" 键是大气收敛标志(由 summary.result_valid 派生),与 grid_points.status
|
||||
// 的 completed 重命名无关,勿改(改键名会破坏历史种子导入客户端)。
|
||||
"converged": converged,
|
||||
"max_relc": max_relc,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -615,12 +615,11 @@ pub async fn get_workflow_points(
|
||||
}
|
||||
}
|
||||
|
||||
/// 单网格点详情:点行 + 全部尝试历史 + conv.json 逐阶段诊断。
|
||||
/// 单网格点详情:点行 + 全部尝试历史 + summary_json 逐阶段诊断。
|
||||
///
|
||||
/// conv.json 读自 `seeds_dir/<point>/conv.json`(单层目录)。点名经 `is_valid_point_name`
|
||||
/// 白名单(无 `/`、`\`,拒前导 `.`)——即路径穿越的前置闸门;读盘后再做 canonicalize
|
||||
/// 归属兜底校验(纵深防御)。缺失/读失败/解析失败一律 `conv: null`(仍 200),
|
||||
/// 前端降级显示"诊断文件不可用"。
|
||||
/// conv 诊断面板读自 `grid_points.summary_json`(点级诊断快照,DB 权威源)。
|
||||
/// synspec-only 重跑时由 `record_task_report` 的增量合并保证 TLUSTY 字段不丢失。
|
||||
/// 缺失/读失败/解析失败一律 `conv: null`(仍 200),前端降级显示"诊断文件不可用"。
|
||||
pub async fn get_workflow_point_detail(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((name, point)): AxumPath<(String, String)>,
|
||||
@@ -643,33 +642,21 @@ pub async fn get_workflow_point_detail(
|
||||
};
|
||||
let attempts = state.db.list_point_attempts(&name, &point).await?;
|
||||
|
||||
let conv_path = std::path::Path::new(&state.seeds_dir)
|
||||
.join(&point)
|
||||
.join("conv.json");
|
||||
let conv: Option<common::models::ModelSummary> =
|
||||
match tokio::fs::read_to_string(&conv_path).await {
|
||||
Ok(s) => {
|
||||
let confined = std::path::Path::new(&state.seeds_dir)
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.zip(conv_path.canonicalize().ok())
|
||||
.map(|(root, f)| f.starts_with(root))
|
||||
.unwrap_or(false);
|
||||
if confined {
|
||||
match serde_json::from_str(&s) {
|
||||
Ok(summary) => Some(summary),
|
||||
Err(e) => {
|
||||
tracing::warn!("网格点 {} 的 conv.json 解析失败: {}", point, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("网格点 {} 的 conv.json 路径越界,拒绝读取", point);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
// conv 诊断面板数据源:grid_points.summary_json(点级诊断快照)。
|
||||
// 此前读磁盘 conv.json,synspec-only 重跑会覆盖 TLUSTY 字段;改为查 DB 后由
|
||||
// record_task_report 的增量合并保证 TLUSTY 诊断不丢失。
|
||||
let conv: Option<common::models::ModelSummary> = match state
|
||||
.db
|
||||
.get_point_summary_json(&name, &point)
|
||||
.await
|
||||
{
|
||||
Ok(Some(json)) => serde_json::from_str(&json).ok(),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
tracing::warn!("网格点 {} 的 summary_json 读取失败: {}", point, e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
+101
-48
@@ -52,10 +52,10 @@ impl Database {
|
||||
/// 的去重值个数,即 `cno_sum` 升序中的桶序号——与 `initialize_grid` 的波次分组口径一致
|
||||
/// (`scheduler.rs` 沿排序序扫描,cno_sum 变化 >1e-5 则 wave+1)。
|
||||
///
|
||||
/// 历史种子导入(`import_seed`)用它替代此前硬编码的 `wave=0`:导入点按现有规则归入
|
||||
/// 正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流(所有
|
||||
/// cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合 import_results
|
||||
/// 工具按 cno_sum 升序导入即可保证最终波次一致。
|
||||
/// 离线导入工具(`tools/import_db`)用它替代此前硬编码的 `wave=0`:导入点按现有规则
|
||||
/// 归入正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流
|
||||
/// (所有 cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合
|
||||
/// 导入工具按 cno_sum 升序导入即可保证最终波次一致。
|
||||
pub async fn compute_wave_for_cno_sum(&self, workflow_name: &str, cno_sum: f64) -> Result<i32> {
|
||||
let pool = self.pool.clone();
|
||||
let wf = workflow_name.to_string();
|
||||
@@ -527,50 +527,6 @@ impl Database {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 历史种子导入专用:把网格点标记为 converged 并记录大气收敛途径 `tlusty_success_method`。
|
||||
///
|
||||
/// 与正常 `record_task_report` 路径的区别:导入不走 task 队列,无 task_type 可取,
|
||||
/// 故由导入工具(import_results)依据旧 conv.json 的 stages 是否含 seed_nc 判定该点
|
||||
/// 当初是冷启动收敛(cold_run)还是种子步进收敛(seed_step),经 multipart 字段透传至此。
|
||||
/// 导入点因此融入冷启动/种子步进统计,而非独立为 imported 分类。
|
||||
///
|
||||
/// - `success_method`:须为 "cold_run" 或 "seed_step",非法值兜底为 "cold_run"(防注入)。
|
||||
/// 语义为 TLUSTY 阶段策略,写入 `tlusty_success_method` 列。
|
||||
/// - `elapsed_sec`:旧版 conv.json 的单点墙钟耗时(`summary.elapsed_sec`),落入
|
||||
/// `last_elapsed_sec` 列使迁移点在详情页/点表保留真实耗时;无此数据传 None。
|
||||
pub async fn mark_grid_point_imported(
|
||||
&self,
|
||||
name: &str,
|
||||
workflow_name: &str,
|
||||
elapsed_sec: Option<f64>,
|
||||
success_method: &str,
|
||||
) -> Result<()> {
|
||||
// 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。
|
||||
let method = match success_method {
|
||||
"seed_step" => "seed_step",
|
||||
_ => "cold_run",
|
||||
};
|
||||
let pool = self.pool.clone();
|
||||
let name_owned = name.to_string();
|
||||
let wf = workflow_name.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let conn = pool
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
|
||||
// 阶段列(5b):导入点是历史 TLUSTY 大气收敛 → tlusty_status='converged',
|
||||
// synspec_status 保持 NULL(历史结果仅大气,光谱未运行/未记录)。与正常结算路径的
|
||||
// 状态一致性(审查 #2 修正:原实现不设阶段列,导入点与正常点状态口径不一致)。
|
||||
conn.execute(
|
||||
"UPDATE grid_points SET status = 'completed', tlusty_success_method = ?1, last_elapsed_sec = ?2, tlusty_status = 'converged' \
|
||||
WHERE name = ?3 AND workflow_name = ?4",
|
||||
params![method, elapsed_sec, name_owned, wf],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_grid_point_status(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -934,4 +890,101 @@ impl Database {
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// 取网格点的 summary_json(点级诊断快照),供详情页 conv 诊断面板渲染。
|
||||
///
|
||||
/// 与 `get_workflow_point_row` 分离:summary_json 可能很大(含 stages/itek_history
|
||||
/// 等嵌套诊断),列表查询(`list_workflow_points`)不需要它,故独立查询避免每行
|
||||
/// 拖带大 JSON。返回 None = 该点无诊断数据(未跑过或尚未结算)。
|
||||
pub async fn get_point_summary_json(
|
||||
&self,
|
||||
wf: &str,
|
||||
point: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let pool = self.pool.clone();
|
||||
let wf = wf.to_string();
|
||||
let point = point.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Result<Option<String>> {
|
||||
let conn = pool
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
|
||||
let res = conn.query_row(
|
||||
"SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
|
||||
params![point, wf],
|
||||
|r| r.get::<_, Option<String>>(0),
|
||||
);
|
||||
match res {
|
||||
Ok(v) => Ok(v),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// 离线/批量导入专用:把一个点级 [`ModelSummary`] 增量合并写入 grid_points。
|
||||
///
|
||||
/// 供离线导入工具(`tools/import_db`)逐份合并多节点 conv.json 时复用。与在线
|
||||
/// `record_task_report` 路径共享 `merge_point_summary` 合并逻辑,保证一致性:
|
||||
/// incoming.stages 非空 → 整体替换;为空 → 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。
|
||||
///
|
||||
/// 同时更新 status / method / elapsed_sec / tlusty_status 等调度列(等价于旧
|
||||
/// `mark_grid_point_imported` 的职责并入此方法)。调用方须先 `upsert_grid_point_named`
|
||||
/// 建点(保证行存在)。
|
||||
///
|
||||
/// - `method`:TLUSTY 收敛策略(`cold_run` / `seed_step`),由 stages 是否含 `seed_nc` 判定。
|
||||
/// - 返回合并后的 summary 文本(供调用方日志/验证)。
|
||||
pub async fn upsert_point_summary(
|
||||
&self,
|
||||
name: &str,
|
||||
workflow_name: &str,
|
||||
incoming: &common::models::ModelSummary,
|
||||
method: &str,
|
||||
) -> Result<String> {
|
||||
// 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。
|
||||
let method = match method {
|
||||
"seed_step" => "seed_step",
|
||||
_ => "cold_run",
|
||||
};
|
||||
let converged = incoming.result_valid && !incoming.atmosphere_has_nan;
|
||||
|
||||
// 增量合并:读旧 summary_json → merge_point_summary → 写回。
|
||||
let prior_json = self.get_point_summary_json(workflow_name, name).await?;
|
||||
let prior = prior_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<common::models::ModelSummary>(s).ok());
|
||||
let merged = common::summary_merge::merge_point_summary(prior.as_ref(), incoming);
|
||||
let merged_json = serde_json::to_string(&merged).unwrap_or_default();
|
||||
|
||||
let pool = self.pool.clone();
|
||||
let name_owned = name.to_string();
|
||||
let wf = workflow_name.to_string();
|
||||
let merged_json_clone = merged_json.clone();
|
||||
let elapsed_sec = merged.elapsed_sec;
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let conn = pool
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
|
||||
// 收敛 → completed(与 record_task_report success 分支同口径:tlusty_status='converged')。
|
||||
// 未收敛 → pending(让调度器后续处理,不在此翻 failed——离线导入无失败语义)。
|
||||
if converged {
|
||||
conn.execute(
|
||||
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, \
|
||||
tlusty_success_method = ?2, tlusty_status = 'converged', summary_json = ?3 \
|
||||
WHERE name = ?4 AND workflow_name = ?5",
|
||||
params![elapsed_sec, method, merged_json_clone, name_owned, wf],
|
||||
)?;
|
||||
} else {
|
||||
// 未收敛:仍写入 summary_json(保留诊断供排查),但状态不变更。
|
||||
conn.execute(
|
||||
"UPDATE grid_points SET last_elapsed_sec = ?1, summary_json = ?2 \
|
||||
WHERE name = ?3 AND workflow_name = ?4",
|
||||
params![elapsed_sec, merged_json_clone, name_owned, wf],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
Ok(merged_json)
|
||||
}
|
||||
}
|
||||
|
||||
+726
-13
@@ -69,7 +69,12 @@ const GRID_POINTS_SCHEMA: &str = "CREATE TABLE grid_points (
|
||||
-- H1 修复(M11):运行时回退把点打回 pending 时记录的「剩余策略链」(JSON 数组)。
|
||||
-- 供调度路径识别「该点已失败过 cold_run、正在等种子」→ 重派时用剩余链而非完整 YAML 链,
|
||||
-- 避免重跑已失败策略导致的无界失败重试活锁(见 scheduler.rs H1 注释)。NULL = 无标记。
|
||||
pending_strategies TEXT
|
||||
pending_strategies TEXT,
|
||||
-- 最近一次尝试的真实墙钟耗时(秒,Worker 回报值;旧数据为 None)。
|
||||
last_elapsed_sec REAL,
|
||||
-- 点级诊断快照(完整 ModelSummary JSON)。详情页 conv 诊断面板的数据源。
|
||||
-- synspec-only 重跑时经 merge_point_summary 增量合并,保留 TLUSTY 字段不丢失。
|
||||
summary_json TEXT
|
||||
)";
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -431,15 +436,20 @@ impl Database {
|
||||
if !has_tasks_elapsed {
|
||||
let _ = conn.execute("ALTER TABLE tasks ADD COLUMN elapsed_sec REAL", []);
|
||||
}
|
||||
let has_gp_elapsed = conn
|
||||
.prepare("PRAGMA table_info(grid_points)")?
|
||||
.query_map([], |r| r.get::<_, String>(1))?
|
||||
.any(|r| r.map(|n| n == "last_elapsed_sec").unwrap_or(false));
|
||||
if !has_gp_elapsed {
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL",
|
||||
[],
|
||||
);
|
||||
// grid_points 幂等补列:last_elapsed_sec(列表展示用)与 summary_json(点级诊断快照)。
|
||||
// 新库由 GRID_POINTS_SCHEMA 建表时即含此二列;此 ALTER 仅兜底旧库(表已存在但缺列)。
|
||||
// 与上方 tasks 列迁移同模式:PRAGMA 检测 → 缺列才 ALTER。
|
||||
for (col, sql) in [
|
||||
("last_elapsed_sec", "ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL"),
|
||||
("summary_json", "ALTER TABLE grid_points ADD COLUMN summary_json TEXT"),
|
||||
] {
|
||||
let has_col = conn
|
||||
.prepare("PRAGMA table_info(grid_points)")?
|
||||
.query_map([], |r| r.get::<_, String>(1))?
|
||||
.any(|r| r.map(|n| n == col).unwrap_or(false));
|
||||
if !has_col {
|
||||
let _ = conn.execute(sql, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段独立配置迁移(见 docs/task_engine_decoupling_design.md §4.1):
|
||||
@@ -1143,9 +1153,30 @@ mod tests {
|
||||
db.update_grid_status(&p.model_name(), GridPointStatus::Queued, "wf_b")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(&p2.model_name(), "wf_a", None, "seed_step")
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let summary = common::models::ModelSummary {
|
||||
name: p2.model_name(),
|
||||
params: p2.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.001),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: None,
|
||||
synspec_error: None,
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 0.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
db.upsert_point_summary(&summary.name, "wf_a", &summary, "seed_step")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 全局(None):3 个点,pending/queued/converged 分开计数;
|
||||
// 导入点按 seed_step 途径计入 seed_step_converged(不再有独立 imported 分类)
|
||||
@@ -1782,8 +1813,17 @@ mod tests {
|
||||
synspec_config: common::models::PhaseConfig::default_synspec(),
|
||||
synspec_params: None,
|
||||
tlusty_chain_params: None,
|
||||
seed_chain_params: None,
|
||||
tlusty_input_params: None,
|
||||
atmosphere_ref: None,
|
||||
energy_tolerance: None,
|
||||
temp_max_factor: None,
|
||||
temp_floor: None,
|
||||
temp_ceiling: None,
|
||||
emflux_tolerance: None,
|
||||
convergence_min_ratio: None,
|
||||
bfac_max: None,
|
||||
bfac_min: None,
|
||||
};
|
||||
let syn_task = Uuid::new_v4();
|
||||
let old_task = Uuid::new_v4();
|
||||
@@ -3553,4 +3593,677 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// synspec-only 重跑后 tlusty_success_method / tlusty_status 须保留 prior 值,
|
||||
/// 不能被 NULL 覆写(CASE 守卫修复验证)。
|
||||
///
|
||||
/// 场景:先以 TLUSTY 启用(cold_run)跑成功 → tlusty_success_method = "cold_run"。
|
||||
/// 再以 TLUSTY 关闭(仅 SYNSPEC,场景 B)重跑成功 → tlusty_success_method 仍须为
|
||||
/// "cold_run",不能被覆写为 NULL。synspec_success_method 应更新为 "standard"。
|
||||
#[tokio::test]
|
||||
async fn test_synspec_only_rerun_preserves_tlusty_attribution() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("synrerun.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let wf = "wf_synrerun";
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 25000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, wf).await.unwrap();
|
||||
|
||||
// ── 第一轮:TLUSTY 启用 + cold_run,成功 ──
|
||||
let task1 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task1).await.unwrap();
|
||||
|
||||
let summary1 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0005),
|
||||
elapsed_sec: 300.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0005),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.3),
|
||||
elapsed_sec: 300.3,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report1 = TaskReport {
|
||||
task_id: task1.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0005),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 300.3,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary1).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report1, wf).await.unwrap();
|
||||
|
||||
// 验证第一轮:tlusty_success_method = cold_run, tlusty_status = converged
|
||||
let row1 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row1.status, "completed");
|
||||
assert_eq!(row1.tlusty_success_method.as_deref(), Some("cold_run"));
|
||||
assert_eq!(row1.tlusty_status.as_deref(), Some("converged"));
|
||||
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
|
||||
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
|
||||
|
||||
// ── 模拟场景 B:reset_terminal_points_for_recompute 翻回 pending ──
|
||||
db.reset_terminal_points_for_recompute(wf).await.unwrap();
|
||||
|
||||
// ── 第二轮:TLUSTY 关闭 + SYNSPEC 启用(synspec-only),成功 ──
|
||||
let task2 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: false,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
atmosphere_ref: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task2).await.unwrap();
|
||||
|
||||
// synspec-only 的 summary:stages 为空 → merge_point_summary 走字段级合并
|
||||
let summary2 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: true,
|
||||
final_max_relc: None,
|
||||
final_chmax: None,
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.25),
|
||||
elapsed_sec: 0.25,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report2 = TaskReport {
|
||||
task_id: task2.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: None,
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 0.25,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary2).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report2, wf).await.unwrap();
|
||||
|
||||
// ── 核心断言:synspec-only 重跑后 tlusty 侧归因须保留 ──
|
||||
let row2 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row2.status, "completed", "重跑成功后应为 completed");
|
||||
// ★ 修复前:tsm2 = None(裸赋值覆写)。修复后:保留 "cold_run"。
|
||||
assert_eq!(
|
||||
row2.tlusty_success_method.as_deref(),
|
||||
Some("cold_run"),
|
||||
"synspec-only 重跑后 tlusty_success_method 须保留,不能覆写为 NULL"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.tlusty_status.as_deref(),
|
||||
Some("converged"),
|
||||
"synspec-only 重跑后 tlusty_status 须保留"
|
||||
);
|
||||
// synspec 侧应更新为新值
|
||||
assert_eq!(
|
||||
row2.synspec_success_method.as_deref(),
|
||||
Some("standard"),
|
||||
"synspec_success_method 应更新为 standard"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.synspec_status.as_deref(),
|
||||
Some("converged"),
|
||||
"synspec_status 应为 converged"
|
||||
);
|
||||
|
||||
// summary_json 的 TLUSTY 诊断也须保留(merge_point_summary 字段级合并)
|
||||
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
|
||||
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(ms.stages.len(), 1, "stages 须保留 prior 的 TLUSTY 链");
|
||||
assert_eq!(ms.stages[0].label, "nl");
|
||||
assert_eq!(ms.final_max_relc, Some(0.0005), "final_max_relc 须保留");
|
||||
assert_eq!(ms.synspec_rc, Some(0), "synspec_rc 应为新值");
|
||||
assert_eq!(ms.elapsed_sec, 0.25, "elapsed_sec 应为新值");
|
||||
|
||||
// last_elapsed_sec 语义:最近一次尝试耗时。synspec-only 重跑后为 0.25s(synspec 耗时),
|
||||
// 原 TLUSTY 耗时保留在 stages[].elapsed_sec。ETA 不依赖此列(用 AVG(tasks.elapsed_sec))。
|
||||
let last_elapsed = read_grid_last_elapsed(&db, &name, wf).await;
|
||||
assert_eq!(
|
||||
last_elapsed, Some(0.25),
|
||||
"last_elapsed_sec 应为 synspec-only 耗时(最近一次尝试),非原 TLUSTY 总耗时"
|
||||
);
|
||||
}
|
||||
|
||||
/// 辅助:读取 grid_points 的阶段归因列。
|
||||
async fn read_grid_attrs(db: &Database, name: &str, wf: &str) -> GridAttrs {
|
||||
let pool = db.pool.clone();
|
||||
let name = name.to_string();
|
||||
let wf = wf.to_string();
|
||||
tokio::task::spawn_blocking(move || -> GridAttrs {
|
||||
let conn = pool.get().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT status, tlusty_success_method, synspec_success_method, tlusty_status, synspec_status \
|
||||
FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
|
||||
rusqlite::params![name, wf],
|
||||
|r| {
|
||||
Ok(GridAttrs {
|
||||
status: r.get(0)?,
|
||||
tlusty_success_method: r.get(1)?,
|
||||
synspec_success_method: r.get(2)?,
|
||||
tlusty_status: r.get(3)?,
|
||||
synspec_status: r.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct GridAttrs {
|
||||
status: String,
|
||||
tlusty_success_method: Option<String>,
|
||||
synspec_success_method: Option<String>,
|
||||
tlusty_status: Option<String>,
|
||||
synspec_status: Option<String>,
|
||||
}
|
||||
|
||||
/// synspec-only 重跑**失败**后 tlusty_success_method / tlusty_status 仍须保留。
|
||||
///
|
||||
/// 失败分支的 UPDATE 不写 success_method 列,但 tlusty_status / synspec_status
|
||||
/// 有 CASE 守卫。验证失败报告不会清空 prior 的 TLUSTY 归因。
|
||||
#[tokio::test]
|
||||
async fn test_synspec_only_rerun_failure_preserves_tlusty_attribution() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("synfail.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let wf = "wf_synfail";
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 25000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, wf).await.unwrap();
|
||||
|
||||
// 第一轮:TLUSTY 启用 + cold_run,成功。
|
||||
let task1 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task1).await.unwrap();
|
||||
let summary1 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0005),
|
||||
elapsed_sec: 300.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0005),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.3),
|
||||
elapsed_sec: 300.3,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report1 = TaskReport {
|
||||
task_id: task1.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0005),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 300.3,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary1).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report1, wf).await.unwrap();
|
||||
assert_eq!(read_grid_attrs(&db, &name, wf).await.tlusty_success_method.as_deref(), Some("cold_run"));
|
||||
|
||||
// 翻回 pending 模拟场景 B 重跑。
|
||||
db.reset_terminal_points_for_recompute(wf).await.unwrap();
|
||||
|
||||
// 第二轮:synspec-only,失败(synspec 产出脏谱)。
|
||||
let task2 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: false,
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
atmosphere_ref: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task2).await.unwrap();
|
||||
// synspec 失败:result_valid=false, stages 为空(synspec-only),synspec_rc=1。
|
||||
let summary2 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: false,
|
||||
final_max_relc: None,
|
||||
final_chmax: None,
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(1),
|
||||
synspec_error: Some("spec 含 NaN".into()),
|
||||
synspec_sec: Some(0.2),
|
||||
elapsed_sec: 0.2,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: Some("synspec 失败".into()),
|
||||
};
|
||||
let report2 = TaskReport {
|
||||
task_id: task2.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Failed,
|
||||
result_valid: false,
|
||||
max_relc: None,
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 0.2,
|
||||
error_message: Some("synspec 失败".to_string()),
|
||||
summary_json: serde_json::to_string(&summary2).unwrap(),
|
||||
failed_stage: Some("synspec".to_string()),
|
||||
};
|
||||
db.record_task_report(&report2, wf).await.unwrap();
|
||||
|
||||
// 失败后 tlusty 侧归因仍须保留。
|
||||
let row = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row.status, "failed", "失败后状态应为 failed");
|
||||
assert_eq!(
|
||||
row.tlusty_success_method.as_deref(),
|
||||
Some("cold_run"),
|
||||
"synspec-only 失败后 tlusty_success_method 须保留"
|
||||
);
|
||||
assert_eq!(
|
||||
row.tlusty_status.as_deref(),
|
||||
Some("converged"),
|
||||
"synspec-only 失败后 tlusty_status 须保留(CASE 守卫)"
|
||||
);
|
||||
// synspec 侧应反映失败。
|
||||
assert_eq!(row.synspec_status.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
/// 辅助:读取 grid_points.last_elapsed_sec。
|
||||
async fn read_grid_last_elapsed(db: &Database, name: &str, wf: &str) -> Option<f64> {
|
||||
let pool = db.pool.clone();
|
||||
let name = name.to_string();
|
||||
let wf = wf.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Option<f64> {
|
||||
let conn = pool.get().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT last_elapsed_sec FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
|
||||
rusqlite::params![name, wf],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// TLUSTY-only 重跑成功后 synspec 归因列应被显式清空(clear_synspec=true),
|
||||
/// summary_json 保留 prior synspec 字段(merge_point_summary TLUSTY-only 路径)。
|
||||
///
|
||||
/// 场景:先正常管线(TLUSTY+SYNSPEC)成功 → 再 TLUSTY-only(synspec 关闭)重跑成功
|
||||
/// → 新大气使旧光谱失效 → synspec_success_method/synspec_status 清 NULL,
|
||||
/// summary_json 中 synspec_rc/synspec_sec 保留自 prior。
|
||||
#[tokio::test]
|
||||
async fn test_tlusty_only_rerun_clears_synspec_attribution() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("tlonly.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let wf = "wf_tlonly";
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 25000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, wf).await.unwrap();
|
||||
|
||||
// ── 第一轮:正常管线(TLUSTY + SYNSPEC 双开),成功 ──
|
||||
let task1 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task1).await.unwrap();
|
||||
let summary1 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0005),
|
||||
elapsed_sec: 300.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0005),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.3),
|
||||
elapsed_sec: 300.3,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report1 = TaskReport {
|
||||
task_id: task1.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0005),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 300.3,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary1).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report1, wf).await.unwrap();
|
||||
let row1 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
|
||||
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
|
||||
|
||||
// ── 翻回 pending 模拟 TLUSTY-only 重跑 ──
|
||||
db.reset_terminal_points_for_recompute(wf).await.unwrap();
|
||||
|
||||
// ── 第二轮:TLUSTY-only(synspec 关闭),成功 ──
|
||||
let task2 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig {
|
||||
enabled: false,
|
||||
..PhaseConfig::default_synspec()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task2).await.unwrap();
|
||||
// TLUSTY-only summary:stages 非空(TLUSTY 跑了),synspec_rc=None(synspec 没跑)
|
||||
let summary2 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.0008),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0003),
|
||||
elapsed_sec: 280.0,
|
||||
note: None,
|
||||
last_iter: Some(15),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0003),
|
||||
final_chmax: Some(0.0008),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: None, // synspec 未运行
|
||||
synspec_error: None,
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 280.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report2 = TaskReport {
|
||||
task_id: task2.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0003),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 280.0,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary2).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report2, wf).await.unwrap();
|
||||
|
||||
// ── 核心断言:synspec 列应被显式清空(clear_synspec=true)──
|
||||
let row2 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row2.status, "completed");
|
||||
assert_eq!(
|
||||
row2.tlusty_success_method.as_deref(),
|
||||
Some("cold_run"),
|
||||
"tlusty_success_method 应更新为 cold_run"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.tlusty_status.as_deref(),
|
||||
Some("converged"),
|
||||
"tlusty_status 应为 converged"
|
||||
);
|
||||
// ★ synspec 列被 clear_synspec 显式置 NULL(新大气使旧光谱失效)
|
||||
assert_eq!(
|
||||
row2.synspec_success_method,
|
||||
None,
|
||||
"TLUSTY-only 重跑后 synspec_success_method 须清 NULL(clear_synspec)"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.synspec_status,
|
||||
None,
|
||||
"TLUSTY-only 重跑后 synspec_status 须清 NULL(clear_synspec)"
|
||||
);
|
||||
|
||||
// summary_json:TLUSTY 诊断来自 incoming,synspec 字段保留自 prior
|
||||
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
|
||||
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(ms.stages.len(), 1, "stages 来自 incoming");
|
||||
assert_eq!(ms.final_max_relc, Some(0.0003), "final_max_relc 来自 incoming");
|
||||
assert_eq!(
|
||||
ms.synspec_rc,
|
||||
Some(0),
|
||||
"synspec_rc 保留 prior 值(merge_point_summary TLUSTY-only 路径)"
|
||||
);
|
||||
assert_eq!(ms.synspec_sec, Some(0.3), "synspec_sec 保留 prior 值");
|
||||
}
|
||||
|
||||
/// `get_task_tlusty_enabled`:正常任务返回 true,synspec-only 任务返回 false,
|
||||
/// 不存在的 task_id 返回 None。
|
||||
#[tokio::test]
|
||||
async fn test_get_task_tlusty_enabled() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("tlusty_enabled.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 20000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-4.0).into(),
|
||||
logo: (-4.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, "wf_tle").await.unwrap();
|
||||
|
||||
// 正常任务(tlusty_enabled=true)
|
||||
let task_normal = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some("wf_tle".to_string()),
|
||||
tlusty_config: PhaseConfig::default_tlusty(),
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task_normal).await.unwrap();
|
||||
let enabled = db.get_task_tlusty_enabled(&task_normal.task_id).await.unwrap();
|
||||
assert_eq!(enabled, Some(true), "正常任务 tlusty_enabled 应为 true");
|
||||
|
||||
// synspec-only 任务(tlusty_enabled=false)
|
||||
let task_synonly = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some("wf_tle".to_string()),
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: false,
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
atmosphere_ref: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task_synonly).await.unwrap();
|
||||
let enabled = db.get_task_tlusty_enabled(&task_synonly.task_id).await.unwrap();
|
||||
assert_eq!(enabled, Some(false), "synspec-only 任务 tlusty_enabled 应为 false");
|
||||
|
||||
// 不存在的 task_id → None
|
||||
let fake_id = Uuid::new_v4();
|
||||
let enabled = db.get_task_tlusty_enabled(&fake_id).await.unwrap();
|
||||
assert_eq!(enabled, None, "不存在的 task_id 应返回 None");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,33 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查询某 task 的 `tlusty_enabled` 标志。
|
||||
///
|
||||
/// 用于 `report_task` API 在写 `.7` 种子文件前判断本次任务是否为 synspec-only
|
||||
/// (`tlusty_enabled=false`)。synspec-only 任务未重算大气,节点上传的 `.7` 是从
|
||||
/// 服务端下载的原样字节 → 服务端跳过写入,避免冗余 I/O 和潜在的误覆盖。
|
||||
///
|
||||
/// 返回 `None` 表示 task 行不存在(已被清理或 task_id 无效),调用方应 fallback 为
|
||||
/// `true`(保守,不阻断种子写入主流程)。tasks 行在 queue 清理后仍保留(仅 task_queue
|
||||
/// 被清),故在首次上报与幂等重放两条路径均有效。
|
||||
pub async fn get_task_tlusty_enabled(&self, task_id: &uuid::Uuid) -> Result<Option<bool>> {
|
||||
let pool = self.pool.clone();
|
||||
let id = task_id.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Result<Option<bool>> {
|
||||
let conn = pool
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
|
||||
let mut stmt = conn.prepare("SELECT tlusty_enabled FROM tasks WHERE task_id = ?1")?;
|
||||
let res = stmt.query_row(params![id], |r| r.get::<_, bool>(0));
|
||||
match res {
|
||||
Ok(enabled) => Ok(Some(enabled)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// 列出指定网格点在当前工作流内仍处于 `pending` 的 tasks 行 task_id。
|
||||
///
|
||||
/// tasks 行的 pending 意为"已创建但从未上报",可能是真在途(队列行仍 pending/
|
||||
@@ -486,10 +513,13 @@ impl Database {
|
||||
} else if converged {
|
||||
// 半失败:大气收敛 + 光谱失败。tlusty 阶段确已收敛(tlusty_enabled 防御性守卫——
|
||||
// 该分支在 synspec-only 下不可达,但避免未来代码路径把禁用阶段误标为 converged)。
|
||||
(tlusty_enabled.then_some("converged"), Some("failed"))
|
||||
// synspec_status gate on synspec_enabled:synspec 禁用时为 None → CASE 守卫保留 prior,
|
||||
// 不把禁用阶段误标为 "failed"(TLUSTY-only 重跑失败不应清空既有 synspec 状态)。
|
||||
(tlusty_enabled.then_some("converged"), synspec_enabled.then_some("failed"))
|
||||
} else if tlusty_enabled {
|
||||
// 大气未收敛(TLUSTY 启用)→ TLUSTY 失败、SYNSPEC 未运行。
|
||||
(Some("failed"), Some("pending"))
|
||||
// synspec_status gate on synspec_enabled:同上,禁用时保留 prior。
|
||||
(Some("failed"), synspec_enabled.then_some("pending"))
|
||||
} else {
|
||||
// synspec-only 失败(converged 由 synspec_rc 决定)。
|
||||
(None, Some("failed"))
|
||||
@@ -506,11 +536,60 @@ impl Database {
|
||||
// - 阶段列(5b):tlusty_status 用 CASE 守卫保留既有终态——半失败重试的 synspec-only
|
||||
// 任务(tlusty 关闭)不覆盖已收敛的 tlusty_status(设计 §7.3 打开项 #2:仅 synspec
|
||||
// 侧流转);synspec_status 自由流转。
|
||||
|
||||
// clear_synspec:TLUSTY-only 重跑成功(tlusty_enabled && !synspec_enabled)时,
|
||||
// 新大气使旧光谱失效 → 显式清空 synspec 归因列为 NULL(而非保留旧值)。
|
||||
// 与 synspec-only 重跑(tlusty_enabled=false → 大气未变 → 保留 tlusty 列)对称:
|
||||
// 另一阶段"跑了且产生了新产物"时,当前阶段的旧归因应作废。
|
||||
let clear_synspec = tlusty_enabled && !synspec_enabled;
|
||||
|
||||
// summary_json 增量合并:synspec-only 重跑(tlusty_enabled=false)时只覆盖 synspec
|
||||
// 字段,保留 grid_points 既有 summary 中的 TLUSTY 诊断(stages/final_max_relc/各 check)。
|
||||
// 合并逻辑共享 common::summary_merge,与离线导入工具保持一致。
|
||||
let merged_summary_json: Option<String> = {
|
||||
let prior_json: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
|
||||
params![point_name, wf],
|
||||
|r| r.get::<_, Option<String>>(0),
|
||||
)
|
||||
.ok()
|
||||
.flatten();
|
||||
let prior = prior_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<common::models::ModelSummary>(s).ok());
|
||||
let incoming = serde_json::from_str::<common::models::ModelSummary>(
|
||||
&report_cloned.summary_json,
|
||||
)
|
||||
.ok();
|
||||
// incoming 解析失败(错误路径 {"error":...} 或空字符串)时跳过 summary 写入,
|
||||
// 只更新原有列——与此前「server 只透传不解析 summary_json」的容错语义一致。
|
||||
incoming.map(|inc| {
|
||||
let merged = common::summary_merge::merge_point_summary(prior.as_ref(), &inc);
|
||||
serde_json::to_string(&merged).unwrap_or_default()
|
||||
})
|
||||
};
|
||||
|
||||
let grid_changed = if pipe_ok {
|
||||
tx.execute(
|
||||
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, tlusty_success_method = ?2, synspec_success_method = ?3,
|
||||
// 阶段列 CASE 守卫(对称修复):
|
||||
// - synspec-only 重跑(tlusty_enabled=0)→ tlusty 列为 NULL → CASE 守卫保留 prior。
|
||||
// - TLUSTY-only 重跑(synspec_enabled=0, clear_synspec=true)→ synspec 列显式置 NULL
|
||||
// (新大气使旧光谱失效),summary_merge 同步保留 prior synspec 字段。
|
||||
// - 正常管线(双开)→ 全部 CASE 的 THEN 分支命中,写入新值。
|
||||
// 此前 tlusty_success_method/synspec_success_method/synspec_status 为裸赋值,
|
||||
// synspec-only 成功会把 tlusty_success_method 从 "cold_run" 覆写为 NULL,
|
||||
// 污染 stats 统计(workflows.rs:99 / grid.rs:579 按 tlusty_success_method 归因)。
|
||||
//
|
||||
// last_elapsed_sec 语义:最近一次尝试的墙钟耗时。synspec-only 重跑后该列反映
|
||||
// synspec-only 耗时(~0.25s),TLUSTY 分项耗时保留在 summary_json.stages[].elapsed_sec。
|
||||
// ETA 不依赖此列(用 AVG(tasks.elapsed_sec)),conv 面板用 summary_json.elapsed_sec。
|
||||
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1,
|
||||
tlusty_success_method = CASE WHEN ?2 IS NOT NULL THEN ?2 ELSE tlusty_success_method END,
|
||||
synspec_success_method = CASE WHEN ?9 THEN NULL WHEN ?3 IS NOT NULL THEN ?3 ELSE synspec_success_method END,
|
||||
tlusty_status = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE tlusty_status END,
|
||||
synspec_status = ?7
|
||||
synspec_status = CASE WHEN ?9 THEN NULL WHEN ?7 IS NOT NULL THEN ?7 ELSE synspec_status END,
|
||||
summary_json = CASE WHEN ?8 IS NOT NULL THEN ?8 ELSE summary_json END
|
||||
WHERE name = ?4 AND workflow_name = ?5 AND status != 'completed'",
|
||||
params![
|
||||
report_cloned.elapsed_sec,
|
||||
@@ -519,16 +598,23 @@ impl Database {
|
||||
point_name,
|
||||
wf,
|
||||
tlusty_status,
|
||||
synspec_status
|
||||
synspec_status,
|
||||
merged_summary_json,
|
||||
clear_synspec,
|
||||
],
|
||||
)?
|
||||
} else {
|
||||
tx.execute(
|
||||
// synspec_status 同步加 CASE 守卫(对称修复):synspec_enabled=0 时
|
||||
// synspec_status 为 None(上方 if-else 已 gate)→ 保留 prior。
|
||||
// 与 success 分支、tlusty_status 守卫口径一致。
|
||||
// last_elapsed_sec 语义见 success 分支注释。
|
||||
"UPDATE grid_points SET status = 'failed', last_elapsed_sec = ?1,
|
||||
tlusty_status = CASE WHEN ?4 IS NOT NULL THEN ?4 ELSE tlusty_status END,
|
||||
synspec_status = ?5
|
||||
synspec_status = CASE WHEN ?5 IS NOT NULL THEN ?5 ELSE synspec_status END,
|
||||
summary_json = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE summary_json END
|
||||
WHERE name = ?2 AND workflow_name = ?3 AND status NOT IN ('completed', 'failed')",
|
||||
params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status],
|
||||
params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status, merged_summary_json],
|
||||
)?
|
||||
};
|
||||
|
||||
|
||||
@@ -325,8 +325,6 @@ async fn main() -> Result<()> {
|
||||
|
||||
let report_router = Router::new()
|
||||
.route("/task/report", post(api::task::report_task))
|
||||
// 历史种子导入同样上传 .7 大气文件,并入宽松 body limit / 并发限流组。
|
||||
.route("/admin/import_seed", post(api::task::import_seed))
|
||||
.layer(DefaultBodyLimit::max(REPORT_BODY_LIMIT))
|
||||
.layer(tower::ServiceBuilder::new().concurrency_limit(REPORT_MAX_CONCURRENCY));
|
||||
|
||||
@@ -426,6 +424,9 @@ async fn main() -> Result<()> {
|
||||
"/admin/nodes/:node_id/quota",
|
||||
post(api::admin::set_node_quota),
|
||||
)
|
||||
// 临时迁移端点:扫 seeds_dir 的 conv.json → 写 grid_points.summary_json。
|
||||
// 迁移完成后删除本路由 + api/migrate.rs 即可。
|
||||
.route("/admin/migrate_conv", post(api::migrate::migrate_conv))
|
||||
.layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT));
|
||||
|
||||
// 合并两个子 router:各自携带自己的 body limit,互不覆盖。
|
||||
|
||||
@@ -261,7 +261,7 @@ impl GridScheduler {
|
||||
|
||||
/// 读取指定工作流的 TLUSTY 物理迭代步进链(`config::GridConfig.tlusty_chain`),
|
||||
/// 序列化为 JSON Value 供 TaskSpec 携带。节点 executor 反序列化为 `Vec<ChainStep>`
|
||||
/// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax/metals
|
||||
/// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax
|
||||
/// 等阶段参数真正生效(此前 executor 硬编码用 default 链,忽略用户配置)。
|
||||
/// 工作流未配置 tlusty_chain(空数组)→ None(executor 用 default 链兜底)。
|
||||
async fn get_workflow_tlusty_chain(&self, workflow_name: &str) -> Option<serde_json::Value> {
|
||||
@@ -273,6 +273,19 @@ impl GridScheduler {
|
||||
serde_json::to_value(&cfg.tlusty_chain).ok()
|
||||
}
|
||||
|
||||
/// 读取指定工作流的种子热启动链(`config::GridConfig.seed_chain`),序列化为 JSON
|
||||
/// Value 供 TaskSpec 携带。仅 seed_step 策略下由 executor 读取。
|
||||
/// 与 `get_workflow_tlusty_chain` 对称。工作流未配置 seed_chain(空数组)→ None
|
||||
///(executor 用 `default_seed_chain()` 兜底)。
|
||||
async fn get_workflow_seed_chain(&self, workflow_name: &str) -> Option<serde_json::Value> {
|
||||
let wf = self.db.get_workflow(workflow_name).await.ok()??;
|
||||
let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "seed_chain")?;
|
||||
if cfg.seed_chain.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::to_value(&cfg.seed_chain).ok()
|
||||
}
|
||||
|
||||
/// 读取指定工作流的 TLUSTY 输入文件全局参数(`config::GridConfig.tlusty_input`),
|
||||
/// 序列化为 JSON Value 供 TaskSpec 携带。包含 NFREAD 频率网格、ions 能级表、
|
||||
/// nst extra_keys 等不随阶段变化的参数。节点 executor 反序列化为 `TlustyInput`
|
||||
@@ -286,6 +299,38 @@ impl GridScheduler {
|
||||
.and_then(|t| serde_json::to_value(t).ok())
|
||||
}
|
||||
|
||||
/// 一次性读取工作流的全部物理校验阈值(能量守恒 / 温度结构 / emflux)。
|
||||
/// 统一读取避免对同一 YAML 多次解析。返回 8 元组,对应 TaskSpec 的 8 个标量字段:
|
||||
/// (energy_tolerance, temp_max_factor, temp_floor, temp_ceiling, emflux_tolerance,
|
||||
/// convergence_min_ratio, bfac_max, bfac_min)。
|
||||
async fn get_workflow_validation_thresholds(
|
||||
&self,
|
||||
workflow_name: &str,
|
||||
) -> Option<(
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
)> {
|
||||
let wf = self.db.get_workflow(workflow_name).await.ok()??;
|
||||
let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "validation_thresholds")?;
|
||||
Some((
|
||||
cfg.energy_tolerance,
|
||||
cfg.temp_max_factor,
|
||||
cfg.temp_floor,
|
||||
cfg.temp_ceiling,
|
||||
cfg.emflux_tolerance,
|
||||
cfg.convergence_min_ratio,
|
||||
cfg.bfac_max,
|
||||
cfg.bfac_min,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/// 从策略链解析出「首个可派发」的顺位(见 docs/task_engine_decoupling_design.md §4.2)。
|
||||
///
|
||||
/// 判定:
|
||||
@@ -404,7 +449,21 @@ impl GridScheduler {
|
||||
let (tlusty_cfg, synspec_cfg) = self.get_workflow_stage_configs(workflow_name).await;
|
||||
let synspec_params = self.get_workflow_synspec_params(workflow_name).await;
|
||||
let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await;
|
||||
let seed_chain = self.get_workflow_seed_chain(workflow_name).await;
|
||||
let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await;
|
||||
let (
|
||||
energy_tolerance,
|
||||
temp_max_factor,
|
||||
temp_floor,
|
||||
temp_ceiling,
|
||||
emflux_tolerance,
|
||||
convergence_min_ratio,
|
||||
bfac_max,
|
||||
bfac_min,
|
||||
) = self
|
||||
.get_workflow_validation_thresholds(workflow_name)
|
||||
.await
|
||||
.unwrap_or((None, None, None, None, None, None, None, None));
|
||||
|
||||
// 双阶段全关是退化配置(save_workflow 已拦截,此处兜底防御):无可执行阶段,
|
||||
// 整工作流跳过派发(修复审查 #5)。
|
||||
@@ -538,6 +597,7 @@ impl GridScheduler {
|
||||
synspec_config: synspec_cfg.clone(),
|
||||
synspec_params: synspec_params.clone(),
|
||||
tlusty_chain_params: tlusty_chain.clone(),
|
||||
seed_chain_params: seed_chain.clone(),
|
||||
tlusty_input_params: tlusty_input.clone(),
|
||||
// 显式绑定大气来源(设计 §5.2,修复审查 #3):仅 SYNSPEC-only(TLUSTY 关闭)
|
||||
// 场景需要外部大气——节点凭 atmosphere_ref(或 point_name 兜底)从本地归档
|
||||
@@ -547,6 +607,14 @@ impl GridScheduler {
|
||||
} else {
|
||||
Some(name.clone())
|
||||
},
|
||||
energy_tolerance,
|
||||
temp_max_factor,
|
||||
temp_floor,
|
||||
temp_ceiling,
|
||||
emflux_tolerance,
|
||||
convergence_min_ratio,
|
||||
bfac_max,
|
||||
bfac_min,
|
||||
};
|
||||
|
||||
self.db.insert_task(&task_spec).await?;
|
||||
@@ -834,7 +902,21 @@ impl GridScheduler {
|
||||
let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await;
|
||||
let synspec_params = self.get_workflow_synspec_params(workflow_name).await;
|
||||
let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await;
|
||||
let seed_chain = self.get_workflow_seed_chain(workflow_name).await;
|
||||
let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await;
|
||||
let (
|
||||
energy_tolerance,
|
||||
temp_max_factor,
|
||||
temp_floor,
|
||||
temp_ceiling,
|
||||
emflux_tolerance,
|
||||
convergence_min_ratio,
|
||||
bfac_max,
|
||||
bfac_min,
|
||||
) = self
|
||||
.get_workflow_validation_thresholds(workflow_name)
|
||||
.await
|
||||
.unwrap_or((None, None, None, None, None, None, None, None));
|
||||
|
||||
// SYNSPEC 链回退:重试光谱合成。无邻居种子门控(大气来自目标点自身既有产物,
|
||||
// 见 docs/task_engine_decoupling_design.md §5)——旧实现把 synspec 失败误归因到
|
||||
@@ -874,8 +956,18 @@ impl GridScheduler {
|
||||
synspec_params,
|
||||
// TLUSTY 已关闭(半失败重试只重跑光谱),不执行 chain/input → None。
|
||||
tlusty_chain_params: None,
|
||||
seed_chain_params: None,
|
||||
tlusty_input_params: None,
|
||||
atmosphere_ref: Some(name.to_string()),
|
||||
// 不重算大气 → 不做物理正确性校验。
|
||||
energy_tolerance: None,
|
||||
temp_max_factor: None,
|
||||
temp_floor: None,
|
||||
temp_ceiling: None,
|
||||
emflux_tolerance: None,
|
||||
convergence_min_ratio: None,
|
||||
bfac_max: None,
|
||||
bfac_min: None,
|
||||
};
|
||||
self.db.insert_task(&task_spec).await?;
|
||||
self.db
|
||||
@@ -959,8 +1051,17 @@ impl GridScheduler {
|
||||
synspec_config: synspec_cfg.clone(),
|
||||
synspec_params,
|
||||
tlusty_chain_params: tlusty_chain.clone(),
|
||||
seed_chain_params: seed_chain.clone(),
|
||||
tlusty_input_params: tlusty_input.clone(),
|
||||
atmosphere_ref: None,
|
||||
energy_tolerance,
|
||||
temp_max_factor,
|
||||
temp_floor,
|
||||
temp_ceiling,
|
||||
emflux_tolerance,
|
||||
convergence_min_ratio,
|
||||
bfac_max,
|
||||
bfac_min,
|
||||
};
|
||||
|
||||
self.db.insert_task(&task_spec).await?;
|
||||
@@ -1018,6 +1119,7 @@ mod tests {
|
||||
logo: vec![(-2.0).into()],
|
||||
},
|
||||
tlusty_chain: vec![],
|
||||
seed_chain: vec![],
|
||||
tlusty_input: None,
|
||||
synspec_input: None,
|
||||
nworkers: 4,
|
||||
@@ -1030,6 +1132,14 @@ mod tests {
|
||||
linelist: None,
|
||||
tlusty_stage: None,
|
||||
synspec_stage: None,
|
||||
energy_tolerance: None,
|
||||
temp_max_factor: None,
|
||||
temp_floor: None,
|
||||
temp_ceiling: None,
|
||||
emflux_tolerance: None,
|
||||
convergence_min_ratio: None,
|
||||
bfac_max: None,
|
||||
bfac_min: None,
|
||||
};
|
||||
|
||||
scheduler.initialize_grid(&cfg, "test_wf").await.unwrap();
|
||||
@@ -1075,6 +1185,7 @@ mod tests {
|
||||
logo: vec![(-2.0).into()],
|
||||
},
|
||||
tlusty_chain: vec![],
|
||||
seed_chain: vec![],
|
||||
tlusty_input: None,
|
||||
synspec_input: None,
|
||||
nworkers: 4,
|
||||
@@ -1087,6 +1198,14 @@ mod tests {
|
||||
linelist: None,
|
||||
tlusty_stage: None,
|
||||
synspec_stage: None,
|
||||
energy_tolerance: None,
|
||||
temp_max_factor: None,
|
||||
temp_floor: None,
|
||||
temp_ceiling: None,
|
||||
emflux_tolerance: None,
|
||||
convergence_min_ratio: None,
|
||||
bfac_max: None,
|
||||
bfac_min: None,
|
||||
};
|
||||
|
||||
// wf_a 初始化并推入队列
|
||||
@@ -1195,6 +1314,7 @@ mod tests {
|
||||
logo: vec![(-2.0).into()],
|
||||
},
|
||||
tlusty_chain: vec![],
|
||||
seed_chain: vec![],
|
||||
tlusty_input: None,
|
||||
synspec_input: None,
|
||||
nworkers: 4,
|
||||
@@ -1207,6 +1327,14 @@ mod tests {
|
||||
linelist: None,
|
||||
tlusty_stage: None,
|
||||
synspec_stage: None,
|
||||
energy_tolerance: None,
|
||||
temp_max_factor: None,
|
||||
temp_floor: None,
|
||||
temp_ceiling: None,
|
||||
emflux_tolerance: None,
|
||||
convergence_min_ratio: None,
|
||||
bfac_max: None,
|
||||
bfac_min: None,
|
||||
};
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -2,11 +2,39 @@ use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use common::models::{GridPointParams, ModelSummary};
|
||||
use mq::sqlite_queue::SqliteTaskQueue;
|
||||
use server::{api::AppState, db::Database, scheduler::GridScheduler};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt; // for oneshot
|
||||
|
||||
/// 测试辅助:把一个已 upsert 的点标记为导入收敛(写 summary_json + status=completed)。
|
||||
/// 等价旧 mark_grid_point_imported。
|
||||
async fn mark_imported(db: &Database, name: &str, wf: &str, params: &GridPointParams, method: &str) {
|
||||
let summary = ModelSummary {
|
||||
name: name.to_string(),
|
||||
params: params.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.001),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: None,
|
||||
synspec_error: None,
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 0.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
db.upsert_point_summary(name, wf, &summary, method)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_api_flow() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -1256,343 +1284,6 @@ async fn test_cors_same_origin_and_local_policy() {
|
||||
assert!(res.headers().get("access-control-allow-origin").is_none());
|
||||
}
|
||||
|
||||
/// 构造一个合法的旧版 conv.json (ModelSummary) 文本,用于 import_seed 测试。
|
||||
/// 关键:name 用源精度真名(`g5.0`),验证服务端逐字符保真落库。
|
||||
fn make_legacy_conv_json(name: &str, converged: bool) -> String {
|
||||
let summary = serde_json::json!({
|
||||
"name": name,
|
||||
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
|
||||
"stages": [],
|
||||
"converged": converged,
|
||||
"final_max_relc": 0.000321,
|
||||
"final_chmax": null,
|
||||
"seed": null,
|
||||
"atmosphere_has_nan": false,
|
||||
"synspec_rc": 0,
|
||||
"synspec_error": null,
|
||||
"synspec_sec": 1.5,
|
||||
"elapsed_sec": 42.0,
|
||||
"note": null,
|
||||
});
|
||||
serde_json::to_string(&summary).unwrap()
|
||||
}
|
||||
|
||||
/// 构造**旧版 Python run_one.py 形态**的 conv.json(stage 含 itek_attempts/final 嵌套,
|
||||
/// 顶层含 elapsed_sec/synspec_*)——验证 import_seed 对真实历史数据的解析兼容性。
|
||||
fn make_python_legacy_conv_json(name: &str) -> String {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
|
||||
"stages": [
|
||||
{"label": "lte", "chmax": null, "lte": "T",
|
||||
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0}],
|
||||
"converged": true,
|
||||
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0},
|
||||
"best_max_relc": 0.0, "elapsed_sec": 2.1},
|
||||
{"label": "nl", "chmax": null, "lte": "F",
|
||||
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321,
|
||||
"worst_depth": 1, "last_iter": 17, "n_depths": 50}],
|
||||
"converged": true,
|
||||
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321,
|
||||
"worst_depth": 1, "last_iter": 17, "n_depths": 50},
|
||||
"best_max_relc": 0.000321, "elapsed_sec": 640.0}
|
||||
],
|
||||
"converged": true,
|
||||
"final_max_relc": 0.000321,
|
||||
"final_chmax": null,
|
||||
"seed": null,
|
||||
"atmosphere_has_nan": false,
|
||||
"synspec_rc": 0,
|
||||
"synspec_sec": 3.1,
|
||||
"elapsed_sec": 715.0,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 构造一个 multipart/form-data body,含 report(JSON 文本) + seed_file(二进制)。
|
||||
fn make_import_multipart(
|
||||
boundary: &str,
|
||||
report_json: &str,
|
||||
seed_bytes: &[u8],
|
||||
seed_name: &str,
|
||||
success_method: &str,
|
||||
) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
|
||||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"report\"\r\n");
|
||||
body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
|
||||
body.extend_from_slice(report_json.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"Content-Disposition: form-data; name=\"seed_file\"; filename=\"{}\"\r\n",
|
||||
seed_name
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
|
||||
body.extend_from_slice(seed_bytes);
|
||||
body.extend_from_slice(b"\r\n");
|
||||
// 大气收敛途径字段(cold_run/seed_step):模拟 import_results 工具判定后透传的途径。
|
||||
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
|
||||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"tlusty_success_method\"\r\n");
|
||||
body.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
|
||||
body.extend_from_slice(success_method.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_import_seed_admin_endpoint() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("import_db.db");
|
||||
let queue_db_path = temp_dir.path().join("import_queue.db");
|
||||
let seeds_dir = temp_dir.path().join("results");
|
||||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||||
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let queue = Arc::new(
|
||||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||||
|
||||
let state = AppState {
|
||||
db: db.clone(),
|
||||
queue,
|
||||
scheduler,
|
||||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||||
5,
|
||||
std::time::Duration::from_secs(300),
|
||||
),
|
||||
admin_token: Some("admin-secret".to_string()),
|
||||
auth_disabled: false,
|
||||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||||
};
|
||||
|
||||
let api_router = axum::Router::new().route(
|
||||
"/admin/import_seed",
|
||||
axum::routing::post(server::api::task::import_seed),
|
||||
);
|
||||
let auth_layer =
|
||||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||||
let app = axum::Router::new()
|
||||
.nest("/api", api_router.layer(auth_layer))
|
||||
.with_state(state);
|
||||
|
||||
// 1. 无 admin token → 401
|
||||
let conv = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4", true);
|
||||
let body_bytes = make_import_multipart(
|
||||
"boundary1",
|
||||
&conv,
|
||||
b"FAKE_ATMOS_7",
|
||||
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
|
||||
"cold_run",
|
||||
);
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/import_seed?workflow=wf_import")
|
||||
.header("content-type", "multipart/form-data; boundary=boundary1")
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// 2. admin token + 收敛点 → 200,conv.json + .7 落地,grid_points=converged
|
||||
let body_bytes = make_import_multipart(
|
||||
"boundary2",
|
||||
&conv,
|
||||
b"FAKE_ATMOS_7",
|
||||
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
|
||||
"cold_run",
|
||||
);
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/import_seed?workflow=wf_import")
|
||||
.header("authorization", "Bearer admin-secret")
|
||||
.header("content-type", "multipart/form-data; boundary=boundary2")
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
// 关键断言:磁盘目录名用旧 conv.json 的源精度真名(g5.0),conv.json 与 .7 均落地。
|
||||
let point_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4");
|
||||
assert!(point_dir.join("conv.json").is_file(), "conv.json 应落地");
|
||||
assert!(
|
||||
point_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4.7").is_file(),
|
||||
".7 种子文件应落地"
|
||||
);
|
||||
|
||||
// grid_points 应被幂等 upsert 且标记为 converged。
|
||||
let gp = db
|
||||
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("grid_points 应存在");
|
||||
assert_eq!(gp.0, "completed", "导入的收敛点应为 converged 状态");
|
||||
|
||||
// 3. 幂等:重复导入同名点不应报错,状态仍 converged。
|
||||
let body_bytes = make_import_multipart(
|
||||
"boundary3",
|
||||
&conv,
|
||||
b"FAKE_ATMOS_7_AGAIN",
|
||||
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
|
||||
"cold_run",
|
||||
);
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/import_seed?workflow=wf_import")
|
||||
.header("authorization", "Bearer admin-secret")
|
||||
.header("content-type", "multipart/form-data; boundary=boundary3")
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let gp = db
|
||||
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(gp.0, "completed");
|
||||
|
||||
// 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。
|
||||
let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false);
|
||||
let body_bytes =
|
||||
make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run");
|
||||
let res = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/import_seed?workflow=wf_import")
|
||||
.header("authorization", "Bearer admin-secret")
|
||||
.header("content-type", "multipart/form-data; boundary=boundary4")
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let fail_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4_fail");
|
||||
assert!(
|
||||
fail_dir.join("conv.json").is_file(),
|
||||
"未收敛点仍应记录 conv.json"
|
||||
);
|
||||
assert!(
|
||||
!fail_dir
|
||||
.join("t20000_g5.0_he-2_c-4_n-4_o-4_fail.7")
|
||||
.exists(),
|
||||
"未收敛点不应写 .7 种子"
|
||||
);
|
||||
}
|
||||
|
||||
/// 旧版 Python run_one.py conv.json 的完整导入链路(import_results 工具的服务端侧):
|
||||
/// 嵌套 stages(itek_attempts/final)应被正常解析,收敛标记落地,且旧版的
|
||||
/// elapsed_sec 经 P3 迁移链路进入 grid_points.last_elapsed_sec(迁移完整性)。
|
||||
#[tokio::test]
|
||||
async fn test_import_seed_python_legacy_conv_json() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("import_legacy_db.db");
|
||||
let queue_db_path = temp_dir.path().join("import_legacy_queue.db");
|
||||
let seeds_dir = temp_dir.path().join("results");
|
||||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||||
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let queue = Arc::new(
|
||||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||||
|
||||
let state = AppState {
|
||||
db: db.clone(),
|
||||
queue,
|
||||
scheduler,
|
||||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||||
5,
|
||||
std::time::Duration::from_secs(300),
|
||||
),
|
||||
admin_token: Some("admin-secret".to_string()),
|
||||
auth_disabled: false,
|
||||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||||
};
|
||||
|
||||
let api_router = axum::Router::new().route(
|
||||
"/admin/import_seed",
|
||||
axum::routing::post(server::api::task::import_seed),
|
||||
);
|
||||
let auth_layer =
|
||||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||||
let app = axum::Router::new()
|
||||
.nest("/api", api_router.layer(auth_layer))
|
||||
.with_state(state);
|
||||
|
||||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let conv = make_python_legacy_conv_json(name);
|
||||
let body_bytes = make_import_multipart(
|
||||
"boundaryL",
|
||||
&conv,
|
||||
b"FAKE_ATMOS_7",
|
||||
&format!("{name}.7"),
|
||||
"cold_run",
|
||||
);
|
||||
let res = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/import_seed?workflow=wf_legacy")
|
||||
.header("authorization", "Bearer admin-secret")
|
||||
.header("content-type", "multipart/form-data; boundary=boundaryL")
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"旧版嵌套 stages 的 conv.json 应被接受"
|
||||
);
|
||||
|
||||
// grid_points:converged + cold_run 手段(旧版 conv.json 无 seed_nc 阶段)+ 旧版 elapsed_sec 已落库
|
||||
let row = db
|
||||
.get_workflow_point_row("wf_legacy", name)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("grid_points 应存在");
|
||||
assert_eq!(row.status, "completed");
|
||||
assert_eq!(row.tlusty_success_method.as_deref(), Some("cold_run"));
|
||||
assert_eq!(
|
||||
row.last_elapsed_sec,
|
||||
Some(715.0),
|
||||
"旧版 conv.json 的单点耗时应完整迁移到 last_elapsed_sec"
|
||||
);
|
||||
|
||||
// conv.json 原文落盘(嵌套诊断数据 itek_attempts/final 不丢失,详情页解析走兼容路径)
|
||||
assert!(seeds_dir.join(name).join("conv.json").is_file());
|
||||
assert!(seeds_dir.join(name).join(format!("{name}.7")).is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_node_disable_enable_flow() {
|
||||
@@ -2145,7 +1836,7 @@ async fn dispatch_and_report(
|
||||
task_id,
|
||||
point_name: p.model_name(),
|
||||
params: p.clone(),
|
||||
seed_point_name: seed,
|
||||
seed_point_name: seed.clone(),
|
||||
timeout_sec: 7200,
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
@@ -2157,6 +1848,26 @@ async fn dispatch_and_report(
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&spec).await.unwrap();
|
||||
// 构造合法 ModelSummary 作为 summary_json(让 record_task_report 能解析合并写入 grid_points)。
|
||||
let summary = common::models::ModelSummary {
|
||||
name: p.model_name(),
|
||||
params: p.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: converged,
|
||||
final_max_relc: if converged { Some(0.0005) } else { Some(9.5e5) },
|
||||
final_chmax: Some(0.001),
|
||||
seed,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: None,
|
||||
synspec_error: None,
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 120.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report = common::models::TaskReport {
|
||||
task_id,
|
||||
point_name: p.model_name(),
|
||||
@@ -2176,7 +1887,7 @@ async fn dispatch_and_report(
|
||||
} else {
|
||||
Some("nl stage diverged".to_string())
|
||||
},
|
||||
summary_json: "{}".to_string(),
|
||||
summary_json: serde_json::to_string(&summary).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report, wf).await.unwrap();
|
||||
@@ -2292,9 +2003,7 @@ async fn test_wf_stats_endpoint() {
|
||||
)
|
||||
.await;
|
||||
dispatch_and_report(&db, "wf_stats", &p_failed, "cold_run", None, false).await;
|
||||
db.mark_grid_point_imported(&p_imported.model_name(), "wf_stats", None, "cold_run")
|
||||
.await
|
||||
.unwrap();
|
||||
mark_imported(&db, &p_imported.model_name(), "wf_stats", &p_imported, "cold_run").await;
|
||||
|
||||
// 回拨任务创建时间:测试内 insert/report 同秒完成,墙钟差为 0 会被 ETA 估算
|
||||
// 过滤(avg 必须 > 0);造 120s 的真实感样本,使 avg_point_sec/eta_sec 非空。
|
||||
@@ -2456,9 +2165,7 @@ async fn seed_obs_fixture(db: &Database, db_path: &std::path::Path, wf: &str) ->
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
db.mark_grid_point_imported(&p_imported.model_name(), wf, None, "cold_run")
|
||||
.await
|
||||
.unwrap();
|
||||
mark_imported(&db, &p_imported.model_name(), wf, &p_imported, "cold_run").await;
|
||||
|
||||
let conn = rusqlite::Connection::open(db_path).unwrap();
|
||||
conn.execute(
|
||||
@@ -2709,14 +2416,9 @@ async fn test_point_detail_endpoint() {
|
||||
|
||||
let n = seed_obs_fixture(&db, &db_path, "wf_pd").await;
|
||||
|
||||
// 给 cold 点写一份合法 conv.json(rescued 刻意不写,验证 null 降级)
|
||||
let cold_dir = seeds_dir.join(&n.cold);
|
||||
std::fs::create_dir_all(&cold_dir).unwrap();
|
||||
std::fs::write(
|
||||
cold_dir.join("conv.json"),
|
||||
make_legacy_conv_json(&n.cold, true),
|
||||
)
|
||||
.unwrap();
|
||||
// conv 诊断面板数据源已改为 grid_points.summary_json(dispatch_and_report 写入),
|
||||
// 不再需要磁盘 conv.json。rescued 点最终 seed_step 成功 → 也有 summary_json,
|
||||
// 但此处验证 null 降级用 pending 点(未 report,无 summary)。
|
||||
|
||||
async fn get_detail(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
|
||||
let res = app
|
||||
@@ -2757,10 +2459,11 @@ async fn test_point_detail_endpoint() {
|
||||
assert_eq!(attempts.len(), 1);
|
||||
assert!(attempts[0]["seed_point_name"].is_null(), "冷启动无种子来源");
|
||||
assert_eq!(attempts[0]["status"], "completed");
|
||||
assert_eq!(data["conv"]["result_valid"], true, "conv.json 应被解析");
|
||||
assert_eq!(data["conv"]["final_max_relc"], 0.000321);
|
||||
assert_eq!(data["conv"]["result_valid"], true, "summary_json 应被解析");
|
||||
assert_eq!(data["conv"]["final_max_relc"], 0.0005);
|
||||
|
||||
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回),conv 为 null ----
|
||||
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回)----
|
||||
// 最终 seed_step 成功 → record_task_report 写入 summary_json,conv 非 null。
|
||||
let uri = format!("/api/workflows/wf_pd/points/{}", n.rescued);
|
||||
let (st, data) = get_detail(&app, &uri).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
@@ -2775,7 +2478,13 @@ async fn test_point_detail_endpoint() {
|
||||
attempts[1]["seed_point_name"], n.cold,
|
||||
"种子来源应为 cold 点"
|
||||
);
|
||||
assert!(data["conv"].is_null(), "无 conv.json 应返回 null 而非报错");
|
||||
assert_eq!(data["conv"]["result_valid"], true, "rescued 最终成功应有 summary");
|
||||
|
||||
// ---- 3b. pending 点:从未 report → 无 summary_json → conv 为 null(降级不报错)----
|
||||
let uri = format!("/api/workflows/wf_pd/points/{}", n.pending);
|
||||
let (st, data) = get_detail(&app, &uri).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert!(data["conv"].is_null(), "未结算点无 summary 应返回 null");
|
||||
|
||||
// ---- 4. 路径穿越 / 非法字符 → 400 ----
|
||||
for bad in ["..%2Fevil", "a%2Fb", ".hidden", ".."] {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 验证「历史种子导入的工作流名」与「正式工作流名」的隔离关系。
|
||||
//!
|
||||
//! 用户意图:import_results 把旧 Python 计算结果导入,标记为已完成,避免重算。
|
||||
//! 用户意图:离线导入工具把旧计算结果导入,标记为已完成,避免重算。
|
||||
//! 关键问题:导入到工作流 A,之后正式启动工作流 B(同名/异名),B 能否看到 A 标记的 converged?
|
||||
|
||||
use common::config::GridConfig;
|
||||
use common::models::GridPointParams;
|
||||
use common::models::{GridPointParams, ModelSummary};
|
||||
use mq::sqlite_queue::SqliteTaskQueue;
|
||||
use server::{db::Database, scheduler::GridScheduler};
|
||||
use std::sync::Arc;
|
||||
@@ -21,6 +21,47 @@ fn make_params() -> GridPointParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个收敛的 ModelSummary(result_valid=true, atmosphere_has_nan=false),
|
||||
/// 用 point_name 作权威名。供测试模拟离线导入写入 summary_json。
|
||||
fn make_converged_summary(name: &str, params: &GridPointParams) -> ModelSummary {
|
||||
ModelSummary {
|
||||
name: name.to_string(),
|
||||
params: params.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.001),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: None,
|
||||
synspec_error: None,
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 0.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试辅助:模拟离线导入——upsert 点 + 写收敛 summary(等价旧 mark_grid_point_imported)。
|
||||
async fn mark_imported(
|
||||
db: &Database,
|
||||
name: &str,
|
||||
workflow: &str,
|
||||
params: &GridPointParams,
|
||||
method: &str,
|
||||
) {
|
||||
db.upsert_grid_point_named(name, params, 0, workflow)
|
||||
.await
|
||||
.unwrap();
|
||||
let summary = make_converged_summary(name, params);
|
||||
db.upsert_point_summary(name, workflow, &summary, method)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// 构造只含一个网格点(t20000_g5.0_he-2_c-4_n-4_o-4)的 config。
|
||||
fn make_grid_cfg() -> GridConfig {
|
||||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
@@ -49,13 +90,8 @@ async fn test_same_workflow_name_preserves_converged() {
|
||||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let p = make_params();
|
||||
|
||||
// 模拟 import_seed:upsert + mark_imported,工作流名 = sdB_cno
|
||||
db.upsert_grid_point_named(name, &p, 0, "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(name, "sdB_cno", None, "cold_run")
|
||||
.await
|
||||
.unwrap();
|
||||
// 模拟离线导入:upsert + 写收敛 summary,工作流名 = sdB_cno
|
||||
mark_imported(&db, name, "sdB_cno", &p, "cold_run").await;
|
||||
|
||||
// 之后正式启动同名工作流:initialize_grid(sdB_cno)
|
||||
sched
|
||||
@@ -80,13 +116,8 @@ async fn test_different_workflow_name_causes_recompute() {
|
||||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let p = make_params();
|
||||
|
||||
// 模拟 import_seed:导入到 "imported" 工作流
|
||||
db.upsert_grid_point_named(name, &p, 0, "imported")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(name, "imported", None, "cold_run")
|
||||
.await
|
||||
.unwrap();
|
||||
// 模拟离线导入:导入到 "imported" 工作流
|
||||
mark_imported(&db, name, "imported", &p, "cold_run").await;
|
||||
|
||||
// 之后正式启动 "sdB_cno" 工作流
|
||||
sched
|
||||
@@ -118,13 +149,8 @@ async fn test_mixed_grid_import_then_init_avoids_recompute() {
|
||||
let (db, sched) = setup().await;
|
||||
let p_old = make_params(); // t20000_g5.0_...
|
||||
|
||||
// 模拟 import_seed:旧网格里这个点已收敛,导入到 sdB_cno
|
||||
db.upsert_grid_point_named("t20000_g5.0_he-2_c-4_n-4_o-4", &p_old, 0, "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", None, "cold_run")
|
||||
.await
|
||||
.unwrap();
|
||||
// 模拟离线导入:旧网格里这个点已收敛,导入到 sdB_cno
|
||||
mark_imported(&db, "t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", &p_old, "cold_run").await;
|
||||
|
||||
// 正式启动 sdB_cno,config 比旧网格多了一个新点(t25000)
|
||||
let yaml = "grid:\n teff: [20000, 25000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
@@ -172,13 +198,8 @@ async fn test_precision_diff_import_then_init_preserves_converged() {
|
||||
logo: GridAxisValue::from_value(-4.0),
|
||||
};
|
||||
|
||||
// 模拟 import_results 重写 name 后入库:grid_points.name = canonical(g5.0)
|
||||
db.upsert_grid_point_named(canonical, &p, 0, "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(canonical, "sdB_cno", None, "cold_run")
|
||||
.await
|
||||
.unwrap();
|
||||
// 模拟离线导入重写 name 后入库:grid_points.name = canonical(g5.0)
|
||||
mark_imported(&db, canonical, "sdB_cno", &p, "cold_run").await;
|
||||
|
||||
// 启动同名工作流:initialize_grid 用配置 model_name()(=g5.0) 插入
|
||||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
|
||||
+6
-1
@@ -157,6 +157,11 @@ IELCOR=-1
|
||||
- nl: NITER=100
|
||||
- **不设 CHMAX**(用默认 0.001)、**不设 ITEK**(用默认 4)
|
||||
|
||||
> 注(2026-08-07 起的当前实现):`nst_writer` 在前两行之外还会显式写出
|
||||
> line3/line4 的 NLTE 高频关键字(NMU/ISPLIN/CNU2/NFTAIL/DFTAIL 与
|
||||
> HMIX0…TMOLIM,共 23 个,值等于 TLUSTY 内置默认)。配方物理等价,仅行数
|
||||
> 由旧 2 行扩为 4+ 行。
|
||||
|
||||
### 3.3 CNO 模型原子(`.5` 里声明的 nlevs)
|
||||
|
||||
> 下表"nlevs"列是 `.5` 文件里每个离子实际声明的 NLTE 能级数(即 `gen_input5.py`
|
||||
@@ -220,7 +225,7 @@ IELCOR=-1
|
||||
### 错误 6:nst 行长截断 bug
|
||||
- **来源**:tlusty 的 nst 解析器有 ~72 字符行宽限制
|
||||
- **影响**:参数太多时(如加了 IDLTE/IACC),行尾参数被静默截断 → 用默认值
|
||||
- **纠正**:write_nst 把参数分两行写(line1 ≤ 64 字符)
|
||||
- **纠正**:write_nst 把参数分多行写(每行 ≤ 64 字符);当前实现为 4+ 行(见 §3.2 注)
|
||||
- **教训**:Fortran 的固定格式行宽限制是隐蔽 bug 源
|
||||
|
||||
### 错误 7:丰度范围设置过高(最严重的错误)
|
||||
|
||||
+11
-6
@@ -121,17 +121,20 @@ ions段: iat iz nlevs ilast ilvlin nonstd typion filei
|
||||
|
||||
### 3.2 nst 文件(非标准参数,每阶段统一结构,NITER 不同)
|
||||
|
||||
`nst_writer::generate_nst_content` 对**所有阶段(含 lte)统一生成两行**:
|
||||
`nst_writer::generate_nst_content` 对**所有阶段(含 lte)统一生成 4+ 行**:前 2 行与旧实现字节级一致(阶段参数 + `IELCOR=-1`),从第 3 行起显式写出 `global.nst` 的 NLTE 高频关键字(值等于 TLUSTY 内置默认,物理等价):
|
||||
```
|
||||
ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,[CHMAX=..][,ITEK=..],NITER=<阶段>
|
||||
[ORELAX=..][,IDLTE=..][,IACC=..][,ICHANG=..],IELCOR=-1
|
||||
NMU=3,ISPLIN=0,CNU2=3.,NFTAIL=21,DFTAIL=0.25
|
||||
HMIX0=-1.,MLTYPE=1,IPTURB=1,IBFINT=1,IHECOR=0,IRTE=0,IBC=3,IFRYB=0,IOPTAB=0,IFMOL=0,IFALI=5,IFPOPR=4,JALI=1,TRAD=0.,WDIL=0.,TFLOOR=8000.,TDISK=0.,TMOLIM=9000.
|
||||
```
|
||||
- lte: `NITER=0`(灰大气不迭代,只做一次形式解)
|
||||
- nc: `NITER=10`, nl: `NITER=100`, seed_nc: `NITER=20`
|
||||
- **不设 CHMAX**(用默认 0.001,强迫 nc 真正收敛)
|
||||
- **不设 ITEK**(用默认 4)
|
||||
- 尾部统一 `IELCOR=-1`(电子密度修正关闭)
|
||||
- 参数分行写(line1 ≤64c, line2 余下),避免 tlusty nst 解析器 ~72 字符行宽截断
|
||||
- 频率截断 `FRCMAX/MIN/FRLMAX/MIN` 仅当显式配非零值才写出(0=TLUSTY 内置默认,不覆盖)
|
||||
- 参数分行写(每行 ≤64c),避免 tlusty nst 解析器 ~72 字符行宽截断
|
||||
|
||||
每个参数的作用与原理:
|
||||
|
||||
@@ -300,9 +303,11 @@ synspec_stage: # SYNSPEC 阶段独立配置(enabled/
|
||||
enabled: true
|
||||
policy: skip_converged
|
||||
strategies: ["standard"]
|
||||
synspec_input: # SYNSPEC 数值参数(fort.55 波长范围等)
|
||||
wstart: 3000.0
|
||||
wend: 7000.0
|
||||
synspec_input: # SYNSPEC fort.55 控制卡(9 行子结构体,按行配置)
|
||||
line1: {imode: 0, idstd: 0, iprin: 1} # 行1: IMODE/IDSTD(=0 自动 2ND/3)/IPRIN
|
||||
line6: # 行6: 波长范围等(其余行走 SynspecInput::default)
|
||||
alam0: 3000.0 # 光谱波长起始点 (Å)
|
||||
alast: 7000.0 # 光谱波长终止点 (Å)
|
||||
```
|
||||
|
||||
### 第2步:创建并启动工作流(HTTP API / Dashboard)
|
||||
@@ -367,7 +372,7 @@ curl -X POST localhost:8090/api/workflows/sdB_cno/points \
|
||||
|
||||
6. **nst 文件行长限制(已修复)**:tlusty 的 nst 解析器有 ~72 字符的行宽限制。
|
||||
如果参数太多写在一行(如加了 IDLTE/IACC 后 >72 字符),行尾的参数会被
|
||||
静默截断。`nst_writer` 把参数分两行写(line1≤64c, line2 余下参数)。
|
||||
静默截断。`nst_writer` 把参数分多行写(每行 ≤64c),避免触及该行宽限制。
|
||||
这是个隐蔽 bug——截断后 tlusty 不报错而是用默认值,导致"看似成功实则参数
|
||||
没生效"。
|
||||
|
||||
|
||||
+11
-36
@@ -648,44 +648,19 @@ Worker 节点向服务端上报的任务计算结果。
|
||||
-F 'seed_file=@/path/to/t35000_g5.5_he-1_c-2_n-2_o-2.7'
|
||||
```
|
||||
|
||||
### 4.3 历史种子批量导入 (`POST /api/admin/import_seed`)
|
||||
### 4.3 迁移端点 (`POST /api/admin/migrate_conv`)
|
||||
|
||||
把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)批量回灌进 DCTS。与 `/task/report` 的关键区别:**跳过任务归属校验**(历史数据无领用语义),直接幂等落库。供 [`tools/import_results`](file:///home/fmq/program/tlusty/tl208-s54/dcts/tools/import_results/src/main.rs) 调用。
|
||||
临时迁移端点:扫 `seeds_dir` 下的 `conv.json` → 增量合并写入 `grid_points.summary_json`。
|
||||
让详情页 conv 诊断面板从 DB 读取(不再依赖磁盘文件)。**迁移完成后删除本端点即可。**
|
||||
|
||||
- **处理函数**: [`import_seed`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/api/task.rs)
|
||||
- **鉴权**: **Admin 角色**(admin token 或登录 session)
|
||||
- **请求格式**: `multipart/form-data`
|
||||
- Part `report`: 旧版 `conv.json` 的**原文 JSON**(映射为 `ModelSummary`,服务端解析出 `name` / `params` / `converged` / `final_max_relc`)
|
||||
- Part `seed_file`: 二进制数据(`.7` 大气种子文件;收敛点必传)
|
||||
- Part `tlusty_success_method` *(可选)*: 文本 `cold_run` / `seed_step`。由 `tools/import_results` 依据旧 `conv.json` 的 stages 是否含 `seed_nc` 判定后设置,写入导入点大气归因 `tlusty_success_method`(缺省按 seed_step 统计)
|
||||
- **Query 参数**: `workflow`(可选,默认 `imported`):目标工作流名,种子导入到该工作流的 `grid_points`。
|
||||
- **命名保真**: `point_name` 取旧 `conv.json` 的 `name` 字段(源精度真名,如 `t20000_g5.0_...`),**逐字符**落库(磁盘目录、`grid_points.name`、`seeds.point_name`),与旧版 Python `gen_input5.model_name` 完全一致。
|
||||
- **幂等**: `ON CONFLICT DO NOTHING` upsert `grid_points`、`conv.json` 与 `.7` 原子覆盖写,可重复运行。
|
||||
- **响应 Schema**:
|
||||
- `200 OK`:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"point_name": "t20000_g5.0_he-2_c-4_n-4_o-4",
|
||||
"converged": true,
|
||||
"max_relc": 0.000321
|
||||
}
|
||||
```
|
||||
- `400 Bad Request`: 缺少 `report` / `conv.json` 解析失败 / 非法网格点名称
|
||||
- `401 Unauthorized`: 缺少或无效 admin 凭据
|
||||
- **curl 示例**:
|
||||
```bash
|
||||
curl -X POST "http://localhost:8090/api/admin/import_seed?workflow=sdB_cno" \
|
||||
-H "Authorization: Bearer <admin_token>" \
|
||||
-F 'report=@/path/to/old_results/t20000_g5.0_he-2_c-4_n-4_o-4/conv.json;type=application/json' \
|
||||
-F 'seed_file=@/path/to/old_results/t20000_g5.0_he-2_c-4_n-4_o-4/t20000_g5.0_he-2_c-4_n-4_o-4.7'
|
||||
```
|
||||
- **批量导入工具**: [`import_results`](file:///home/fmq/program/tlusty/tl208-s54/dcts/tools/import_results/src/main.rs) 自动扫描结果目录、校验无 NaN 且收敛、逐点调用本端点(同时把完整产物树迁移到 `data/result/`):
|
||||
```bash
|
||||
cargo run -p import_results -- --dir <旧 results 根目录> \
|
||||
--config workflows/sdB_cno.yaml \
|
||||
--server http://127.0.0.1:8090 --workflow sdB_cno --token <admin_token>
|
||||
```
|
||||
- **鉴权**: Admin 角色
|
||||
- **Query 参数**: `workflow`(必填)、`dir`(可选,缺省取 `seeds_dir`)
|
||||
- **特性**: 幂等(ON CONFLICT DO NOTHING)、增量合并(synspec-only 保留 TLUSTY 字段)、多版本兼容(旧 `converged` 键)
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8090/api/admin/migrate_conv?workflow=sdB_cno" \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ dcts/
|
||||
│ └── mq/ # [Library] SQLite 事务型分布式任务队列
|
||||
├── dashboard/ # [Web] ESM 模块化运维看板(Vite)
|
||||
├── workflows/sdB_cno.yaml # 示例网格工作流配置
|
||||
├── tools/
|
||||
│ └── import_results/ # [Binary] 历史计算结果导入工具(旧版网格结果 → 种子/收敛入库)
|
||||
├── scripts/ # 结果拉取等运维脚本
|
||||
└── docs/ # 分主题架构与规格技术文档
|
||||
```
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
# TLUSTY 收敛性分析与修复(2026-08-08 ~ 08-09)
|
||||
|
||||
> 本文完整记录了对 `sdB_cno` 工作流 1305 个失败网格点的根因分析、实测验证过程、
|
||||
> 以及最终落地的代码改动。所有 tlusty/synspec 实测均使用**生产二进制**
|
||||
> `dcts/assets/tlusty_static`(md5=`14cd144b...`),而非开发目录的 `tlusty/tlusty.exe`
|
||||
> (md5=`9b249e00...`)——两者编译版本不同,数值行为有差异。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据库分析(`dcts.db`)
|
||||
|
||||
### 1.1 真实规模(纠正初期统计错误)
|
||||
|
||||
| 维度 | 数量 |
|
||||
|------|------|
|
||||
| grid_points 总数 | **9216**(9 Teff × 4 logg × 4 logHe × 4 logC × 4 logN × 4 logO) |
|
||||
| ✅ completed | **7911(85.8%)** |
|
||||
| ❌ failed | **1305(14.2%)** |
|
||||
|
||||
初期曾误把 `tasks` 表的 12344 行(含重试)当作网格点总数,得出"60% 失败"的错误结论。
|
||||
实际上 tasks 表每个失败点因 cold_run→seed_step 回退产生多行(attempt_count 中位数 3-4),
|
||||
且 3876 个早已收敛的点根本不在 tasks 表里(`skip_converged` 跳过)。
|
||||
|
||||
### 1.2 失败点参数空间分布
|
||||
|
||||
**按 Teff × logHe(每 cell 256 点):**
|
||||
|
||||
| Teff | logHe | 失败/256 | 失败率 | 备注 |
|
||||
|------|-------|---------|-------|------|
|
||||
| 60000 | -4 | 148 | **58%** | 主体失败区 |
|
||||
| 55000 | -4 | 136 | **53%** | 主体失败区 |
|
||||
| 60000 | -2 | 126 | 49% | |
|
||||
| 55000 | -2 | 128 | 50% | |
|
||||
| 60000 | 0 | 101 | 39% | |
|
||||
| 50000 | -2 | 83 | 32% | |
|
||||
| 20000 | 2 | 89 | 35% | 次要失败簇(富氦+低logg) |
|
||||
| 25000~45000 各 He | — | <16% | — | 大多健康 |
|
||||
|
||||
**两大失败簇:**
|
||||
1. **高温贫氦区(55-60kK / He=-4,-2,0)**:733 个,占失败总数 **56%**
|
||||
2. **20kK 富氦区(He=2 / logg=6.0-6.5)**:89 个(35%)
|
||||
|
||||
**按 logg**:logg=5.0 最难(661 个,占 51%),logg=6.5 最好(139 个)。
|
||||
|
||||
**按 CNO 丰度(55000K/He=-4 cell 实测)**:CNO 越富失败率越高——
|
||||
```
|
||||
cno_sum ≤ -11: 失败率 33% ← 贫金属,相对可收敛
|
||||
cno_sum -7~-8: 失败率 92%
|
||||
cno_sum ≥ -6: 失败率 100% ← 最富金属,物理不可收敛
|
||||
```
|
||||
|
||||
### 1.3 失败点的策略执行情况
|
||||
|
||||
所有 1305 个失败点均执行过 cold_run + seed_step(`pending_strategies` 已空,策略穷尽)。
|
||||
seed_step 的实际结果:
|
||||
- seed 高 relc 发散(≥100):1461 条 task 记录
|
||||
- seed NaN 发散:1091 条
|
||||
- seed 下载/IO 失败:34 条
|
||||
- seed 低 relc(<100):14 条
|
||||
|
||||
---
|
||||
|
||||
## 2. 可能的失败原因(分析假设)
|
||||
|
||||
### 2.1 ORELAX 未设置 → 满牛顿步长振荡
|
||||
|
||||
所有 salvage 的 `.nc.nst` / `.seed_nc.nst` 只有 2 行,**无 ORELAX**:
|
||||
```
|
||||
ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,CHMAX=0.001,NITER=10
|
||||
IELCOR=-1
|
||||
```
|
||||
tlusty 源码 `tlusty208.f:844`:`IF(ORELAX.EQ.0.) ORELAX=UN`(UN=1.0),即默认 100% 牛顿步长。
|
||||
满步长在硬网格点易振荡发散。
|
||||
|
||||
### 2.2 Ng/Kantorovich 加速器把解推飞
|
||||
|
||||
tlusty 默认 `IACC=7`(Ng 加速从 iter7 开始)。fort.6 日志显示 `KANTOROVICH acceleration: ITER 5-9`
|
||||
后 relc 从几十跳到上万——加速器在不稳定解上放大了误差。
|
||||
|
||||
### 2.3 灰 LTE 起点离高温 NLTE 解太远
|
||||
|
||||
55kK 点 cold nc iter1 relc 就高达 12.7-21.6(对比正常点 0.06-0.2),LTE 灰大气作为 NLTE 迭代
|
||||
起点在高温下偏差太大。
|
||||
|
||||
### 2.4 富金属方向 seed_step 发散(物理原因)
|
||||
|
||||
seed_finder.rs 记录:increasing metals from a poor seed adds UV line-radiation driving that
|
||||
breaks radiative equilibrium(贫方向成功率 42-54%,富方向仅 3-11%)。
|
||||
|
||||
### 2.5 synspec 在 Balmer edge(3645Å)数值发散
|
||||
|
||||
459 个点 tlusty 收敛了(max_relc<0.001)但 synspec 产出的 spec 在 3645Å(H n=2 bound-free edge)
|
||||
突然变 NaN。机制:`ST0(I)=ET(IJ,I)/CH(IJ,I)`(synspec54.f:12990),表层 NLTE 粒子数为 0 导致 0/0=NaN。
|
||||
|
||||
### 2.6 IACC≤4 被源码强制改 7
|
||||
|
||||
`tlusty208.f:1928`:`IF(IACC.LE.4) IACC=7`。设 IACC=0 无法禁用 Ng 加速器。
|
||||
|
||||
---
|
||||
|
||||
## 3. 实测验证(生产二进制 tlusty_static)
|
||||
|
||||
### 3.1 关键教训:二进制选择
|
||||
|
||||
**初期所有测试误用了 `tlusty/tlusty.exe`(开发版,Apr 2025)而非生产版
|
||||
`dcts/assets/tlusty_static`(Jul 2025)。** 两者 md5 不同,数值行为不同
|
||||
(如 55kK 点 iter1 relc:tlusty.exe=12.7,salvage 记录=254)。
|
||||
以下结论均以**生产二进制**为准,凡 tlusty.exe 结果均已标注作废。
|
||||
|
||||
### 3.2 部分读取陷阱
|
||||
|
||||
tlusty 运行中读取 fort.9 会得到**不完整迭代**的 relc(如 iter2=0.003 看似收敛),
|
||||
但完整跑完后该迭代实际 relc 可能是 18700。**所有结论必须等运行完成后才可靠。**
|
||||
本会话多次因部分读取得出错误中间结论,最终以完整运行结果为准。
|
||||
|
||||
### 3.3 测试点 A:t20000_g5.0_he-4_c-4_n-2_o-2(20kK,次要区)
|
||||
|
||||
**注意:此点非主体失败区代表(20000/He=-4 cell 仅 2/256 失败),结论代表性有限。**
|
||||
|
||||
| 配置 | iter1 | 最终 iter | fort.7 NaN | 结论 |
|
||||
|------|-------|----------|-----------|------|
|
||||
| default ORELAX=1.0(tlusty.exe) | 0.217 | iter7=NaN | 4349 | ❌ 发散 |
|
||||
| ORELAX=0.3, NITER=10(tlusty.exe) | 0.217 | iter10=1.33 | 0 | ⚠️ 非NaN但未收敛 |
|
||||
| ORELAX=0.1, NITER=15(tlusty.exe) | 0.217 | iter15=0.556 | 0 | ⚠️ 振荡但无NaN |
|
||||
| **default(tlusty_static)** | 0.217 | **iter6=0.00672** | 0 | ✅ **收敛中** |
|
||||
|
||||
**结论**:此点在生产二进制下 default 直接收敛中。salvage 标记失败可能是 seed_nc 阶段
|
||||
失败或旧二进制偶然发散。
|
||||
|
||||
### 3.4 测试点 B:t55000_g5.0_he-4_c-1_n-2_o-2(55kK,主体失败区)
|
||||
|
||||
| 配置 | iter1 | 最终 iter | fort.7 NaN | 结论 |
|
||||
|------|-------|----------|-----------|------|
|
||||
| default N30 | 12.7 | iter10=1.95e8 | 0 | ❌ 发散 |
|
||||
| ORELAX=0.05 N50 | 12.7 | iter10=9.9e11 | 0 | ❌ 发散 |
|
||||
| ORELAX=0.01 N50 | 12.7 | iter9=1.05e6 | 0 | ❌ 发散 |
|
||||
| ORELAX=0.005 N50 | 12.7 | iter10=1.95e8 | 0 | ❌ 发散 |
|
||||
| ITEK=-50 N50(禁Ng) | 12.7 | iter11=NaN | 2047 | ❌ 更糟 |
|
||||
| IACC=0(禁加速) | 12.7 | iter5=3.89e8 | 0 | ❌ 无效(源码强制 IACC=7) |
|
||||
| seed_nc default(收敛邻居种子) | 19.5 | iter8=1.28e8 | 0 | ❌ 富方向发散 |
|
||||
| seed_nc IACC=0 | 19.5 | iter8=1.28e8 | 0 | ❌ IACC=0 无效 |
|
||||
|
||||
**结论**:55kK 主体失败区,**所有 nst 参数组合都发散**。iter1 relc=12.7(灰 LTE 起点太远),
|
||||
Ng 加速器 iter5-7 把解推飞。富方向 seed_step(ΔlogC=+2)iter3 外层深度爆炸到 18700。
|
||||
这是物理极限,非参数可解。
|
||||
|
||||
### 3.5 测试点 C:t25000_g5.0_he-4_c-4_n-4_o-3(25kK,中温区)
|
||||
|
||||
| 配置 | 最终 iter | fort.7 NaN | 结论 |
|
||||
|------|----------|-----------|------|
|
||||
| default N15 | iter10=4.72e-6 | 0 | ✅ **收敛** |
|
||||
| ORELAX=0.3 N30 | iter10=3.92e-6 | 0 | ✅ **收敛** |
|
||||
|
||||
**结论**:25kK 失败点在生产二进制下 default 和 ORELAX=0.3 **都收敛了**。
|
||||
此点 salvage 标记失败是旧二进制偶然发散——用新代码重跑即可救回。
|
||||
|
||||
### 3.6 测试点 D:t45000_g6.0_he-4_c-1_n-3_o-3(45kK,中温区)
|
||||
|
||||
| 配置 | iter1 | 最终 iter | fort.7 NaN | 结论 |
|
||||
|------|-------|----------|-----------|------|
|
||||
| default N15 | 21.6 | iter6=2.93e7 | 0 | ❌ Ng 加速爆炸 |
|
||||
| ORELAX=0.3 N30 | 21.6 | iter16=2.81e13 | 0 | ❌ 延迟但最终发散 |
|
||||
|
||||
**结论**:45kK 点 ORELAX 无法阻止最终发散,但 default 和 ORELAX=0.3 全程 0 NaN。
|
||||
(注:中间曾观察到 iter10=0.000335 的"收敛"假象,证实是部分读取 artifact。)
|
||||
|
||||
### 3.7 测试点 E:t20000_g6.5_he2_c-4_n-3_o-4(20kK 富氦高重力簇)
|
||||
|
||||
conv.json 显示 seed_nc relc=0.0115(接近收敛),但 **nl 爆炸到 5.32e16**。
|
||||
nc 阶段 default 和 ORELAX=0.3 轨迹接近(iter4: 1.31 vs 1.27),问题在 nl 阶段。
|
||||
|
||||
### 3.8 synspec Balmer edge NaN 测试(t50000_g5.0_he-4_c-2_n-3_o-2)
|
||||
|
||||
tlusty 收敛(max_relc=0.000247),但 synspec spec 在 **3645.88Å(Balmer limit)** 变 NaN,
|
||||
前 87750 行正常,之后全 NaN(313695 行)。
|
||||
|
||||
| 配置 | 第一个 NaN 波长 | spec NaN 行数 | 结论 |
|
||||
|------|----------------|-------------|------|
|
||||
| 旧版 fort.55(IDSTD=50) | 3645.54Å | 30 万+ | ❌ |
|
||||
| 新版 fort.55(IDSTD=0) | 3645.88Å | 313695 | ❌ IDSTD 无效 |
|
||||
| ICONTL=1(纯连续谱) | 3645.88Å | 313695 | ❌ 连续谱 H bf 本身发散 |
|
||||
|
||||
**结论**:Balmer edge NaN 不是 fort.55 配置问题,是 synspec 连续谱氢 bound-free
|
||||
在表层 NLTE 粒子数为 0 时产生 0/0=NaN。大气 depth-1 粒子数块确认含大量 `0.000000E+00`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 综合结论
|
||||
|
||||
### 4.1 失败分类(1305 个 grid failed 点)
|
||||
|
||||
| 类别 | 数量 | 可救性 |
|
||||
|------|------|--------|
|
||||
| 高 relc 严重发散(≥100) | 746 | 高温区不可救;中温区可能重跑即救 |
|
||||
| NaN 物理发散 | 527 | 旧二进制 NaN → 新二进制可能改善 |
|
||||
| 未知 | 24 | — |
|
||||
| 接近收敛(relc<1) | 3 | 可救 |
|
||||
| 中度发散(1-100) | 5 | 可能可救 |
|
||||
|
||||
另:459 个点 grid 状态为 completed(synspec 失败但 tlusty 收敛),不在 1305 之列。
|
||||
|
||||
### 4.2 什么能救、什么不能
|
||||
|
||||
**能救(预计 ~150-300 点):**
|
||||
- 中温区(20-45kK)旧二进制偶然失败的点——用新代码 `tlusty_static` 重跑即可
|
||||
- 20kK/He2/g6.5 簇 nl 阶段振荡发散——ORELAX=0.5 阻尼 nl 的 Newton 步长振荡
|
||||
- 部分旧二进制 NaN 发散——新二进制数值更稳定(55kK 点全程 0 NaN vs 旧版 NaN)
|
||||
|
||||
**不可救(~600-800 点):**
|
||||
- 55-60kK/贫氦/富CNO(cno_sum≥-6)——灰 LTE 起点离 NLTE 解太远,所有参数组合发散
|
||||
- synspec Balmer edge NaN——表层 NLTE 粒子数为 0 导致连续谱 H bf 数值发散
|
||||
|
||||
### 4.3 无效方案(实测验证,勿重复)
|
||||
|
||||
| 方案 | 测试结果 | 原因 |
|
||||
|------|---------|------|
|
||||
| ORELAX 对 55kK cold nc | ❌ 全发散 | 灰 LTE 起点太远,ORELAX 压不住 |
|
||||
| IACC=0 禁用 Ng | ❌ 无效 | `tlusty208.f:1928` 强制 IACC=7 |
|
||||
| ITEK=-50 禁用 Ng | ❌ 更快 NaN | 失去加速后纯 Newton 也发散 |
|
||||
| 新版 fort.55(IDSTD=0) 修 synspec | ❌ 仍 NaN | IDSTD 与 Balmer edge 无关 |
|
||||
| TFLOOR 防发散 | ❌ 零效果 | 生产二进制实测轨迹与无 TFLOOR 逐位相同 |
|
||||
| 调大 chmax | ❌ 有害 | nc 提前停止给 nl 坏种子 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 落地的代码改动
|
||||
|
||||
### 5.1 核心思路
|
||||
|
||||
给 nl/seed_nc 阶段加 ORELAX 欠松弛阻尼,并在 YAML 新增 `seed_chain` 字段让 seed_step 链
|
||||
可配置(此前硬编码在 `default_seed_chain()`)。配合当前 `skip_converged` 策略,重启工作流
|
||||
自动重跑 1305 个失败点(收敛的 7911 点保留)。
|
||||
|
||||
### 5.2 改动清单(6 个文件)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `crates/common/src/config.rs` | `GridConfig` 新增 `seed_chain: Vec<ChainStep>` 字段 |
|
||||
| `crates/common/src/models.rs` | `TaskSpec` 新增 `seed_chain_params` 字段 |
|
||||
| `crates/server/src/scheduler.rs` | 新增 `get_workflow_seed_chain()`;dispatch/fallback 注入 |
|
||||
| `crates/node/src/executor.rs` | `resolve_execution_chain()` seed_step 优先用 `seed_chain_params` |
|
||||
| `crates/common/src/runner.rs` | `default_seed_chain()` 兜底:seed_nc `orelax: Some(0.3)`,nl `orelax: Some(0.5)` |
|
||||
| `workflows/sdB_cno.yaml` | `tlusty_chain` nl 加 `orelax: 0.5`;新增 `seed_chain` 块 |
|
||||
|
||||
### 5.3 ORELAX 取值依据
|
||||
|
||||
- **nl 步 orelax=0.5**:nl 含谱线(ilvlin=100)扰动大,0.5 是保守的欠松弛值,
|
||||
阻尼 Newton 步长振荡但不至于过度阻尼导致谱线约束不足。
|
||||
- **seed_nc 步 orelax=0.3**:seed_nc 从已收敛邻居热启动,起点扰动小,可用更激进的 0.3。
|
||||
- **nc 步不加 orelax**:实测中温区 nc 用 default 即可收敛,不加更安全。
|
||||
|
||||
### 5.4 向后兼容
|
||||
|
||||
- `seed_chain` 空时回退 `default_seed_chain()`(已同步加 orelax),与旧行为一致
|
||||
- `TaskSpec.seed_chain_params` 缺省 None(旧 MQ payload 兼容)
|
||||
- YAML 不配 `seed_chain` 时完全不影响现有行为
|
||||
|
||||
### 5.5 验证
|
||||
|
||||
- `cargo build` ✅ 编译通过
|
||||
- `cargo test` ✅ **194 个测试全绿,0 失败**
|
||||
(含新增 `seed_step_uses_custom_seed_chain_when_provided` 测试)
|
||||
|
||||
---
|
||||
|
||||
## 6. 后续建议
|
||||
|
||||
### 6.1 重启工作流验证
|
||||
|
||||
当前 `skip_converged` 策略重启时自动重置 1305 个失败点回 pending。
|
||||
部署新二进制 + 新代码后重启,观察失败率下降情况。预计中温区(20-45kK)的
|
||||
~150 个失败点可被救回。
|
||||
|
||||
### 6.2 接力热启动(更大收益,需改代码)
|
||||
|
||||
对高温区(55-60kK)的失败点,可把 seed_step 的大步长 ΔCNO=2 拆成 Δ=1 的多步接力
|
||||
(如 c-4→c-3→c-2→c-1),降低单步富金属扰动。需改 `scheduler.rs` 的 fallback 逻辑
|
||||
支持多跳 seed。这是唯一可能救高温区的物理手段。
|
||||
|
||||
### 6.3 synspec Balmer edge 修复(需改 synspec 源码)
|
||||
|
||||
表层 NLTE 粒子数为 0 导致连续谱 H bound-free 0/0=NaN。需在 `synspec54.f` 的
|
||||
OPAC/连续谱计算加 NaN 守卫,或编译时启用 `-ffpe-trap=invalid,zero` 定位。
|
||||
|
||||
### 6.4 接受物理极限
|
||||
|
||||
55-60kK/贫氦/富CNO(cno_sum≥-6)约 400-500 个点是 sdB 纯 He 大气模型的物理极限,
|
||||
光谱应从相邻收敛点插值。
|
||||
|
||||
---
|
||||
|
||||
## 附:关键文件路径
|
||||
|
||||
- 数据库: `/home/fmq/下载/dcts.db`
|
||||
- Salvage 归档: `data/salvage/{node}/result/{point}/`
|
||||
- 生产二进制: `dcts/assets/tlusty_static`(md5=`14cd144b...`)
|
||||
- 开发二进制(勿用于测试): `tlusty/tlusty.exe`(md5=`9b249e00...`)
|
||||
- tlusty 源码: `tlusty/tlusty208.f`(IACC 强制改 7 在 :1928;ORELAX 默认在 :844)
|
||||
- synspec 源码: `synspec/synspec54.f`(NaN 源头 ST0=ET/CH 在 :12990)
|
||||
- seed_finder.rs: `crates/common/src/seed_finder.rs`(贫富方向逻辑 :14-40)
|
||||
@@ -156,7 +156,7 @@ pub fn apply_migrations(conn: &mut Connection) -> Result<()>;
|
||||
|
||||
服务端 `record_task_report`(db.rs:1920)已持有 `&TaskReport`——**无需改签名**,只在结算 UPDATE 补两列。
|
||||
|
||||
> **保真边界(审查修正)**:`ModelSummary`(models.rs:599-616)与 `StageSummary`(577-595)**不含 conv.json 的逐次 itek 迭代数组**——该细节在 Rust serde 往返中结构性丢失,仍仅存磁盘 conv.json。落库的 summary_json 是"阶段级摘要"(含 last_iter / worst_depth / n_depths 等部分迭代诊断),非原始全量。若需完整逐次迭代入库,须给 `StageSummary` 加 `#[serde(flatten)]` 保留字段(暂缓,先文档化此边界)。
|
||||
> **保真边界(审查修正,2026-08 更新)**:`ModelSummary`(models.rs:616-641)与 `StepSummary`(590-612)现已携带逐次 itek 迭代诊断数组(`StepSummary.itek_history: Vec<IterCheck>`,runner.rs:425 填充),随 summary_json 全量落库(见状态表「itek 全量保真 ✅ 已实施」),不再仅存磁盘 conv.json。落库的 summary_json 与磁盘 conv.json 同源于 runner 的 `StepSummary`,含 last_iter / worst_depth / n_depths 与完整收敛轨迹。
|
||||
|
||||
### 3.3 迁移 M1(tasks 表,均在线 ADD COLUMN)
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ flowchart LR
|
||||
| **2. NLTE 连续谱 (nc)** | `tlusty.exe` | 切换到 NLTE,忽略束缚-束缚线跃迁(`ilvlin=0`),收敛基础电离平衡。 | `lte=F, ltgray=F, ilvlin=0, niter=10` | 10 次 |
|
||||
| **3. NLTE 完整谱线 (nl)** | `tlusty.exe` | 引入全部线跃迁(`ilvlin=100`),求解包含非平衡辐射场的大气结构;**要求收敛**(`require_converged=true`)。 | `lte=F, ltgray=F, ilvlin=100, niter=100` | 100 次 |
|
||||
| **3'. 种子热启动 (seed_nc)** | `tlusty.exe` | 以近邻收敛大气 `.7` 为 `fort.8` 初猜做 NLTE 连续谱收敛(跳过低温度灰大气阶段)。 | `lte=F, ltgray=F, ilvlin=0, niter=20, ichang=0` | 20 次 |
|
||||
| **4. 合成光谱 (synspec)** | `synspec.exe` | 基于收敛大气结构(`.7`),计算高分辨率合成光谱。 | 波长窗/展宽/截断由 `synspec:` 数值参数配置 | — |
|
||||
| **4. 合成光谱 (synspec)** | `synspec.exe` | 基于收敛大气结构(`.7`),计算高分辨率合成光谱。 | 波长窗/展宽/截断由 `synspec_input:`(fort.55 控制卡)配置 | — |
|
||||
|
||||
> 阶段标签由 workflow 配置保证唯一(`lte`/`nc`/`nl`/`seed_nc`),各阶段输入卡/日志/大气/收敛诊断
|
||||
> 以 `<name>.<label>.<后缀>` 快照落盘归档(见 [`tlusty_result_artifacts.md`](./tlusty_result_artifacts.md))。
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# fort.14(`.emflux`)全 NaN 现象分析
|
||||
|
||||
> 调研日期:2026-08-08
|
||||
> 范围:TLUSTY 208 输出文件 fort.14(出射谱 λ-Fλ,runner 快照为 `.emflux`)中出现全 NaN 的物理与数值含义。
|
||||
> 核心问题:**`.emflux` 全 NaN 意味着什么?是否代表 TLUSTY 大气模型收敛失败?**
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
**`.emflux` 全文件 NaN = TLUSTY 大气模型结构(TEMP/POPUL/ELEC)数值发散到不可用程度的可靠标志。** 源码机制与真实数据双重佐证:
|
||||
|
||||
- **源码层面**:fort.14 的流量 `FLUX` 由辐射转移求解器(RTEFR1)基于大气结构(TEMP/POPUL)计算。大气发散 → 不透明度计算产生 Inf/0 → 源函数 Inf/Inf=NaN → 整列 FLUX 全 NaN → OUTPRI 在耗尽 NITER 上限后写进 fort.14。全 NaN 只能由大气发散引起,**不存在"大气收敛良好但辐射转移单独失败"的路径**。
|
||||
- **数据层面**:162 个网格点中,**62/62(100%)的 emflux-NaN 点 `converged=false`**;`converged=true` 且 emflux 含 NaN 的点 = **0**。没有任何局部 NaN 样本。
|
||||
|
||||
**对校验设计的结论**:emflux bolometric 校验把"全 NaN 判失败"是**正确的**,语义应表述为"**大气收敛失败(辐射转移求解发散)**",而非仅"辐射转移失败"。它与 `atmosphere_has_nan` 同级、互为冗余,且能捕获后者的检测盲区。
|
||||
|
||||
---
|
||||
|
||||
## 1. fort.14 的写出机制
|
||||
|
||||
### 1.1 写出位置与格式
|
||||
|
||||
fort.14 由 `SUBROUTINE OUTPRI`(`tlusty208.f:14156`)写出,唯一活跃写出语句在 **`tlusty208.f:14188`**:
|
||||
|
||||
```fortran
|
||||
14181 DO IJ=1,NFREQ
|
||||
14182 IJP=IJ
|
||||
14183 IF(ispodf.eq.0) IJP=JIK(IJ)
|
||||
14184 IF(IJX(IJP).NE.-1) THEN
|
||||
14185 WRITE(13,602) FREQ(IJP),FLUX(IJP),FH(IJP)
|
||||
14186 TOTF=TOTF+FLUX(IJP)*W(IJP)
|
||||
14187 FLAM=FLUX(IJP)*FREQ(IJP)*FREQ(IJP)/2.997925E18
|
||||
14188 write(14,614) 2.997925e18/freq(ijp),slam
|
||||
14189 END IF
|
||||
14190 END DO
|
||||
```
|
||||
|
||||
- **FORMAT** `614 FORMAT(F15.3,1pe15.3)`(`tlusty208.f:14406`)—— 第一列波长 λ(Å),第二列 Fλ。
|
||||
- **写出变量**:`FLUX(IJP)`(COMMON/SURFAC/,`MODELQ.FOR:135`),不是局部量。`FLAM` 是从 Fν 到 Fλ 的换算(`Fλ = Fν · ν² / c`)。
|
||||
- **关键**:`FLUX` 是直接从 COMMON 块读已存的值,OUTPRI 本身**不重新求解辐射转移**。
|
||||
|
||||
### 1.2 写出时机
|
||||
|
||||
OUTPRI 仅在 `IF(LFIN)` 块内被调用(`tlusty208.f:3887-3889`):
|
||||
|
||||
```fortran
|
||||
3887: IF(LFIN) THEN
|
||||
3888: CALL OUTPRI
|
||||
3889: END IF
|
||||
```
|
||||
|
||||
`LFIN` 的判定(`tlusty208.f:47596`):
|
||||
|
||||
```fortran
|
||||
47596: LFIN=ABS(CHMX).LE.CHMAX.OR.ITER.GE.NITER
|
||||
```
|
||||
|
||||
即**最后一次迭代**(收敛 `CHMX≤CHMAX` 或耗尽迭代上限 `ITER≥NITER`)才写 fort.14。这意味着:fort.14 反映的是**最终大气状态**下的辐射转移解。
|
||||
|
||||
---
|
||||
|
||||
## 2. NaN 的产生链(源码追踪)
|
||||
|
||||
### 2.1 FLUX 的计算路径
|
||||
|
||||
`FLUX(IJ)` 的赋值有三处(都在辐射转移求解子程序中):
|
||||
|
||||
| 位置 | 子程序 | 语句 |
|
||||
|------|--------|------|
|
||||
| `tlusty208.f:31800` | `RTEFR1`(Feautrier 形式解) | `FLUX(IJ)=AHout*half` |
|
||||
| `tlusty208.f:32225` | `RTEDF2` | `FLUX(IJ)=AH` |
|
||||
| `tlusty208.f:32848` | `RTEDF1` | `FLUX(IJ)=FH(IJ)*RAD1(1)-half*hextrd(ij)` |
|
||||
|
||||
这些在每次迭代的频率循环中被调用(如 `tlusty208.f:4269-4271`):
|
||||
|
||||
```fortran
|
||||
4269: DO IJ=1,NFREQ
|
||||
4270: CALL OPACF1(IJ) ← 不透明度计算
|
||||
4271: CALL RTEFR1(IJ) ← 辐射转移求解
|
||||
```
|
||||
|
||||
### 2.2 NaN 的具体产生点
|
||||
|
||||
**第一步:不透明度计算 OPACF1(`tlusty208.f:4800`)依赖大气结构**
|
||||
|
||||
| 源码行 | 语句 | 发散时 |
|
||||
|--------|------|--------|
|
||||
| `tlusty208.f:4857` / `:45375` | `HKT1(ID)=HK/T`(T=TEMP(ID)) | TEMP→0 时 HKT1→Inf |
|
||||
| `tlusty208.f:4858` | `XKF(ID)=EXP(-HKT1(ID)*FR)` | HKT1·FR 极大时下溢到 0 |
|
||||
| `tlusty208.f:31444` / `:33455` | `ABTRA(ITR,ID)=POPUL(II,ID)` | POPUL 含 NaN → ABTRA 含 NaN |
|
||||
| `tlusty208.f:31446` | `EMTRA(ITR,ID)=POPUL(JJ,ID)*ANE*SBF(II)*...` | POPUL 极大值 × 其他 → Inf |
|
||||
|
||||
**第二步:辐射转移 RTEFR1(`tlusty208.f:32265`)源函数计算**
|
||||
|
||||
| 源码行 | 语句 | 发散时 |
|
||||
|--------|------|--------|
|
||||
| `tlusty208.f:32352` | `ST0(ID)=EMIS1(ID)/AB0(ID)` | **两个无穷大相除 = NaN**(典型源头) |
|
||||
| `tlusty208.f:32361` | `SS0(ID)=-SCAT1(ID)/AB0(ID)` | 同上 |
|
||||
|
||||
NaN 一旦进入 `RAD1`(辐射转移矩阵解),就通过 `AH`/`AHout` 传播到 `FLUX(IJ)`,然后波及整列所有频率点。
|
||||
|
||||
### 2.3 为什么发散后不会提前 STOP
|
||||
|
||||
TLUSTY 有一个发散护栏(`tlusty208.f:47587-47591`):
|
||||
|
||||
```fortran
|
||||
47587: IF(ITER.NE.1 .AND. ABS(CHMX).GT.1.D16) THEN
|
||||
47588: WRITE(6,610) ITER,CHMX
|
||||
47590: STOP
|
||||
47591: END IF
|
||||
```
|
||||
|
||||
但 **CHMX 是温度相对变化的最大值**(`tlusty208.f:47468` `CHT=CHANGT(ID)/TEMP(ID)`),当 TEMP 含 NaN 时 CHMX 也变 NaN。而 **NaN 与任何数比较都返回 `.FALSE.`**:
|
||||
- `ABS(NaN).GT.1.D16` 为假 → **STOP 不触发**(护栏对 NaN 失效)。
|
||||
- `ABS(NaN).LE.CHMAX` 为假 → `LFIN` 只能靠 `ITER.GE.NITER`(耗尽上限)成立。
|
||||
|
||||
因此程序**一路跑到 NITER,然后带着含 NaN 的 TEMP/POPUL 走完最后一次频率循环,把整列 FLUX 全写成 NaN,最后 OUTPRI 把全 NaN 的 FLUX 倒进 fort.14**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 两种 NaN 场景的区分
|
||||
|
||||
fort.14 的 NaN 理论上有两种可能来源:
|
||||
|
||||
| 场景 | 描述 | 实际是否存在 |
|
||||
|------|------|-------------|
|
||||
| **A. 大气模型发散** | TEMP/POPUL 含 NaN → FLUX 全 NaN | ✅ 存在(62 例) |
|
||||
| **B. 大气收敛良好,局部辐射转移溢出** | 某些波长点不透明度极值 → 局部 NaN | ❌ 数据中不存在 |
|
||||
|
||||
**源码判定**:场景 B 在机制上极不可能。FLUX 全 NaN 要求所有频率点的源函数都变为 NaN,这只能由大气结构整体发散引起。局部 NaN(少数频率点)需要大气结构有效但个别频率点溢出——但 TLUSTY 的频率循环对每个频率点用同一套 TEMP/POPUL,大气有效时不会让某个频率点单独产生 NaN(除非该频率点恰好触发了吸收系数的精确除零,但这在实测数据中从未出现)。
|
||||
|
||||
**数据判定**:在 162 个有 `.emflux` 的网格点中,**所有 62 个 emflux-NaN 点都是 100% 全 NaN**(如 t55000_g5.0:4514/4514 行全 NaN;t60000_g6.5:4420/4420 行全 NaN)。**没有任何一个点是局部/特定波长 NaN**。这直接排除了场景 B。
|
||||
|
||||
---
|
||||
|
||||
## 4. 真实数据验证
|
||||
|
||||
### 4.1 全量统计(162 个网格点)
|
||||
|
||||
| 情形 | 数量 | conv.json converged |
|
||||
|------|------|---------------------|
|
||||
| `.emflux` 全 NaN + `.7` 含 NaN | **53** | 全部 false |
|
||||
| `.emflux` 全 NaN + `.7` 检测无 NaN | **9** | 全部 false |
|
||||
| `.emflux` 正常 + `.7` 含 NaN | **0** | — |
|
||||
| 两者都正常(0 NaN) | **100** | 混合 |
|
||||
|
||||
**决定性事实**:
|
||||
1. **62/62(100%)的 emflux-NaN 点 `converged=false`**。
|
||||
2. `converged=true` 且 emflux 含 NaN 的点 = **0**。
|
||||
3. 全 NaN 点都是 100% 整文件 NaN,无局部 NaN。
|
||||
|
||||
### 4.2 典型全 NaN 点
|
||||
|
||||
`t55000_g5.0_he-2_c-1_n-4_o-4`:
|
||||
- `.7` 从第 11 行(depth 1 的 TEMP/ELEC/DENS/POPUL)开始全是 NaN(5200/5210 行 NaN)。
|
||||
- `conv.json`:`converged:false`、`atmosphere_has_nan:true`、`best_max_relc:39100`(seed_nc 第 3 次迭代发散)。
|
||||
- `.nl.err`:`IEEE_INVALID_FLAG IEEE_UNDERFLOW_FLAG IEEE_DENORMAL` 浮点异常。
|
||||
|
||||
### 4.3 检测器盲区:9 个"`.7` 检测无 NaN 但 emflux 全 NaN"的点
|
||||
|
||||
这 9 个点最关键——它们决定了 emflux 校验是否有独立价值:
|
||||
|
||||
- `.7` 不含字面 NaN/Inf(`atmosphere_has_nan` 检测器通过),但实际已发散成**巨大但有限的非物理值**:
|
||||
- TEMP ≈ 1.015e-15(趋近 0)
|
||||
- POPUL 达 5e12(极大)
|
||||
- `final_max_relc` 在 1e16 ~ 1e27(灾难性发散)
|
||||
- 这些值不是字面 NaN,但在 OPACF1 里 `HK/TEMP`→巨大、POPUL 乘积→Inf、`EMIS1/AB0`→Inf/Inf=NaN。
|
||||
- `synspec_error` 为 `"spec 含 NaN/Inf/溢出行 (共 303554 行)"` —— 发散大气喂给 SYNSPEC 后整谱也是 NaN/溢出。
|
||||
- **原因**:`atmosphere_has_nan`(`conv_check.rs:18`)的正则 `(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,}|[eE]\+(?:3\d{2}|[4-9]\d{2,}))` 只匹配 `nan/inf/***/E+300+`,**抓不到"巨大但有限的发散值"**(如 5e12 POPUL、1e-15 TEMP)。
|
||||
|
||||
**这 9 个点靠 `converged=false`(max_relc 极大)+ emflux 全 NaN + synspec NaN 三重否决才被正确标记。emflux 全 NaN 校验在这类点上恰好补位了 `atmosphere_has_nan` 的盲区。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 对校验设计的影响
|
||||
|
||||
### 5.1 emflux bolometric 校验的语义修正
|
||||
|
||||
现有 `check_emflux_bolometric`(`conv_check.rs`)把"全 NaN(占比>50%)"判失败。根据本调研:
|
||||
|
||||
- **判定正确**:全 NaN 是大气发散的充分(且几乎必要的)标志。
|
||||
- **语义需精确化**:错误描述从 ~~"辐射转移可能失败"~~ 改为 **"大气收敛失败(辐射转移求解发散)"**。
|
||||
|
||||
```rust
|
||||
// 修正后(conv_check.rs)
|
||||
error: Some(format!(
|
||||
"emflux NaN/不可解析占比 {:.0}% 过高(>{:.0}%),大气收敛失败(辐射转移求解发散)",
|
||||
nan_ratio * 100.0, 50.0
|
||||
)),
|
||||
```
|
||||
|
||||
### 5.2 与 `atmosphere_has_nan` 的关系
|
||||
|
||||
emflux 全 NaN 校验与 `atmosphere_has_nan` **同级、互为冗余**:
|
||||
|
||||
| 校验 | 覆盖的发散形态 | 盲区 |
|
||||
|------|---------------|------|
|
||||
| `atmosphere_has_nan` | `.7` 含字面 NaN/Inf/`***`/E+300+ | 巨大但有限的发散值(如 5e12 POPUL、1e-15 TEMP) |
|
||||
| `check_emflux_bolometric`(NaN 分支) | 大气发散导致的辐射转移全 NaN | 需 TLUSTY 启用且 fort.14 存在 |
|
||||
| `check_fort9`(max_relc) | max_relc 极大(收敛失败) | 无法区分真收敛/假收敛 |
|
||||
|
||||
三者交叉覆盖,任一独立即可拦截大部分发散点。emflux 全 NaN 校验的独特价值在于捕获那 9 个 `atmosphere_has_nan` 盲区点。
|
||||
|
||||
### 5.3 不影响的部分
|
||||
|
||||
- **局部 NaN(如 `.spec` 中 Balmer 线区域的 NaN)**:这是 SYNSPEC 处理 Balmer 线 Stark 加宽时的正常行为(与 `tests/synspec/hhe/results/hhe35nc.spec` 一致),不是收敛失败。emflux 校验不涉及 `.spec`,且阈值设在 50%(容忍局部 NaN,只拦全 NaN)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 结论
|
||||
|
||||
1. **`.emflux` 全 NaN 的物理含义**:TLUSTY 大气模型结构(TEMP/POPUL/ELEC)已数值发散到不可用程度,导致最后一次频率循环里 OPACF1→RTEFR1 把整列 FLUX 全部污染为 NaN,OUTPRI 在耗尽 NITER 上限后写入 fort.14。
|
||||
|
||||
2. **是否等于 TLUSTY 收敛失败?—— 是**。源码机制(CHMX 变 NaN 后 STOP 护栏与收敛判定双双失效,全 NaN 只能在大气发散时产生)+ 数据(62/62 全 NaN 点 converged=false,converged=true 点 0 个有 NaN)双重佐证。**全 NaN emflux 是大气收敛失败的充分(且几乎必要的)标志。**
|
||||
|
||||
3. **"`.7` 大气无 NaN(按检测器)且 converged=true,但 `.emflux` 全 NaN"是否可能?—— 不可能**。162 点中此组合为 0 例。出现"`.7` 检测无 NaN + emflux 全 NaN"的 9 例,都是 `.7` 实际已发散成巨大有限值/近零温度(检测器盲区),converged 全为 false。
|
||||
|
||||
4. **emflux 全 NaN 校验的独立价值**:能捕获 `atmosphere_has_nan` 正则盲区(巨大有限发散值),起到交叉验证补位作用。
|
||||
|
||||
---
|
||||
|
||||
## 附录:关键源码行号
|
||||
|
||||
均在 `/home/fmq/program/tlusty/tl208-s54/tlusty/tlusty208.f`:
|
||||
|
||||
| 功能 | 行号 |
|
||||
|------|------|
|
||||
| fort.14 写出(OUTPRI) | 14188 |
|
||||
| FORMAT 614 | 14406 |
|
||||
| FLUX 来源 COMMON | `MODELQ.FOR:135` |
|
||||
| FLUX 赋值(辐射转移解) | 31800, 32225, 32848 |
|
||||
| NaN 产生(不透明度 HK/TEMP) | 4857, 45375 |
|
||||
| NaN 产生(POPUL 布居) | 31444, 31446, 33455 |
|
||||
| NaN 传播(源函数 EMIS1/AB0) | 32352, 32361 |
|
||||
| 发散 STOP 护栏(对 NaN 失效) | 47587-47591 |
|
||||
| LFIN 收敛判定 | 47596 |
|
||||
| OUTPRI 调用时机(LFIN) | 3887-3889 |
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
> 调研日期:2026-08-04
|
||||
> 范围:TLUSTY 208 + SYNSPEC 54 计算的恒星大气与光谱的**物理正确性**保证方法。
|
||||
> 前置文档:`docs/task_failure_detection_analysis.md`(程序"是否崩溃/收敛"的判定,是本调研的下层)。
|
||||
> 前置文档:`docs/tlusty&synspec收敛性判断.md`(程序"是否崩溃/收敛"的判定,是本调研的下层;该文档 §4 记录了漏洞 1-5 的修复历程)。
|
||||
> 核心命题:**程序正常退出(rc=0)且 max_relc < chmax ≠ 光谱在物理上正确**。本文调研介于两者之间的静默错误来源与验证方法。
|
||||
> 方法:源码级静态分析 + 天体物理方法学综述 + 三方独立复核。
|
||||
>
|
||||
> **修订说明(2026-08-07)**:本调研初版(2026-08-04)基于修复前的代码状态撰写。经核查,漏洞 1(`.spec` 内容校验)、漏洞 2(fort.9 缺失分支虚构 best_max_relc)、漏洞 4(NaN 阈值)已在后续提交中修复。**fort.55 字段错位(§5)也已修复**——`SynspecInput` 重构为 9 个按行子结构体,与 SYNSPEC `READ(55,*)` 语句一一对应,IDSTD 从 50 修正为 0(自动 2ND/3)。本次修订同步了这些已修复项的现状,仅保留仍真实存在的缺口(`.6` 能量守恒解析、诊断产物未解析)。
|
||||
>
|
||||
> **修订说明(2026-08-08)**:经源码级二次核查,同步近期重构导致的引用漂移与数据更新:① 行号校正——`runner.rs:613→:598`、`runner.rs:660→:645`、`nst_writer.rs:16`(ND 默认已迁至 `config.rs:712 default_nd()`)、`synspec54.f:1157→:1230`(IBFAC 经 `READ(84,*)` 读出)、`hhe35nl.6:340→:341`;② 结构体名 `StageSummary` 更正为 `StepSummary`(`models.rs:590`);③ ND/ions 表述更正——二者已非硬编码,分别在 `config.rs:712/833` 作为可配默认值(经 YAML 覆盖,见 `workflows/sdB_cno.yaml:63-90`);④ §5.2 DOPSTD 公式补全为 `DSTD=SQRT(1.4E7*TSTD+VTS)` + `DOPSTD=1.E7/ALAM0*DSTD`;⑤ §1.3 fort.13 改述为"默认结构性排除";⑥ 数据统计刷新——网格点 284→361,`.iden` 格式分类 44/221→92 逐线表/243 频率网格/26 其他;⑦ EQW 断言更正——逐线表 EQW **并非全为 0**,约 43/92(≈47%)含非零值(强线可达上千 mA),但作为系统化 EW 来源仍不可靠,结论(改走 `.spec` 数值积分)不变。
|
||||
|
||||
---
|
||||
|
||||
@@ -13,9 +17,9 @@
|
||||
当前 pipeline 对光谱正确性的保证**基本停留在"数值收敛"层面**,完全没有做物理不变量校验。三方调研发现:
|
||||
|
||||
1. **最高 ROI 缺口**:TLUSTY 已经无条件地把**能量守恒诊断**(`TOTAL SURFACE FLUX`、逐深度 `RAD/TOT`、`CON/TOT`、`(RAD+CON)/TOT`)写进已归档的 `.6` 文件,**却从未被任何 Rust 代码解析**。这是 Hubeny 官方推荐的 1% 判据,零成本可得,是物理正确性的第一道硬门槛。
|
||||
2. **系统性的配方风险**:`fort55_writer.rs` 写的字段 `idrv/ifreq` 与 SYNSPEC 实际读取的 `IDSTD/IPRIN` 错位(已核实),导致所有光谱的标准深度参考点与输出详尽度偏离预期。
|
||||
2. **系统性的配方风险(已修复,见 §5)**:~~`fort55_writer.rs` 写的字段 `idrv/ifreq` 与 SYNSPEC 实际读取的 `IDSTD/IPRIN` 错位~~。已重构为 9 个按行子结构体与 SYNSPEC `READ(55,*)` 语句一一对应,IDSTD 修正为 0(自动 2ND/3),并补全了原漏生成的分子表行(避免 VTB 行被吞导致 vturb 错误覆盖)。
|
||||
3. **网格级错误传播**:通过 `converged && !nan` 门控上传的种子大气(`.7`),会把"伪收敛但物理未平衡"的点扩散给整个物理族——这是网格计算特有的静默放大机制,现有检查无法识别。
|
||||
4. **完整的方法学分层**(§6):硬门槛(线上每点)→ 质量监控(抽样)→ 深度核查(线下基准比对),覆盖辐射平衡、光谱自洽、网格一致性、外部基准四个维度。
|
||||
4. **完整的方法学分层**(§6):硬门槛(线上每点)→ 质量监控(抽样)→ 深度核查(线下基准比对),覆盖辐射平衡、光谱自洽、网格一致性、外部基准四个维度。**外部基准的特殊性**(§3.4):本网格是 CNO 全 NLTE 独立扫描,社区经典网格均不含 CNO NLTE,故外部基准对 CNO 维度失效——需用**网格内部 CNO 物理自洽验证**(路线 A)+ **同代码栈 Pacheco 2021**(路线 C)替代。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,13 +34,13 @@ TLUSTY 在 `LFIN`(最终迭代)时由 `OUTPRI` 子程序无条件写出,
|
||||
```
|
||||
TOTAL SURFACE FLUX 6.77712339D+12 ← 实际积分通量 TOTF
|
||||
ID MASS TAUROSS TEMP NE DENS P_gas LOG(G_rad) RAD/TOT CON/TOT (RAD+CON)/TOT
|
||||
1 ... 8.913E-01 0.000E+00 8.91264E-01
|
||||
1 ... 1.001E+00 0.000E+00 1.00106E+00
|
||||
```
|
||||
|
||||
- `FLTT = SIG4P * TEFF**4`(预期通量 = σTeff⁴/4π,`SIG4P` 见 `BASICS.FOR:51`)
|
||||
- `RAD/TOT` = 辐射通量 / σTeff⁴
|
||||
- `(RAD+CON)/TOT` = 总通量(辐射+对流)/ σTeff⁴ —— **此列应≈1.0**
|
||||
- 实测样本(发散模型 max_relc≈1e21):`(RAD+CON)/TOT ≈ 0.891`,即 **11% 能量守恒违反**;收敛良好模型应≈1.0
|
||||
- 实测样本(收敛良好的 sdB 模型,28 次迭代,`hhe35nl.6:341`):`(RAD+CON)/TOT = 1.00106E+00`(逐深度恒定,能量守恒误差 0.1%)。**未收敛/发散模型**的此列会显著偏离 1(典型表现是表层偏离或逐深度不单调),是物理正确性的第一道硬门槛。
|
||||
|
||||
**关键事实**:`.6` 已在归档白名单(`STAGE_SNAPSHOT_SUFFIXES` 含 "6"),但 `crates/` 下无任何代码解析它。**数据已就位,只差一个 `.6` 解析器写入 conv.json**。
|
||||
|
||||
@@ -54,14 +58,14 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
| 辐射平衡逐深度 | `RECHCK` | 17→`.cont` | 无条件 | **被 SYNSPEC 覆盖丢失** |
|
||||
| 统计平衡残差 | `CHCKSE` | 16→fort.16 | `ICHCKP≠0`(nst 未设) | **未启用** |
|
||||
| 冷却/加热率 | `COOLRT` | 86/87/88 | `ICOOLP≠0`(nst 未设) | **未启用** |
|
||||
| 出射辐射场谱 | `OUTPRI` | 13→fort.13 | 无条件 | **白名单排除**(result_filter.rs:179) |
|
||||
| 出射辐射场谱 | `OUTPRI` | 13→fort.13 | 无条件 | **默认结构性排除**(非 `BARE_KEEPS`、不带 `<name>.` 前缀,`result_filter.rs:67-72`;与 fort.1/2/3 等中间单元同等处理,非专门列入排除名单) |
|
||||
| TLUSTY 出射谱 | `OUTPRI` | 14→`.emflux` | 无条件 | **已快照归档,未解析**(可做 bolometric 积分) |
|
||||
| b 因子(NLTE 偏离) | `OUTPRI` | 12→`.bfac` | 无条件 | **已快照归档,未解析** |
|
||||
| SYNSPEC 谱线统计 | START/OUTPRI | 6→`.log` | 无条件 | **已归档,未解析**(含 `LINES-TOTAL`、波长范围、等效宽度) |
|
||||
|
||||
### 1.4 conv.json 的现状
|
||||
|
||||
`ModelSummary` / `StageSummary` / `ConvCheckResult`(`models.rs`)记录的字段**全部是数值收敛指标**(max_relc, last_iter, worst_depth, synspec_rc 等),**没有任何物理正确性字段**(能量守恒、辐射平衡、统计平衡残差)。
|
||||
`ModelSummary` / `StepSummary` / `ConvCheckResult`(`models.rs:616/590/564`)记录的字段**全部是数值收敛指标**(max_relc, last_iter, worst_depth, synspec_rc 等),**没有任何物理正确性字段**(能量守恒、辐射平衡、统计平衡残差)。
|
||||
|
||||
---
|
||||
|
||||
@@ -75,32 +79,32 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
|---------|---------|---------|------------|--------|
|
||||
| **NITER 截断被当收敛** | 大气未达统计平衡,布居数错误 | `tlusty208.f:14728` `LFIN=ABS(CHMX).LE.CHMAX.OR.ITER.GE.NITER`,OR 短路:CHMX 再大也会因 `ITER≥NITER` 判 LFIN | 部分(`conv_check.rs` 重判 max_relc<chmax,但若截断时"刚好"<chmax 则通过) | 极高 |
|
||||
| **ACCEL2/Ng 加速伪收敛** | 加速外推后下次 CHMX 人为变小,提前判收敛但平衡未达成 | `tlusty208.f:29693-29797`,无加速后发散回退检查 | 未捕获(max_relc 变小与真收敛难区分) | 高 |
|
||||
| **模型截断但物理不充分** | 关键 H/He 跃迁被省略,NLTE 种群错误但程序正常 | `gen_input5.rs` 固定 H=9lev、HeI=14、HeII=14;远低于 MLEVEL=1134 上限 | 未捕获(需物理判断) | 高 |
|
||||
| **ND=50 深度网格偏稀** | 大气结构分辨率不足,谱合成输入质量差 | `nst_writer.rs:6` ND=50;`BASICS.FOR:9` MDEPTH=100 | 未捕获(需对比 ND=99) | 中-高 |
|
||||
| **模型截断但物理不充分** | 关键 H/He 跃迁被省略,NLTE 种群错误但程序正常 | 默认原子模型 `default_ions()`(`config.rs:833`,可经 YAML `tlusty_input.dot5.ions` 覆盖,`workflows/sdB_cno.yaml:63-90` 即显式列出完整 22 行表)H=9lev、HeI=14、HeII=14;远低于 MLEVEL=1134 上限 | 未捕获(需物理判断) | 高 |
|
||||
| **ND=50 深度网格偏稀** | 大气结构分辨率不足,谱合成输入质量差 | `default_nd()`(`config.rs:712`,经 `TlustyInput.nst.depth.nd` 可配)ND=50 默认;`BASICS.FOR:9` MDEPTH=100 | 未捕获(需对比 ND=99) | 中-高 |
|
||||
|
||||
### 2.2 第二层:SYNSPEC 谱合成错误
|
||||
|
||||
| 错误来源 | 物理后果 | 源码证据 | 当前是否捕获 | 严重度 |
|
||||
|---------|---------|---------|------------|--------|
|
||||
| **★fort.55 字段错位** | 标准深度取最深层(IDSTD=50=ND 而非自动 2ND/3≈33)、输出详尽度偏离、Lyman 线翼开关被误设 | `fort55_writer.rs:5` 写 `imode,idrv,ifreq`;SYNSPEC `:253` 读 `IMODE,IDSTD,IPRIN` —— **已核实错位** | 未捕获 | **高(系统性)** |
|
||||
| **★fort.55 字段错位(已修复,见 §5)** | ~~标准深度取最深层(IDSTD=50=ND)~~、输出详尽度偏离;IOPHLI 无论写何值都被 SYNSPEC 无条件置 0(见 §5.2) | 旧 `fort55_writer.rs:5` 写 `imode,idrv,ifreq` 与 SYNSPEC `synspec54.f:253` 读 `IMODE,IDSTD,IPRIN` 错位。**已重构为 9 子结构体按行一一对应**(`config.rs` Fort55Line1-6 + Fort55LineMol/Vtb/Ang) | 已修复(IDSTD 现 0=自动 2ND/3) | ~~高(系统性)~~ → 已消解 |
|
||||
| **NLTE 静默降级为 LTE** | 匹配不上的 NLTE 线被 set to LTE,线强度错误但程序继续 | `synspec54.f:9114-9176`,仅写 fort.11 计数 | 未捕获(DCTS 不解析 fort.11) | 高 |
|
||||
| **IBFAC=0 默认全程按 LTE 读种群** | fort.8 不含/不全含 NLTE 种群时种群保持 LTE 不替换 | `synspec54.f:1157` IBFAC=0 默认;`:11220` NUMP>IP 才替换 | 未捕获 | 高 |
|
||||
| **波长网格步长固定且窗口窄** | 窄线被漏掉,谱"正常但缺线" | `fort55_writer.rs:12` SPACE=0.01;默认窗口仅 1400-1410Å(`runner.rs:522`) | 未捕获 | 中-高 |
|
||||
| **线表缺失(只链 fort.19)** | 缺关键谱线,有计数但无"应有线却缺失"校验 | `runner.rs:540` symlink 单一线表;`:9157` NLIN0 计数 | 未捕获 | 高 |
|
||||
| **IBFAC=0 默认全程按 LTE 读种群** | fort.8 不含/不全含 NLTE 种群时种群保持 LTE 不替换 | `synspec54.f:1230` `READ(84,*)` 读出 IBFAC,默认 0;`:11220` NUMP>IP 才替换 | 未捕获 | 高 |
|
||||
| **波长网格步长固定且窗口窄** | 窄线被漏掉,谱"正常但缺线" | SPACE=0.01(`config.rs:1519` `default_space()`);默认窗口仅 1400-1410Å(`config.rs:1508,1511` `default_alam0/alast`),sdB_cno 工作流显式配 3000-7000Å | 未捕获 | 中-高 |
|
||||
| **线表缺失(只链 fort.19)** | 缺关键谱线,有计数但无"应有线却缺失"校验 | `runner.rs:598` symlink 单一 `runtime.linelist`;`synspec54.f:9157` NLIN0 计数 | 未捕获 | 高 |
|
||||
|
||||
### 2.3 第三层:数据传递与种子继承(网格级错误放大)
|
||||
|
||||
| 错误来源 | 物理后果 | 源码证据 | 当前是否捕获 | 严重度 |
|
||||
|---------|---------|---------|------------|--------|
|
||||
| **★种子继承的错误传播** | 伪收敛点的 `.7` 通过 `converged && !nan` 门控上传为种子,被相邻 SeedStep 点热启动,**错误沿网格拓扑传播**;`directed_cno_distance` 倾向选贫方向,一个坏种子可污染整个 Teff/logg 族 | `executor.rs:193` 种子门控;`seed_finder.rs` 最近邻选种 | **未捕获**(坏种子本身"通过"了所有检查) | **极高** |
|
||||
| **★种子继承的错误传播** | 伪收敛点的 `.7` 通过 `converged && !nan` 门控上传为种子,被相邻 SeedStep 点热启动,**错误沿网格拓扑传播**;`directed_cno_distance` 倾向选贫方向,一个坏种子可污染整个 Teff/logg 族 | `reporter.rs:122` 种子门控 `converged && !atmo_has_nan`;`executor.rs:221` 读取种子 bytes;`seed_finder.rs:26-40` 有向最近邻选种(RICH_PENALTY=4.0 > POOR_PENALTY=1.0,倾向贫方向) | **未捕获**(坏种子本身"通过"了所有检查) | **极高** |
|
||||
|
||||
### 2.4 第四层:编译/数值精度
|
||||
|
||||
| 错误来源 | 物理后果 | 源码证据 | 当前是否捕获 | 严重度 |
|
||||
|---------|---------|---------|------------|--------|
|
||||
| **生产编译无 -fcheck/-ffpe-trap** | 数组越界、IEEE 异常 → rc=0 + 静默写越界内存,产出无 NaN 标记但物理错误的大气 | `deployment.md:86,90` `gfortran -fno-automatic -O3` | 未捕获(详见 task_failure_detection_analysis.md §4 漏洞 3) | 高 |
|
||||
| **生产编译无 -fcheck/-ffpe-trap** | 数组越界、IEEE 异常 → rc=0 + 静默写越界内存,产出无 NaN 标记但物理错误的大气 | `deployment.md:86,90` `gfortran -fno-automatic -O3` | 未捕获(详见 `tlusty&synspec收敛性判断.md` §4 漏洞 3) | 高 |
|
||||
|
||||
> 详见 `task_failure_detection_analysis.md` 的 gfortran 运行时错误退出码实测表。
|
||||
> 详见 `tlusty&synspec收敛性判断.md` 的 gfortran 运行时错误退出码实测表。
|
||||
|
||||
---
|
||||
|
||||
@@ -115,11 +119,11 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
| **通量守恒** | 每深度 πF(rad+conv)=σTeff⁴ | `(RAD+CON)/TOT` 偏离 1 ≤1%(Hubeny 官方判据);任一深度 >5% 直接判坏 | `.6` 末表 | 低 | **线上** |
|
||||
| **max_relc 收敛门槛** | 状态向量相对修正已可忽略 | < chmax(默认 10⁻³,对 sdB/早型星合理;精密线分析可收紧到 10⁻⁴) | `*_chmax*.9` | 低(已实现) | **线上** |
|
||||
| **假收敛排查** | max_relc 单调下降 ≥3 个量级 | 初始/最终 max_relc ≥10³ | fort.9 全序列 | 中 | 抽样 |
|
||||
| **NaN/Inf 否决(收紧)** | 所有深度点温度/密度为有限物理值 | **任意 1 行命中即否决**(当前 10% 阈值过松) | `.7` | 低 | **线上** |
|
||||
| **NaN/Inf 否决(收紧)** | 所有深度点温度/密度为有限物理值 | **任意 1 行命中即否决**(已实现 0 行容忍,`conv_check.rs:264-265`) | `.7` | 低 | **线上** |
|
||||
| **温度结构边界** | T(τ) 落在物理合理范围 | 表层 T ≲ 2-3×Teff;idstd 处 T≈(2/3)Teff;总体 10¹-10⁸ K | `.7` | 中 | 抽样 |
|
||||
| **统计平衡残差(NLTE)** | 跃迁平衡 R_ij·n_i=R_ji·n_j、粒子数守恒 | b 因子无 NaN/Inf;关键能级 b∈10⁻³-10³ | `.bfac` | 中 | 抽样 |
|
||||
|
||||
**对 TLUSTY 的已知坑**:(a) fort.9 缺失分支无条件判收敛 + 虚构 `best_max_relc=0.0`(见 task_failure_detection_analysis.md 漏洞 2);(b) Kantorovich 加速的伪收敛风险;(c) 表层(worst_depth=1)最易出 NaN。
|
||||
**对 TLUSTY 的已知坑**:(a) fort.9 缺失分支虚构 `best_max_relc=0.0`(**已修复**:现按 `niter==0`/`niter>0` 区分——grey start 保留 converged=true 但 best_max_relc=None;异常返回 INFINITY,`runner.rs:448-477`、`conv_check.rs:88-98`);(b) Kantorovich 加速的伪收敛风险;(c) 表层(worst_depth=1)最易出 NaN。
|
||||
|
||||
### 3.2 光谱合成层面(SYNSPEC 产出)
|
||||
|
||||
@@ -147,15 +151,78 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
|
||||
### 3.4 外部基准比对(ground truth)
|
||||
|
||||
| 基准 | 验证什么 | 容差/判据 | 数据来源 | 难度 | 时机 |
|
||||
|------|---------|----------|---------|------|------|
|
||||
| **Lanz & Hubeny 2003/2007 网格** | 与社区标准 TLUSTY/SYNSPEC 网格逐点一致 | 连续谱 <1-2%,线轮廓 <5% | tlusty.oca.eu(OSTAR2002/BSTAR2006,免费) | 中 | 建库时 |
|
||||
| **NLTE-OBGRID** | Hubeny 更新版网格(更完整 H 原子) | 同上 | MAST archive.stsci.edu/hlsp/nlte-obgrid | 中 | 建库时 |
|
||||
| **PHOENIX / CASTELLI / ATLAS9** | 与独立代码(不同物理近似)合理一致 | 光学几个百分点;UV(400nm)可达 10-20% | STScI | 中-高 | 建库时 |
|
||||
| **CALSPEC 实测标准星** | 终极 ground truth | Bohlin+2020:TLUSTY 白矮星网格 1500Å-30μm 一致到 **1%** | MAST(GD153/GD71/G191B2B) | 高 | 深度核查 |
|
||||
| **Gaia FGK 基准星** | 非光谱学基本参数(干涉/角直径)校验 | gold standard | blancocuaresma.com/s/benchmarkstars | 高 | 深度核查 |
|
||||
> **核心矛盾与适用性说明(本网格特有)**:本网格是 6 维 sdB + H/He/**CNO 全 NLTE** 独立扫描(Teff 20-60kK、logg 5.0-6.5、logHe −4..2、logC/logN/logO 各 −4..−1,见 `workflows/sdB_cno.yaml`)。社区经典网格 OSTAR2002/BSTAR2006/NLTE-OBGRID **均不含 CNO 的 NLTE 同时建模**(CNO 按 LTE 或仅 H/He NLTE),PHOENIX/ATLAS9 更无。因此**逐点外部比对 CNO 维度几乎不可能**——任何外部基准最多只能验证 H/He 部分。正确破局:用**代码同源性**验证实现正确性,用**网格内部物理规律**验证 CNO NLTE 物理正确性。
|
||||
|
||||
**实测比对必须考虑的系统误差**:消光(RV=3.1)、视向速度、instrumental broadening(需 ROTINS 卷积)、距离/标度因子。
|
||||
#### 路线 A(最高 ROI):网格内部 CNO 物理自洽验证
|
||||
|
||||
这是本网格**独有的、外部基准反而做不到**的验证。CNO NLTE 有强可检验预测:**线强对丰度的单调响应 + CNO 互不耦合**。已有 361 个网格点数据(`data/result/` 实测,持续增长),零额外计算成本。
|
||||
|
||||
| 校验 | 物理不变量 | 判据 | 所需数据 | 难度 |
|
||||
|------|----------|------|---------|------|
|
||||
| **C 线 EW 随 logC 单调** | 固定 Teff/logg/logHe,C II/C III 线强随 C 丰度单调递增 | 同族 4 个 logC 点 EW 单调,无反转/平台 | 同族 `.spec` + 线表 | 中 |
|
||||
| **CNO 线互不耦合** | 固定 C/N,扫 O 时 C/N 线 EW 几乎不变(NLTE 交叉耦合弱) | C 线 EW 跨 logO 变化 <5% | 同族 `.spec` + 线表 | 中 |
|
||||
| **电离序列协同** | N III 4634-4641 与 He II 4686 协同增减(共依赖 ne/T) | 两线强度比在网格上平滑无突变 | `.spec` 序列 | 中 |
|
||||
|
||||
> **EW 数据来源的现实约束**:理想情况下从 `.iden`(SYNSPEC fort.12 逐线表)直接读 EQW 字段(`synspec54.f:9719` `WRITE(12,603) ...EQW...`)。但经核查,当前归档的 361 个 `.iden` 存在两个问题:(1) **EQW 字段(第 7 列)大面积缺失**——SYNSPEC 仅在线心强度权重 `WW1` 非零时才填 EQW(`:9705`),当前配置下多数逐线表行的 EQW 仍为 `0.0`,仅约 43/92(≈47%)的逐线表含非零 EQW(强线如 He II 3203Å 的 EQW 可达上千 mA),作为系统化 EW 来源并不可靠;(2) **格式不统一**——仅 92 个是逐线表格式,243 个是频率网格格式,另有 26 个其他形态(`data/result/` 实测)。因此 EW 实测需改走**对 `.spec` 归一化谱按线窗口数值积分**(`∫(1−F)dλ`),不能依赖 `.iden`。线波长定位参考线表(fort.19)。此为路线 A 脚本的实现前提。
|
||||
|
||||
#### 路线 B:TLUSTY 官方 hhe 样本的退化比对(端到端回归)
|
||||
|
||||
仓库已有 `hotsd/tlusty (副本)/hhe35nl.6` 等 TLUSTY 官方 H/He NLTE 标准样本(Teff=35000K,**不含 CNO**)。这是**同源码、同原子数据**的最干净回归测试。
|
||||
|
||||
- **操作**:用本 pipeline 跑一个 `logC=logN=logO=−10`(等效无 CNO)的点,配置与 hhe35nl 对齐。
|
||||
- **判据**:`.7` 大气结构(T(τ)、逐深度温度/密度/电子密度)与官方样本逐深度一致到 **<0.1%**——CNO 丰度趋零时物理上退化为纯 H/He,任何偏离都指向 pipeline 的输入封装/参数传递 bug。
|
||||
- **覆盖**:直接验证 §2.1(深度网格、模型截断)、§2.2(fort.55 字段错位的副作用)是否影响大气结构。投入低(跑 1 个点)。
|
||||
|
||||
#### 路线 C:同代码栈社区网格——Pacheco & Díaz 2021
|
||||
|
||||
唯一能做**近似逐点比对**的社区基准,因为与本网格用**完全相同的代码栈**:
|
||||
|
||||
| 维度 | 本网格 | Pacheco 2021(ApJS 256, 41) | 可比性 |
|
||||
|------|--------|------------------------------|--------|
|
||||
| 代码 | TLUSTY **v208** + SYNSPEC **54** | TLUSTY **v205/v208** + SYNSPEC **54** | ✅ 同版本,差异最小 |
|
||||
| Teff | 20k/30k/40k/50k/60k | 20k/25k/30k/35k/45k/65k | ✅ 重叠 20k/30k(余插值) |
|
||||
| logg | 5.0/5.5/6.0/6.5 | 4.5/5.5/6.5 | ✅ 重叠 5.5/6.5 |
|
||||
| logHe | −4..2 | He-poor/He-rich 两支(约 −3..+2) | ⚠️ 仅两端可比 |
|
||||
| CNO | logC/N/O 各 −4..−1 **独立扫描** | **整体 solar 或 [Fe/H]−1.5 两组固定比例** | ❌ **无法逐点比对 CNO 维度** |
|
||||
| 波长 | 3000-7000Å | 1000-10000Å @ 0.01Å | ✅ 本网格是其子集 |
|
||||
| 数据 | — | 公开:SpecModels / SVO / Vizier | ✅ 可下载 |
|
||||
|
||||
**比对策略**(绕开 CNO 丰度差异):
|
||||
1. **连续谱形状**(3200-3700Å、4400-4800Å 无强 CNO 线窗口):取本网格 CNO 最接近太阳(logC≈−3.6/N≈−4.2/O≈−3.3)的点,在相同 Teff/logg/logHe 下比对连续谱,容差 **1-2%**。
|
||||
2. **CNO 线波长定位**:线强因丰度不同而异,但 C II 4267、C III 4640-4650、N III 4630-4640 的**线心波长必须一致**(同套原子数据),漂移 = 线表加载错。
|
||||
3. **非重叠点插值**:Teff=40k/50k 用其 35k/45k/65k 插值曲线,本网格点应落 ±5% 内。
|
||||
|
||||
**数据获取**:`http://specmodels.iag.usp.br/` 或 `http://svo2.cab.inta-csic.es/theory//newov2/`。
|
||||
|
||||
#### 路线 D:实测光谱 ground truth——SDSS / LAMOST sdB 库
|
||||
|
||||
终极基准是真实恒星。大量 sdB 有实测光谱 + 已测定参数:
|
||||
|
||||
- **SDSS hotSubDwarf catalog**(Kepler et al. 2015-2019,~10⁴ 颗):每颗有 Teff/logg/logHe 及部分 C/N 丰度。
|
||||
- 选 5-10 颗参数落入本网格的 sdB,取最近邻网格点,卷积仪器轮廓(SDSS R≈2000)后比对。
|
||||
- **容差**:线轮廓吻合、Balmer/He EW 10-15%(含噪声与投影自转)。
|
||||
|
||||
#### 经典社区网格的适用范围(仅 H/He 部分)
|
||||
|
||||
下列网格**不含 CNO NLTE**,只能用于验证 H/He 连续谱与 Balmer/He 线,**不能验证 CNO 维度**:
|
||||
|
||||
| 基准 | CNO 处理 | 可验证 | 数据来源 |
|
||||
|------|---------|--------|---------|
|
||||
| OSTAR2002 / BSTAR2006(Lanz & Hubeny 2003/2007) | LTE | H/He 连续谱、线轮廓 | tlusty.oca.eu |
|
||||
| NLTE-OBGRID | LTE | H/He 连续谱(更完整 H 原子) | MAST archive.stsci.edu/hlsp/nlte-obgrid |
|
||||
| PHOENIX / ATLAS9 | LTE(独立代码) | 独立代码交叉(光学几个百分点;UV 可达 10-20%) | STScI |
|
||||
| CALSPEC 标准星(Bohlin+2020) | — | TLUSTY WD 网格 1500Å-30μm 1% 一致(**仅适用 WD,非 sdB**) | MAST(GD153/GD71) |
|
||||
|
||||
#### 优先级与实测系统误差
|
||||
|
||||
**务实排序(针对"无 CNO 外部基准"现实)**:
|
||||
|
||||
1. **P0 路线 A**(网格内部 CNO 单调性/解耦)——数据现成,CNO 独有验证,投入最低。
|
||||
2. **P0 路线 B**(hhe 退化比对)——同源码回归测试,最干净。
|
||||
3. **P1 路线 C**(Pacheco 2021)——唯一同版本同物理基准,连续谱/线位。
|
||||
4. **P2 路线 D**(SDSS 实测)——终极 ground truth,投入最高。
|
||||
|
||||
**实测比对(路线 D)必须考虑的系统误差**:消光(RV=3.1)、视向速度、instrumental broadening(需 ROTINS 卷积)、距离/标度因子。
|
||||
|
||||
---
|
||||
|
||||
@@ -178,25 +245,47 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
|
||||
---
|
||||
|
||||
## 5. fort.55 字段错位问题(已核实的系统性 bug)
|
||||
## 5. fort.55 字段错位问题(已修复)
|
||||
|
||||
这是一个影响**所有光谱**的系统性问题,值得单独记录。
|
||||
> **状态:已修复(2026-08-07)**。本节保留问题描述作为历史记录,处置见 §5.3。
|
||||
> 影响所有光谱的系统性 bug:Rust 端 `SynspecInput` 的字段命名与 SYNSPEC `READ(55,*)` 语句的变量名脱节,且大量字段硬编码不可配。修复方式是重构为 9 个按行分组的子结构体,字段名与 SYNSPEC 源码一一对应。
|
||||
|
||||
### 5.1 事实
|
||||
### 5.1 事实(修复前的错位)
|
||||
|
||||
- `fort55_writer.rs:5` 生成的第一行:`imode, idrv, ifreq`(值如 `0, 50, 1`)
|
||||
- SYNSPEC `synspec54.f:253` 实际读取:`IMODE, IDSTD, IPRIN`
|
||||
- 即 **`idrv=50` 被当作 `IDSTD=50`,`ifreq=1` 被当作 `IPRIN=1`**
|
||||
旧 `SynspecInput` 是扁平 7 字段结构体,与 SYNSPEC fort.55 的 9 行 30+ 字段规范脱节:
|
||||
|
||||
### 5.2 影响
|
||||
- **第 1 行错位**:`fort55_writer.rs:5`(旧)写 `imode, idrv, ifreq`;SYNSPEC `synspec54.f:253` 读 `IMODE, IDSTD, IPRIN`。即 **`idrv=50` 被当作 `IDSTD=50`,`ifreq=1` 被当作 `IPRIN=1`**。而真正的 `IFREQ`(频率网格开关)在第 4 行(`synspec54.f:2146`)。
|
||||
- **第 6 行错位**:旧字段 `abs_cutoff=0.01` 被写到 SPACE 位置(相邻频率点间距),而非任何"截止"参数。真正的截止参数 CUTOF0(行6第3列)被硬编码为 `10`、CUTOFS(连续谱间距,行6第4列)硬编码为 `0`,均不可配。
|
||||
- **第 2-5 行全硬编码**:INMOD/INTRPL/ICHANG/ICHEMC(行2)、IOPHLI/nun*(行3)、IFREQ/INLTE/ICONTL/INLIST/IFHE2(行4)、IHYDPR/IHE1PR/IHE2PR(行5)全部不可通过 YAML 配置。
|
||||
- **行 7/8 完全缺失**:VTB(湍流速度,行7)、NMU0/ANG0/IFLUX(角度/比强度,行8)不生成,靠 SYNSPEC 的 `err=/end=` 标号侥幸跳过——脆弱巧合。
|
||||
|
||||
- **IDSTD=50**(=ND,最深层):SYNSPEC 的标准深度(`synspec54.f:2155`)本应自动取 `2*ND/3≈33`,现被强制取最深层。标准深度是谱线多普勒宽度、Stark 加宽的参考层,取错层会让线翼轮廓参考错误深度。
|
||||
- **IPRIN=1**:SYNSPEC 的输出详尽度开关,影响 `.log` 里打印的诊断量多少。
|
||||
- **第 3 行 `IOPHLI=0`**(`fort55_writer.rs:7` 第一个 0):`synspec54.f:255` 读取,IOPHLI 控制 Lyman 线翼处理,0 可能关闭相关处理。
|
||||
### 5.2 影响(修复前的物理后果)
|
||||
|
||||
### 5.3 处置
|
||||
- **IDSTD=50**(=ND,最深层):SYNSPEC 的标准深度(`synspec54.f:2152-2155`,仅在 `IDSTD=0` 时自动取 `2*ND/3≈33`)本应自动取 `2*ND/3≈33`,现被强制取最深层。该层的 T/ne 用于谱线多普勒宽度(`DSTD=SQRT(1.4E7*TSTD+VTS)`,`synspec54.f:2265`;再 `DOPSTD=1.E7/ALAM0*DSTD`,`:8131/8728`)、参考丰度(AVAB)、波长网格步长的归一化基准。实测 8 个不同 Teff/logg/He/CNO 的收敛点,`sqrt(T50/T33)` 稳定在 **2.0-2.13**,即 DOPSTD 系统性偏大 ~100-113%(VTS 与 `1.E7/ALAM0` 系数对 IDSTD 错层带来的相对放大不改变此量级结论)。`AB0=EXP(...)/DOPSTD/AVAB`(`:8913`)直接影响每条谱线的线强归一化,且 `IF(AB0.LT.UN) GO TO 10`(`:8914`)还改变哪些谱线被保留。
|
||||
- **IPRIN=1**:SYNSPEC 的输出详尽度开关,仅影响 `.log` 里打印的诊断量多少,不影响光谱数值。
|
||||
- **IOPHLI 被 SYNSPEC 无条件置 0**:SYNSPEC `synspec54.f:255` 读取 IOPHLI 后,`:269` 紧接 `iophli=0`(注释 `disabling an old option`)**无条件覆盖**。即无论 fort.55 写什么,Lyman 线翼准分子卫星线处理都被**必然关闭**。这是 SYNSPEC 源码的既定行为,非 fort.55 错位所致。
|
||||
|
||||
这是配方层面的 bug,需要单独修复(修正 `fort55_writer.rs` 的字段语义,使 IDSTD=0 自动、IPRIN/IPRIND 按需设置),并重算受影响的光谱。本调研仅记录,不含修复实现。
|
||||
### 5.3 处置(已实施)
|
||||
|
||||
**修复方式**:重构 `SynspecInput`(`config.rs`)为 9 个按行分组的子结构体(`Fort55Line1`..`Fort55Line6` + `Fort55LineMol` + `Fort55LineVtb` + `Fort55LineAng`),字段名严格使用 SYNSPEC 源码变量名,行内字段顺序与 `READ(55,*)` 语句完全一致。每字段带文档注释(物理含义 + `synspec54.f` 行号)。`generate_fort55_content`(`fort55_writer.rs`)改为按子结构体逐行序列化。`SynspecInput::validate()`(`config.rs`)在配置加载时校验不触发不支持的条件读取路径(DM 数组/显式角度/风模式/分子 iunitm)。
|
||||
|
||||
**覆盖范围**:fort.55 全部 9 行 30+ 字段均可通过 YAML `synspec_input` 块按行配置,支持后续不同工作流(sdB/sdO/WD 等)的差异化参数。单元测试覆盖行数、字段对齐、VTB 默认负值、默认值关键字段、全自定义五个维度。
|
||||
|
||||
**行为变化**:
|
||||
|
||||
| 项 | 修复前 | 修复后 | 影响 |
|
||||
|----|--------|--------|------|
|
||||
| **IDSTD** | 50(最深层,错位) | **0(自动取 2ND/3≈33)** | **光谱线强归一化修正**,所有光谱需重算 |
|
||||
| IPRIN | 1(被 ifreq 错位占用) | 1(语义正确,显式可配) | 无行为变化 |
|
||||
| 真正的 IFREQ | 1(硬编码行4) | 1(显式可配) | 无行为变化 |
|
||||
| **行7 分子表** | 漏生成(VTB 行被吞) | 显式生成 `nmlist=0` | 修正行序错位(原靠 EOF 兜底) |
|
||||
| **行8 VTB** | 漏生成(end=30 跳过→vturb 保持 2) | 生成 `-1.0`(`vtb<0` 不覆盖→vturb 保持 2) | 无行为变化(均保持大气 vturb=2) |
|
||||
| 行9 NMU0 | 漏生成(end=100 跳过) | 生成 `0`(不算比强度) | 无行为变化 |
|
||||
| 其余字段 | 硬编码 | 可配,默认值与原硬编码一致 | 无行为变化 |
|
||||
|
||||
> **行7 分子表的关键性**:SYNSPEC 在 IMODE∈[-3,1](默认满足)时会额外读一行分子表(`synspec54.f:2208`)。gfortran list-directed READ 满足变量列表后消费整行剩余——若漏生成此行,VTB 行会被分子表 READ 吞掉,导致 `vtb` 读到 NMU0 行的首值,`if(vtb.ge.0.)` 触发 vturb 被覆盖为 0(审查 Critical-1)。显式生成 `nmlist=0` 行可保证行序对齐。VTB 默认 -1.0 是双重保险:即使行序错位,`vtb<0` 也不会触发覆盖。
|
||||
|
||||
**重算指引**:fort.55 只影响 SYNSPEC 阶段,TLUSTY 大气结构(`.7`)不受影响,种子库可继续用。重算只需把 `synspec_stage.policy` 临时改为 `force_recompute` 重跑 SYNSPEC 阶段,无需重算 TLUSTY。
|
||||
|
||||
---
|
||||
|
||||
@@ -208,9 +297,9 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
|
||||
| # | 校验项 | 现状 | 所需投入 | 依据 |
|
||||
|---|--------|------|---------|------|
|
||||
| 1 | SYNSPEC `.spec`/`.cont` 内容校验(NaN/Inf/负值/全零/行数) | **未做**(漏洞 1) | 低 | task_failure_detection_analysis.md P0 |
|
||||
| 2 | 大气 NaN 否决收紧到 0 行容忍 | 10% 阈值过松(漏洞 4) | 低 | conv_check.rs:212 |
|
||||
| 3 | max_relc < chmax + is_finite + fort.9 真实存在 | 已实现,但 fort.9 缺失分支有虚构值(漏洞 2) | 低 | runner.rs:417 |
|
||||
| 1 | SYNSPEC `.spec`/`.cont` 内容校验(NaN/Inf/负值/全零/行数) | **已修复(漏洞 1)**:`spec_is_valid`(`conv_check.rs:287-335`)在 `runner.rs:645` 调用,校验 NaN/Inf/溢出/行数不足/全零 | — | `tlusty&synspec收敛性判断.md` §4 漏洞 1(P0) |
|
||||
| 2 | 大气 NaN 否决收紧到 0 行容忍 | **已修复(漏洞 4)**:`atmosphere_has_nan` 现 0 行容忍(`conv_check.rs:264-265`),含 NaN/Inf/`***` 溢出/E+300 超高指数四类 | — | `conv_check.rs:264` |
|
||||
| 3 | max_relc < chmax + is_finite + fort.9 真实存在 | **已修复(漏洞 2 进阶)**:fort.9 缺失返回 INFINITY;`niter==0`(grey start)与 `niter>0`(异常)分流,best_max_relc=None 不再虚构 0.0 | — | `runner.rs:448-477`、`conv_check.rs:88-98` |
|
||||
| 4 | **通量守恒 `(RAD+CON)/TOT` 偏离 1 ≤1%**(从 `.6` 提取) | **未做(数据已就位)** | 低(写一个 .6 解析器) | §1.1,Hubeny 官方判据 |
|
||||
|
||||
### 第二层:质量监控(线上抽样 + 线下定期)
|
||||
@@ -226,14 +315,19 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
|
||||
### 第三层:深度核查(线下,建库时 + 每次配方变更时)
|
||||
|
||||
| # | 校验项 | 所需投入 |
|
||||
|---|--------|---------|
|
||||
| 11 | 与 Lanz & Hubeny 2003/2007 / NLTE-OBGRID 重叠点逐波长比对 | 中 |
|
||||
| 12 | 与 PHOENIX/CASTELLI 独立代码比对 | 中-高 |
|
||||
| 13 | 与 CALSPEC/SDSS 实测标准星比对 | 高 |
|
||||
| 14 | Balmer/He 线序列单调性与基准 | 中 |
|
||||
| 15 | 网格二阶差分离群点检测 | 中 |
|
||||
| 16 | 统计平衡残差 / b 因子合理性(`.bfac`) | 中 |
|
||||
> 外部基准的适用性见 §3.4:经典网格(OSTAR/BSTAR/NLTE-OBGRID)**不含 CNO NLTE**,仅能验证 H/He 部分;CNO 维度的外部比对首选 Pacheco 2021(同代码栈),其余靠网格内部物理规律(路线 A)。
|
||||
|
||||
| # | 校验项 | 所需投入 | 对应路线 |
|
||||
|---|--------|---------|---------|
|
||||
| 11 | **网格内部 CNO 单调性/解耦**(固定 Teff/logg/logHe 扫 logC/N/O) | 中 | §3.4 路线 A |
|
||||
| 12 | **hhe 官方样本退化比对**(CNO=−10 退化到纯 H/He,与 hhe35nl 逐深度对比) | 低 | §3.4 路线 B |
|
||||
| 13 | **Pacheco 2021 连续谱/线位比对**(同代码栈,重叠 Teff/logg/logHe 点) | 中 | §3.4 路线 C |
|
||||
| 14 | 与 OSTAR2002/BSTAR2006/NLTE-OBGRID 重叠点 **H/He 部分**比对(不含 CNO 验证) | 中 | §3.4 经典网格表 |
|
||||
| 15 | 与 PHOENIX/ATLAS9 独立代码比对(H/He 连续谱交叉) | 中-高 | §3.4 经典网格表 |
|
||||
| 16 | 与 SDSS/LAMOST 实测 sdB 比对(卷积仪器轮廓后) | 高 | §3.4 路线 D |
|
||||
| 17 | Balmer/He 线序列单调性与基准 | 中 | — |
|
||||
| 18 | 网格二阶差分离群点检测 | 中 | — |
|
||||
| 19 | 统计平衡残差 / b 因子合理性(`.bfac`) | 中 | — |
|
||||
|
||||
### 配方变更触发规则
|
||||
|
||||
@@ -247,11 +341,11 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
|
||||
**投入产出比排序**:
|
||||
|
||||
1. **P0:解析 `.6` 的能量守恒诊断**(§1.1)—— 数据已就位,Hubeny 官方 1% 判据,是物理正确性的第一道硬门槛。同时修 `.spec` 内容校验(§6 第一层)。
|
||||
2. **P1:fort.55 字段错位修复**(§5)—— 影响所有光谱的系统性 bug,需修正后重算。
|
||||
1. **P0:解析 `.6` 的能量守恒诊断**(§1.1)—— 数据已就位,Hubeny 官方 1% 判据,是物理正确性的第一道硬门槛。第一层硬门槛 #1/#2/#3 已在近期提交修复(`.spec` 内容校验、NaN 0 行容忍、fort.9 缺失分流),**此项 #4 是第一层唯一仍真实存在的缺口**。
|
||||
2. **~~P1:fort.55 字段错位修复~~(已完成,见 §5)**——`SynspecInput` 已重构为 9 子结构体与 SYNSPEC 一一对应,IDSTD 修正为 0。**待办:重算受影响光谱**(把 `synspec_stage.policy` 改 `force_recompute` 重跑 SYNSPEC 阶段,TLUSTY 大气不受影响)。
|
||||
3. **P1:网格级种子传播校验**(§2.3)—— 这是网格计算特有的静默放大机制,可通过"同一物理族下游点的 max_relc/通量守恒分布异常"间接检测。
|
||||
4. **P2:质量监控层**(§6 第二层)—— bolometric 积分、Planck 检验、连续谱自洽。
|
||||
5. **P3:建库时基准比对**(§6 第三层)—— 与 Lanz & Hubeny 网格逐点比对是最直接的同代码 ground truth。
|
||||
5. **P2:深度核查层**(§6 第三层,§3.4)—— 分两支:**(a) 网格内部 CNO 单调性 + hhe 退化比对**(路线 A/B,数据现成、CNO 独有,优先做);**(b) 外部基准**——同代码栈的 Pacheco 2021(路线 C)是唯一能比 CNO 连续谱/线位的社区网格;经典网格(OSTAR/BSTAR/NLTE-OBGRID)不含 CNO NLTE,仅能验证 H/He 部分。
|
||||
|
||||
---
|
||||
|
||||
@@ -263,20 +357,29 @@ TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,
|
||||
- TLUSTY User's Guide IV, Hubeny 2021(arXiv:2104.02829)—— CHMAX=10⁻⁴ 高精度例
|
||||
|
||||
**社区基准网格(ground truth)**
|
||||
- OSTAR2002 / BSTAR2006(tlusty.oca.eu)—— Lanz & Hubeny 2003/2007 社区标准网格
|
||||
- NLTE-OBGRID(MAST archive.stsci.edu/hlsp/nlte-obgrid)—— Hubeny 更新版
|
||||
- WD-GRID(MAST archive.stsci.edu/hlsp/wd-grid)—— Bohlin+2020 白矮星 NLTE 网格,1500Å-30μm 对 CALSPEC 1% 一致
|
||||
|
||||
> 适用性:以下经典网格**均不含 CNO NLTE 同时建模**(CNO 按 LTE),只能验证 H/He 部分(§3.4)。本网格(sdB + CNO 全 NLTE)的外部基准首选 Pacheco 2021(同代码栈、含 CNO NLTE)。
|
||||
|
||||
- **Pacheco & Díaz 2021**(ApJS 256, 41, DOI:10.3847/1538-4365/ac2508)—— **TLUSTY v208 + SYNSPEC 54**(与本网格同版本),sdB/sdO NLTE 含 C/N/O NLTE 线覆盖;Teff 10-65kK、logg 4.5-6.5、He-poor/rich 两支;1000-10000Å @ 0.01Å;数据公开(SpecModels `specmodels.iag.usp.br` / SVO `svo2.cab.inta-csic.es/theory//newov2` / Vizier)。**CNO 丰度为整体 solar 或 [Fe/H]−1.5 两组固定比例,非独立扫描**——不能逐点比对 CNO 维度,但可比对连续谱形状与线波长定位。
|
||||
- **Németh et al. 2014**(ASPC 481, 95;arXiv:1308.0252)—— Pacheco 2021 的前身,TLUSTY/SYNSPEC NLTE sdB/sdO 网格(3200-7200Å),覆盖 (He-)sdB/sdO 全参数空间。
|
||||
- **Latour et al. 2011**(ApJ 733, 100)—— sdO NLTE 网格(TLUSTY/SYNSPEC),含 H+He 及可选 C/N/O NLTE 模型。
|
||||
- OSTAR2002 / BSTAR2006(tlusty.oca.eu)—— Lanz & Hubeny 2003/2007 社区标准网格(**H/He NLTE,CNO LTE**)
|
||||
- NLTE-OBGRID(MAST archive.stsci.edu/hlsp/nlte-obgrid)—— Hubeny 更新版(**H/He NLTE,CNO LTE**)
|
||||
- WD-GRID(MAST archive.stsci.edu/hlsp/wd-grid)—— Bohlin+2020 白矮星 NLTE 网格,1500Å-30μm 对 CALSPEC 1% 一致(**仅适用 WD,非 sdB**)
|
||||
|
||||
**交叉代码比对**
|
||||
- Witzke+2021, MPS-ATLAS(A&A)—— 三代码流量差异:~400nm 最大,其他波段几个百分点
|
||||
- CASTELLI/Kurucz ATLAS9(STScI)
|
||||
|
||||
**本仓库内相关文档**
|
||||
- `docs/task_failure_detection_analysis.md` —— rc 不可靠性、fort.9/NaN 阈值漏洞、gfortran 退出码实测(第一层硬门槛的依据)
|
||||
- `docs/tlusty&synspec收敛性判断.md` —— rc 不可靠性、fort.9/NaN 阈值漏洞 1-5 修复历程、gfortran 退出码实测(第一层硬门槛的依据;注意该文档 §4 部分漏洞描述仍是修复前状态,本文档已同步现状)
|
||||
- `docs/tlusty_result_artifacts.md` —— fort 单元语义、`.emflux`/`.bfac` 快照、TLUSTY fort.14 与 SYNSPEC `.spec` 关系
|
||||
- `docs/PIPELINE.md` —— 阶段链、nst 配方(ND=50、默认 CHMAX=0.001)、物理极限区
|
||||
|
||||
**源码证据**
|
||||
- 能量守恒诊断:`tlusty208.f:14179-14191,14271-14273`(OUTPRI);`:49854`(RECHCK);实测 `hotsd/tlusty (副本)/hhe35nl.6:334-339`
|
||||
- fort.55 错位:`fort55_writer.rs:5` vs `synspec54.f:253`(已核实)
|
||||
- 种子传播:`executor.rs:193`、`seed_finder.rs`
|
||||
- 能量守恒诊断:`tlusty208.f:14179-14191,14271-14273`(OUTPRI);`:49854`(RECHCK);实测 `hotsd/tlusty (副本)/hhe35nl.6:341`(`(RAD+CON)/TOT=1.00106E+00`,收敛良好)
|
||||
- fort.55 错位(已修复):旧 `fort55_writer.rs:5` 写 `imode,idrv,ifreq` vs SYNSPEC `synspec54.f:253` 读 `IMODE,IDSTD,IPRIN`;现重构为 `config.rs` 的 `Fort55Line1`-`Fort55Line6` + `Fort55LineMol`/`Fort55LineVtb`/`Fort55LineAng` 9 子结构体按行对应。`synspec54.f:269` `iophli=0` 无条件覆盖(已核实,SYNSPEC 既定行为)。
|
||||
- 种子传播:`reporter.rs:122`(门控)、`executor.rs:221`(读种子 bytes)、`seed_finder.rs:26-40`(有向最近邻)
|
||||
- 漏洞 1 修复:`conv_check.rs:287-335`(spec_is_valid)、`runner.rs:645`(调用点)
|
||||
- 漏洞 4 修复:`conv_check.rs:264-265`(0 行容忍)
|
||||
- 漏洞 2 进阶修复:`runner.rs:448-477`、`conv_check.rs:88-98`
|
||||
|
||||
@@ -207,14 +207,14 @@ UI 布局草图如下:
|
||||
(`tasks` 表的 `*_policy` / `*_strategies` 列,见 `FallbackSnapshot`),与 §4.2「不修改原有
|
||||
policy,保持用户初始配置」语义一致。2026-08-04 起策略不再门控回退(回退只由策略链驱动),
|
||||
但 policy 仍随重试任务落库,供审计与下次启动时的策略重置逻辑消费。数值参数
|
||||
(`synspec: {wstart/wend/...}`、`timeout_sec`)仍取当前 YAML(用户编辑意图优先),形成
|
||||
(`synspec_input`(fort.55 控制卡)、`timeout_sec`)仍取当前 YAML(用户编辑意图优先),形成
|
||||
「旧链 / 旧 policy + 新数值参数」的混合口径——这是有意取舍:策略链与 policy 是任务级
|
||||
回退语义记录必须用派发时的,运行参数允许跟随最新配置。
|
||||
|
||||
### 7.3 完整 YAML 编辑器(数值参数编辑通道)已恢复
|
||||
|
||||
内嵌面板(§6.2)只编辑阶段三维配置(enabled / policy / strategies);`synspec:` 数值参数块
|
||||
(波长范围、展宽、截断等)不在面板编辑范围。**2026-08 补充**:引擎面板新增「YAML 配置」按钮,
|
||||
内嵌面板(§6.2)只编辑阶段三维配置(enabled / policy / strategies);`synspec_input`(fort.55
|
||||
控制卡)等数值参数块不在面板编辑范围。**2026-08 补充**:引擎面板新增「YAML 配置」按钮,
|
||||
恢复原 `yamlEditor.js` 的完整 YAML 查看/编辑弹窗(语法高亮 + 行号 + 脏状态守卫 + 运行中锁定 +
|
||||
服务端校验错误内联横幅),数值参数等完整配置均可在此查看与修改后直接保存(PUT
|
||||
/api/workflows/:name)。该弹窗与内嵌面板的保存互相独立、以各自编辑内容为准——二者维度正交:
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
| G1 | 无逐点 HTTP 端点 | 前端拿不到任何点级数据 | 新增 `/workflows/:name/points` |
|
||||
| G2 | 无按工作流的统计端点(`get_grid_summary_stats` 未暴露;`/api/status` 只有全局) | 卡片/详情无法显示每工作流进度 | 新增 `/workflows/:name/stats`;列表接口内联计数 |
|
||||
| G3 | `queued` 被并进 `pending`(`db.rs:1596,1612`) | 无法区分"排队中"与"未入队" | 新端点拆开统计 |
|
||||
| G4 | 单点耗时/迭代数未进库(`TaskReport.elapsed_sec` 收到即丢;`last_iter` 在 `StageSummary` 前被丢弃) | 列表无耗时列、无 ETA | 迁移补列 + runner 携带 `last_iter`(Phase 3) |
|
||||
| G4 | 单点耗时/迭代数未进库(`TaskReport.elapsed_sec` 收到即丢;`last_iter` 在 `StepSummary` 前被丢弃) | 列表无耗时列、无 ETA | 迁移补列 + runner 携带 `last_iter`(Phase 3) |
|
||||
| G5 | 无进度时间序列 | 画不出"进度-时间"曲线 | 新表 `workflow_progress_snapshots`(Phase 3) |
|
||||
| G6 | 失败点无法重试(start 只重置 `queued`,`db.rs:886-899`) | 失败点永久卡死 | 新增 retry 端点(复用 `reset_specific_grid_points_to_pending` 思路) |
|
||||
| G7 | conv.json 不提供下载/查看 | 逐阶段诊断无法展示 | 点详情端点读盘解析返回 |
|
||||
@@ -330,7 +330,7 @@ LIMIT ? LIMIT OFFSET ?;
|
||||
|
||||
1. `grid_points` 加列:`elapsed_sec REAL`(最近一次成功/失败尝试的墙钟耗时)。
|
||||
在 `record_task_report` 写入——`TaskReport.elapsed_sec` 本来就在手(`models.rs:318`),当前被丢弃。
|
||||
2. `StageSummary` 携带 `last_iter / worst_depth / n_depths`(`models.rs:364-373` 扩字段,
|
||||
2. `StepSummary` 携带 `last_iter / worst_depth / n_depths`(`models.rs:590-612` 扩字段,
|
||||
`runner.rs:302-317` 处 `ConvCheckResult` 已有值,只是没搬过去)→ conv.json 与点详情获得迭代数。
|
||||
3. 新表 `workflow_progress_snapshots(workflow_name TEXT, ts DATETIME, pending INT, queued INT,
|
||||
running INT, completed INT, failed INT)`:由 `main.rs:111-197` 的 30s 后台循环顺手写一行;
|
||||
@@ -394,7 +394,7 @@ LIMIT ? LIMIT OFFSET ?;
|
||||
- 验收:能回答"这个点为什么失败/被谁救回",能看到冷启动成功参数区,能一键重试失败点。
|
||||
|
||||
### Phase 3 — 时序与精算(可选增强)
|
||||
- 后端:迁移(`elapsed_sec` 列、`StageSummary.last_iter`)+ `workflow_progress_snapshots` + ETA 精算。
|
||||
- 后端:迁移(`elapsed_sec` 列、`StepSummary.last_iter`)+ `workflow_progress_snapshots` + ETA 精算。
|
||||
- 前端:概览 ETA 精确化 + 进度-时间 sparkline + 点表耗时列去"~"。
|
||||
|
||||
---
|
||||
|
||||
+2
-2
@@ -254,12 +254,12 @@ batch_deploy_profiles() {
|
||||
mkdir -p data
|
||||
if [ "$need_server" = true ]; then
|
||||
server_tar="data/.batch_dcts-server.tar.gz"
|
||||
echo " -> [Docker Compose] 导出 server 镜像归档包: ${server_tar} (仅打包 1 次)..."
|
||||
echo " -> [Docker Compose] 导出 server 镜像归档包: ${server_tar}..."
|
||||
docker save dcts-server:latest | gzip > "${server_tar}"
|
||||
fi
|
||||
if [ "$need_node" = true ]; then
|
||||
node_tar="data/.batch_dcts-node.tar.gz"
|
||||
echo " -> [Docker Compose] 导出 node 镜像归档包: ${node_tar} (仅打包 1 次)..."
|
||||
echo " -> [Docker Compose] 导出 node 镜像归档包: ${node_tar}..."
|
||||
docker save dcts-node:latest | gzip > "${node_tar}"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -207,9 +207,11 @@ sync_dir() { # $1=user $2=ip $3=port $4=远端绝对目录 $5=本地目标
|
||||
return 0
|
||||
fi
|
||||
# scp 原子写入:先落地到 .partial 临时目录,成功后同文件系统原子 mv 为最终目录名。
|
||||
# 中途失败残留的 .partial 由下方 scp_dir 的 RETURN 时清理钩子统一兜底,
|
||||
# 避免下次因"目录已存在"误判为已同步而跳过、留下不完整子目录。
|
||||
trap 'rm -rf "${dest}"/.*.partial 2>/dev/null || true' RETURN
|
||||
# 开拷前先清理可能的历史 .partial 残留(上次中断留下的半成品),scp_dir 自身失败
|
||||
# 时也会清理各自的 .partial;这样避免下次因"目录已存在"误判为已同步而漏传。
|
||||
# (原先用 trap RETURN 兜底,但 RETURN 陷阱会被外层 backup_node 继承,函数返回时
|
||||
# dest 已出作用域,触发 set -u 的"未绑定变量"报错,故改为前置清理。)
|
||||
rm -rf "${dest}"/.*.partial 2>/dev/null || true
|
||||
while IFS= read -r d; do
|
||||
[ -n "$d" ] || continue
|
||||
echo -e " ${YELLOW}[→]${NC} scp 新增: ${d}"
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# push_import_results.sh — 推送 import_results 工具到 Windows 计算节点并编译
|
||||
# ==============================================================================
|
||||
# 把 import_results 工具的源码推送到 deploy.env.d/<profile> 指定的 Windows 节点,
|
||||
# 在该节点原生编译(Windows 上 cargo build),并打印确切的运行命令。
|
||||
#
|
||||
# 设计前提:
|
||||
# - 旧版 Python run_grid.py 的结果在 Windows 机的 E:/fmq/grid(工具必须在该机运行才能读到)
|
||||
# - 该机已部署 node(Docker Compose),data 目录在 E:/fmq/dcts/data
|
||||
# - DCTS 服务端地址来自 profile 的 DCTS_SERVER_URL
|
||||
# - Windows 机需已装 Rust 工具链(cargo/rustc);脚本会自检并给出安装提示
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/push_import_results.sh # 默认用 node-dckj-win-01.env
|
||||
# ./scripts/push_import_results.sh -f deploy.env.d/other-node.env # 指定其他 profile
|
||||
# ./scripts/push_import_results.sh --no-build # 只推源码不编译(远端已有缓存时加速)
|
||||
# ./scripts/push_import_results.sh --archive-dir E:/fmq/dcts/data/result # 覆盖归档目录
|
||||
#
|
||||
# 运行(推完编译后,在 Windows 机上执行,或本脚本末尾会打印):
|
||||
# E:\fmq\dcts-src\target\release\import_results.exe \
|
||||
# -d E:/fmq/grid -c E:/fmq/dcts-src/workflows/sdB_cno.yaml \
|
||||
# -r E:/fmq/dcts/data/result -s http://100.66.1.2:8090 \
|
||||
# -w sdB_cno -t <admin_token>
|
||||
# ==============================================================================
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${WORK_DIR}"
|
||||
|
||||
# 默认值(可被 profile 覆盖)
|
||||
PROFILE_FILE="deploy.env.d/node-dckj-win-01.env"
|
||||
REMOTE_USER="fmq"
|
||||
REMOTE_IP="192.168.6.102"
|
||||
REMOTE_PORT="22"
|
||||
REMOTE_SRC_DIR="E:/fmq/dcts-src"
|
||||
DCTS_SERVER_URL="http://100.66.1.2:8090"
|
||||
RESULT_DIR="" # 空 → 用 profile 推导(默认 E:/fmq/dcts/data/result)
|
||||
DO_BUILD=true
|
||||
|
||||
# SSH ControlMaster 复用目录(与 deploy.sh 一致,便于共享连接)
|
||||
SSH_CONTROL_DIR="${SSH_CONTROL_DIR:-$HOME/.ssh/cm}"
|
||||
|
||||
# =============================================================================
|
||||
# 解析命令行参数
|
||||
# =============================================================================
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-f|--profile) PROFILE_FILE="$2"; shift 2 ;;
|
||||
--no-build) DO_BUILD=false; shift ;;
|
||||
--archive-dir) RESULT_DIR="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '2,30p' "$0"
|
||||
exit 0 ;;
|
||||
*) echo -e "${RED}未知参数: $1${NC}"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# 加载 profile(SSH 连接信息 + 服务端地址)
|
||||
# =============================================================================
|
||||
if [ ! -f "$PROFILE_FILE" ]; then
|
||||
echo -e "${RED}错误: 找不到 profile '${PROFILE_FILE}'${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${CYAN}[i] 加载 profile: ${PROFILE_FILE}${NC}"
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$PROFILE_FILE"; set +a
|
||||
|
||||
# profile 提供的变量优先(REMOTE_* / DCTS_SERVER_URL)
|
||||
[ -n "${REMOTE_USER:-}" ] && REMOTE_USER="${REMOTE_USER}"
|
||||
[ -n "${REMOTE_IP:-}" ] && REMOTE_IP="${REMOTE_IP}"
|
||||
[ -n "${REMOTE_PORT:-}" ] && REMOTE_PORT="${REMOTE_PORT}"
|
||||
# 源码目录默认放到部署目录旁(E:/fmq/dcts-src),与运行中的 node(E:/fmq/dcts)隔离
|
||||
[ -n "${REMOTE_DIR:-}" ] && REMOTE_SRC_DIR="${REMOTE_DIR}-src"
|
||||
|
||||
# 结果目录:命令行 > 参数 > 默认(部署目录下的 data/result,node 的 DCTS_RESULT_DIR 卷映射到此)
|
||||
if [ -z "${RESULT_DIR}" ]; then
|
||||
RESULT_DIR="${REMOTE_DIR}/data/result"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}📢 目标节点: ${YELLOW}${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}${NC}"
|
||||
echo -e "${CYAN}📢 源码推送: ${YELLOW}${REMOTE_SRC_DIR}${NC}"
|
||||
echo -e "${CYAN}📢 结果目录: ${YELLOW}${RESULT_DIR}${NC}"
|
||||
echo -e "${CYAN}📢 服务端: ${YELLOW}${DCTS_SERVER_URL}${NC}"
|
||||
echo ""
|
||||
|
||||
# =============================================================================
|
||||
# 建立 SSH ControlMaster 连接复用(一次性输密码,后续 scp/ssh 复用)
|
||||
# =============================================================================
|
||||
mkdir -p "${SSH_CONTROL_DIR}"
|
||||
SOCK="${SSH_CONTROL_DIR}/${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}"
|
||||
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${SOCK} -o ControlPersist=1800 -o StrictHostKeyChecking=no"
|
||||
|
||||
if ssh -p "${REMOTE_PORT}" -o ControlPath="${SOCK}" -O check "${REMOTE_USER}@${REMOTE_IP}" >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}[√] 复用已有 SSH 连接${NC}"
|
||||
else
|
||||
echo -e "${CYAN}=== 建立 SSH 连接(请输入 ${REMOTE_USER}@${REMOTE_IP} 的密码)===${NC}"
|
||||
if ! ssh -p "${REMOTE_PORT}" ${SSH_OPTS} -fN "${REMOTE_USER}@${REMOTE_IP}"; then
|
||||
echo -e "${RED}SSH 连接失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[√] SSH 连接已建立${NC}"
|
||||
fi
|
||||
trap 'ssh -p "${REMOTE_PORT}" -o ControlPath="${SOCK}" "${REMOTE_USER}@${REMOTE_IP}" -O exit 2>/dev/null || true' EXIT
|
||||
|
||||
# =============================================================================
|
||||
# 自检远端 Rust 工具链
|
||||
# =============================================================================
|
||||
echo -e "\n${CYAN}=== 检查远端 Rust 工具链 ===${NC}"
|
||||
HAS_CARGO=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
|
||||
"powershell -NoProfile -Command \"if (Get-Command cargo -ErrorAction SilentlyContinue) { 'yes' } else { 'no' }\"" 2>/dev/null | tr -d '[:space:]')
|
||||
if [ "$HAS_CARGO" != "yes" ]; then
|
||||
echo -e "${RED}[✗] 远端 ${REMOTE_IP} 未检测到 cargo/rustc${NC}"
|
||||
echo -e "${YELLOW}请先在该 Windows 机上安装 Rust 工具链:${NC}"
|
||||
echo -e " 方式1(PowerShell,需联网): winget install Rustlang.Rustup"
|
||||
echo -e " 方式2(浏览器): 访问 https://rustup.rs 下载 rustup-init.exe 并运行"
|
||||
echo -e " 安装后重开 PowerShell 再跑本脚本。"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[√] 远端已安装 Rust 工具链${NC}"
|
||||
|
||||
# =============================================================================
|
||||
# 同步源码到远端(仅 git 跟踪文件,排除 target/data/assets 等大目录)
|
||||
# =============================================================================
|
||||
echo -e "\n${CYAN}=== 同步源码到 ${REMOTE_SRC_DIR} ===${NC}"
|
||||
# 远端先建目录
|
||||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
|
||||
"powershell -NoProfile -Command \"New-Item -ItemType Directory -Force -Path '${REMOTE_SRC_DIR}' | Out-Null\""
|
||||
|
||||
# 单文件 scp 辅助:失败时打印诊断并退出(不静默吞错)。
|
||||
# Windows OpenSSH scp 对带正斜杠的远端路径需用 ':' 分隔主机,路径部分正斜杠 OK。
|
||||
scp_one() {
|
||||
local src="$1" dst="$2"
|
||||
if ! scp -P "${REMOTE_PORT}" ${SSH_OPTS} "$src" "${REMOTE_USER}@${REMOTE_IP}:${dst}"; then
|
||||
echo -e "${RED}[✗] 传输失败: ${src} -> ${dst}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
# 递归目录 scp:先建/清远端目标目录再传,避免 Windows scp -r 创建子目录失败。
|
||||
scp_dir() {
|
||||
local sub="$1"
|
||||
[ -d "$sub" ] || return 0
|
||||
# 远端先删除旧目录并重建空目录(确保 scp -r 能写入)
|
||||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
|
||||
"powershell -NoProfile -Command \"Remove-Item -Recurse -Force '${REMOTE_SRC_DIR}/${sub}' -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Force -Path '${REMOTE_SRC_DIR}/${sub}' | Out-Null\"" || true
|
||||
echo -e " -> ${sub}/"
|
||||
if ! scp -P "${REMOTE_PORT}" ${SSH_OPTS} -r "$sub" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_SRC_DIR}/"; then
|
||||
echo -e "${RED}[✗] 传输目录失败: ${sub}${NC}"
|
||||
echo -e "${YELLOW} 若反复失败,可在 Windows 机上手动 git clone 后仅同步增量,或检查磁盘空间/权限${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${CYAN} -> 传输顶层文件 + crates/tools/workflows ...${NC}"
|
||||
scp_one Cargo.toml "${REMOTE_SRC_DIR}/"
|
||||
[ -f Cargo.lock ] && scp_one Cargo.lock "${REMOTE_SRC_DIR}/"
|
||||
# import_results 依赖 common crate,二者都要同步;workflows 含配置(决定命名精度)。
|
||||
scp_dir crates
|
||||
scp_dir tools
|
||||
scp_dir workflows
|
||||
echo -e "${GREEN}[√] 源码同步完成${NC}"
|
||||
|
||||
# =============================================================================
|
||||
# 远端编译 import_results(release)
|
||||
# =============================================================================
|
||||
if [ "$DO_BUILD" = true ]; then
|
||||
echo -e "\n${CYAN}=== 远端编译 import_results (release) ===${NC}"
|
||||
echo -e "${YELLOW} (首次编译会拉 crate 依赖,可能耗时数分钟,请耐心等待...)${NC}"
|
||||
# 用 ssh 透传的退出码判断成败,不依赖输出最后一行文本:
|
||||
# cargo 把 Compiling/Finished 进度写到 stderr,与 PowerShell 打印 $LASTEXITCODE 的 stdout
|
||||
# 在 2>&1 合并后顺序会交错,tail -1 可能抓到 "Finished ..." 而非 "0",导致把成功误判成失败。
|
||||
# 这里让 powershell `exit $LASTEXITCODE` → ssh 透传 → ${PIPESTATUS[0]} 取管道首段退出码。
|
||||
# 暂时关掉 errexit/pipefail:否则 cargo 失败时管道整体非零会先触发 set -e 退出,丢失自定义提示。
|
||||
set +e +o pipefail
|
||||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
|
||||
"powershell -NoProfile -Command \"Set-Location '${REMOTE_SRC_DIR}'; cargo build --release -p import_results; exit \$LASTEXITCODE\"" 2>&1 | tee /tmp/import_build.log | tail -20
|
||||
BUILD_RC=${PIPESTATUS[0]}
|
||||
set -e -o pipefail
|
||||
rm -f /tmp/import_build.log
|
||||
if [ "$BUILD_RC" -ne 0 ]; then
|
||||
echo -e "${RED}[✗] 远端编译失败 (exit=${BUILD_RC})${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[√] 编译成功${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}[i] 跳过编译(--no-build)${NC}"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 在远端生成 run_import.bat(Windows 批处理),用户双击或在 cmd 里运行即可,无需手敲命令
|
||||
# =============================================================================
|
||||
# 交互式 .bat 各参数的默认值(来自 profile 推导,用户双击时可逐项覆盖,回车即用默认)
|
||||
EXE_PATH_WIN=$(echo "${REMOTE_SRC_DIR}/target/release/import_results.exe" | sed 's|/|\\|g')
|
||||
CONFIG_PATH="${REMOTE_SRC_DIR}/workflows/sdB_cno.yaml"
|
||||
WORK_DIR_WIN=$(echo "${REMOTE_SRC_DIR}" | sed 's|/|\\|g')
|
||||
GRID_DIR="E:/fmq/grid" # 旧版 run_grid.py 的 results 根目录
|
||||
WORKFLOW_NAME="sdB_cno" # 工作流名,须匹配将来在 Dashboard 创建/启动的工作流
|
||||
|
||||
# .bat 内容:用 quoted heredoc 避免反引号/%/$ 被 bash 解释,占位符稍后替换。
|
||||
# 每个参数都用 set /p 交互读取,空回车则采用 [方括号] 里的默认值;token 空回车则只迁移 result 不入库。
|
||||
BAT_CONTENT=$(cat <<'BATEOF'
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "__WORK_DIR__"
|
||||
|
||||
echo ============================================================
|
||||
echo DCTS 历史计算结果导入工具
|
||||
echo ============================================================
|
||||
echo 以下每项直接回车即采用 [方括号] 内的默认值
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
set /p DATA_DIR="旧数据目录 (run_grid.py results 根) [__DATA_DIR__]: "
|
||||
if "!DATA_DIR!"=="" set "DATA_DIR=__DATA_DIR__"
|
||||
|
||||
set /p CONFIG="workflow 配置文件 [__CONFIG__]: "
|
||||
if "!CONFIG!"=="" set "CONFIG=__CONFIG__"
|
||||
|
||||
set /p RESULT_DIR="结果目录 [__RESULT__]: "
|
||||
if "!RESULT_DIR!"=="" set "RESULT_DIR=__RESULT__"
|
||||
|
||||
set /p SERVER="服务端地址 [__SERVER__]: "
|
||||
if "!SERVER!"=="" set "SERVER=__SERVER__"
|
||||
|
||||
set /p WORKFLOW="工作流名 -w (须匹配将来启动的工作流) [__WORKFLOW__]: "
|
||||
if "!WORKFLOW!"=="" set "WORKFLOW=__WORKFLOW__"
|
||||
|
||||
set /p ADMIN_TOKEN="Admin Token (直接回车则只迁移 result 不入库): "
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo 即将执行,请确认:
|
||||
echo 旧数据目录 : !DATA_DIR!
|
||||
echo workflow : !CONFIG!
|
||||
echo 结果目录 : !RESULT_DIR!
|
||||
echo 服务端 : !SERVER!
|
||||
echo 工作流名 : !WORKFLOW!
|
||||
echo Token : !ADMIN_TOKEN!
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
if "!ADMIN_TOKEN!"=="" (
|
||||
echo [i] 未输入 token,仅迁移产物树到 result(入库会因无鉴权失败,但不阻断迁移)
|
||||
"__EXE__" -d "!DATA_DIR!" -c "!CONFIG!" -r "!RESULT_DIR!" -s "!SERVER!" -w "!WORKFLOW!"
|
||||
) else (
|
||||
"__EXE__" -d "!DATA_DIR!" -c "!CONFIG!" -r "!RESULT_DIR!" -s "!SERVER!" -w "!WORKFLOW!" -t "!ADMIN_TOKEN!"
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo 执行结束。按任意键退出...
|
||||
pause >nul
|
||||
BATEOF
|
||||
)
|
||||
BAT_CONTENT="${BAT_CONTENT//__WORK_DIR__/${WORK_DIR_WIN}}"
|
||||
BAT_CONTENT="${BAT_CONTENT//__EXE__/${EXE_PATH_WIN}}"
|
||||
BAT_CONTENT="${BAT_CONTENT//__DATA_DIR__/${GRID_DIR}}"
|
||||
BAT_CONTENT="${BAT_CONTENT//__CONFIG__/${CONFIG_PATH}}"
|
||||
BAT_CONTENT="${BAT_CONTENT//__RESULT__/${RESULT_DIR}}"
|
||||
BAT_CONTENT="${BAT_CONTENT//__SERVER__/${DCTS_SERVER_URL}}"
|
||||
BAT_CONTENT="${BAT_CONTENT//__WORKFLOW__/${WORKFLOW_NAME}}"
|
||||
|
||||
# 本地写临时 .bat(CRLF 行尾 + UTF-8 with BOM),再用 scp 上传——
|
||||
# 绕开 “ssh 管道 + powershell $input 自动变量 + stdin 双重消费” 导致的空文件问题。
|
||||
BAT_REMOTE_PATH="${REMOTE_SRC_DIR}/run_import.bat"
|
||||
# 用固定可预测的临时路径,便于在成功/失败两条出口都显式清理(脚本头部已有 SSH ControlMaster 的 EXIT trap,
|
||||
# 这里不再叠加 trap 以免覆盖它)。
|
||||
TMP_BAT="${TMPDIR:-/tmp}/dcts_run_import.$$.bat"
|
||||
# 关键:必须写成 UTF-8 **带 BOM**(EF BB BF)。
|
||||
# 原因:chcp 65001 只改控制台显示编码,不改 cmd.exe 解析 .bat 文件的编码。无 BOM 时 cmd 按系统 ANSI(中文
|
||||
# Windows 为 GBK)逐字节解析 UTF-8 文件,中文多字节序列错位会导致 echo/if 解析错乱——
|
||||
# 报 "'数据目录' is not recognized as an internal or external command" 且 if 块边界被破坏,
|
||||
# 使 import_results.exe 根本不执行。带 BOM 后现代 cmd(Win10 1903+)会按 UTF-8 正确解析整个文件。
|
||||
# CRLF 行尾:命令替换 $(...) 会吞掉末尾换行,直接 printf '%s\r\n' 只在末尾加一个 \r,
|
||||
# 故先按 LF 展开再用 sed 给每行补 \r,确保每行都是 CRLF。
|
||||
{
|
||||
printf '\xEF\xBB\xBF' # UTF-8 BOM
|
||||
printf '%s\n' "$BAT_CONTENT" | sed 's/$/\r/'
|
||||
} > "$TMP_BAT"
|
||||
echo -e "\n${CYAN}=== 上传 run_import.bat 到远端 ===${NC}"
|
||||
if scp -P "${REMOTE_PORT}" ${SSH_OPTS} "$TMP_BAT" "${REMOTE_USER}@${REMOTE_IP}:${BAT_REMOTE_PATH}"; then
|
||||
echo -e "${GREEN}[√] 已生成: ${BAT_REMOTE_PATH}${NC}"
|
||||
else
|
||||
echo -e "${RED}[✗] 上传 run_import.bat 失败${NC}"
|
||||
rm -f "$TMP_BAT"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$TMP_BAT"
|
||||
|
||||
# =============================================================================
|
||||
# 输出最终提示
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo -e "${BLUE}==============================================================${NC}"
|
||||
echo -e "${GREEN}🎉 推送 + 编译完成!${NC}"
|
||||
echo -e "${BLUE}==============================================================${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}在 Windows 机 ${REMOTE_IP} 上启动导入(任选其一):${NC}"
|
||||
echo ""
|
||||
echo -e " 方式1(推荐):双击运行 ${YELLOW}${WORK_DIR_WIN}\\run_import.bat${NC}"
|
||||
echo -e " 会逐项交互提示:旧数据目录 / workflow 配置 / 结果目录 / 服务端 / 工作流名 / Token"
|
||||
echo -e " 每项直接回车即采用 [方括号] 内的默认值;Token 空回车则只迁移 result 不入库"
|
||||
echo ""
|
||||
echo -e " 方式2(cmd): cd /d ${WORK_DIR_WIN} 然后 run_import.bat"
|
||||
echo ""
|
||||
echo -e "${YELLOW}参数默认值(双击时可逐项覆盖):${NC}"
|
||||
echo -e " -d ${GRID_DIR} 旧版 Python run_grid.py 的 results 根目录"
|
||||
echo -e " -c ${CONFIG_PATH}"
|
||||
echo -e " -r ${RESULT_DIR} 结果写入目标(node 容器卷映射处)"
|
||||
echo -e " -s ${DCTS_SERVER_URL} 服务端地址(来自 profile)"
|
||||
echo -e " -w ${WORKFLOW_NAME} 工作流名(必须等于将来要启动的工作流名)"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️ 注意:${NC}"
|
||||
echo -e " - -w sdB_cno 必须匹配将来在 Dashboard 创建/启动的工作流名,否则 converged 标记被隔离看不到、导致重算"
|
||||
echo -e " - 工具可重复运行(DB 幂等 + result 原子覆盖),网络中断后重跑 run_import.bat 即可补齐"
|
||||
echo ""
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "import_results"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../../crates/common" }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
@@ -1,109 +0,0 @@
|
||||
# import_results
|
||||
|
||||
> DCTS 历史计算结果导入工具。把旧版 Python 单机 `run_grid.py`/`run_one.py` 的计算结果
|
||||
> 完整迁移到当前分布式系统,**避免重算已完成的网格点**。
|
||||
|
||||
---
|
||||
|
||||
## 📦 模块概览
|
||||
|
||||
`import_results` 承担**双职责**:
|
||||
|
||||
1. **入库 + 种子库**:经服务端 `/api/admin/import_seed` 把旧版的收敛结果(`conv.json` + `.7`
|
||||
大气文件)上传,把 `grid_points` 标记为 `converged`。这样启动工作流时这些点会被跳过,
|
||||
不再重复计算。
|
||||
2. **完整产物树迁移**:把旧目录的全部产物文件,按**新版 node result 格式 + workflow 配置
|
||||
的小数精度命名**,拷贝到本地 `data/result/<name>/`。docker-compose 里 server/node 都挂载
|
||||
`./data:/app/data`,node 的 `DCTS_RESULT_DIR=/app/data/result`,故工具在服务器宿主机
|
||||
运行即可被 node 识别为已有结果归档。
|
||||
|
||||
**命名以 workflow 配置为准**:`GridConfig::from_yaml_str` 保留 YAML 源小数精度(如
|
||||
`logg: 5.0` → `g5.0`),而非旧 `conv.json` 里的 name。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 编译
|
||||
|
||||
```bash
|
||||
cargo build -p import_results --release
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 完整导入(产物树 + 入库)
|
||||
./target/release/import_results \
|
||||
--dir /旧数据/results \
|
||||
--config workflows/sdB_cno.yaml \
|
||||
--result-dir data/result \
|
||||
--server http://127.0.0.1:8090 \
|
||||
--workflow sdB_cno \
|
||||
--token <admin_token>
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `-d/--dir <dir>` | 旧版 `run_grid.py` 的 results 根目录(默认 `data/results`) |
|
||||
| `-c/--config <yaml>` | **必填**。workflow 配置,用于推导权威命名 + 校验网格点 |
|
||||
| `-r/--result-dir <dir>` | 产物树写入目标(默认 `data/result`,与 node 的 DCTS_RESULT_DIR 同卷)。旧长名 `--archive-dir` 仍兼容(仅长选项) |
|
||||
| `-s/--server <url>` | 服务端 API 地址(默认 `http://127.0.0.1:8090`) |
|
||||
| `-w/--workflow <name>` | **必填**。目标工作流名,**必须等于将来要启动的工作流名** |
|
||||
| `-t/--token <token>` | 服务端 Admin 鉴权令牌 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键注意事项
|
||||
|
||||
### 1. `-w` 工作流名必须匹配将来要启动的工作流
|
||||
|
||||
`grid_points` 唯一键是复合 `(workflow_name, name)`,工作流强隔离。若导入到工作流 `A`,
|
||||
之后启动工作流 `B`,`B` 看不到 `A` 的 converged 标记,会**重算全部已算过的点**。
|
||||
|
||||
正确顺序:
|
||||
```bash
|
||||
# 1. 先导入(-w = 目标工作流名)
|
||||
./import_results -d /旧数据/results -c workflows/sdB_cno.yaml -w sdB_cno ...
|
||||
|
||||
# 2. 在 Dashboard 用同名配置创建工作流 sdB_cno
|
||||
# 3. 启动 sdB_cno
|
||||
# → initialize_grid 对已导入点 DO NOTHING(保留 converged,跳过重算)
|
||||
# → 对配置里有但旧数据没有的新点,插入 pending(正常计算)
|
||||
```
|
||||
|
||||
### 2. 文件名映射规则(旧 → 新 result 格式)
|
||||
|
||||
| 旧文件 | 新文件 |
|
||||
|---|---|
|
||||
| `conv.json` | `conv.json` |
|
||||
| `<old>.7/.spec/.cont/.iden/.log` | `<new>.7/.spec/.cont/.iden/.log` |
|
||||
| `<old>.lte.7/.nc.7/.nl.7` | `<new>.lte.7/.nc.7/.nl.7` |
|
||||
| `<old>.nc_chmax0p001_itekNone.9` | `<new>.nc_chmax0.001.9` |
|
||||
| `<old>.5/.6`(旧版只留最后阶段) | `<new>.nl.5/.nl.6` |
|
||||
| `fort.8` | `fort.8` |
|
||||
|
||||
**跳过**:迁移后的文件名统一经结果白名单(`common::result_filter::is_result_worthy`,
|
||||
与 node 实时结果归档共用同一份判别规则)复核。不在白名单内的一律跳过,包括:
|
||||
- 软链(`data`/`fort.19`)
|
||||
- `fort.84`、`*.tmp`
|
||||
- 新版已清理的裸文件(`nst`/`fort.9`/`fort.12`/`fort.17`,内容已被 `.iden/.cont/_chmax.9` 覆盖)
|
||||
- Tlusty 中间工作单元(`fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等,无语义价值)
|
||||
- 无 `_chmax` 的 `.9` 阶段快照(与 `<name>.<label>_chmax*.9` 内容重复,唯一保留 `_chmax.9`)
|
||||
|
||||
旧版因「每阶段覆盖」丢失的中间阶段 `.5/.6/.err/.nst`(只留最后 nl 阶段)无法重建,
|
||||
归到 `.nl.` 标签下。
|
||||
|
||||
### 3. 导入数据的字段去向
|
||||
|
||||
| 旧 conv.json 字段 | 落库位置 | 展示 |
|
||||
|---|---|---|
|
||||
| `name`(被工具重写为配置权威名) | `grid_points.name` | 点表/详情 |
|
||||
| `converged && !atmosphere_has_nan` | `grid_points.status='converged'` | 状态徽章 |
|
||||
| —(固定值) | `grid_points.success_method='imported'` | 方法徽章"导入" |
|
||||
| `final_max_relc` | 种子库记录 + conv.json 原文 | 点详情 |
|
||||
| `elapsed_sec` | `grid_points.last_elapsed_sec` | 点表"耗时"列 |
|
||||
| 全文(含 `stages[].itek_attempts/final` 嵌套诊断) | `seeds_dir/<name>/conv.json` 原文 | 点详情阶段链(扁平字段兼容解析,嵌套诊断原文保留不丢失) |
|
||||
|
||||
旧版 stage 的迭代诊断(`last_iter` 等)嵌在 `final`/`itek_attempts` 里而非扁平字段,
|
||||
详情页阶段链不显示迭代数,但原始数据随 conv.json 原文完整保留。
|
||||
File diff suppressed because it is too large
Load Diff
+127
-46
@@ -27,14 +27,99 @@ grid:
|
||||
logo: [-4, -3, -2, -1]
|
||||
# 共 4*2*2*3*3*3 = 432 个点
|
||||
|
||||
# ---- TLUSTY 输入文件全局参数(.5 + nst 的非阶段差异部分)----
|
||||
# 与 .5 文件 + nst 文件全字段一一对应(见 config.rs 的 Dot5Input / NstInput)。
|
||||
# grid(teff/logg/丰度)和 tlusty_chain(lte/ltgray/ilvlin 等)在运行时覆写对应字段。
|
||||
# 删掉本块 → 走 TlustyInput::default()(与下方声明完全等价)。
|
||||
tlusty_input:
|
||||
# .5 文件配置(tlusty208.f:178-450 的 READ(IBUFF,*) 顺序)
|
||||
dot5:
|
||||
# 行1 几何(grid 覆写 teff/logg;type=stellar 恒星大气,disk 吸积盘)
|
||||
geometry: {type: stellar, teff: 35000.0, logg: 5.5}
|
||||
# 行2 LTE/LTGRAY(chain 覆写)
|
||||
lte: false
|
||||
ltgray: false
|
||||
# 行3 nst 文件名(空串=不读 nst)
|
||||
finstd: "nst"
|
||||
# 行4 NFREAD 连续频率网格点数(>0 从预设表读)
|
||||
nfread: 2000
|
||||
# 行5 IABSET 丰度集选择(不列出=默认 0;1=abun1 表,2=abun0 表)
|
||||
# iabset: null
|
||||
# 行5+ atoms 块(grid 覆写 He/C/N/O 的 abn;列出哪些行就渲染哪些元素)
|
||||
# mode: 2=显式统计平衡(H/He/CNO), 0=不参与(Li/Be/B); abn=grid 注入或 "0." 太阳默认
|
||||
atoms:
|
||||
- {iat: 1, mode: 2, abn: "0.", modpf: 0} # H
|
||||
- {iat: 2, mode: 2, modpf: 0} # He (abn 由 grid)
|
||||
- {iat: 3, mode: 0, abn: "0.", modpf: 0} # Li
|
||||
- {iat: 4, mode: 0, abn: "0.", modpf: 0} # Be
|
||||
- {iat: 5, mode: 0, abn: "0.", modpf: 0} # B
|
||||
- {iat: 6, mode: 2, modpf: 0} # C (abn 由 grid)
|
||||
- {iat: 7, mode: 2, modpf: 0} # N (abn 由 grid)
|
||||
- {iat: 8, mode: 2, modpf: 0} # O (abn 由 grid)
|
||||
# ions 块(chain 覆写每行 ilvlin;列出哪些行就渲染哪些元素)
|
||||
# 显式列出完整 22 行表(H/He/C/N/O)。如需去掉某元素,删对应 iat 行即可;
|
||||
# 如需加 Fe 等,追加对应行。nonstd≠0 时配 nonstd_extra 附加行(ODF/NONSTD)。
|
||||
# 注:ilvlin 由 tlusty_chain 的各阶段注入(nlevs==1 裸核强制 0),此处不列。
|
||||
ions:
|
||||
# H (iat=1)
|
||||
- {iat: 1, iz: 0, nlevs: 9, nonstd: 0, typion: " H 1", filei: "data/h1.dat"}
|
||||
- {iat: 1, iz: 1, nlevs: 1, nonstd: 0, typion: " H 2", filei: " "}
|
||||
# He (iat=2)
|
||||
- {iat: 2, iz: 0, nlevs: 14, nonstd: 0, typion: "He 1", filei: "data/he1.dat"}
|
||||
- {iat: 2, iz: 1, nlevs: 14, nonstd: 0, typion: "He 2", filei: "data/he2.dat"}
|
||||
- {iat: 2, iz: 2, nlevs: 1, nonstd: 0, typion: "He 3", filei: " "}
|
||||
# C (iat=6)
|
||||
- {iat: 6, iz: 0, nlevs: 40, nonstd: 0, typion: " C 1", filei: "data/c1.dat"}
|
||||
- {iat: 6, iz: 1, nlevs: 22, nonstd: 0, typion: " C 2", filei: "data/c2.dat"}
|
||||
- {iat: 6, iz: 2, nlevs: 46, nonstd: 0, typion: " C 3", filei: "data/c3_34+12lev.dat"}
|
||||
- {iat: 6, iz: 3, nlevs: 25, nonstd: 0, typion: " C 4", filei: "data/c4.dat"}
|
||||
- {iat: 6, iz: 4, nlevs: 1, nonstd: 0, typion: " C 5", filei: " "}
|
||||
# N (iat=7)
|
||||
- {iat: 7, iz: 0, nlevs: 34, nonstd: 0, typion: " N 1", filei: "data/n1.dat"}
|
||||
- {iat: 7, iz: 1, nlevs: 42, nonstd: 0, typion: " N 2", filei: "data/n2_32+10lev.dat"}
|
||||
- {iat: 7, iz: 2, nlevs: 32, nonstd: 0, typion: " N 3", filei: "data/n3.dat"}
|
||||
- {iat: 7, iz: 3, nlevs: 48, nonstd: 0, typion: " N 4", filei: "data/n4_34+14lev.dat"}
|
||||
- {iat: 7, iz: 4, nlevs: 16, nonstd: 0, typion: " N 5", filei: "data/n5.dat"}
|
||||
- {iat: 7, iz: 5, nlevs: 1, nonstd: 0, typion: " N 6", filei: " "}
|
||||
# O (iat=8)
|
||||
- {iat: 8, iz: 0, nlevs: 33, nonstd: 0, typion: " O 1", filei: "data/o1_23+10lev.dat"}
|
||||
- {iat: 8, iz: 1, nlevs: 48, nonstd: 0, typion: " O 2", filei: "data/o2_36+12lev.dat"}
|
||||
- {iat: 8, iz: 2, nlevs: 41, nonstd: 0, typion: " O 3", filei: "data/o3_28+13lev.dat"}
|
||||
- {iat: 8, iz: 3, nlevs: 39, nonstd: 0, typion: " O 4", filei: "data/o4.dat"}
|
||||
- {iat: 8, iz: 4, nlevs: 6, nonstd: 0, typion: " O 5", filei: "data/o5.dat"}
|
||||
- {iat: 8, iz: 5, nlevs: 1, nonstd: 0, typion: " O 6", filei: " "}
|
||||
# CHANGE 块(ICHANG<0 时每能级行;不列出 = 无)
|
||||
change: null
|
||||
|
||||
# nst 高频关键字(236 个中物理相关的,按用途分组)
|
||||
# 默认值为 sdB 调优值(非 TLUSTY 原始默认)。ChainStep 的 niter/chmax 等阶段级覆盖。
|
||||
nst:
|
||||
depth: {nd: 50, nmu: 3, nlambd: 3, isplin: 0}
|
||||
frequency: {ispodf: 1, ddnu: 50.0, cnu1: 6.0, cnu2: 3.0, nftail: 21, dftail: 0.25,
|
||||
frcmax: 0.0, frcmin: 0.0, frlmax: 0.0, frlmin: 0.0}
|
||||
physics: {vtb: 2.0, hmix0: -1.0, mltype: 1, ipturb: 1, ielcor: -1, ibfint: 1, ihecor: 0}
|
||||
radiative_transfer: {irte: 0, ibc: 3, ifryb: 0, ioptab: 0, ifmol: 0}
|
||||
nlte: {ifali: 5, ifpopr: 4, jali: 1}
|
||||
irradiation: {trad: 0.0, wdil: 0.0}
|
||||
temperature: {tfloor: 8000.0, tdisk: 0.0, tmolim: 9000.0}
|
||||
|
||||
# nst 冷门关键字逃逸口(诊断打印 IPR*/内部开关 IOP* 等 ~200 个)
|
||||
# 示例(当前未启用):
|
||||
# nst_extra_keys:
|
||||
# - [CUTBAL, "0.3"]
|
||||
# - [NDGREY, "20"]
|
||||
nst_extra_keys: []
|
||||
|
||||
# ---- 收敛链 ----
|
||||
# 参与计算的元素集 / 电离态 **不再由链配置(metals)控制**,而是在上方
|
||||
# tlusty_input.dot5.atoms(8 元素)与 dot5.ions(22 行)中显式声明;此处每个
|
||||
# step 只控制阶段参数(lte/ltgray/ilvlin/niter/chmax 等)。
|
||||
# 已验证的三步配方 (tests/sdB_spectra/GUIDE.md 实测, Teff=35000 logg=5.5):
|
||||
# LTE grey (T T, NITER=0) -> NLTE 连续谱 (nc, ilvlin=0) -> NLTE 谱线 (nl, ilvlin=100)
|
||||
# nc 步是关键:在不考虑谱线扰动下收敛 NLTE 电离平衡,给 nl 一个稳定种子。
|
||||
# 跳过 nc(grey -> 完整 NLTE)会发散。
|
||||
#
|
||||
# 各 step 的隐式默认值在此显式写出(与代码兜底等价,见下):
|
||||
# - metals: cno —— C/N/O 参与 NLTE(runner.rs 兜底默认;缺省时 .5 输入也按此处理)
|
||||
# - chmax: 0.001 —— 收敛阈值(runner.rs 兜底默认;缺省时与原兜底完全一致)
|
||||
# - itek / ichang / idlte / iacc / orelax 留空 → 用 tlusty 内部默认(未结构化为可写值)。
|
||||
# 注意:设 CHMAX=0.1 会让 nc 提前停止,给 nl 一个坏种子导致发散——勿调大。
|
||||
@@ -46,56 +131,44 @@ grid:
|
||||
# 物理上 nc(纯连续谱)缺少谱线约束,外层温度永不真正收敛——追求高 NITER
|
||||
# 没意义。nl(含谱线)会自修正到正确解(流量差异 <3e-12)。
|
||||
tlusty_chain:
|
||||
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0, metals: cno, chmax: 0.001}
|
||||
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10, metals: cno, chmax: 0.001}
|
||||
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100, metals: cno, chmax: 0.001}
|
||||
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0, chmax: 0.001}
|
||||
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10, chmax: 0.001}
|
||||
# nl 步加 orelax=0.5:谱线(ilvlin=100)扰动大,欠松弛阻尼 Newton 步长振荡,
|
||||
# 避免中温区(20-45kK) nl 阶段 relc 振荡发散(实测验证)。
|
||||
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100, chmax: 0.001, orelax: 0.5}
|
||||
|
||||
# ---- TLUSTY 输入文件全局参数(.5 + nst 的非阶段差异部分)----
|
||||
# 这些参数不随收敛阶段变化。此处显式声明当前生效值,作为可配置项的文档。
|
||||
# 如需调整(如降 nfread 加速、换 ions 能级文件、暴露 nst 高级关键字),改本块即可。
|
||||
# 删掉本块 → 走代码内硬编码默认(与下方声明完全等价)。
|
||||
tlusty_input:
|
||||
# .5 frequencies 块:连续频率网格点数。>0 读预设表(高精度、慢)。
|
||||
# 2000 是高精度档(官方 hhe 样本用 50);降到 1000 可加速但牺牲精度。
|
||||
nfread: 2000
|
||||
# atoms 块每元素的 mode:2=显式统计平衡(H/He/CNO),0=不参与(Li/Be/B)。
|
||||
# 缺省元素走默认。abn(丰度)由 grid 的 loghe/logc/logn/logo 计算,不在此配。
|
||||
atoms:
|
||||
H: {mode: 2}
|
||||
He: {mode: 2}
|
||||
C: {mode: 2}
|
||||
N: {mode: 2}
|
||||
O: {mode: 2}
|
||||
# ions 能级数据表(空 = 用代码默认的 H/He/C/N/O 共 23 行标准表)。
|
||||
# 如需换能级文件或调整 nlevs,列出完整表(完全替换默认)。
|
||||
ions: []
|
||||
# nst 额外关键字(逃逸口):自由传入任意 KEY=VALUE,追加到 nst 末尾。
|
||||
# 用于暴露未结构化的 nst 开关(FRCMAX/CUTBAL/TAU/NDGREY 等,见 tlusty 手册)。
|
||||
# 示例(当前未启用):
|
||||
# nst_extra_keys:
|
||||
# - [FRCMAX, "0.01"]
|
||||
# - [CUTBAL, "0.3"]
|
||||
nst_extra_keys: []
|
||||
# 种子热启动链(seed_step 策略用,cold_run 失败后回退)。
|
||||
# seed_nc 从已收敛邻居热启动,起点扰动小,用更激进的 orelax=0.3;
|
||||
# nl 与 cold_run 链一致 orelax=0.5。空时 executor 回退 default_seed_chain()(代码内置同值)。
|
||||
seed_chain:
|
||||
- {label: seed_nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 20, chmax: 0.001, orelax: 0.3}
|
||||
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100, chmax: 0.001, orelax: 0.5}
|
||||
|
||||
# ---- 种子步进回退(旧字段,保留兼容;新配置见下方 tlusty_stage 块)----
|
||||
# 冷启动失败时,自动用已收敛的邻居模型作种子,用 LTGRAY=F 热启动重试。
|
||||
# 这是高温 He-poor / 富金属区收敛的关键(见 EXPERIENCE.md §5Y)。
|
||||
# 失败的冷启动结果会备份到 <model>.coldfail/ 目录。
|
||||
seed_step_fallback: true
|
||||
|
||||
# ---- 执行参数 ----
|
||||
timeout_sec: 7200 # 单模型墙钟时间上限(120分钟)
|
||||
|
||||
# ---- 光谱合成 SYNSPEC 输入参数(fort.55 数值参数;与 tlusty_input 对称)----
|
||||
# ---- 光谱合成 SYNSPEC 输入参数(fort.55 控制卡,9 行子结构体)----
|
||||
# 启用开关见下方 synspec_stage 块。
|
||||
#
|
||||
# fort.55 字段与 synspec54.f 的 READ(55,*) 语句逐行一一对应,字段名严格使用
|
||||
# SYNSPEC 源码变量名。各行含义见 crates/common/src/config.rs 的 Fort55Line* 文档注释。
|
||||
# 仅列出与默认值不同的字段;未列出的字段走 SynspecInput::default()(见 config.rs)。
|
||||
#
|
||||
# 重要修复:
|
||||
# - line1.idstd 从旧实现的 50(错位 bug,强制最深层)修正为 0(自动取 2*ND/3≈33,
|
||||
# τ≈1 参考层)。此变更影响光谱线强归一化,修正后所有光谱需重算(见 §5)。
|
||||
# - 新增 line_mol(分子表行):SYNSPEC 在 IMODE∈[-3,1] 时会额外读这一行,旧实现漏生成
|
||||
# 导致 VTB 行被吞、vturb 被错误覆盖。现默认 nmlist=0(无分子线)。
|
||||
# - line_vtb 默认 -1.0(不覆盖大气 vturb=2 km/s);如需自定义湍流速度设非负值。
|
||||
synspec_input:
|
||||
wstart: 3000.0 # 光谱波长起始点 (Å)
|
||||
wend: 7000.0 # 光谱波长终止点 (Å)
|
||||
imode: 0
|
||||
idrv: 50
|
||||
ifreq: 1
|
||||
rel_cutoff: 0.0001
|
||||
abs_cutoff: 0.01
|
||||
line1: {imode: 0, idstd: 0, iprin: 1} # 行1: IMODE, IDSTD(=0 自动 2ND/3), IPRIN
|
||||
# line2-line5 走默认(INMOD=1 TLUSTY 模型 / IOPHLI 被 SYNSPEC 强制 0 / IFREQ=1 INLTE=1 / Stark 表全 0)
|
||||
line6:
|
||||
alam0: 3000.0 # 光谱波长起始点 (Å)
|
||||
alast: 7000.0 # 光谱波长终止点 (Å)
|
||||
cutoff0: 10 # 谱线截止距离(推荐 5-10)
|
||||
cutoffs: 0 # 连续谱间距(0→内部取 3)
|
||||
relop: 0.0001 # 线心/连续不透明度最小比
|
||||
space: 0.01 # 相邻频率点最大间距 (Å)
|
||||
# line_mol (nmlist=0 无分子表) / line_vtb (vtb=-1 不覆盖大气 vturb) / line_ang (NMU0=0 不算比强度) 走默认
|
||||
|
||||
# ====================================================================
|
||||
# 阶段独立配置(见 docs/task_engine_decoupling_design.md §2-§3)
|
||||
@@ -113,8 +186,16 @@ tlusty_stage:
|
||||
policy: skip_converged
|
||||
strategies: [cold_run, seed_step]
|
||||
|
||||
# ---- 种子步进回退(旧字段,保留兼容;新配置见下方 tlusty_stage 块)----
|
||||
# 冷启动失败时,自动用已收敛的邻居模型作种子,用 LTGRAY=F 热启动重试。
|
||||
# 这是高温 He-poor / 富金属区收敛的关键(见 EXPERIENCE.md §5Y)。
|
||||
# 失败的冷启动结果会备份到 <model>.coldfail/ 目录。
|
||||
seed_step_fallback: true
|
||||
|
||||
synspec_stage:
|
||||
enabled: true
|
||||
policy: skip_converged
|
||||
strategies: [standard]
|
||||
|
||||
# ---- 执行参数 ----
|
||||
timeout_sec: 7200 # 单模型墙钟时间上限(120分钟)
|
||||
|
||||
Reference in New Issue
Block a user