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:
fmq
2026-08-09 12:09:48 +08:00
parent d16b3d3cdc
commit 43b82b1ae2
45 changed files with 6059 additions and 3184 deletions
File diff suppressed because it is too large Load Diff
+784 -1
View File
@@ -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)+1DM 列质量深度数组(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);
// 行 1ND 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 常量(cgserg 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.0LTE);
/// 某些深度块开头有 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.0grey 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.6TLUSTY stdout 日志)提取失败诊断提示。
///
/// # 背景(漏洞 5P2
@@ -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。比值 1e4monotonic 允许 ×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());
}
}
+320 -22
View File
@@ -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 时仅写出 nmlistlist-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
View File
@@ -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 每行 ilvlinnlevs==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 blockH/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;
}
// 行1geometry
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(&params, "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(&params, "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=2Li/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(&params, "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(&params, "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.5L2/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(&params, "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 应渲染为 ''");
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 + ChainStepline2 含 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/IELCORinput_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");
}
}
+2 -2
View File
@@ -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
View File
@@ -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_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
@@ -335,26 +358,22 @@ impl<'a> ExecutionRunner<'a> {
}
let tlusty_skipped = !tlusty_enabled;
// tlusty_input 为 NoneYAML 未配 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,
};
+270
View File
@@ -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 链的 summarystages 非空 + 各校验字段)。
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 summarystages 为空)。
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 summarystages 非空,但 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));
}
}