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:
+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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user