feat(all): 物理正确性五重硬门槛、输入文件结构化与 fort.55 错位修复、conv 诊断 DB 化与阶段归因修复、ORELAX 收敛修复与导入工具下线

物理正确性校验体系(common/conv_check.rs +494 行)
- 新增 5 类硬门槛:能量守恒(.6)、温度结构(.7)、emflux 积分校验(.emflux,含全 NaN 判失败)、假收敛排查(itek 轨迹首末比)、b 因子合理性(.bfac)
- runner 在 TLUSTY 阶段结束后执行全部校验,任一失败判 final_converged=false
- GridConfig 新增 8 个可配阈值,经 scheduler→executor→runner 全链路透传

输入文件配置结构化重构(config.rs +1453 行)
- TlustyInput 拆为 dot5/nst 分层结构,字段名严格映射 tlusty208.f READ 语句;SynspecInput 重构为 9 个 Fort55Line 子结构体
- 移除 ChainStep.metals 字段,元素集改由 dot5.atoms/ions 显式声明(gen_input5/nst_writer 同步重写为三源融合 / 分层覆盖)
- fort.55 修复行结构 bug:补全分子表行(7→9 行),IDSTD 50→0 错位修正(影响全部光谱线强归一化,需重算 SYNSPEC 阶段)

conv 诊断 DB 化与阶段归因修复(server)
- 单点详情 conv 面板从磁盘 conv.json 改读 DB grid_points.summary_json;grid_points 新增 summary_json/last_elapsed_sec 两列(旧库幂等 ALTER)
- record_task_report 阶段归因列加 CASE 守卫 + clear_synspec 对称处理,修复 synspec-only/TLUSTY-only 重跑污染统计
- 新增 summary_merge.rs 点级增量合并,避免重跑覆盖诊断字段

收敛性 ORELAX 修复与 seed_chain 可配(sdB_cno.yaml + node)
- nl 阶段加 orelax=0.5、seed_nc 加 orelax=0.3,阻尼中温区 relc 振荡发散
- seed_chain 块可配,executor 优先采用用户配置而非内置默认链

导入工具下线
- 删除 import_results 客户端工具及 Windows 推送脚本;移除 /admin/import_seed 端点
- 改为服务端临时 migrate_conv 端点(扫 conv.json 增量合并入库,迁移后可删)

文档与分析
- 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档
- spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错
This commit is contained in:
fmq
2026-08-09 12:09:48 +08:00
parent d16b3d3cdc
commit 43b82b1ae2
45 changed files with 6059 additions and 3184 deletions
File diff suppressed because it is too large Load Diff
+784 -1
View File
@@ -1,4 +1,7 @@
use crate::models::{ConvCheckResult, IterCheck};
use crate::models::{
BfacCheckResult, ConvergenceTraceCheckResult, ConvCheckResult, EmfluxCheckResult,
EnergyCheckResult, IterCheck, TempStructCheckResult,
};
use regex::Regex;
use std::fs::File;
use std::io::{BufRead, BufReader};
@@ -334,6 +337,494 @@ pub fn spec_is_valid(path: &Path) -> Option<String> {
None
}
/// 解析 Fortran 数值字符串为 f64,兼容 **D 记数法**(如 `1.00106D+00`)。
///
/// `.6` 能量守恒表的 `TOTAL SURFACE FLUX` 行用 D 记数法(双精度标记),数据行用 E 记数法。
/// `parse_fortran_float`(见上)覆盖标准/E/无-E 记数法,但不处理 D。本函数在 `parse_fortran_float`
/// 之前把 D/d 归一化为 E,复用其无-E 与溢出处理逻辑。仅用于 `.6` 解析(不改动公共函数行为,
/// 避免 fort.9/fort.7 解析受影响)。
fn parse_fortran_d_float(s: &str) -> Option<f64> {
let trimmed = s.trim();
// 快路径:不含 D/d 直接走 parse_fortran_float
if !trimmed.to_ascii_lowercase().contains('d') {
return parse_fortran_float(trimmed);
}
let normalized = trimmed.replacen(['D', 'd'], "E", 1);
parse_fortran_float(&normalized)
}
/// 校验 `.6`TLUSTY stdout)中的能量守恒诊断 `(RAD+CON)/TOT` 列。
///
/// # 物理不变量
/// TLUSTY 在 LFIN(最终迭代)时由 `OUTPRI``tlusty208.f:3889`)无条件写出逐深度通量表:
/// 每深度 `(RAD+CON)/TOT = (辐射通量 + 对流通量) / σTeff⁴`,**应 ≈ 1.0**(能量守恒)。
/// Hubeny 官方判据:偏离 1 ≤1% 为收敛良好;>5% 明显发散(见
/// `docs/spectrum_correctness_analysis.md` §1.1/§3.1)。任一深度超 `tolerance` 即判失败。
///
/// # 返回值
/// - `Some(EnergyCheckResult)`:找到能量守恒表并完成校验(含 valid 判定)。
/// - `None`:`.6` 缺失、无能量守恒表、或无有效数据行——**跳过**(不误判失败)。
/// 与 `atmosphere_has_nan` 对缺失文件返回 false(语义"无 NaN")的保守策略一致:
/// 非常规配置(TLUSTY-only、极简 niter)可能不产出此表,不应因此判失败。
///
/// # 解析策略
/// 表格由 `TOTAL SURFACE FLUX`(唯一,LFIN 时)标记,其后含 `(RAD+CON)/TOT` 表头行,
/// 随后是逐深度数据行(ID + 10 列,最后一列即校验目标)。用表头行而非行号定位,
/// 对不同 ND(如 50/99)自适应。
pub fn check_energy_conservation(path: &Path, tolerance: f64) -> Option<EnergyCheckResult> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
// 状态机:找到表头后开始收集数据行。
let mut found_header = false;
let mut values: Vec<(i32, f64)> = Vec::new(); // (depth_id, rad_con_tot)
for line in reader.lines().map_while(Result::ok) {
if !found_header {
// 定位含 (RAD+CON)/TOT 的表头行(OUTPRI 写出,固定文本)。
if line.contains("(RAD+CON)/TOT") {
found_header = true;
}
continue;
}
// 表头之后的数据行:首个 token 是深度 ID(整数),末 token 是 (RAD+CON)/TOT。
// 遇到非数据行(如分隔线、下一段标题)即结束当前表收集。
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.is_empty() {
continue; // 空行容忍(表后常见)
}
// 深度 ID:正整数。非整数 → 表已结束(如下一段 "MODEL QUANTITIES" 标题)。
let depth_id: i32 = match tokens[0].parse() {
Ok(id) if id >= 1 => id,
_ => break,
};
// NaN/溢出行:标记但不计入(避免误把发散值当有效偏离)。
if nan_re.is_match(&line) {
continue;
}
// 末 token = (RAD+CON)/TOT。支持 E/D 记数法。
let val = match parse_fortran_d_float(tokens.last().unwrap()) {
Some(v) if v.is_finite() => v,
_ => continue,
};
values.push((depth_id, val));
}
if !found_header || values.is_empty() {
return None; // 无能量守恒表或无有效数据 → 跳过
}
// 找偏离最大的深度点。
let (worst_depth, max_dev) = values
.iter()
.map(|(id, v)| (*id, (v - 1.0).abs()))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap(); // values 非空,safe
let valid = max_dev <= tolerance;
Some(EnergyCheckResult {
valid,
max_deviation: max_dev,
worst_depth: Some(worst_depth),
n_depths_checked: values.len(),
tolerance,
error: if valid {
None
} else {
Some(format!(
"能量守恒校验失败:(RAD+CON)/TOT 在深度 {} 偏离 1 达 {:.4}(> 阈值 {:.4})",
worst_depth, max_dev, tolerance
))
},
})
}
/// 校验 `.7` 大气文件的温度结构边界(见 docs/spectrum_correctness_analysis.md §3.1/§4)。
///
/// # 物理不变量
/// T(τ) 应落在物理合理范围:表层(ID=1)T ≲ 2-3×Teff;总体 10¹-10⁸ K。
/// 表层 T >>Teff(如 >3×Teff)或任一深度越界 → T(τ) 非物理(红旗 §4)。
///
/// # `.7` 文件格式
/// TLUSTY `OUTPUT` 子程序(`tlusty208.f:14066-14108`)写出:
/// - 行 1`ND NUMPAR`(深度数、每深度参数数 = NLEVEL+NUMLT
/// - 行 2..ceil(ND/6)+1DM 列质量深度数组(FORMAT 502,每行 6 个)
/// - 之后 ND 个深度块,每块 ceil(NUMPAR/5) 行(FORMAT 503,每行 5 个),
/// **每块第一个值即温度 TEMP(ID)**`tlusty208.f:14079`INPMOD `X(1)=TEMP`)。
///
/// # 参数
/// - `teff`:有效温度(K),来自网格点参数。
/// - `max_factor`:表层 T / Teff 上限(默认 3.0)。
/// - `temp_floor` / `temp_ceiling`:全层温度绝对边界(默认 10 / 1e8 K)。
///
/// # 返回值
/// `Some(TempStructCheckResult)`:完成校验。`None``.7` 缺失/格式不可解析 → 跳过。
pub fn check_temperature_structure(
path: &Path,
teff: f64,
max_factor: f64,
temp_floor: f64,
temp_ceiling: f64,
) -> Option<TempStructCheckResult> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let mut lines = reader.lines().map_while(Result::ok);
// 行 1ND NUMPAR
let header = lines.next()?;
let mut hdr = header.split_whitespace();
let nd: usize = hdr.next()?.parse().ok()?;
let numpar: usize = hdr.next()?.parse().ok()?;
if nd == 0 || numpar == 0 {
return None;
}
let dm_lines = (nd + 5) / 6; // FORMAT 502 每行 6 个
let block_lines = (numpar + 4) / 5; // FORMAT 503 每行 5 个
// 跳过 DM 数组。检查截断:若行不足说明文件残缺,无法可靠定位温度块 → 跳过。
for _ in 0..dm_lines {
lines.next()?;
}
// 逐深度块:每块取首个数值 token = TEMP(ID)
let mut temps: Vec<f64> = Vec::with_capacity(nd);
for _ in 0..nd {
// 块的第一行第一个 token 即温度(FORMAT 503 行首)
let first_line = lines.next()?;
let first_token = first_line.split_whitespace().next()?;
let t = parse_fortran_float(first_token)?;
temps.push(t);
// 跳过该块剩余行(截断时 next()? 同样提前返回 None
for _ in 1..block_lines {
lines.next()?;
}
}
if temps.is_empty() {
return None;
}
let surface_temp = temps[0];
let bottom_temp = temps[temps.len() - 1];
let surface_ratio = if teff > 0.0 { surface_temp / teff } else { f64::INFINITY };
// 判定:表层超 max_factor×Teff,或任一深度越界 [floor, ceiling]。
let mut violations: Vec<String> = Vec::new();
if surface_ratio > max_factor {
violations.push(format!(
"表层 T={:.0}K = {:.2}×Teff 超上限 {:.1}×Teff",
surface_temp, surface_ratio, max_factor
));
}
for (i, &t) in temps.iter().enumerate() {
if !t.is_finite() || t < temp_floor || t > temp_ceiling {
violations.push(format!(
"深度 {} T={:.3e}K 越界 [{:.0}, {:.0e}]",
i + 1,
t,
temp_floor,
temp_ceiling
));
break; // 报第一个越界即可
}
}
let valid = violations.is_empty();
Some(TempStructCheckResult {
valid,
teff,
surface_temp,
bottom_temp,
surface_ratio,
n_depths_checked: temps.len(),
max_factor,
temp_floor,
temp_ceiling,
error: if valid {
None
} else {
Some(format!("温度结构校验失败:{}", violations.join("; ")))
},
})
}
/// Stefan-Boltzmann 常量(cgserg cm⁻² s⁻¹ K⁻⁴)。
/// TLUSTY 用 `SIG4P = σ_SB/4π``BASICS.FOR:51`);此处用完整 σ_SB 做 bolometric 比对。
const SIGMA_SB: f64 = 5.670374419e-5;
/// 校验 `.emflux` 的 bolometric 通量守恒(见 docs/spectrum_correctness_analysis.md §3.2/§4)。
///
/// # 物理不变量
/// ∫Fλ dλ(出射谱全波长积分)≈ σ_SB × Teff⁴。偏离 >2% → 能量不守恒(红旗 §4)。
///
/// # `.emflux` 格式
/// SYNSPEC 写出的 fort.14 快照(runner `snapshot_tlusty_outputs`),两列:波长 λ(Å) / Fλ。
/// 部分样本全 NaN(大气收敛失败导致辐射转移求解发散)—— NaN 占比过高(>50%)直接判失败。
/// 详见 `docs/fort14_nan_analysis.md`fort.14 全 NaN 是大气结构发散的充分(且几乎必要的)标志。
///
/// # 算法
/// 跳过 NaN/不可解析行,对剩余 (λ, Fλ) 按波长排序后梯形积分。NaN 占比 >0.5 → 失败。
/// 否则比较 integrated / (σTeff⁴),偏离 1 超 `tolerance` → 失败。
///
/// # 返回值
/// `Some(EmfluxCheckResult)`:完成校验(含全 NaN 判失败)。`None`:文件缺失 → 跳过。
pub fn check_emflux_bolometric(
path: &Path,
teff: f64,
tolerance: f64,
) -> Option<EmfluxCheckResult> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
let mut total = 0usize;
let mut points: Vec<(f64, f64)> = Vec::new(); // (lambda, flux)
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
total += 1;
if nan_re.is_match(&line) {
continue;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.len() < 2 {
continue;
}
let (lam, flux) = match (
parse_fortran_d_float(tokens[0]),
parse_fortran_d_float(tokens[1]),
) {
(Some(l), Some(f)) if l.is_finite() && f.is_finite() => (l, f),
_ => continue,
};
points.push((lam, flux));
}
if total == 0 {
return None; // 空文件 → 跳过
}
let nan_ratio = 1.0 - (points.len() as f64 / total as f64);
let sigma_teff4 = SIGMA_SB * teff.powi(4);
// NaN 占比过高 → 大气收敛失败(辐射转移求解发散),直接判失败(不积分)。
// 源码机制:大气 TEMP/POPUL 发散 → OPACF1 中 HK/TEMP→Inf、POPUL 乘积→Inf →
// RTEFR1 源函数 EMIS1/AB0=Inf/Inf=NaN → 整列 FLUX 全 NaN → OUTPRI 写进 fort.14。
// 全 NaN 只能由大气发散引起,不存在"大气好但辐射转移单独失败"的路径。
if nan_ratio > 0.5 {
return Some(EmfluxCheckResult {
valid: false,
integrated_flux: 0.0,
sigma_teff4,
ratio: 0.0,
nan_ratio,
n_points: points.len(),
tolerance,
error: Some(format!(
"emflux NaN/不可解析占比 {:.0}% 过高(>{:.0}%),大气收敛失败(辐射转移求解发散)",
nan_ratio * 100.0,
50.0
)),
});
}
// 梯形积分:按波长排序后 ∫Fλ dλ。
points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut integral = 0.0;
for w in points.windows(2) {
let (lam0, f0) = w[0];
let (lam1, f1) = w[1];
integral += 0.5 * (f0 + f1) * (lam1 - lam0);
}
let ratio = if sigma_teff4 > 0.0 {
integral / sigma_teff4
} else {
f64::INFINITY
};
let valid = (ratio - 1.0).abs() <= tolerance;
Some(EmfluxCheckResult {
valid,
integrated_flux: integral,
sigma_teff4,
ratio,
nan_ratio,
n_points: points.len(),
tolerance,
error: if valid {
None
} else {
Some(format!(
"emflux bolometric 校验失败:∫Fλdλ / σTeff⁴ = {:.4} 偏离 1 超 {:.2}",
ratio, tolerance
))
},
})
}
/// 收敛轨迹(假收敛)排查(见 docs/spectrum_correctness_analysis.md §2.1/§3.1)。
///
/// # 物理不变量
/// 真收敛的 max_relc 轨迹应单调下降 ≥3 个量级(首/末比 ≥10³)。Ng/Kantorovich 加速
/// 可人为压低 max_relc 造成"假收敛"——数值达标(< chmax)但统计平衡未真正达成。
///
/// # 判定
/// - 轨迹 <3 拍:无法判定 → 返回 None(跳过,避免短链误杀)。
/// - 首/末比 < `min_ratio`(默认 1000):可疑假收敛 → 失败。
/// - 含 NaN/非正值轨迹也判失败。
///
/// # 调用时机
/// 仅在 `converged=true` 的 stage 上做(发散 stage 不需排查假收敛)。
/// 数据来自 `check_fort9` 已解析的 `itek_history`,无需重读文件。
pub fn check_convergence_trace(
itek: &[IterCheck],
min_ratio: f64,
) -> Option<ConvergenceTraceCheckResult> {
if itek.len() < 3 {
return None; // 轨迹太短,无法判定
}
let valid_values: Vec<f64> = itek
.iter()
.map(|c| c.max_relc)
.filter(|v| v.is_finite() && *v > 0.0)
.collect();
if valid_values.len() < 2 {
return Some(ConvergenceTraceCheckResult {
valid: false,
first_max_relc: itek[0].max_relc,
last_max_relc: itek.last().map(|c| c.max_relc).unwrap_or(0.0),
ratio: 0.0,
monotonic: false,
n_iters: itek.len(),
min_ratio,
error: Some("收敛轨迹含 NaN/非正值,无法判定收敛质量".to_string()),
});
}
let first = valid_values[0];
let last = *valid_values.last().unwrap();
let ratio = first / last;
let monotonic = valid_values
.windows(2)
.all(|w| w[0] >= w[1] * 0.5); // 容忍 Ng 加速的局部反弹(×2 内)
let valid = ratio >= min_ratio;
Some(ConvergenceTraceCheckResult {
valid,
first_max_relc: first,
last_max_relc: last,
ratio,
monotonic,
n_iters: itek.len(),
min_ratio,
error: if valid {
None
} else {
Some(format!(
"假收敛排查失败:首末 max_relc 比 {:.1e} < {:.0e}(可能 Ng 加速伪收敛)",
ratio, min_ratio
))
},
})
}
/// 校验 `.bfac` b 因子合理性(见 docs/spectrum_correctness_analysis.md §3.1/§6 #19)。
///
/// # 物理不变量
/// NLTE 偏离因子 b=n_NLTE/n_LTE)对关键能级应落在 [10⁻³, 10³]b=1.0 表示 LTE。
/// 极端值(>1e3 或 <1e-3)比例过高 → 统计平衡未达成或布居数错误。
///
/// # `.bfac` 格式
/// 与 `.7` 同构(`tlusty208.f:14344-14350` WRITBFAC):首行 `ND NUMPAR`DM 数组,
/// 逐深度块(T/ne/dens + NLTE 布居偏离因子)。表层 grey start 块全 1.0LTE);
/// 某些深度块开头有 NaN(布居为零的能级),属正常。
///
/// # 判定
/// 扫描所有数值 token,排除 NaN/1.0(LTE 占位),统计有效 b 因子中极端值占比。
/// 占比 > 10% → 失败。
///
/// # 返回值
/// `Some(BfacCheckResult)`:完成校验。`None`:文件缺失/无有效 b 因子 → 跳过。
pub fn check_bfactor(path: &Path, max_value: f64, min_value: f64) -> Option<BfacCheckResult> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
let mut total_tokens = 0usize;
let mut nan_count = 0usize;
let mut b_values: Vec<f64> = Vec::new();
let mut in_data = false; // 跳过首行 + DM 数组
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if !in_data {
// 检测首行(两个整数 ND NUMPAR),之后进入数据区
if tokens.len() == 2
&& tokens[0].parse::<usize>().is_ok()
&& tokens[1].parse::<usize>().is_ok()
{
in_data = true;
}
continue;
}
for tok in &tokens {
total_tokens += 1;
if nan_re.is_match(tok) {
nan_count += 1;
continue;
}
if let Some(v) = parse_fortran_float(tok) {
// 跳过 LTE 占位 b=1.0grey start 表层);正值有限数才计入
if v.is_finite() && v > 0.0 && (v - 1.0).abs() > 1e-10 {
b_values.push(v);
}
}
}
}
if b_values.is_empty() {
return None; // 无有效 b 因子(纯 LTE 模型?)→ 跳过
}
let nan_ratio = if total_tokens > 0 {
nan_count as f64 / total_tokens as f64
} else {
0.0
};
let extreme_count = b_values
.iter()
.filter(|&&b| b > max_value || b < min_value)
.count();
let extreme_ratio = extreme_count as f64 / b_values.len() as f64;
let max_b = b_values.iter().cloned().fold(0.0f64, f64::max);
let min_b = b_values.iter().cloned().fold(f64::INFINITY, f64::min);
let tolerance = 0.1; // 极端值占比上限 10%
let valid = extreme_ratio <= tolerance;
Some(BfacCheckResult {
valid,
n_values: b_values.len(),
extreme_ratio,
max_b,
min_b,
nan_ratio,
max_value,
min_value,
error: if valid {
None
} else {
Some(format!(
"b 因子校验失败:极端值(>{:.0e} 或 <{:.0e})占比 {:.0}% > {:.0}%",
max_value, min_value, extreme_ratio * 100.0, tolerance * 100.0
))
},
})
}
/// 从 fort.6TLUSTY stdout 日志)提取失败诊断提示。
///
/// # 背景(漏洞 5P2
@@ -741,4 +1232,296 @@ mod tests {
assert!((res.itek_history[1].max_relc - 5.0e-4).abs() < 1e-15);
assert_eq!(res.itek_history[1].n_depths, 2);
}
/// 构造一段 `.6` 能量守恒表片段(LFIN 时 OUTPRI 写出)。
/// 每行:ID + 10 列,最后一列是 `(RAD+CON)/TOT`。
fn make_energy_table(rad_con_tot_values: &[f64]) -> String {
let header = " TOTAL SURFACE FLUX 6.77712339D+12\n\n\
----------------------\n\
FINAL MODEL ATMOSPHERE\n\
----------------------\n\
ID MASS TAUROSS TEMP NE DENS P_gas LOG(G_rad) RAD/TOT CON/TOT (RAD+CON)/TOT\n";
let mut body = String::new();
for (i, v) in rad_con_tot_values.iter().enumerate() {
body.push_str(&format!(
" {:>2} 2.917E-07 5.001E-08 32890.1 3.011E+08 5.840E-16 2.632E-03 2.989E+00 1.001E+00 0.000E+00 {:e}\n",
i + 1,
v
));
}
format!("{}{}", header, body)
}
#[test]
fn test_energy_conservation_pass() {
// hhe35nl 真实偏离量级:~0.001(收敛良好),阈值 0.01 应通过。
// 深度 2 偏离略大(模拟真实:不同深度偏离略有差异)。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.6");
std::fs::write(
&file_path,
make_energy_table(&[1.0005e0, 1.00106e0, 0.9998e0]),
)
.unwrap();
let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表");
assert!(res.valid, "偏离 0.00106 < 阈值 0.01 应通过");
assert!((res.max_deviation - 1.06e-3).abs() < 1e-10);
assert_eq!(res.n_depths_checked, 3);
assert_eq!(res.worst_depth, Some(2)); // 深度 2 偏离最大
assert!(res.error.is_none());
}
#[test]
fn test_energy_conservation_fail() {
// 深度 2 偏离 0.15>5%,明显发散),阈值 0.01 应失败。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.6");
std::fs::write(
&file_path,
make_energy_table(&[1.001e0, 1.15e0, 0.98e0]),
)
.unwrap();
let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表");
assert!(!res.valid, "偏离 0.15 > 阈值 0.01 应失败");
assert!((res.max_deviation - 0.15).abs() < 1e-10);
assert_eq!(res.worst_depth, Some(2));
assert!(res.error.is_some());
}
#[test]
fn test_energy_conservation_missing_table() {
// `.6` 无能量守恒表(如 TLUSTY 未到 LFIN)→ None(跳过,不误判失败)。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.6");
std::fs::write(&file_path, "some TLUSTY log without energy table\n").unwrap();
assert!(check_energy_conservation(&file_path, 0.01).is_none());
}
#[test]
fn test_energy_conservation_missing_file() {
// `.6` 文件缺失 → None(跳过)。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("nonexistent.6");
assert!(check_energy_conservation(&file_path, 0.01).is_none());
}
#[test]
fn test_energy_conservation_d_notation() {
// `TOTAL SURFACE FLUX` 行用 D 记数法;数据行也可能用 D。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.6");
let content = " TOTAL SURFACE FLUX 6.77712339D+12\n\n\
----------------------\n\
FINAL MODEL ATMOSPHERE\n\
----------------------\n\
ID MASS (RAD+CON)/TOT\n\
1 2.917E-07 1.001D+00\n\
2 3.974E-07 9.99D-01\n";
std::fs::write(&file_path, content).unwrap();
let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表");
assert!(res.valid, "D 记数法偏离 ≤0.001 应通过");
assert!((res.max_deviation - 1.0e-3).abs() < 1e-10);
}
/// 构造一个最小 `.7` 大气文件(ND=3 深度,NUMPAR=3 即仅 T/ne/dens 无 NLTE 布居)。
/// 格式:行1 `ND NUMPAR`DM 数组 `ceil(ND/6)=1` 行;每深度块 `ceil(NUMPAR/5)=1` 行。
/// 每深度块首值即温度 T。
fn make_dot7(temps: &[f64]) -> String {
let nd = temps.len();
let numpar = 3;
let mut s = format!(" {} {}\n", nd, numpar);
// DM 数组:每行 6 个(FORMAT 502)。ND≤6 时一行。
let dm: Vec<String> = (0..nd).map(|i| format!("1.0E-0{}", i + 1)).collect();
s.push_str(&dm.join(" "));
s.push('\n');
// 逐深度块:每块 ceil(3/5)=1 行,首值=T。
for t in temps {
s.push_str(&format!(" {:.5} 1.0E+10 1.0E-15\n", t));
}
s
}
#[test]
fn test_temperature_structure_pass() {
// Teff=50000K,表层 30000K(0.6×Teff)、底层 200000K(4×Teff)——物理合理。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.7");
std::fs::write(&file_path, make_dot7(&[3.0e4, 1.0e5, 2.0e5])).unwrap();
let res =
check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7");
assert!(res.valid, "表层 0.6×Teff、全层 [3e4,2e5] 应通过");
assert!((res.surface_temp - 3.0e4).abs() < 1.0);
assert!((res.bottom_temp - 2.0e5).abs() < 1.0);
assert!((res.surface_ratio - 0.6).abs() < 1e-4);
assert_eq!(res.n_depths_checked, 3);
}
#[test]
fn test_temperature_structure_surface_too_hot() {
// Teff=50000K,表层 200000K = 4×Teff > 3.0 上限 → 失败。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.7");
std::fs::write(&file_path, make_dot7(&[2.0e5, 2.5e5, 3.0e5])).unwrap();
let res =
check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7");
assert!(!res.valid, "表层 4×Teff > 3.0 上限应失败");
assert!(res.surface_ratio > 3.0);
assert!(res.error.is_some());
}
#[test]
fn test_temperature_structure_out_of_range() {
// 某深度温度 1e9 K > ceiling 1e8 → 失败。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.7");
std::fs::write(&file_path, make_dot7(&[3.0e4, 1.0e9, 2.0e5])).unwrap();
let res =
check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7");
assert!(!res.valid, "深度2 T=1e9 越界 [10,1e8] 应失败");
}
#[test]
fn test_temperature_structure_missing_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("nonexistent.7");
assert!(check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1e8).is_none());
}
#[test]
fn test_temperature_structure_truncated_file() {
// DM 数组被截断(ND=3 需 1 行 DM,但此处只给首行头无 DM 行)。
// 修复前:静默跳过 → 读到后续行当作温度(可能误判)。
// 修复后:next()? 返回 None → 跳过(不误报)。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("truncated.7");
std::fs::write(&file_path, " 50 517\n").unwrap(); // 只有首行,无 DM/深度块
assert!(check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1e8).is_none());
}
#[test]
fn test_emflux_bolometric_pass() {
// 构造一个 ∫Fλdλ ≈ σTeff⁴ 的简化谱:Teff=50000K。
// σ×50000⁴ = 5.670374419e-5 × 6.25e18 = 3.544e14 erg/cm²/s。
// 用梯形:λ∈[100,1100]Å 共 1001 点,均匀 Fλ 使积分 = 3.544e14。
// 积分 = Fλ × (1100-100) = Fλ × 1000。故 Fλ = 3.544e11。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.emflux");
let mut content = String::new();
let sigma_teff4 = 5.670374419e-5 * 50000.0f64.powi(4);
let flux = sigma_teff4 / 1000.0; // 均匀谱,积分=Fλ×1000
for i in 0..1001 {
let lam = 100.0 + i as f64;
content.push_str(&format!("{:>15.3} {:e}\n", lam, flux));
}
std::fs::write(&file_path, content).unwrap();
let res = check_emflux_bolometric(&file_path, 50000.0, 0.02).expect("应解析 emflux");
assert!(res.valid, "均匀谱积分 = σTeff⁴ 应通过");
assert!((res.ratio - 1.0).abs() < 0.01, "比值应≈1.0,实际 {}", res.ratio);
assert_eq!(res.nan_ratio, 0.0);
}
#[test]
fn test_emflux_bolometric_all_nan() {
// 全 NaN → 大气收敛失败(辐射转移求解发散),应判失败(而非跳过)。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.emflux");
let content: String = (0..100).map(|i| format!("{:>12.3} NaN\n", 100.0 + i as f64)).collect();
std::fs::write(&file_path, content).unwrap();
let res = check_emflux_bolometric(&file_path, 50000.0, 0.02).expect("应解析");
assert!(!res.valid, "全 NaN 应判失败");
assert!(res.nan_ratio > 0.5);
assert!(res.error.is_some());
}
#[test]
fn test_emflux_missing_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("nonexistent.emflux");
assert!(check_emflux_bolometric(&file_path, 50000.0, 0.02).is_none());
}
// ===== 假收敛排查(#8)测试 =====
#[test]
fn test_convergence_trace_pass() {
// 真收敛:首=100, 逐拍除10, 10拍末=1e-8, 比值 1e10 > 1000。单调。
let itek: Vec<IterCheck> = (0..10)
.map(|i| IterCheck {
iter: i + 1,
max_relc: 100.0 / 10.0_f64.powi(i),
n_depths: 50,
})
.collect();
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
assert!(res.valid, "比值远 > 1000 应通过");
assert!(res.ratio >= 1000.0);
assert!(res.monotonic);
}
#[test]
fn test_convergence_trace_suspect() {
// 假收敛嫌疑:首=10, 末=0.005, 比值 2000 但刚过阈值 → 通过;
// 改为 首=10, 末=0.05, 比值 200 < 1000 → 失败。
let itek: Vec<IterCheck> = (0..6)
.map(|i| IterCheck {
iter: i + 1,
max_relc: 10.0 - i as f64 * 1.65, // 10→1.75 近线性下降
n_depths: 50,
})
.collect();
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
assert!(!res.valid, "比值 <1000 应判假收敛嫌疑");
assert!(res.ratio < 1000.0);
assert!(res.error.is_some());
}
#[test]
fn test_convergence_trace_too_short() {
// 2拍:无法判定 → None
let itek = vec![
IterCheck { iter: 1, max_relc: 10.0, n_depths: 50 },
IterCheck { iter: 2, max_relc: 0.001, n_depths: 50 },
];
assert!(check_convergence_trace(&itek, 1000.0).is_none());
}
#[test]
fn test_convergence_trace_ng_bounce() {
// Ng 加速局部反弹但整体收敛:10→0.5→2→0.001。比值 1e4monotonic 允许 ×2 反弹。
let itek = vec![
IterCheck { iter: 1, max_relc: 10.0, n_depths: 50 },
IterCheck { iter: 2, max_relc: 0.5, n_depths: 50 },
IterCheck { iter: 3, max_relc: 2.0, n_depths: 50 }, // 反弹 ×4 > ×2 → 非单调
IterCheck { iter: 4, max_relc: 0.001, n_depths: 50 },
];
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
assert!(res.valid, "比值 1e4 > 1000 应通过");
assert!(!res.monotonic, "反弹 ×4 > ×2 应判非单调");
}
// ===== b 因子合理性(#19)测试 =====
#[test]
fn test_bfactor_pass() {
// 正常 b 因子:多数在 [1e-3, 1e3],少量极端。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.bfac");
let content = " 50 3\n\
1.0E-07 2.0E-07 3.0E-07 4.0E-07 5.0E-07 6.0E-07\n\
1.0E+04 5.0E+01 2.0E+00 1.0E+00 5.0E-01\n\
3.0E+02 1.5E+00 8.0E-01 1.0E+00 2.0E+00\n";
std::fs::write(&file_path, content).unwrap();
let res = check_bfactor(&file_path, 1.0e3, 1.0e-3).expect("应解析 bfac");
// 1.0E+04 是极端值(>1e3)1个/约8个有效值 ≈12.5%。
// 这个测试验证极端值检测;具体 valid 取决于阈值。
assert!(res.n_values > 0);
assert!(res.max_b > 1.0e3);
}
#[test]
fn test_bfactor_missing_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("nonexistent.bfac");
assert!(check_bfactor(&file_path, 1.0e3, 1.0e-3).is_none());
}
}
+320 -22
View File
@@ -1,41 +1,339 @@
//! SYNSPEC fort.55 控制卡生成器。
//!
//! `generate_fort55_content` 把 [`SynspecInput`](按行分组的子结构体)序列化为 fort.55 文本。
//! 每行字段顺序严格对应 `synspec54.f` 的 `READ(55,*)` 语句,字段名一一映射——详见各
//! `Fort55Line*` 结构体的文档注释(附 `synspec54.f` 行号)。
use crate::config::SynspecInput;
/// Dynamic generator for SYNSPEC fort.55 parameter control file
/// 把 [`SynspecInput`] 序列化为 SYNSPEC fort.55 控制卡文本(9 行)。
///
/// 每行对应 `synspec54.f` 的一个 `READ(55,*)` 语句,行内字段顺序与 READ 语句完全一致:
/// - 行1 (`:253`): `IMODE IDSTD IPRIN`
/// - 行2 (`:254`): `INMOD INTRPL ICHANG ICHEMC`
/// - 行3 (`:255`): `IOPHLI nunalp nunbet nungam nunbal`
/// - 行4 (`:2146`): `IFREQ INLTE ICONTL INLIST IFHE2`
/// - 行5 (`:2148`): `IHYDPR IHE1PR IHE2PR`
/// - 行6 (`:2149`): `ALAM0 ALAST CUTOF0 CUTOFS RELOP SPACE`
/// - 行7 (`:2208`, IMODE∈[-3,1] 条件读取): `nmlist [iunitm...]`
/// - 行8 (`:2251`): `VTB`
/// - 行9 (`:2301`, IFWIN≤0): `NMU0 ANG0 IFLUX`
///
/// # 行7(分子表)的必要性
/// SYNSPEC 在 IMODE∈[-3,1](默认 IMODE=0 满足)时会**额外读一行分子表**(`:2208`)。
/// gfortran list-directed READ 满足变量列表后会消费整行剩余,故行7必须独立成行,
/// 否则 VTB 行会被分子表 READ 吞掉,导致 vturb 被错误覆盖。见 `Fort55LineMol` 文档。
pub fn generate_fort55_content(cfg: &SynspecInput) -> String {
let line1 = format!(" {} {} {}", cfg.imode, cfg.idrv, cfg.ifreq);
let line2 = " 1 0 0 0";
let line3 = " 0 0 0 0 0";
let line4 = " 1 1 0 0 0";
let line5 = " 0 0 0";
let line6 = format!(
" {:.1} {:.1} 10 0 {} {}",
cfg.wstart, cfg.wend, cfg.rel_cutoff, cfg.abs_cutoff
);
let line7 = " 0 0";
let l1 = &cfg.line1;
let l2 = &cfg.line2;
let l3 = &cfg.line3;
let l4 = &cfg.line4;
let l5 = &cfg.line5;
let l6 = &cfg.line6;
let lm = &cfg.line_mol;
let lv = &cfg.line_vtb;
let la = &cfg.line_ang;
// SYNSPEC 用 list-directed READ`READ(55,*)`)读取,对空白分隔的数值格式宽容,
// 不要求固定列宽,只需 token 间有空白分隔即可。波长用 .1(与官方样本一致),
// 其余浮点用通用 Display。
format!(
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
line1, line2, line3, line4, line5, line6, line7
// 行1: IMODE, IDSTD, IPRIN
"{imode} {idstd} {iprin}\n\
{inmod} {intrpl} {ichang} {ichemc}\n\
{iophli} {nunalp} {nunbet} {nungam} {nunbal}\n\
{ifreq} {inlte} {icontl} {inlist} {ifhe2}\n\
{ihydpr} {ihe1pr} {ihe2pr}\n\
{alam0:.1} {alast:.1} {cutoff0} {cutoffs} {relop} {space}\n\
{nmlist}\n\
{vtb}\n\
{nmu0} {ang0} {iflux}\n",
// 行1
imode = l1.imode,
idstd = l1.idstd,
iprin = l1.iprin,
// 行2
inmod = l2.inmod,
intrpl = l2.intrpl,
ichang = l2.ichang,
ichemc = l2.ichemc,
// 行3
iophli = l3.iophli,
nunalp = l3.nunalp,
nunbet = l3.nunbet,
nungam = l3.nungam,
nunbal = l3.nunbal,
// 行4
ifreq = l4.ifreq,
inlte = l4.inlte,
icontl = l4.icontl,
inlist = l4.inlist,
ifhe2 = l4.ifhe2,
// 行5
ihydpr = l5.ihydpr,
ihe1pr = l5.ihe1pr,
ihe2pr = l5.ihe2pr,
// 行6
alam0 = l6.alam0,
alast = l6.alast,
cutoff0 = l6.cutoff0,
cutoffs = l6.cutoffs,
relop = l6.relop,
space = l6.space,
// 行7 分子表(nmlist=0 时仅写出 nmlistlist-directed READ 消费整行)
nmlist = lm.nmlist,
// 行8 VTB
vtb = lv.vtb,
// 行9 角度
nmu0 = la.nmu0,
ang0 = la.ang0,
iflux = la.iflux,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
Fort55Line1, Fort55Line2, Fort55Line3, Fort55Line4, Fort55Line5, Fort55Line6,
Fort55LineAng, Fort55LineMol, Fort55LineVtb,
};
/// 基本:自定义配置的波长值出现在生成的文本中。
#[test]
fn test_fort55_generation() {
let cfg = SynspecInput {
wstart: 3000.0,
wend: 7000.0,
imode: 0,
idrv: 50,
ifreq: 1,
rel_cutoff: 0.0001,
abs_cutoff: 0.01,
line6: Fort55Line6 {
alam0: 3000.0,
alast: 7000.0,
..Fort55Line6::default()
},
..SynspecInput::default()
};
let content = generate_fort55_content(&cfg);
assert!(content.contains("3000.0"));
assert!(content.contains("7000.0"));
assert!(content.contains("3000.0"), "应含起始波长 3000.0");
assert!(content.contains("7000.0"), "应含终止波长 7000.0");
}
/// fort.55 必须恰好 9 行(含行7 分子表 + 行8 VTB + 行9 角度)。
#[test]
fn test_fort55_line_count() {
let cfg = SynspecInput::default();
let content = generate_fort55_content(&cfg);
let lines: Vec<&str> = content.lines().collect();
assert_eq!(
lines.len(),
9,
"fort.55 应为 9 行(行7 分子表 + 行8 VTB + 行9 角度均显式生成),实际 {} 行:\n{}",
lines.len(),
content
);
}
/// 核心回归:字段位置与 synspec54.f READ 语句严格对齐,含行7 分子表。
/// 重点防护历史上 idrv→IDSTD、ifreq→IPRIN、abs_cutoff→SPACE 的错位 bug
/// 以及行7 分子表缺失导致 VTB 被吞的问题。
#[test]
fn test_fort55_field_alignment() {
let cfg = SynspecInput {
line1: Fort55Line1 {
imode: 0,
idstd: 0,
iprin: 1,
},
line4: Fort55Line4 {
ifreq: 7, // 真正的 IFREQ,应在行4第1列
inlte: 1,
icontl: 0,
inlist: 0,
ifhe2: 0,
},
line6: Fort55Line6 {
alam0: 3000.0,
alast: 7000.0,
cutoff0: 10.0,
cutoffs: 0.0,
relop: 0.0001,
space: 0.01,
},
line_vtb: Fort55LineVtb { vtb: 5.0 },
..SynspecInput::default()
};
let content = generate_fort55_content(&cfg);
let lines: Vec<&str> = content.lines().collect();
// 行1: IMODE(0) IDSTD(0) IPRIN(1)
let l1: Vec<&str> = lines[0].split_whitespace().collect();
assert_eq!(l1[0], "0", "行1第1列应为 IMODE=0");
assert_eq!(l1[1], "0", "行1第2列应为 IDSTD=0(不再是旧的错位 idrv=50");
assert_eq!(l1[2], "1", "行1第3列应为 IPRIN=1");
// 行4: IFREQ(7) INLTE(1) ICONTL(0) INLIST(0) IFHE2(0)
let l4: Vec<&str> = lines[3].split_whitespace().collect();
assert_eq!(l4.len(), 5, "行4应有 5 个字段");
assert_eq!(l4[0], "7", "行4第1列应为真正的 IFREQ=7(不再错位到行1)");
// 行6: ALAM0 ALAST CUTOF0 CUTOFS RELOP SPACE
let l6: Vec<&str> = lines[5].split_whitespace().collect();
assert_eq!(l6.len(), 6, "行6应有 6 个字段");
assert_eq!(l6[0], "3000.0", "行6第1列 ALAM0");
assert_eq!(l6[1], "7000.0", "行6第2列 ALAST");
assert_eq!(l6[2], "10", "行6第3列 CUTOF0");
assert_eq!(l6[5], "0.01", "行6第6列 SPACE");
// 行7 分子表:nmlist=0(单字段)
let l7: Vec<&str> = lines[6].split_whitespace().collect();
assert_eq!(l7.len(), 1, "行7 分子表默认 nmlist=0 应为单字段");
assert_eq!(l7[0], "0", "行7 nmlist=0");
// 行8 VTB:自定义值 5.0
let l8: Vec<&str> = lines[7].split_whitespace().collect();
assert_eq!(l8.len(), 1, "行8 应为单字段 VTB");
assert_eq!(
l8[0], "5",
"行8 VTB=5(自定义值,证明 VTB 行未被分子表吞掉)"
);
// 行9 角度:NMU0 ANG0 IFLUX
let l9: Vec<&str> = lines[8].split_whitespace().collect();
assert_eq!(l9.len(), 3, "行9 应有 3 个字段 NMU0/ANG0/IFLUX");
}
/// 关键回归:VTB 默认值必须是负值(-1.0),否则 SYNSPEC 会把 vturb 覆盖为 0。
///
/// 背景(审查 Critical-1):SYNSPEC `:2253` `if(vtb.ge.0.)` 为真时会用 `vtb²·1e10`
/// 覆盖所有深度的 vturb。默认 VTB=0 会把大气自带的 vturb=2 km/s 覆盖为 0,导致
/// 谱线多普勒宽度错误。VTB=-1 触发"不覆盖"分支,保留大气 vturb。
#[test]
fn test_fort55_default_vtb_is_negative() {
let cfg = SynspecInput::default();
assert!(
cfg.line_vtb.vtb < 0.0,
"默认 VTB 必须为负值(实际 {}),否则 SYNSPEC 会覆盖 vturb 为 0",
cfg.line_vtb.vtb
);
let content = generate_fort55_content(&cfg);
let lines: Vec<&str> = content.lines().collect();
let vtb_line = lines[7];
assert!(
vtb_line.trim_start().starts_with('-'),
"行8 VTB 应以负号开头(实际 '{}'),确保不覆盖大气 vturb",
vtb_line
);
}
/// 默认值生成的 fort.55 关键字段应与官方样本 `hotsd/synspec/fort.55.lin` 对齐。
/// 注意:官方样本是 7 行(靠 EOF 跳过 VTB/角度),本生成器是 9 行(显式生成),
/// 行数不同但 line1-6 的值与样本一致。唯一差异是 IDSTD(样本 50,默认现 0)和
/// VTB(样本无独立行,默认现 -1)。
#[test]
fn test_fort55_default_key_fields() {
let cfg = SynspecInput::default();
let content = generate_fort55_content(&cfg);
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 9, "应为 9 行");
// 行1: 官方样本 "0 50 1";默认 "0 0 1"idstd 修正为 0
let l1: Vec<&str> = lines[0].split_whitespace().collect();
assert_eq!(l1[0], "0", "IMODE=0 与样本一致");
assert_eq!(l1[1], "0", "IDSTD=0(样本是 50,这是本次修复的行为变化)");
assert_eq!(l1[2], "1", "IPRIN=1 与样本一致");
// 行2: "1 0 0 0" 与样本一致
let l2: Vec<&str> = lines[1].split_whitespace().collect();
assert_eq!(l2, vec!["1", "0", "0", "0"], "行2 与样本一致");
// 行4: "1 1 0 0 0" 与样本一致
let l4: Vec<&str> = lines[3].split_whitespace().collect();
assert_eq!(l4, vec!["1", "1", "0", "0", "0"], "行4 与样本一致");
// 行6: 默认波长 1400/1410、CUTOF0=10、CUTOFS=0、RELOP=0.0001、SPACE=0.01
let l6: Vec<&str> = lines[5].split_whitespace().collect();
assert_eq!(l6[0], "1400.0", "ALAM0=1400.0 与样本一致");
assert_eq!(l6[1], "1410.0", "ALAST=1410.0 与样本一致");
assert_eq!(l6[2], "10", "CUTOF0=10 与样本一致");
assert_eq!(l6[3], "0", "CUTOFS=0 与样本一致");
assert_eq!(l6[4], "0.0001", "RELOP=0.0001 与样本一致");
assert_eq!(l6[5], "0.01", "SPACE=0.01 与样本一致");
// 行7 分子表: nmlist=0(样本行7第一个值也是 0)
let l7: Vec<&str> = lines[6].split_whitespace().collect();
assert_eq!(l7[0], "0", "nmlist=0 与样本一致");
// 行8 VTB: -1(样本无独立 VTB 行,靠 EOF 跳过;本生成器用 -1 显式不覆盖)
// 行9 角度: NMU0=0(样本无,靠 EOF 跳过;本生成器显式 0 不算比强度)
}
/// 全自定义:所有字段设非默认值,验证无字段丢失或错位。
#[test]
fn test_fort55_full_custom() {
let cfg = SynspecInput {
line1: Fort55Line1 {
imode: 1,
idstd: -5,
iprin: 3,
},
line2: Fort55Line2 {
inmod: 0,
intrpl: 0, // 注意:intrpl>0+inmod>0 会触发 DM 数组条件读取,生成器不支持
ichang: 1,
ichemc: 1,
},
line3: Fort55Line3 {
iophli: 1,
nunalp: 2,
nunbet: 3,
nungam: 4,
nunbal: 5,
},
line4: Fort55Line4 {
ifreq: 2,
inlte: 25,
icontl: 1,
inlist: 1,
ifhe2: 1,
},
line5: Fort55Line5 {
ihydpr: 2,
ihe1pr: 1,
ihe2pr: 1,
},
line6: Fort55Line6 {
alam0: 900.0,
alast: 1100.0,
cutoff0: 5.0,
cutoffs: 3.0,
relop: 0.001,
space: 0.05,
},
line_mol: Fort55LineMol { nmlist: 0 },
line_vtb: Fort55LineVtb { vtb: 5.0 },
line_ang: Fort55LineAng {
nmu0: 5,
ang0: 0.1,
iflux: 1,
},
};
let content = generate_fort55_content(&cfg);
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 9);
// 抽查几个易错位的关键值
assert!(
lines[0].split_whitespace().nth(1) == Some("-5"),
"行1第2列 IDSTD=-5 应正确写出负数"
);
assert!(
lines[3].split_whitespace().next() == Some("2"),
"行4第1列 真正的 IFREQ=2"
);
assert!(
lines[7].split_whitespace().next() == Some("5"),
"行8 VTB=5(VTB 行独立存在,未被分子表吞掉)"
);
assert!(
lines[8].split_whitespace().next() == Some("5"),
"行9 NMU0=5"
);
}
}
+337 -504
View File
@@ -1,398 +1,181 @@
use crate::config::{AtomConfig, IonConfig, TlustyInput};
//! TLUSTY .5 输入文件生成器。
//!
//! [`make_input5`] 把 [`TlustyInput`](全局模板)+ grid(网格点)+ chain(阶段)三源融合,
//! 渲染出 .5 文件文本。grid/chain 覆写 global 模板的对应字段后统一渲染。
use crate::config::{ChainStep, Dot5Input, Geometry, IonRow, NonstdExtra, TlustyInput};
use crate::models::GridPointParams;
use std::collections::HashMap;
struct IonDef {
iat: i32,
iz: i32,
nlevs: i32,
typion: &'static str,
filei: &'static str,
}
const IONS_H: &[IonDef] = &[
IonDef {
iat: 1,
iz: 0,
nlevs: 9,
typion: " H 1",
filei: "data/h1.dat",
},
IonDef {
iat: 1,
iz: 1,
nlevs: 1,
typion: " H 2",
filei: " ",
},
];
const IONS_HE: &[IonDef] = &[
IonDef {
iat: 2,
iz: 0,
nlevs: 14,
typion: "He 1",
filei: "data/he1.dat",
},
IonDef {
iat: 2,
iz: 1,
nlevs: 14,
typion: "He 2",
filei: "data/he2.dat",
},
IonDef {
iat: 2,
iz: 2,
nlevs: 1,
typion: "He 3",
filei: " ",
},
];
const IONS_C: &[IonDef] = &[
IonDef {
iat: 6,
iz: 0,
nlevs: 40,
typion: " C 1",
filei: "data/c1.dat",
},
IonDef {
iat: 6,
iz: 1,
nlevs: 22,
typion: " C 2",
filei: "data/c2.dat",
},
IonDef {
iat: 6,
iz: 2,
nlevs: 46,
typion: " C 3",
filei: "data/c3_34+12lev.dat",
},
IonDef {
iat: 6,
iz: 3,
nlevs: 25,
typion: " C 4",
filei: "data/c4.dat",
},
IonDef {
iat: 6,
iz: 4,
nlevs: 1,
typion: " C 5",
filei: " ",
},
];
const IONS_N: &[IonDef] = &[
IonDef {
iat: 7,
iz: 0,
nlevs: 34,
typion: " N 1",
filei: "data/n1.dat",
},
IonDef {
iat: 7,
iz: 1,
nlevs: 42,
typion: " N 2",
filei: "data/n2_32+10lev.dat",
},
IonDef {
iat: 7,
iz: 2,
nlevs: 32,
typion: " N 3",
filei: "data/n3.dat",
},
IonDef {
iat: 7,
iz: 3,
nlevs: 48,
typion: " N 4",
filei: "data/n4_34+14lev.dat",
},
IonDef {
iat: 7,
iz: 4,
nlevs: 16,
typion: " N 5",
filei: "data/n5.dat",
},
IonDef {
iat: 7,
iz: 5,
nlevs: 1,
typion: " N 6",
filei: " ",
},
];
const IONS_O: &[IonDef] = &[
IonDef {
iat: 8,
iz: 0,
nlevs: 33,
typion: " O 1",
filei: "data/o1_23+10lev.dat",
},
IonDef {
iat: 8,
iz: 1,
nlevs: 48,
typion: " O 2",
filei: "data/o2_36+12lev.dat",
},
IonDef {
iat: 8,
iz: 2,
nlevs: 41,
typion: " O 3",
filei: "data/o3_28+13lev.dat",
},
IonDef {
iat: 8,
iz: 3,
nlevs: 39,
typion: " O 4",
filei: "data/o4.dat",
},
IonDef {
iat: 8,
iz: 4,
nlevs: 6,
typion: " O 5",
filei: "data/o5.dat",
},
IonDef {
iat: 8,
iz: 5,
nlevs: 1,
typion: " O 6",
filei: " ",
},
];
/// 把对数丰度 logx 格式化为 10^logx 的科学计数法字符串。
///
/// 审查修复 #N3:深度防御 powf 溢出——config.validate 已把丰度轴限制在 [-20, 10]
/// 但 fmt_abn 也可能被 tlusty_chain 里的元素丰度等其它路径调用。若 logx 超出约 [-308, 308]
/// `10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5 输入文件导致 TLUSTY 行为未定义。
/// 配置层校验在前,此处仅作最终防线:溢出时 saturate 到 f64 可表示范围并按原格式输出。
/// 深度防御:config.validate 已把丰度轴限制在 [-20, 10],但此处也作最终防线——
/// 若 logx 超出约 [-300, 300]`10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5。
fn fmt_abn(logx: f64) -> String {
let val = if logx.abs() < 300.0 {
10.0f64.powf(logx)
} else if logx < 0.0 {
0.0
} else {
// 超出安全范围:clamp 到 0(丰度对数极小)或 f64::MAX(丰度对数极大),
// 避免写出 inf。配置层应已拦截,这里不会成为正常路径。
if logx < 0.0 {
0.0
} else {
f64::MAX
}
f64::MAX
};
format!("{:.4E}", val)
}
/// 元素符号首字母大写归一("h"→"H", "he"→"He", "HE"→"He"
/// 容忍用户在 YAML atoms 块用小写/全大写写元素符号。
fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str().to_lowercase().as_str(),
None => String::new(),
}
}
/// 用 grid + chain 覆写 global 模板,生成渲染用的 Dot5Input
fn merge(global: &Dot5Input, grid: &GridPointParams, chain: &ChainStep) -> Dot5Input {
let mut m = global.clone();
/// 默认 ions 能级数据表(与历史 IONS_* 常量等价)。
/// 用户未在 `tlusty_input.ions` 配置时使用此表,按 metals 筛选参与元素。
fn default_ions(metals: &str) -> Vec<IonConfig> {
let mt = metals.to_lowercase();
let has_c = mt.contains('c');
let has_n = mt.contains('n');
let has_o = mt.contains('o');
let mut ions: Vec<IonConfig> = Vec::new();
// H(恒在)
for d in IONS_H {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
// grid → geometry.teff/grav(恒星大气路径)
if let Geometry::Stellar { teff, logg } = &mut m.geometry {
*teff = grid.teff.value();
*logg = grid.logg.value();
}
// He(恒在)
for d in IONS_HE {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
}
let extend = |ions: &mut Vec<IonConfig>, table: &[IonDef]| {
for d in table {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
// chain → lte/ltgray
m.lte = chain.lte == "T";
m.ltgray = chain.ltgray == "T";
// grid → atoms 块 He/C/N/O 的 abn 列
for row in &mut m.atoms {
match row.iat {
2 => row.abn = Some(fmt_abn(grid.loghe.value())),
6 => row.abn = Some(fmt_abn(grid.logc.value())),
7 => row.abn = Some(fmt_abn(grid.logn.value())),
8 => row.abn = Some(fmt_abn(grid.logo.value())),
_ => {}
}
};
if has_c {
extend(&mut ions, IONS_C);
}
if has_n {
extend(&mut ions, IONS_N);
// chain → ions 每行 ilvlinnlevs==1 裸核强制 0
for ion in &mut m.ions {
let ilv = if ion.nlevs == 1 { 0 } else { chain.ilvlin };
ion.ilvlin = Some(ilv);
}
if has_o {
extend(&mut ions, IONS_O);
}
ions
m
}
/// Constructs the complete text of a `.5` input file for TLUSTY
///
/// `input_cfg`:全局输入参数(NFREAD / atoms.mode / ions 表)。
/// None → 走代码内硬编码默认(与改动前行为完全一致,向后兼容)。
pub fn make_input5(
params: &GridPointParams,
lte: &str,
ltgray: &str,
metals: &str,
ilvlin: i32,
input_cfg: Option<&TlustyInput>,
) -> String {
let mt = metals.to_lowercase();
let has_c = mt.contains('c');
let has_n = mt.contains('n');
let has_o = mt.contains('o');
/// 渲染 atoms 块。直接渲染传入的完整列表(元素集由 dot5.atoms 决定,不再用 metals filter);
/// abn 缺省用 "0."(太阳默认)。iabset=Some 时写出 `natoms iabset`(行5 两列),
/// 否则只写 `natoms`iostat 兜底默认 0)。
fn render_atoms(atoms: &[crate::config::AtomRow], iabset: Option<i32>) -> String {
let header = match iabset {
Some(i) => format!(" {} {}\n* mode abn modpf\n", atoms.len(), i),
None => format!(" {}\n* mode abn modpf\n", atoms.len()),
};
let mut out = header;
for r in atoms {
let abn = r.abn.clone().unwrap_or_else(|| "0.".to_string());
out.push_str(&format!(" {} {} {}\n", r.mode, abn, r.modpf));
}
out
}
// 元素符号 → (mode, abn)。mode 优先取 input_cfg.atoms 覆写,否则走默认。
// abn(丰度)永远由 GridPointParams 计算——每网格点不同,不可全局配置
// 查找时对 key 做首字母大写归一(H/He/Li/Be/B/C/N/O),容忍用户写小写 "h"/"he"。
//
// M1 修复:config.rs 的 `atoms` 是 Serde 反序列化的 `HashMap<String, AtomConfig>`
// 键按 YAML 原样存储(无规范化)。若用户写 `he:`/`HE:`,原实现只归一探针、不归一存储键,
// 查不到规范键 "He" → mode 覆写静默回落默认(错误物理:想要的 LTE-only 变统计平衡)。
// 这里把用户键也按同一 capitalize_first 规则归一一次,保证任意大小写都能命中。
let atoms_normalized: Option<HashMap<String, &AtomConfig>> = input_cfg.map(|c| {
let mut m = HashMap::with_capacity(c.atoms.len());
for (k, v) in &c.atoms {
let canon = capitalize_first(k);
// 归一撞键检测:用户同时写 "C" 与 "c"(或 "He"/"HE")等大小写变体时,两键归一为
// 同一规范键。原 collect() 静默覆盖(迭代序非确定 → 取哪个任选)。现显式提示
// 让歧义可见,避免 mode 覆写悄悄取到任意一个变体。
if let Some(_prev) = m.insert(canon.clone(), v) {
tracing::warn!(
"atoms 键 {:?} 与已有键(归一后同为 {:?})冲突:取最后读取项,请改用单一规范键",
k,
canon
);
/// 渲染 ions 块。直接渲染传入的完整列表(元素集由 dot5.ions 决定,不再用 metals filter);
/// ilast 由 nlevs 推导;nonstd≠0 渲染附加行
/// 列宽固定右对齐,与真实 tlusty 输入(tests/tlusty/hhe/fort.5)逐字节一致:
/// iat→col4, iz→col10, nlevs→col16, ilast→col23, ilvl→col30, nonstd→col37。
fn render_ions(ions: &[IonRow]) -> String {
let mut out = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string();
for ion in ions {
let ilast = ion.ilast.unwrap_or(if ion.nlevs == 1 { 1 } else { 0 });
let ilvl = ion.ilvlin.unwrap_or(0);
out.push_str(&format!(
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, ion.nonstd, ion.typion, ion.filei
));
// NONSTD≠0 附加行
if let Some(extra) = &ion.nonstd_extra {
match extra {
NonstdExtra::Positive {
iupsum,
icup,
modeff,
nff,
} => {
out.push_str(&format!(
" {} {} {} {}\n",
iupsum, icup, modeff, nff
));
}
NonstdExtra::Negative {
inodf1,
inodf2,
fiodf1,
fiodf2,
fibfcs,
} => {
out.push_str(&format!(
" {} {} '{}' '{}' '{}'\n",
inodf1, inodf2, fiodf1, fiodf2, fibfcs
));
}
}
}
m
});
let atom_mode = |sym: &str, default_mode: i32| -> i32 {
atoms_normalized
.as_ref()
.and_then(|m| m.get(&capitalize_first(sym)))
.and_then(|a| a.mode)
.unwrap_or(default_mode)
};
let atom_modpf = |sym: &str| -> i32 {
atoms_normalized
.as_ref()
.and_then(|m| m.get(&capitalize_first(sym)))
.and_then(|a| a.modpf)
.unwrap_or(0)
};
// Atoms blockH/He 恒在;Li/Be/B 恒在(mode=0 不参与);C/N/O 按 metals。
let mut atom_rows: Vec<(i32, String, i32)> = vec![
(atom_mode("H", 2), "0.".to_string(), atom_modpf("H")), // 1 H
(atom_mode("He", 2), fmt_abn(*params.loghe), atom_modpf("He")), // 2 He
(atom_mode("Li", 0), "0.".to_string(), atom_modpf("Li")), // 3 Li
(atom_mode("Be", 0), "0.".to_string(), atom_modpf("Be")), // 4 Be
(atom_mode("B", 0), "0.".to_string(), atom_modpf("B")), // 5 B
];
if has_c {
atom_rows.push((atom_mode("C", 2), fmt_abn(*params.logc), atom_modpf("C")));
// 6 C
}
if has_n {
atom_rows.push((atom_mode("N", 2), fmt_abn(*params.logn), atom_modpf("N")));
// 7 N
}
if has_o {
atom_rows.push((atom_mode("O", 2), fmt_abn(*params.logo), atom_modpf("O")));
// 8 O
}
let natoms = atom_rows.len() as i32;
let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms);
for (mode, abn, modpf) in &atom_rows {
atoms_block.push_str(&format!(" {} {} {}\n", mode, abn, modpf));
}
// Ions block:用户配置非空时完全替换默认表,仍按 metals 筛选 iat。
let ions: Vec<IonConfig> = if let Some(cfg) = input_cfg {
if cfg.ions.is_empty() {
default_ions(metals)
} else {
// 用户自定义表——按 metals 筛选参与元素(H/He 恒在,C/N/O 按 metals)。
cfg.ions
.iter()
.filter(|ion| match ion.iat {
1 | 2 => true, // H, He 恒在
6 => has_c,
7 => has_n,
8 => has_o,
_ => true, // 用户自定义元素不筛
})
.cloned()
.collect()
}
} else {
default_ions(metals)
};
let mut ions_block = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string();
for ion in &ions {
let ilast = if ion.nlevs == 1 { 1 } else { 0 };
let ilvl = if ion.nlevs == 1 { 0 } else { ilvlin };
// ions 行字段右对齐到固定列,与真实 tlusty 输入文件
// (tests/tlusty/hhe/fort.5) 逐字节一致:
// iat 结束于 col4(|iat|=4), iz col10(+6), nlevs col16(+6),
// ilast col23(+7), ilvl col30(+7), nonstd col37(+7)。
ions_block.push_str(&format!(
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, 0, ion.typion, ion.filei
));
}
// 终止行:(iat,iz,nlevs,ilast,ilvl,nonstd)=(0,0,0,-1,0,0)typion/filei 全空。
ions_block.push_str(&format!(
// 终止行
out.push_str(&format!(
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
0, 0, 0, -1, 0, 0, " ", " "
));
out
}
// NFREAD:从配置读,None → 默认 2000
let nfread = input_cfg.map(|c| c.nfread).unwrap_or(2000);
/// 渲染 CHANGE 块(ICHANG<0 时,每能级行)
fn render_change(change: &[crate::config::ChangeRow]) -> String {
let mut out = String::new();
for r in change {
out.push_str(&format!(
" {} {} {} {} {} {} {}\n",
r.iold, r.mode, r.nxtold, r.isinew, r.isiold, r.nxtsio, r.rel
));
}
out
}
format!(
"{:.1} {:.1} ! TEFF, GRAV\n \
/// 生成完整的 .5 文件文本。
///
/// 三源融合:global`TlustyInput`,完整模板)+ grid(网格点,覆写 teff/logg/丰度)
/// + chain(阶段,覆写 lte/ltgray/ilvlin)。其余字段直接从 global 取。
pub fn make_input5(grid: &GridPointParams, chain: &ChainStep, global: &TlustyInput) -> String {
let mut m = merge(&global.dot5, grid, chain);
// 空兜底:YAML 的 dot5 块未列出 ions 时,serde default 给空 Vec(而非 default_ions())。
// 空 ions 会产出只有终止行的损坏 .5。此处恢复兜底语义:空 → 用默认 22 行表。
// 注意 merge 已注入了 ilvlin,故此处兜底后需重新注入一次。
if m.ions.is_empty() {
let mut default = crate::config::default_ions();
for ion in &mut default {
let ilv = if ion.nlevs == 1 { 0 } else { chain.ilvlin };
ion.ilvlin = Some(ilv);
}
m.ions = default;
}
// 行1geometry
let line1 = match &m.geometry {
Geometry::Stellar { teff, logg } => format!("{:.1} {:.1} ! TEFF, GRAV", teff, logg),
Geometry::Disk {
xmstar,
xmdot,
rstar,
reldst,
} => format!(
"{} {} {} {} ! XMSTAR, XMDOT, RSTAR, RELDST",
xmstar, xmdot, rstar, reldst
),
};
let lte_str = if m.lte { "T" } else { "F" };
let ltgray_str = if m.ltgray { "T" } else { "F" };
let atoms_block = render_atoms(&m.atoms, m.iabset);
let ions_block = render_ions(&m.ions);
let mut out = format!(
"{}\n \
{} {} ! LTE, LTGRAY\n \
'nst' ! name of file containing non-standard flags\n\
'{}' ! name of file containing non-standard flags\n\
*-----------------------------------------------------------------\n\
* frequencies\n \
{} ! NFREAD\n\
@@ -401,55 +184,136 @@ pub fn make_input5(
{}\
*-----------------------------------------------------------------\n\
* data for ions\n*\n\
{}\
*\n* end\n",
params.teff, params.logg, lte, ltgray, nfread, atoms_block, ions_block
)
{}",
line1, lte_str, ltgray_str, m.finstd, m.nfread, atoms_block, ions_block
);
// CHANGE 块(ICHANG<0 时)
if chain.ichang.map(|i| i < 0).unwrap_or(false) {
if let Some(change) = &m.change {
out.push_str("*-----------------------------------------------------------------\n");
out.push_str("* change block (ICHANG<0)\n");
out.push_str(&render_change(change));
}
}
out.push_str("*\n* end\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TlustyInput;
use crate::models::GridPointParams;
#[test]
fn test_make_input5() {
let params = GridPointParams {
fn grid() -> GridPointParams {
GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let input5 = make_input5(&params, "F", "F", "cno", 100, None);
}
}
fn chain_cno() -> ChainStep {
ChainStep {
label: "nl".into(),
lte: "F".into(),
ltgray: "F".into(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: Some(0.001),
itek: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
}
}
#[test]
fn test_make_input5_basic() {
let input5 = make_input5(&grid(), &chain_cno(), &TlustyInput::default());
assert!(input5.contains("35000.0 5.5"));
assert!(input5.contains("data/h1.dat"));
assert!(input5.contains("data/c1.dat"));
assert!(input5.contains("data/n1.dat"));
assert!(input5.contains("data/o1_23+10lev.dat"));
assert!(input5.contains("2000"), "NFREAD 默认 2000");
}
/// ions 行必须与真实 tlusty 输入文件 (tests/tlusty/hhe/fort.5) 的宽列宽逐字节一致
/// 字段右对齐到固定列:iat→col4, iz→col10, nlevs→col16, ilast→col23, ilvl→col30, nonstd→col37
/// 一旦此处失败,说明 ions 格式被改动且偏离了真实 tlusty 输入惯例(虽不影响 list-directed
/// 解析结果,但破坏与历史参考文件的可 diff 性)。
/// ions 行数值列宽必须与真实 fort.5 逐字节一致(回归守护)
/// 用仅含 H+He 的自定义 ions 列表(模拟旧版 metals="" 的效果)
#[test]
fn test_ions_block_byte_perfect_with_real_fort5() {
// H+He 仅(metals="")对应真实 hhe fort.5 的 ions 集。
let params = GridPointParams {
teff: 35000.0.into(),
logg: 4.0.into(),
loghe: (-2.0).into(),
logc: (-1.0).into(),
logn: (-1.0).into(),
logo: (-1.0).into(),
};
let input5 = make_input5(&params, "T", "T", "", 100, None);
let lines: Vec<&str> = input5.lines().collect();
// 真实 fort.5 的 ions 数据行(数值部分 + typion/filei)。
// 注:filei 真实用 './data/...'Rust/Python 生成用 'data/...'DCTS 靠 data 软链解析),
// 这是既有独立差异,本测试只校验数值列宽 + typion。
let mut chain = chain_cno();
chain.lte = "T".into();
chain.ltgray = "T".into();
chain.ilvlin = 100;
// 构造仅含 H+He 的 global(替代旧版 metals="" filter
let mut global = TlustyInput::default();
global.dot5.ions = vec![
IonRow {
iat: 1,
iz: 0,
nlevs: 9,
ilast: None,
ilvlin: None,
nonstd: 0,
typion: " H 1".into(),
filei: "data/h1.dat".into(),
nonstd_extra: None,
},
IonRow {
iat: 1,
iz: 1,
nlevs: 1,
ilast: None,
ilvlin: None,
nonstd: 0,
typion: " H 2".into(),
filei: " ".into(),
nonstd_extra: None,
},
IonRow {
iat: 2,
iz: 0,
nlevs: 14,
ilast: None,
ilvlin: None,
nonstd: 0,
typion: "He 1".into(),
filei: "data/he1.dat".into(),
nonstd_extra: None,
},
IonRow {
iat: 2,
iz: 1,
nlevs: 14,
ilast: None,
ilvlin: None,
nonstd: 0,
typion: "He 2".into(),
filei: "data/he2.dat".into(),
nonstd_extra: None,
},
IonRow {
iat: 2,
iz: 2,
nlevs: 1,
ilast: None,
ilvlin: None,
nonstd: 0,
typion: "He 3".into(),
filei: " ".into(),
nonstd_extra: None,
},
];
let input5 = make_input5(&grid(), &chain, &global);
let expected_ions_num: &[&str] = &[
" 1 0 9 0 100 0",
" 1 1 1 1 0 0",
@@ -458,11 +322,9 @@ mod tests {
" 2 2 1 1 0 0",
" 0 0 0 -1 0 0",
];
// 从生成结果中提取 ions 数据行的数值部分(引号前)。
let gen_ions_num: Vec<String> = lines
.iter()
let gen_ions_num: Vec<String> = input5
.lines()
.filter(|l| {
// ions 数据行:含引号且首 token 是整数
l.contains('\'')
&& l.split_whitespace()
.next()
@@ -471,132 +333,103 @@ mod tests {
})
.map(|l| l.split('\'').next().unwrap().trim_end().to_string())
.collect();
assert_eq!(
gen_ions_num.as_slice(),
expected_ions_num,
"ions 行数值部分必须与真实 fort.5 逐字节一致(宽列宽)"
);
assert_eq!(gen_ions_num.as_slice(), expected_ions_num);
}
/// 回归守护:None 配置时 NFREAD=2000、atoms.mode 走默认(H/He=2Li/Be/B=0
/// grid 注入:teff/logg 与丰度正确覆写
#[test]
fn test_make_input5_none_cfg_defaults() {
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let input5 = make_input5(&params, "F", "F", "cno", 100, None);
// NFREAD 默认 2000
assert!(input5.contains("2000"), "None 配置时 NFREAD 应为默认 2000");
// H 的 mode=2(显式)
assert!(input5.contains(" 2 0. 0"), "H 的 mode 应为默认 2");
fn test_grid_injection() {
let input5 = make_input5(&grid(), &chain_cno(), &TlustyInput::default());
assert!(input5.contains("35000.0 5.5"));
// He 丰度 = 10^-1
let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0));
assert!(input5.contains(&he_abn), "He abn 应为 {}", he_abn);
// C 丰度 = 10^-2
let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0));
assert!(input5.contains(&c_abn));
}
/// 用户配置生效:nfread 覆写、atoms.mode 覆写、ions 自定义表
/// chain 注入:lte/ltgray 与 ilvlin 正确覆写
#[test]
fn test_make_input5_user_cfg_override() {
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let mut atoms = std::collections::HashMap::new();
atoms.insert(
"C".to_string(),
crate::config::AtomConfig {
mode: Some(1),
modpf: None,
},
);
let cfg = TlustyInput {
nfread: 500,
atoms,
ions: vec![],
nst_extra_keys: vec![],
};
let input5 = make_input5(&params, "F", "F", "cno", 100, Some(&cfg));
// NFREAD 被覆写为 500
fn test_chain_injection() {
let mut chain = chain_cno();
chain.lte = "T".into();
chain.ltgray = "T".into();
let input5 = make_input5(&grid(), &chain, &TlustyInput::default());
assert!(input5.contains("T T"), "lte/ltgray 应为 T T");
}
/// 字节格式回归:验证 .5 前几行的续行空格与归档样本一致(审查 Major-1)。
/// 旧版用 `\n `(空格续行)保留前导空格,新版不得丢失。归档样本见
/// data/result/.../*.lte.5L2/L3/L6 有前导空格)。
#[test]
fn test_dot5_leading_spaces_byte_compat() {
let mut chain = chain_cno();
chain.lte = "T".into();
chain.ltgray = "T".into();
let input5 = make_input5(&grid(), &chain, &TlustyInput::default());
let lines: Vec<&str> = input5.lines().collect();
// L2: " T T ! LTE, LTGRAY"(前导空格)
assert!(
input5.contains("500") && !input5.contains("2000"),
"NFREAD 应被用户配置覆写为 500"
lines[1].starts_with(' '),
"L2 应有前导空格(旧版续行保留),实际: {:?}",
lines[1]
);
// C 的 mode 被覆写为 1(隐式),而非默认 2
// atoms 行格式 " {mode} {abn} {modpf}"C 的 abn 是 fmt_abn(logc)
let c_line = input5
.lines()
.find(|l| l.contains(&format!("{:.4E}", 10.0f64.powf(-2.0))))
.expect("应找到 C 的 atoms 行");
assert!(lines[1].contains("T T"), "L2 应含 T T");
// L3: " 'nst' ! ..."(前导空格)
assert!(
c_line.trim_start().starts_with("1"),
"C 的 mode 应被覆写为 1(隐式),实际: {}",
c_line
lines[2].starts_with(' '),
"L3 应有前导空格,实际: {:?}",
lines[2]
);
assert!(lines[2].contains("'nst'"), "L3 应含 'nst'");
// L6: NFREAD 行(前导空格)
assert!(
lines[5].starts_with(' '),
"L6 应有前导空格,实际: {:?}",
lines[5]
);
assert!(lines[5].contains("2000"), "L6 应含 NFREAD=2000");
}
/// M1 回归:`atoms` 键大小写不敏感查找。用户写小写/全大写元素键(`c:`/`HE:`)时,
/// 与规范键(`C`/`He`)必须同样命中覆写,否则 mode 覆写会静默回落默认(错误物理)。
/// atoms.mode 覆写:用户自定义 atoms 表的 mode 生效。
#[test]
fn test_atoms_key_case_insensitive() {
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
// 用户用小写 "c" 与全大写 "HE" 覆写 mode。
let mut atoms = std::collections::HashMap::new();
atoms.insert(
"c".to_string(),
crate::config::AtomConfig {
mode: Some(1),
modpf: None,
},
);
atoms.insert(
"HE".to_string(),
crate::config::AtomConfig {
mode: Some(0),
modpf: None,
},
);
let cfg = TlustyInput {
nfread: 2000,
atoms,
ions: vec![],
nst_extra_keys: vec![],
};
let input5 = make_input5(&params, "F", "F", "cno", 100, Some(&cfg));
// C(键 "c" 小写)应命中得到 mode=1,而非默认 2。
fn test_atoms_mode_override() {
let mut global = TlustyInput::default();
// 把 C 的 mode 改为 1(隐式)
for r in &mut global.dot5.atoms {
if r.iat == 6 {
r.mode = 1;
}
}
let input5 = make_input5(&grid(), &chain_cno(), &global);
let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0));
let c_line = input5
.lines()
.find(|l| l.contains(&c_abn))
.expect("应找到 C 的 atoms ");
.expect("应找到 C 行");
assert!(
c_line.trim_start().starts_with("1"),
"小写键 c 应覆写 C 的 mode 为 1,实际: {}",
"C mode 为 1,实际: {}",
c_line
);
// He(键 "HE" 全大写)应命中得到 mode=0,而非默认 2。
let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0));
let he_line = input5
.lines()
.find(|l| l.contains(&he_abn))
.expect("应找到 He 的 atoms 行");
assert!(
he_line.trim_start().starts_with("0"),
"全大写键 HE 应覆写 He 的 mode 为 0,实际: {}",
he_line
);
}
/// NFREAD 可配。
#[test]
fn test_nfread_configurable() {
let mut global = TlustyInput::default();
global.dot5.nfread = 500;
let input5 = make_input5(&grid(), &chain_cno(), &global);
assert!(input5.contains("500") && !input5.contains("2000"));
}
/// finstd 可配(空串=不读 nst)。
#[test]
fn test_finstd_configurable() {
let mut global = TlustyInput::default();
global.dot5.finstd = "".to_string();
let input5 = make_input5(&grid(), &chain_cno(), &global);
assert!(input5.contains("''"), "空 finstd 应渲染为 ''");
}
}
+1
View File
@@ -8,4 +8,5 @@ pub mod models;
pub mod nst_writer;
pub mod result_filter;
pub mod runner;
pub mod summary_merge;
pub mod seed_finder;
+192 -3
View File
@@ -430,6 +430,11 @@ pub struct TaskSpec {
/// 旧 payload 反序列化时缺省为 None(旧节点本就用 default 链,无回归)。
#[serde(default)]
pub tlusty_chain_params: Option<serde_json::Value>,
/// 种子热启动链参数(`config::GridConfig.seed_chain` → `Vec<ChainStep>`)。
/// 仅 seed_step 策略下由 executor 读取并透传给 runner。
/// None/空 → executor 用 `default_seed_chain()` 兜底。
#[serde(default)]
pub seed_chain_params: Option<serde_json::Value>,
/// TLUSTY 输入文件(.5 + nst)的全局物理参数(`config::TlustyInput`)。
/// NFREAD 频率网格、ions 能级表、nst extra_keys 等不随阶段变化的参数。
/// None → runner 用代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量)。
@@ -439,6 +444,31 @@ pub struct TaskSpec {
/// 仅 SYNSPEC-only 场景(TLUSTY 关闭)拉取大气用:显式关联大气网格点名。
#[serde(default)]
pub atmosphere_ref: Option<String>,
/// 能量守恒硬门槛(`(RAD+CON)/TOT` 偏离 1 的容许绝对值)。源自工作流 YAML
/// `energy_tolerance`(默认 0.01);None → 跳过校验。旧 payload 缺省为 None(用默认)。
#[serde(default)]
pub energy_tolerance: Option<f64>,
/// 温度结构:表层 T / Teff 的上限因子(默认 3.0,文档§4 红旗)。
#[serde(default)]
pub temp_max_factor: Option<f64>,
/// 温度结构:全层温度绝对下限(K,默认 10)。
#[serde(default)]
pub temp_floor: Option<f64>,
/// 温度结构:全层温度绝对上限(K,默认 1e8)。
#[serde(default)]
pub temp_ceiling: Option<f64>,
/// emflux bolometric:∫Fλdλ 偏离 σTeff⁴ 的容许绝对值(默认 0.02 = 2%)。
#[serde(default)]
pub emflux_tolerance: Option<f64>,
/// 假收敛排查:收敛轨迹首末 max_relc 比值的最小阈值(默认 1000,<此值可疑)。
#[serde(default)]
pub convergence_min_ratio: Option<f64>,
/// b 因子合理性:极端值上限(默认 1e3)。
#[serde(default)]
pub bfac_max: Option<f64>,
/// b 因子合理性:极端值下限(默认 1e-3)。
#[serde(default)]
pub bfac_min: Option<f64>,
}
/// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、
@@ -464,8 +494,17 @@ impl Default for TaskSpec {
synspec_config: PhaseConfig::default_synspec(),
synspec_params: None,
tlusty_chain_params: None,
seed_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
}
}
}
@@ -585,7 +624,139 @@ pub struct IterCheck {
pub n_depths: usize,
}
/// Convergence stage summary recorded in conv.json
/// `.6` 能量守恒诊断(LFIN 时 OUTPRI 写出的 `(RAD+CON)/TOT` 列偏离度)。
///
/// 物理不变量:每深度 πF(辐射+对流) = σTeff⁴,即 `(RAD+CON)/TOT` 应 ≈1.0。
/// Hubeny 官方判据:偏离 1 ≤1% 为收敛良好(见 docs/spectrum_correctness_analysis.md §1.1)。
/// 任一深度超阈值即判失败(硬门槛),与 `atmosphere_has_nan` 同级。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyCheckResult {
/// 是否通过(max_deviation ≤ tolerance)。
pub valid: bool,
/// 逐深度 `(RAD+CON)/TOT` 偏离 1 的最大绝对值。
pub max_deviation: f64,
/// 偏离最大的深度点编号(1-based,表 ID 列);无法定位时为 None。
#[serde(default)]
pub worst_depth: Option<i32>,
/// 实际校验的深度点行数(应为大气 ND,如 50)。
pub n_depths_checked: usize,
/// 使用的容差阈值。
pub tolerance: f64,
/// 失败原因(valid=false 时有值)。
#[serde(default)]
pub error: Option<String>,
}
/// `.7` 大气温度结构边界诊断(见 docs/spectrum_correctness_analysis.md §3.1/§4)。
///
/// 物理不变量:T(τ) 应落在物理合理范围——表层 T ≲ 2-3×Teff,总体 10¹-10⁸ K。
/// 表层 T >>Teff(如 >3×Teff)或任一深度越界 → T(τ) 结构非物理(红旗)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TempStructCheckResult {
/// 是否通过(所有深度温度在合理范围)。
pub valid: bool,
/// Teff(K),来自网格点参数,用于表层倍数判定。
pub teff: f64,
/// 实测表层(深度 1)温度(K)。
pub surface_temp: f64,
/// 实测最深层(深度 ND)温度(K)。
pub bottom_temp: f64,
/// 表层温度 / Teff 比值(红线:>max_factor 即判失败)。
pub surface_ratio: f64,
/// 实际校验的深度点数。
pub n_depths_checked: usize,
/// 表层温度上限因子(如 3.0 → 表层 T ≤ 3×Teff)。
pub max_factor: f64,
/// 温度绝对下限(K,默认 10)。
pub temp_floor: f64,
/// 温度绝对上限(K,默认 1e8)。
pub temp_ceiling: f64,
/// 失败原因(valid=false 时有值)。
#[serde(default)]
pub error: Option<String>,
}
/// `.emflux` bolometric 通量守恒诊断(见 docs/spectrum_correctness_analysis.md §3.2/§4)。
///
/// 物理不变量:∫Fλ dλ(出射谱全波长积分)≈ σ_SB × Teff⁴(bolometric 通量)。
/// 偏离 >2% → 能量不守恒(红旗)。`.emflux` 全 NaN 也判失败(大气收敛失败导致
/// 辐射转移求解发散,详见 `docs/fort14_nan_analysis.md`)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmfluxCheckResult {
/// 是否通过(积分通量偏离 σTeff⁴ ≤ tolerance 且 NaN 占比可接受)。
pub valid: bool,
/// 梯形积分 ∫Fλ dλ(erg/cm²/s)。
pub integrated_flux: f64,
/// 预期 σ_SB × Teff⁴(erg/cm²/s)。
pub sigma_teff4: f64,
/// integrated_flux / sigma_teff4(应 ≈1.0)。
pub ratio: f64,
/// `.emflux` 中 NaN/不可解析行的占比(0.0-1.0)。
pub nan_ratio: f64,
/// 实际参与积分的有效行数。
pub n_points: usize,
/// 容差(偏离 1.0 的绝对值上限,默认 0.02)。
pub tolerance: f64,
/// 失败原因(valid=false 时有值)。
#[serde(default)]
pub error: Option<String>,
}
/// 收敛轨迹(假收敛排查)诊断(见 docs/spectrum_correctness_analysis.md §2.1/§3.1)。
///
/// 物理不变量:真收敛的 max_relc 轨迹应单调下降 ≥3 个量级(首/末比 ≥10³)。
/// Ng/Kantorovich 加速可人为压低 max_relc 造成"假收敛"——数值达标但统计平衡未达成。
/// 本校验仅在 `converged=true` 的 stage 上做(发散 stage 不需排查假收敛)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvergenceTraceCheckResult {
/// 是否通过(首末比 ≥ min_ratio 或轨迹太短无法判定)。
pub valid: bool,
/// 首次迭代 max_relc。
pub first_max_relc: f64,
/// 末次迭代 max_relc。
pub last_max_relc: f64,
/// 首/末比值(≥10³ 为真收敛特征)。
pub ratio: f64,
/// 轨迹是否单调递减。
pub monotonic: bool,
/// 参与判定的迭代拍数。
pub n_iters: usize,
/// 判定的最小首末比阈值(默认 1000)。
pub min_ratio: f64,
/// 失败原因(valid=false 时有值)。
#[serde(default)]
pub error: Option<String>,
}
/// `.bfac` b 因子合理性诊断(见 docs/spectrum_correctness_analysis.md §3.1/§6 #19)。
///
/// 物理不变量:NLTE 偏离因子 b=n_NLTE/n_LTE)对关键能级应落在 [10⁻³, 10³];
/// b=1.0 表示 LTE。表层(深度 1)的布居可能为 NaN(LTE grey start),属正常。
/// 本校验检查:有效 b 因子不含 Inf、极端值比例不超阈值。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BfacCheckResult {
/// 是否通过(极端值占比 ≤ tolerance)。
pub valid: bool,
/// 实际解析的有效 b 因子值数(排除 NaN/LTE 占位)。
pub n_values: usize,
/// b 因子中 >max_value 或 <min_value 的极端值比例。
pub extreme_ratio: f64,
/// b 因子中的最大值。
pub max_b: f64,
/// b 因子中的最小值(排除 0/NaN)。
pub min_b: f64,
/// NaN 行占比(表层 LTE 占位正常)。
pub nan_ratio: f64,
/// 极端值上限(默认 1e3)。
pub max_value: f64,
/// 极端值下限(默认 1e-3)。
pub min_value: f64,
/// 失败原因(valid=false 时有值)。
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepSummary {
pub label: String,
@@ -609,6 +780,9 @@ pub struct StepSummary {
/// 同源于 runner 的 StepSummary,故完整收敛轨迹随 summary_json 落库,不再仅存磁盘。
#[serde(default)]
pub itek_history: Vec<IterCheck>,
/// 收敛轨迹(假收敛排查)校验结果。仅 converged=true 的 stage 有值。
#[serde(default)]
pub conv_trace_check: Option<ConvergenceTraceCheckResult>,
}
/// Full execution summary for a grid point
@@ -637,6 +811,20 @@ pub struct ModelSummary {
/// 极旧版 conv.json 可能缺此字段,default 0.0 兜底(展示层把 ≤0 视为"无数据")。
#[serde(default)]
pub elapsed_sec: f64,
/// `.6` 能量守恒校验结果(`(RAD+CON)/TOT` 偏离度,硬门槛)。
/// None = 未做校验(TLUSTY 未启用 / `.6` 无能量守恒表 / 配置跳过)。
/// 旧版 conv.json 无此字段,`#[serde(default)]` 反序列化为 None。
#[serde(default)]
pub energy_check: Option<EnergyCheckResult>,
/// `.7` 大气温度结构边界校验结果。None = 未做校验。
#[serde(default)]
pub temp_check: Option<TempStructCheckResult>,
/// `.emflux` bolometric 通量守恒校验结果。None = 未做校验。
#[serde(default)]
pub emflux_check: Option<EmfluxCheckResult>,
/// `.bfac` b 因子合理性校验结果。None = 未做校验。
#[serde(default)]
pub bfac_check: Option<BfacCheckResult>,
pub note: Option<String>,
}
@@ -674,6 +862,7 @@ mod tests {
max_relc: 0.5,
n_depths: 50,
}],
conv_trace_check: None,
};
let round: StepSummary =
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
@@ -683,7 +872,7 @@ mod tests {
}
/// 旧版 Python run_one.py 写出的 conv.json 必须能完整解析为 ModelSummary
/// (历史结果迁移链路 import_results → /api/admin/import_seed 的兼容性命门)。
/// (历史结果迁移链路的兼容性命门:旧 conv.json 用 `converged` 键)。
///
/// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套
/// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。
@@ -743,7 +932,7 @@ mod tests {
assert_eq!(s.stages[2].best_max_relc, Some(0.0069));
assert_eq!(s.stages[2].elapsed_sec, 650.2);
// 旧版把迭代诊断嵌在 final/itek_attempts 里(非扁平字段)→ 扁平字段为 None,
// 但原始诊断仍随 conv.json 原文落盘import_seed 存原始 summary_json,无数据丢失。
// 但原始诊断仍随 conv.json 原文落盘,无数据丢失。
assert_eq!(s.stages[2].last_iter, None);
assert_eq!(s.stages[2].worst_depth, None);
}
+248 -128
View File
@@ -1,174 +1,294 @@
//! TLUSTY nst(非标准标志)文件生成器。
//!
//! [`generate_nst_content`] 分层覆盖:ChainStep(阶段级)> `TlustyInput.nst`(全局结构化)
//! > `nst_extra_keys`(逃逸口,后写覆盖前写,warn 冲突)。
//!
//! # 向后兼容
//! 默认配置生成的 nst **前 2 行**与旧实现字节级一致(line1 含
//! ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 + ChainStepline2 含 IELCOR + ChainStep)。
//! line3 显式写出 26 个 TLUSTY 默认关键字(NMU/ISPLIN/CNU2 等)——旧实现不写这些
//! (走 TLUSTY 内置默认),新版显式写出使配置透明化。对 sdB 工作流物理等价
//! (TLUSTY 解析后参数值与旧实现一致)。频率截断 FRCMAX/MIN/FRLMAX/MIN 仅在用户
//! 配非零值时才写出(0=TLUSTY 默认,不显式覆盖,避免 FRLMIN=0 偏差)。
use crate::config::{ChainStep, TlustyInput};
use std::collections::HashSet;
/// 生成 TLUSTY nst(非标准标志)文件内容。
/// 把 f64 格式化为 TLUSTY nst 值字符串。整数值带小数点(如 2.0 → "2."),
/// 与旧实现硬编码 `"VTB=2."` 字节一致;非整数原样输出(如 0.001 → "0.001")。
/// -0.0 归一为 "0."(避免 "-0.")。
fn fmt_real(v: f64) -> String {
if v == 0.0 {
"0.".to_string()
} else if v.fract() == 0.0 {
format!("{}.", v)
} else {
v.to_string()
}
}
/// 生成 nst 文件内容。
///
/// nst 文件由 `KEY=VALUE` 对组成,TLUSTY 的 NSTPAR 子程序逐行解析(无行数限制)。
/// 内容来源(优先级从高到低):
/// 1. `input_cfg.nst_extra_keys`:用户自由传入的任意 KEY=VALUE(逃逸口),追加到末尾
/// 2. `stage`ChainStep):CHMAX/ITEK/NITER/ORELAX/IDLTE/IACC/ICHANG 等阶段差异参数
/// 3. 硬编码默认:ND/NLAMBD/VTB/ISPODF/DDNU/CNU1/IELCORinput_cfg 无对应字段时)。
///
/// `input_cfg` 为 None 时走全默认(与改动前行为完全一致,向后兼容)。
pub fn generate_nst_content(stage: &ChainStep, input_cfg: Option<&TlustyInput>) -> String {
// 第 1 行:深度点数/角度数/湍速/ODF 等物理网格参数 + 收敛控制。
// ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 当前无结构化字段,保留硬编码(如需覆写用 extra_keys)。
let mut line1_parts = vec![
"ND=50".to_string(),
"NLAMBD=3".to_string(),
"VTB=2.".to_string(),
"ISPODF=1".to_string(),
"DDNU=50.".to_string(),
"CNU1=6.".to_string(),
/// 分层覆盖(优先级从高到低):
/// 1. **ChainStep**(阶段级):NITER/CHMAX/ITEK/ORELAX/IDLTE/IACC/ICHANG——若 Some 则写出。
/// 2. **`global.nst`**(全局结构化):~34 个高频关键字,Default 为 sdB 调优值
/// 3. **`global.nst_extra_keys`**(逃逸口):追加末尾,若与已写 key 冲突则 warn
pub fn generate_nst_content(chain: &ChainStep, global: &TlustyInput) -> String {
let n = &global.nst;
let mut written_keys: HashSet<String> = HashSet::new();
let mut out = String::new();
// ── 第 1 行:深度/频率/物理网格参数 + 收敛控制(与旧实现字节一致)──
let mut line1: Vec<String> = vec![
format!("ND={}", n.depth.nd),
format!("NLAMBD={}", n.depth.nlambd),
format!("VTB={}", fmt_real(n.physics.vtb)),
format!("ISPODF={}", n.frequency.ispodf),
format!("DDNU={}", fmt_real(n.frequency.ddnu)),
format!("CNU1={}", fmt_real(n.frequency.cnu1)),
];
for k in &line1 {
written_keys.insert(k.split('=').next().unwrap().to_string());
}
// ChainStep 阶段级覆盖
if let Some(chmax) = chain.chmax {
line1.push(format!("CHMAX={}", chmax));
written_keys.insert("CHMAX".to_string());
}
if let Some(itek) = chain.itek {
line1.push(format!("ITEK={}", itek));
written_keys.insert("ITEK".to_string());
}
line1.push(format!("NITER={}", chain.niter));
written_keys.insert("NITER".to_string());
out.push_str(&line1.join(","));
out.push('\n');
if let Some(chmax) = stage.chmax {
line1_parts.push(format!("CHMAX={}", chmax));
// ── 第 2 行:加速/收敛控制开关 + IELCOR(恒输出,与旧实现一致)──
let mut line2: Vec<String> = Vec::new();
if let Some(orelax) = chain.orelax {
line2.push(format!("ORELAX={}", orelax));
written_keys.insert("ORELAX".to_string());
}
if let Some(itek) = stage.itek {
line1_parts.push(format!("ITEK={}", itek));
if let Some(idlte) = chain.idlte {
line2.push(format!("IDLTE={}", idlte));
written_keys.insert("IDLTE".to_string());
}
line1_parts.push(format!("NITER={}", stage.niter));
if let Some(iacc) = chain.iacc {
line2.push(format!("IACC={}", iacc));
written_keys.insert("IACC".to_string());
}
if let Some(ichang) = chain.ichang {
line2.push(format!("ICHANG={}", ichang));
written_keys.insert("ICHANG".to_string());
}
line2.push(format!("IELCOR={}", n.physics.ielcor));
written_keys.insert("IELCOR".to_string());
out.push_str(&line2.join(","));
out.push('\n');
// 第 2:加速/收敛控制开关。
let mut line2_parts = Vec::new();
if let Some(orelax) = stage.orelax {
line2_parts.push(format!("ORELAX={}", orelax));
// ──3起:global.nst 高频关键字(非默认 ChainStep 覆盖的)──
// 所有值统一格式化为 String,避免 i32/f64 混合数组的类型问题。浮点用 fmt_real 带小数点。
let entries: Vec<(&str, String)> = vec![
("NMU", n.depth.nmu.to_string()),
("ISPLIN", n.depth.isplin.to_string()),
("CNU2", fmt_real(n.frequency.cnu2)),
("NFTAIL", n.frequency.nftail.to_string()),
("DFTAIL", fmt_real(n.frequency.dftail)),
];
write_nst_group(&mut out, &mut written_keys, &entries);
// 频率截断 4 字段:仅当用户显式配非零值时才写出(0=TLUSTY 内置默认,不显式覆盖)。
// 这避免 FRLMIN=0 偏离 TLUSTY 默认(tlusty208.f:1933 frlmin<1e6→×1e13,但 0×1e13=0 无兜底)。
let freq_cutoffs: Vec<(&str, String)> = [
("FRCMAX", &n.frequency.frcmax),
("FRCMIN", &n.frequency.frcmin),
("FRLMAX", &n.frequency.frlmax),
("FRLMIN", &n.frequency.frlmin),
]
.iter()
.filter(|(_, v)| **v != 0.0)
.map(|(k, v)| (*k, fmt_real(**v)))
.collect();
if !freq_cutoffs.is_empty() {
write_nst_group(&mut out, &mut written_keys, &freq_cutoffs);
}
if let Some(idlte) = stage.idlte {
line2_parts.push(format!("IDLTE={}", idlte));
}
if let Some(iacc) = stage.iacc {
line2_parts.push(format!("IACC={}", iacc));
}
if let Some(ichang) = stage.ichang {
line2_parts.push(format!("ICHANG={}", ichang));
}
line2_parts.push("IELCOR=-1".to_string());
let entries2: Vec<(&str, String)> = vec![
("HMIX0", fmt_real(n.physics.hmix0)),
("MLTYPE", n.physics.mltype.to_string()),
("IPTURB", n.physics.ipturb.to_string()),
("IBFINT", n.physics.ibfint.to_string()),
("IHECOR", n.physics.ihecor.to_string()),
("IRTE", n.radiative_transfer.irte.to_string()),
("IBC", n.radiative_transfer.ibc.to_string()),
("IFRYB", n.radiative_transfer.ifryb.to_string()),
("IOPTAB", n.radiative_transfer.ioptab.to_string()),
("IFMOL", n.radiative_transfer.ifmol.to_string()),
("IFALI", n.nlte.ifali.to_string()),
("IFPOPR", n.nlte.ifpopr.to_string()),
("JALI", n.nlte.jali.to_string()),
("TRAD", fmt_real(n.irradiation.trad)),
("WDIL", fmt_real(n.irradiation.wdil)),
("TFLOOR", fmt_real(n.temperature.tfloor)),
("TDISK", fmt_real(n.temperature.tdisk)),
("TMOLIM", fmt_real(n.temperature.tmolim)),
];
write_nst_group(&mut out, &mut written_keys, &entries2);
let mut out = format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(","));
// 第 3 行起:用户自由传入的额外 nst 关键字(逃逸口)。
// 每行一个 KEY=VALUE,追加到末尾。用于暴露未结构化的 220+ nst 关键字
// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。
// 多行格式经 TLUSTY 源码验证(tlusty208.f:1819 NSTPAR 用 `READ(INPFI,500,END=70)`
// + `GO TO 10` 逐行循环读至 EOF,每行用 GETWRD 解析 KEY=VALUE),追加行可被正确解析。
// 安全校验:key/value 含逗号/换行/等号会破坏 nst 的逗号分隔或 KEY=VALUE 解析,
// 跳过非法项并记 warn(避免生成损坏的 nst 导致 tlusty 行为异常)。
// L1 修复:**key 与 value 都校验**——key 含 `=`/`,`/空白/换行会产出畸形 `KEY=VALUE`
// 行,TLUSTY 的 GETWRD 解析器可能误读。key 必须是合法标识符(非空、无上述分隔符)。
if let Some(cfg) = input_cfg {
for (key, value) in &cfg.nst_extra_keys {
let bad_key = key.is_empty()
|| key.contains(',')
|| key.contains('\n')
|| key.contains('=')
|| key.chars().any(char::is_whitespace);
let bad_value = value.contains(',') || value.contains('\n') || value.contains('=');
if bad_key || bad_value {
tracing::warn!(
"跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析",
key,
value
);
continue;
}
out.push_str(&format!("{}={}\n", key, value));
// ── 末尾:nst_extra_keys 逃逸口(后写覆盖,warn 冲突)──
for (key, value) in &global.nst_extra_keys {
let bad_key = key.is_empty()
|| key.contains(',')
|| key.contains('\n')
|| key.contains('=')
|| key.chars().any(char::is_whitespace);
let bad_value = value.contains(',') || value.contains('\n') || value.contains('=');
if bad_key || bad_value {
tracing::warn!(
"跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析",
key,
value
);
continue;
}
if written_keys.contains(key) {
tracing::warn!(
"nst_extra_keys {:?}={} 覆盖了已结构化的关键字(后写覆盖前写)",
key,
value
);
}
out.push_str(&format!("{}={}\n", key, value));
}
out
}
/// 写出一组 nst 关键字(同一行逗号分隔),跳过已写的 key。
/// 用 HashSet::insert 返回值做单次遍历去重,避免 entries 内同键重复时两次都写出。
fn write_nst_group(out: &mut String, written: &mut HashSet<String>, entries: &[(&str, String)]) {
let parts: Vec<String> = entries
.iter()
.filter(|(k, _)| written.insert(k.to_string()))
.map(|(k, v)| format!("{}={}", k, v))
.collect();
if !parts.is_empty() {
out.push_str(&parts.join(","));
out.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nst_generation() {
let stage = ChainStep {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
fn chain_nc() -> ChainStep {
ChainStep {
label: "nc".into(),
lte: "F".into(),
ltgray: "F".into(),
ilvlin: 0,
require_converged: false,
niter: 10,
chmax: None,
itek: None,
metals: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
};
let content = generate_nst_content(&stage, None);
assert!(content.contains("ND=50"));
assert!(content.contains("NITER=10"));
assert!(content.contains("IELCOR=-1"));
}
}
/// extra_keys 追加到 nst 末尾(每行一个 KEY=VALUE)。
#[test]
fn test_nst_extra_keys() {
let stage = ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: Some(0.001),
itek: None,
metals: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
};
let cfg = TlustyInput {
nfread: 2000,
atoms: Default::default(),
ions: vec![],
fn test_nst_default_values() {
let content = generate_nst_content(&chain_nc(), &TlustyInput::default());
assert!(content.contains("ND=50"));
assert!(content.contains("NLAMBD=3"));
// 浮点值带小数点(与旧实现字节一致)
assert!(content.contains("VTB=2."), "VTB 应带小数点");
assert!(content.contains("ISPODF=1"));
assert!(content.contains("DDNU=50."), "DDNU 应带小数点");
assert!(content.contains("CNU1=6."), "CNU1 应带小数点");
assert!(content.contains("IELCOR=-1"));
assert!(content.contains("NITER=10"));
// line3 高频关键字(entries2 修复后应出现)
assert!(content.contains("HMIX0=-1."), "HMIX0 应在 line3");
assert!(content.contains("IBFINT=1"), "IBFINT 应在 line3");
assert!(content.contains("TFLOOR=8000."), "TFLOOR 应在 line3");
assert!(content.contains("NMU=3"), "NMU 应在 line3");
}
/// ChainStep 阶段级覆盖 nst 全局字段。
#[test]
fn test_chain_overrides() {
let mut chain = chain_nc();
chain.niter = 100;
chain.chmax = Some(0.001);
chain.itek = Some(4);
let content = generate_nst_content(&chain, &TlustyInput::default());
assert!(content.contains("NITER=100"));
assert!(content.contains("CHMAX=0.001"));
assert!(content.contains("ITEK=4"));
}
/// nst_extra_keys 追加 + 冲突 warn。
#[test]
fn test_extra_keys_append() {
let global = TlustyInput {
nst_extra_keys: vec![
("FRCMAX".to_string(), "0.01".to_string()),
("CUTBAL".to_string(), "0.3".to_string()),
],
..Default::default()
};
let content = generate_nst_content(&stage, Some(&cfg));
assert!(content.contains("FRCMAX=0.01"), "extra_keys 应追加到 nst");
let content = generate_nst_content(&chain_nc(), &global);
assert!(content.contains("FRCMAX=0.01"));
assert!(content.contains("CUTBAL=0.3"));
// 原有内容仍存在
assert!(content.contains("NITER=100"));
assert!(content.contains("CHMAX=0.001"));
}
/// None 配置时与改动前行为一致(无 extra_keys 行)
/// 非法 extra_keys 被跳过
#[test]
fn test_nst_none_cfg_backward_compat() {
let stage = ChainStep {
label: "lte".to_string(),
lte: "T".to_string(),
ltgray: "T".to_string(),
ilvlin: 0,
require_converged: false,
niter: 0,
chmax: None,
itek: None,
metals: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
fn test_extra_keys_validation() {
let global = TlustyInput {
nst_extra_keys: vec![
("BAD KEY".to_string(), "1".to_string()),
("OK".to_string(), "1".to_string()),
],
..Default::default()
};
let content = generate_nst_content(&stage, None);
// None 配置时只有 2 行(第1行 ND/NITER 等 + 第2行 IELCOR 等),无 extra_keys 追加行。
let non_empty_lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
let content = generate_nst_content(&chain_nc(), &global);
assert!(!content.contains("BAD KEY"));
assert!(content.contains("OK=1"));
}
/// 向后兼容:默认配置生成的 nst 前 2 行与旧实现字节级一致。
/// 旧实现 line1 = "ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=10"
/// 旧实现 line2 = "IELCOR=-1"
/// line3 显式写出 26 个 TLUSTY 默认关键字(物理等价)。
/// 频率截断 FRCMAX/MIN/FRLMAX/MIN 不在默认输出中(0=不写出,走 TLUSTY 默认)。
#[test]
fn test_default_byte_compat_first_two_lines() {
let content = generate_nst_content(&chain_nc(), &TlustyInput::default());
let lines: Vec<&str> = content.lines().collect();
assert!(lines.len() >= 2, "至少 2 行");
assert_eq!(
non_empty_lines.len(),
2,
"None 配置时 nst 应只有 2 行,实际 {} 行: {:?}",
non_empty_lines.len(),
non_empty_lines
lines[0], "ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=10",
"line1 应与旧实现字节一致"
);
assert!(!content.contains("FRCMAX"));
assert_eq!(lines[1], "IELCOR=-1", "line2 应与旧实现字节一致");
// 频率截断 4 字段默认不写出(值为 0)
assert!(!content.contains("FRCMAX"), "FRCMAX=0 不应写出");
assert!(!content.contains("FRCMIN"), "FRCMIN=0 不应写出");
assert!(!content.contains("FRLMAX"), "FRLMAX=0 不应写出");
assert!(!content.contains("FRLMIN"), "FRLMIN=0 不应写出");
}
/// fmt_real 边界:-0.0 归一为 "0."(审查 Nit-1)。
#[test]
fn test_fmt_real_negative_zero() {
assert_eq!(fmt_real(0.0), "0.");
assert_eq!(fmt_real(-0.0), "0.", "-0.0 应归一为 0.");
assert_eq!(fmt_real(2.0), "2.");
assert_eq!(fmt_real(50.0), "50.");
assert_eq!(fmt_real(0.25), "0.25");
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
//! 归档白名单:决定沙盒里的哪些文件值得进入持久归档目录(`data/result/<name>/`)。
//!
//! node 端实时结果归档(`executor::save_result_artifacts`与离线迁移工具(`import_results`
//! 共用本模块的 [`is_result_worthy`],确保两条路径的结果归档口径一致、不会随时间漂移
//! node 端实时结果归档(`executor::save_result_artifacts`共用本模块的
//! [`is_result_worthy`],确保结果归档口径稳定
//!
//! ## 设计原则
//!
+174 -28
View File
@@ -1,5 +1,9 @@
use crate::config::{ChainStep, SynspecInput, TlustyInput};
use crate::conv_check::{atmosphere_has_nan, check_fort9, extract_failure_hint, spec_is_valid};
use crate::conv_check::{
atmosphere_has_nan, check_bfactor, check_convergence_trace, check_emflux_bolometric,
check_energy_conservation, check_fort9, check_temperature_structure, extract_failure_hint,
spec_is_valid,
};
use crate::embedded::RuntimePaths;
use crate::fort55_writer::generate_fort55_content;
use crate::gen_input5::make_input5;
@@ -24,7 +28,6 @@ pub fn default_cold_chain() -> Vec<ChainStep> {
niter: 0,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
@@ -39,7 +42,6 @@ pub fn default_cold_chain() -> Vec<ChainStep> {
niter: 10,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
@@ -54,7 +56,6 @@ pub fn default_cold_chain() -> Vec<ChainStep> {
niter: 100,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
@@ -74,11 +75,10 @@ pub fn default_seed_chain() -> Vec<ChainStep> {
niter: 20,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: Some(0),
idlte: None,
iacc: None,
orelax: None,
orelax: Some(0.3),
},
ChainStep {
label: "nl".to_string(),
@@ -89,11 +89,10 @@ pub fn default_seed_chain() -> Vec<ChainStep> {
niter: 100,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: Some(0),
idlte: None,
iacc: None,
orelax: None,
orelax: Some(0.5),
},
]
}
@@ -222,6 +221,14 @@ impl<'a> ExecutionRunner<'a> {
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecInput>,
tlusty_input: Option<&TlustyInput>,
energy_tolerance: Option<f64>,
temp_max_factor: Option<f64>,
temp_floor: Option<f64>,
temp_ceiling: Option<f64>,
emflux_tolerance: Option<f64>,
convergence_min_ratio: Option<f64>,
bfac_max: Option<f64>,
bfac_min: Option<f64>,
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
@@ -235,6 +242,14 @@ impl<'a> ExecutionRunner<'a> {
7200,
None,
tlusty_input,
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
)
.await
}
@@ -262,6 +277,14 @@ impl<'a> ExecutionRunner<'a> {
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
tlusty_input: Option<&TlustyInput>,
energy_tolerance: Option<f64>,
temp_max_factor: Option<f64>,
temp_floor: Option<f64>,
temp_ceiling: Option<f64>,
emflux_tolerance: Option<f64>,
convergence_min_ratio: Option<f64>,
bfac_max: Option<f64>,
bfac_min: Option<f64>,
) -> Result<ModelSummary> {
// `name` 取自权威的 TaskSpec.point_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
@@ -335,26 +358,22 @@ impl<'a> ExecutionRunner<'a> {
}
let tlusty_skipped = !tlusty_enabled;
// tlusty_input 为 NoneYAML 未配 tlusty_input 块)时用默认值。
// 默认值定义集中在 config.rs 的 TlustyInput::default(),此处不再重复维护。
let default_input = TlustyInput::default();
let input_cfg: &TlustyInput = tlusty_input.unwrap_or(&default_input);
for stage_def in &chain {
if tlusty_skipped {
break;
}
let stage_t0 = Instant::now();
let metals = stage_def.metals.as_deref().unwrap_or("cno");
let input5_text = make_input5(
params,
&stage_def.lte,
&stage_def.ltgray,
metals,
stage_def.ilvlin,
tlusty_input,
);
let input5_text = make_input5(params, stage_def, input_cfg);
let input5_path = model_dir.join(format!("{}.5", name));
tokio::fs::write(&input5_path, &input5_text).await?;
// Write nst file
let nst_text = generate_nst_content(stage_def, tlusty_input);
let nst_text = generate_nst_content(stage_def, input_cfg);
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
// Prepare fort.8 for this stage
@@ -417,6 +436,7 @@ impl<'a> ExecutionRunner<'a> {
worst_depth: None,
n_depths: None,
itek_history: Vec::new(),
conv_trace_check: None,
};
if rc == 0 && fort7.is_file() {
@@ -431,7 +451,26 @@ impl<'a> ExecutionRunner<'a> {
stage_summary.worst_depth = Some(res.worst_depth);
stage_summary.n_depths = Some(res.n_depths);
// itek 全量保真(Phase 5b):逐次迭代诊断随 summary_json/conv.json 落库。
stage_summary.itek_history = res.itek_history;
stage_summary.itek_history = res.itek_history.clone();
// 假收敛排查(§2.1/§3.1):仅对 converged=true 的 stage 做。
// Ng/Kantorovich 加速可压低 max_relc 造成数值达标但平衡未达成。
if res.converged {
if let Some(min_ratio) = convergence_min_ratio {
if let Some(tc) =
check_convergence_trace(&res.itek_history, min_ratio)
{
if !tc.valid {
stage_summary.converged = false;
stage_summary.note = Some(format!(
"未收敛 [{}]",
tc.error.as_deref().unwrap_or("假收敛排查失败")
));
}
stage_summary.conv_trace_check = Some(tc);
}
}
}
// 漏洞5修复:发散时从 fort.6 提取求解器 STOP 行(SOLVE/SOLVES/RYBSOL
// 作为 note,提升归因质量。仅未收敛且无既有 note 时补(避免覆盖错误信息)。
@@ -560,6 +599,87 @@ impl<'a> ExecutionRunner<'a> {
final_converged = false;
}
// 能量守恒硬门槛(docs/spectrum_correctness_analysis.md §1.1 第一层 #4):
// 解析 `.6`(收敛链最后阶段 stdout,含 LFIN 时 OUTPRI 写出的能量守恒表)的
// `(RAD+CON)/TOT` 列。任一深度偏离 1 超阈值即判失败(与 atmosphere_has_nan 同级)。
// 此时 `<name>.6` 仍存在(行 688 的冗余清理在此之后),内容是最后阶段日志。
// energy_tolerance=None → 跳过(非常规配置);`.6` 无能量守恒表 → 函数返回 None 跳过。
let energy_check = if tlusty_enabled {
energy_tolerance.and_then(|tol| {
let fort6 = model_dir.join(format!("{}.6", name));
check_energy_conservation(&fort6, tol)
})
} else {
None
};
let mut energy_failed = false;
if let Some(ref ec) = energy_check {
if !ec.valid {
final_converged = false;
energy_failed = true;
}
}
// 温度结构边界校验(docs/spectrum_correctness_analysis.md §3.1/§4):
// 解析 `.7`(最终大气)逐深度温度 T,表层 >max_factor×Teff 或全层越界判失败。
// 读 final_7(此时已就位);tlusty_enabled=false 时 final_7 来自外部种子,跳过。
let teff = params.teff.value();
let temp_check = if tlusty_enabled {
temp_max_factor.and_then(|mf| {
let floor = temp_floor.unwrap_or(10.0);
let ceiling = temp_ceiling.unwrap_or(1.0e8);
check_temperature_structure(&final_7, teff, mf, floor, ceiling)
})
} else {
None
};
let mut temp_failed = false;
if let Some(ref tc) = temp_check {
if !tc.valid {
final_converged = false;
temp_failed = true;
}
}
// emflux bolometric 通量守恒校验(§3.2/§4):
// 解析 `.emflux`snapshot_tlusty_outputs 已快照),梯形积分 ∫Fλdλ 比 σTeff⁴。
// 全 NaN(辐射转移失败)也判失败。文件缺失 → 跳过(非常规配置)。
let emflux_check = if tlusty_enabled {
emflux_tolerance.and_then(|tol| {
let emflux_path = model_dir.join(format!("{}.emflux", name));
check_emflux_bolometric(&emflux_path, teff, tol)
})
} else {
None
};
let mut emflux_failed = false;
if let Some(ref ec) = emflux_check {
if !ec.valid {
final_converged = false;
emflux_failed = true;
}
}
// b 因子合理性校验(§3.1/§6 #19):解析 `.bfac`snapshot_tlusty_outputs 已快照),
// 检查 NLTE 偏离因子 b 无极端值(>1e3 或 <1e-3 占比 >10% → 失败)。
// 文件缺失/无有效 b 因子 → 跳过(纯 LTE 模型或 grey start)。
let bfac_check = if tlusty_enabled {
bfac_max.and_then(|mx| {
let mn = bfac_min.unwrap_or(1.0e-3);
let bfac_path = model_dir.join(format!("{}.bfac", name));
check_bfactor(&bfac_path, mx, mn)
})
} else {
None
};
let mut bfac_failed = false;
if let Some(ref bc) = bfac_check {
if !bc.valid {
final_converged = false;
bfac_failed = true;
}
}
// Run synspec if enabled and final .7 atmosphere exists
let mut synspec_rc = None;
let mut synspec_err = None;
@@ -588,15 +708,9 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::remove_file(&fort55_path).await;
let _ = tokio::fs::remove_file(&fort19_path).await;
let default_cfg = SynspecInput {
wstart: 1400.0,
wend: 1410.0,
imode: 0,
idrv: 50,
ifreq: 1,
rel_cutoff: 0.0001,
abs_cutoff: 0.01,
};
// synspec_cfg 为 None(工作流未配 synspec_input 块)时用默认值。
// 默认值定义集中在 config.rs 的 SynspecInput::default(),此处不再重复维护。
let default_cfg = SynspecInput::default();
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await {
warn!(
@@ -725,6 +839,34 @@ impl<'a> ExecutionRunner<'a> {
if atmo_has_nan {
notes.push("Invalidated: atmosphere contains NaN/Inf lines".to_string());
}
if energy_failed {
if let Some(ref ec) = energy_check {
if let Some(ref err) = ec.error {
notes.push(err.clone());
}
}
}
if temp_failed {
if let Some(ref tc) = temp_check {
if let Some(ref err) = tc.error {
notes.push(err.clone());
}
}
}
if emflux_failed {
if let Some(ref ec) = emflux_check {
if let Some(ref err) = ec.error {
notes.push(err.clone());
}
}
}
if bfac_failed {
if let Some(ref bc) = bfac_check {
if let Some(ref err) = bc.error {
notes.push(err.clone());
}
}
}
if let Some(ref err) = synspec_err {
notes.push(format!("synspec error: {}", err));
} else if let Some(rc) = synspec_rc {
@@ -752,6 +894,10 @@ impl<'a> ExecutionRunner<'a> {
synspec_error: synspec_err,
synspec_sec,
elapsed_sec,
energy_check,
temp_check,
emflux_check,
bfac_check,
note,
};
+270
View File
@@ -0,0 +1,270 @@
//! 点级 [`ModelSummary`] 的增量合并。
//!
//! # 背景
//! 一个网格点可能经历多次执行:先是完整的 TLUSTY 收敛链(产出 `stages` 等大气诊断),
//! 之后又跑了仅 SYNSPEC 的重算(`stages` 为空,仅刷新 `synspec_rc`/`synspec_sec` 等)。
//! 直接用后者的 summary 覆盖前者会丢失 TLUSTY 诊断——本模块按"stages 是否为空"判定
//! 本次执行是否含 TLUSTY 数据,据此选择整体替换或字段级合并。
//!
//! 合并函数供两条路径复用,保证逻辑一致:
//! - server 端 `record_task_report`(在线 report 时合并写入 grid_points.summary_json
//! - 离线工具 `import_db`(扫 salvage 多节点 conv.json 时逐份合并)
//!
//! # 合并规则
//! - incoming.stages 非空 且 incoming.synspec_rc 非空 → 整体替换(正常管线,TLUSTY+SYNSPEC 都跑了)。
//! - incoming.stages 非空 但 incoming.synspec_rc 为空 → TLUSTY-only 重跑:以 incoming 为基底
//! (含新 TLUSTY 诊断),保留 prior 的 synspec 字段(synspec_rc/error/sec)。新大气使旧光谱
//! 失效,但 prior 的 synspec 数据仍有参考价值(记录了上次的光谱质量),且 DB 层已通过
//! clear_synspec 清空了 synspec_success_method/synspec_status 列。
//! - incoming.stages 为空 → synspec-only 重跑:以 prior 为基底,只覆盖 SYNSPEC/运行态字段,
//! 保留 prior 的 TLUSTY 诊断(stages / final_max_relc / final_chmax / seed / 各 *_check)。
//! - prior 为 None → 直接返回 incoming(首次写入,无历史可保留)。
use crate::models::ModelSummary;
/// 合并点级 [`ModelSummary`]。
///
/// 详见[模块文档][self]的规则说明。
pub fn merge_point_summary(prior: Option<&ModelSummary>, incoming: &ModelSummary) -> ModelSummary {
// 无历史数据 → 直接返回 incoming。
let Some(prior) = prior else {
return incoming.clone();
};
if !incoming.stages.is_empty() {
// TLUSTY 链在本次执行中跑了。
if incoming.synspec_rc.is_some() {
// SYNSPEC 也跑了 → 正常管线,incoming 是最新完整摘要 → 整体替换。
return incoming.clone();
}
// TLUSTY-only 重跑(synspec 禁用):以 incoming 为基底(新 TLUSTY 诊断 + 新运行态字段),
// 保留 prior 的 synspec 字段(上次光谱的 rc/error/sec)。
let mut merged = incoming.clone();
merged.synspec_rc = prior.synspec_rc;
merged.synspec_error = prior.synspec_error.clone();
merged.synspec_sec = prior.synspec_sec;
return merged;
}
// synspec-only 重跑:以 prior 为基底,只覆盖 SYNSPEC / 运行态字段。
let mut merged = prior.clone();
merged.synspec_rc = incoming.synspec_rc;
merged.synspec_error = incoming.synspec_error.clone();
merged.synspec_sec = incoming.synspec_sec;
merged.atmosphere_has_nan = incoming.atmosphere_has_nan;
merged.elapsed_sec = incoming.elapsed_sec;
merged.result_valid = incoming.result_valid;
merged.note = incoming.note.clone();
merged
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{GridAxisValue, GridPointParams, IterCheck, StepSummary};
fn mk_params() -> GridPointParams {
GridPointParams {
teff: GridAxisValue::from_value(25000.0),
logg: GridAxisValue::from_value(5.0),
loghe: GridAxisValue::from_value(2.0),
logc: GridAxisValue::from_value(-2.0),
logn: GridAxisValue::from_value(-2.0),
logo: GridAxisValue::from_value(-2.0),
}
}
/// 构造一个含完整 TLUSTY 链的 summarystages 非空 + 各校验字段)。
fn mk_tlusty_summary() -> ModelSummary {
ModelSummary {
name: "t25000_g5.0_he2_c-2_n-2_o-2".into(),
params: mk_params(),
stages: vec![StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.000756),
elapsed_sec: 479.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![IterCheck {
iter: 1,
max_relc: 0.5,
n_depths: 50,
}],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.000756),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.24),
elapsed_sec: 479.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
}
}
/// 构造一个 synspec-only summarystages 为空)。
fn mk_synspec_only_summary() -> ModelSummary {
let mut s = mk_tlusty_summary();
s.stages = Vec::new();
s.final_max_relc = None;
s.final_chmax = None;
// 模拟 synspec 重跑的结果
s.synspec_rc = Some(1);
s.synspec_error = Some("spec 含 NaN".into());
s.synspec_sec = Some(0.5);
s.atmosphere_has_nan = true;
s.elapsed_sec = 0.5;
s.result_valid = false;
s.note = Some("synspec 失败".into());
s
}
#[test]
fn prior_none_returns_incoming() {
let incoming = mk_tlusty_summary();
let merged = merge_point_summary(None, &incoming);
assert_eq!(merged.name, incoming.name);
assert_eq!(merged.stages.len(), 1);
assert_eq!(merged.final_max_relc, Some(0.000756));
}
#[test]
fn incoming_stages_nonempty_replaces_wholesale() {
let prior = mk_tlusty_summary();
let mut incoming = mk_tlusty_summary();
incoming.final_max_relc = Some(0.0001);
incoming.stages[0].best_max_relc = Some(0.0001);
incoming.stages[0].label = "seed_nc".into();
let merged = merge_point_summary(Some(&prior), &incoming);
// 整体替换:incoming 的值胜出
assert_eq!(merged.final_max_relc, Some(0.0001));
assert_eq!(merged.stages[0].label, "seed_nc");
}
#[test]
fn synspec_only_preserves_tlusty_fields_overrides_synspec() {
let prior = mk_tlusty_summary();
let incoming = mk_synspec_only_summary();
let merged = merge_point_summary(Some(&prior), &incoming);
// TLUSTY 字段保留自 prior
assert_eq!(merged.stages.len(), 1, "stages 应保留 prior 的 TLUSTY 链");
assert_eq!(merged.stages[0].label, "nl");
assert_eq!(merged.final_max_relc, Some(0.000756));
assert_eq!(merged.final_chmax, Some(0.001));
// synspec/运行态字段用 incoming 覆盖
assert_eq!(merged.synspec_rc, Some(1));
assert_eq!(merged.synspec_error.as_deref(), Some("spec 含 NaN"));
assert_eq!(merged.synspec_sec, Some(0.5));
assert!(merged.atmosphere_has_nan);
assert_eq!(merged.elapsed_sec, 0.5);
assert!(!merged.result_valid);
assert_eq!(merged.note.as_deref(), Some("synspec 失败"));
}
#[test]
fn synspec_only_with_no_prior_keeps_empty_stages() {
// prior 为 None 且 incoming stages 为空(该点从未跑过 TLUSTY)
let incoming = mk_synspec_only_summary();
let merged = merge_point_summary(None, &incoming);
assert!(merged.stages.is_empty(), "无历史时保留 incoming 的空 stages");
assert_eq!(merged.synspec_rc, Some(1));
}
/// 往返序列化保真:合并结果应能序列化再反序列化。
#[test]
fn merge_result_roundtrips() {
let prior = mk_tlusty_summary();
let incoming = mk_synspec_only_summary();
let merged = merge_point_summary(Some(&prior), &incoming);
let json = serde_json::to_string(&merged).unwrap();
let back: ModelSummary = serde_json::from_str(&json).unwrap();
assert_eq!(back.stages.len(), 1);
assert_eq!(back.synspec_rc, Some(1));
}
/// 构造一个 TLUSTY-only summarystages 非空,但 synspec_rc=None — synspec 未跑)。
fn mk_tlusty_only_summary() -> ModelSummary {
let mut s = mk_tlusty_summary();
// 新的大气链结果(更优收敛)
s.stages[0].best_max_relc = Some(0.0001);
s.final_max_relc = Some(0.0001);
// synspec 未运行 → 清空 synspec 字段
s.synspec_rc = None;
s.synspec_error = None;
s.synspec_sec = None;
s.elapsed_sec = 280.0; // TLUSTY-only 耗时
s
}
/// TLUSTY-only 重跑(stages 非空 + synspec_rc=None)保留 prior 的 synspec 字段,
/// 使用 incoming 的 TLUSTY 诊断。
#[test]
fn tlusty_only_preserves_prior_synspec_fields() {
let prior = mk_tlusty_summary(); // 含 synspec_rc=Some(0), synspec_sec=Some(0.24)
let incoming = mk_tlusty_only_summary(); // stages 非空, synspec_rc=None
let merged = merge_point_summary(Some(&prior), &incoming);
// TLUSTY 字段来自 incoming(新大气诊断)
assert_eq!(merged.stages.len(), 1, "stages 来自 incoming");
assert_eq!(
merged.final_max_relc,
Some(0.0001),
"final_max_relc 来自 incoming"
);
// synspec 字段保留自 prior
assert_eq!(
merged.synspec_rc,
Some(0),
"synspec_rc 保留 prior 值(synspec 未跑但旧数据仍有参考价值)"
);
assert_eq!(merged.synspec_sec, Some(0.24), "synspec_sec 保留 prior 值");
// 运行态字段来自 incoming
assert_eq!(merged.elapsed_sec, 280.0, "elapsed_sec 来自 incoming");
}
/// TLUSTY-only 重跑 + 无 prior(首次执行就是 TLUSTY-only)→ 返回 incoming。
#[test]
fn tlusty_only_with_no_prior_keeps_empty_synspec() {
let incoming = mk_tlusty_only_summary();
let merged = merge_point_summary(None, &incoming);
assert_eq!(merged.stages.len(), 1);
assert_eq!(
merged.synspec_rc,
None,
"无 prior 时 synspec_rc 为 incoming 的 None"
);
}
/// TLUSTY-only 合并结果往返序列化保真。
#[test]
fn tlusty_only_merge_roundtrips() {
let prior = mk_tlusty_summary();
let incoming = mk_tlusty_only_summary();
let merged = merge_point_summary(Some(&prior), &incoming);
let json = serde_json::to_string(&merged).unwrap();
let back: ModelSummary = serde_json::from_str(&json).unwrap();
assert_eq!(back.stages.len(), 1);
assert_eq!(back.final_max_relc, Some(0.0001));
assert_eq!(back.synspec_rc, Some(0));
}
}
+57 -14
View File
@@ -166,7 +166,7 @@ pub async fn execute_task(
// 执行链来源(优先级):
// 1. TaskSpec.tlusty_chain_params(用户在 YAML `tlusty_chain:` 配置的多阶段 ChainStep
// 数组,由 scheduler 序列化注入)——非空时优先使用,使用户能细粒度控制 niter/chmax/
// metals 等阶段参数。
// ilvlin 等阶段参数。
// 2. default_chain_for_strategy(current_strategy) 兜底——按策略名(cold_run/seed_step
// 选预设默认链(runner.rs 的 default_cold_chain / default_seed_chain)。
// 历史:Phase 6 起仅用 default 链(用户 config.chain 被忽略,是死字段);本次接通后
@@ -175,6 +175,7 @@ pub async fn execute_task(
let chain = resolve_execution_chain(
current_strategy,
&task.tlusty_chain_params,
&task.seed_chain_params,
&task.task_id.to_string(),
);
// TLUSTY 输入文件全局参数(NFREAD/ions 表/nst extra_keys 等)。
@@ -208,6 +209,14 @@ pub async fn execute_task(
task.timeout_sec,
shutdown,
tlusty_input.as_ref(),
task.energy_tolerance,
task.temp_max_factor,
task.temp_floor,
task.temp_ceiling,
task.emflux_tolerance,
task.convergence_min_ratio,
task.bfac_max,
task.bfac_min,
)
.await?;
@@ -450,21 +459,35 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
/// 解析任务要执行的大气链(冷启动链 / 种子热启动链)。
///
/// 优先级(修复回归)
/// - `current_strategy == "seed_step"` → 强制 `default_seed_chain()`(seed_nc→nl)。
/// 自定义 `tlusty_chain` 是冷启动链:首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的
/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。scheduler 在派发与回退两条路径都注入
/// 同一个 `tlusty_chain`,若 seed_step 也沿用自定义链,会把种子回退退化成本地冷启动,
/// 丢失热启动语义。故种子链固定走内置默认,仅在 cold_run 等冷策略下信任用户自定义链
/// 优先级:
/// - `current_strategy == "seed_step"` → 优先 TaskSpec.seed_chain_params(用户 YAML
/// `seed_chain:` 配置),非空即用;为空/反序列化失败 → `default_seed_chain()` 兜底。
/// 不复用 `tlusty_chain`:冷启动链首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的
/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。seed_chain 专用于种子热启动,
/// 首步 seed_nc 的 `ltgray=F` 保留 fort.8 种子
/// - 其余策略 → 优先 TaskSpec.tlusty_chain_params(用户 YAML `tlusty_chain:` 配置),非空即用;
/// 为空/反序列化失败 → `default_chain_for_strategy(current_strategy)` 兜底。
fn resolve_execution_chain(
current_strategy: &str,
tlusty_chain_params: &Option<serde_json::Value>,
seed_chain_params: &Option<serde_json::Value>,
task_id: &str,
) -> Vec<ChainStep> {
if current_strategy == "seed_step" {
common::runner::default_seed_chain()
seed_chain_params
.as_ref()
.and_then(|v| match serde_json::from_value::<Vec<ChainStep>>(v.clone()) {
Ok(c) => Some(c),
Err(e) => {
warn!(
"任务 {} 的 seed_chain_params 反序列化失败,回退 default_seed_chain: {}",
task_id, e
);
None
}
})
.filter(|c| !c.is_empty())
.unwrap_or_else(common::runner::default_seed_chain)
} else {
tlusty_chain_params
.as_ref()
@@ -512,6 +535,10 @@ mod tests {
synspec_error: None,
synspec_sec: Some(10.0),
elapsed_sec: 120.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
}
}
@@ -520,22 +547,38 @@ mod tests {
chain.iter().map(|s| s.label.clone()).collect()
}
/// P1 回归防护:seed_step 即使注入自定义冷启动链,也必须强制走种子热启动默认链
/// seed_nc→nl),否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。
/// P1 回归防护:seed_step 忽略 tlusty_chain_params(冷启动链),只认 seed_chain_params。
/// 否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。
#[test]
fn seed_step_ignores_custom_cold_chain() {
let custom = serde_json::json!([
let custom_cold = serde_json::json!([
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
]);
let chain = resolve_execution_chain("seed_step", &Some(custom), "t1");
// seed_chain_params=None → 走 default_seed_chaintlusty_chain_params 被忽略。
let chain = resolve_execution_chain("seed_step", &Some(custom_cold), &None, "t1");
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
// 首步必须是非灰 LTEltgray=F),否则会删 fort.8 丢弃种子。
assert_eq!(chain[0].ltgray, "F");
}
/// seed_step 使用 seed_chain_params(用户 YAML seed_chain 配置)时生效。
#[test]
fn seed_step_uses_custom_seed_chain_when_provided() {
let custom_seed = serde_json::json!([
{"label": "seed_nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 20, "orelax": 0.3},
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100, "orelax": 0.5},
]);
let chain = resolve_execution_chain("seed_step", &None, &Some(custom_seed), "t1b");
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
// 用户配置的 orelax 生效。
assert_eq!(chain[0].orelax, Some(0.3));
assert_eq!(chain[1].orelax, Some(0.5));
}
#[test]
fn cold_run_uses_custom_chain_when_provided() {
let custom = serde_json::json!([
@@ -544,13 +587,13 @@ mod tests {
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
]);
let chain = resolve_execution_chain("cold_run", &Some(custom), "t2");
let chain = resolve_execution_chain("cold_run", &Some(custom), &None, "t2");
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
#[test]
fn cold_run_falls_back_to_default_when_no_custom_chain() {
let chain = resolve_execution_chain("cold_run", &None, "t3");
let chain = resolve_execution_chain("cold_run", &None, &None, "t3");
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
+4
View File
@@ -235,6 +235,10 @@ mod tests {
synspec_error: synspec_error.map(|s| s.to_string()),
synspec_sec: None,
elapsed_sec: 10.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
}
}
+164
View File
@@ -0,0 +1,164 @@
//! 一次性迁移端点:把 seeds_dir 下的 conv.json 批量导入 grid_points.summary_json。
//!
//! # 背景
//! 旧版 conv.json 只存磁盘不进 DB。本端点扫 `seeds_dir/<name>/conv.json`
//! 解析为 ModelSummary 后经 `merge_point_summary` 增量合并写入 `grid_points.summary_json`
//! 让详情页 conv 诊断面板从 DB 读取(不再依赖磁盘文件)。
//!
//! # 快速删除
//! 本模块是**临时迁移工具**,迁移完成后删除本文件 + main.rs 中路由注册即可。
use super::AppState;
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use common::models::ModelSummary;
use serde::Deserialize;
use serde_json::json;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
/// `/admin/migrate_conv` 的查询参数。
#[derive(Debug, Deserialize)]
pub struct MigrateConvQuery {
/// 目标工作流名(必填)。
pub workflow: String,
/// 扫描根目录(缺省取 AppState.seeds_dir)。
pub dir: Option<String>,
}
/// 递归收集目录下所有 conv.json 路径。
///
/// 用 std::fs 而非 walkdir,避免给 server 引入临时依赖(本模块要快速删除)。
fn collect_conv_jsons(dir: &Path, results: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_conv_jsons(&path, results);
} else if path.file_name().map(|n| n == "conv.json").unwrap_or(false) {
results.push(path);
}
}
}
/// POST /api/admin/migrate_conv — 扫描 seeds_dir 的 conv.json → 增量合并写入 grid_points.summary_json。
///
/// - Admin 鉴权(与其他 /admin/* 端点一致)。
/// - 幂等:`upsert_grid_point_named` 用 `ON CONFLICT DO NOTHING`,可重复运行。
/// - 增量合并:synspec-only 的 conv.json 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。
pub async fn migrate_conv(
State(state): State<AppState>,
Query(query): Query<MigrateConvQuery>,
) -> Result<impl IntoResponse, crate::api::AppError> {
let scan_dir = query.dir.unwrap_or_else(|| state.seeds_dir.clone());
let workflow = query.workflow;
// 1. 递归扫描 conv.json。
let mut paths = Vec::new();
collect_conv_jsons(Path::new(&scan_dir), &mut paths);
if paths.is_empty() {
return Ok((
StatusCode::OK,
Json(json!({
"success": true,
"message": format!("目录 {} 下未找到 conv.json", scan_dir),
"imported": 0,
"skipped": 0,
})),
));
}
info!("迁移开始:扫到 {} 份 conv.jsondir={}, workflow={}", paths.len(), scan_dir, workflow);
// 2. 逐份解析 + upsert + 增量合并。
let mut ok = 0usize;
let mut skipped = 0usize;
for path in &paths {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
warn!("读取失败 {}: {}", path.display(), e);
skipped += 1;
continue;
}
};
// 少数"conv.json"实际是 node 端失败错误占位(`{"error": "..."}`,见 reporter 错误路径),
// 非有效 ModelSummary → 反序列化失败,正确行为是跳过(这些点无有效收敛数据)。
// name 缺失(ModelSummary.name 必填)也属此类,不应兜底导入伪造点。
let summary: ModelSummary = match serde_json::from_str(&text) {
Ok(s) => s,
Err(e) => {
warn!("解析失败 {}: {}", path.display(), e);
skipped += 1;
continue;
}
};
// 权威点名取 summary.name(源精度真名);空则回退目录名。
let point_name = if !summary.name.is_empty() {
summary.name.clone()
} else {
match path.parent().and_then(|p| p.file_name()) {
Some(n) => n.to_string_lossy().to_string(),
None => {
skipped += 1;
continue;
}
}
};
// methodstages 含 seed_nc → seed_step,否则 cold_run。
let method = if summary.stages.iter().any(|s| s.label == "seed_nc") {
"seed_step"
} else {
"cold_run"
};
// upsert 建点(幂等)+ 增量合并写入 summary_json。
let wave = state
.db
.compute_wave_for_cno_sum(&workflow, summary.params.cno_sum())
.await
.unwrap_or(0);
if let Err(e) = state
.db
.upsert_grid_point_named(&point_name, &summary.params, wave, &workflow)
.await
{
warn!("点 {} upsert 失败: {}", point_name, e);
skipped += 1;
continue;
}
if let Err(e) = state
.db
.upsert_point_summary(&point_name, &workflow, &summary, method)
.await
{
warn!("点 {} summary 写入失败: {}", point_name, e);
skipped += 1;
continue;
}
ok += 1;
}
info!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped);
Ok((
StatusCode::OK,
Json(json!({
"success": true,
"message": format!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped),
"imported": ok,
"skipped": skipped,
"total": ok + skipped,
})),
))
}
+3 -2
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod auth;
pub mod data;
pub mod error;
pub mod migrate;
pub mod node;
pub mod rate_limit;
pub mod seed;
@@ -100,8 +101,8 @@ fn required_role(path: &str, method: &axum::http::Method) -> Option<Role> {
if path == "/status" && method == Method::GET {
return Some(Role::Admin);
}
// 管理 API(节点凭据查看/审批/重发/停用/启用、历史种子导入等)→ Admin
// 注:所有 /admin/* 均需 Admin 鉴权(含 /admin/import_seed,统一在此判定即可,
// 管理 API(节点凭据查看/审批/重发/停用/启用等)→ Admin
// 注:所有 /admin/* 均需 Admin 鉴权,统一在此判定即可,
// 无需为单个子路径重复列举(避免出现被前缀匹配遮蔽的不可达分支)。
if path.starts_with("/admin/") {
return Some(Role::Admin);
+35 -232
View File
@@ -1,11 +1,10 @@
use super::{AppState, AuthenticatedNode};
use axum::{
extract::{Extension, Multipart, Query, State},
extract::{Extension, Multipart, State},
response::IntoResponse,
Json,
};
use common::models::{GridPointParams, ModelSummary, TaskReport, TaskStatus};
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
use tokio::fs;
@@ -268,12 +267,43 @@ pub async fn report_task(
if fs::create_dir_all(&model_dir).await.is_ok() {
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
let conv_path = model_dir.join("conv.json");
if fs::write(&conv_tmp, &report.summary_json).await.is_ok() {
// conv.json 写入内容选择:
// - 成功:从 DB 读取合并后的 summary_jsonrecord_task_report 已通过
// merge_point_summary 合并 prior TLUSTY 诊断 + incoming synspec 字段)。
// synspec-only 重跑成功后 raw incoming 只有 synspec 字段(stages 为空),
// 直接写 raw 会丢失 TLUSTY 诊断 → 离线 salvage 导入读 conv.json 时无法恢复。
// - 失败:写 raw incoming(含错误路径 {"error":...},供排错),不读 DBDB
// 可能因 incoming 不可解析而保留 prior,丢失本次错误信息)。
let conv_content = if report.result_valid
&& !report.atmosphere_has_nan
&& report.status == TaskStatus::Completed
{
state
.db
.get_point_summary_json(&workflow_name, &name)
.await
.ok()
.flatten()
.unwrap_or_else(|| report.summary_json.clone())
} else {
report.summary_json.clone()
};
if fs::write(&conv_tmp, &conv_content).await.is_ok() {
let _ = fs::rename(&conv_tmp, &conv_path).await;
}
// Save seed file .7 using atomic temporary writing strategy
if report.result_valid && !report.atmosphere_has_nan {
// Save seed file .7 using atomic temporary writing strategy.
// §7 修复:synspec-only 任务(tlusty_enabled=false)未重算大气,节点上传的 .7 是
// 从服务端下载的原样字节 → 跳过写入,避免冗余 I/O 和潜在的误覆盖(如节点本地归档
// 版本与服务端不一致)。查询失败时 fallback 为 true(保守,不阻断种子写入)。
let tlusty_was_enabled = state
.db
.get_task_tlusty_enabled(&report.task_id)
.await
.ok()
.flatten()
.unwrap_or(true);
if report.result_valid && !report.atmosphere_has_nan && tlusty_was_enabled {
if let Some(bytes) = seed_file_data {
let seed_tmp =
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
@@ -341,230 +371,3 @@ fn extract_params(report: &TaskReport) -> Option<GridPointParams> {
.ok()
.map(|summary| summary.params)
}
/// `/admin/import_seed` 的查询参数。
#[derive(Debug, Deserialize)]
pub struct ImportSeedQuery {
/// 目标工作流名(导入到此工作流的 grid_points)。缺省归入 `imported` 工作流。
#[serde(default = "default_import_workflow")]
pub workflow: String,
}
fn default_import_workflow() -> String {
"imported".to_string()
}
/// 历史种子导入端点(Admin 鉴权)。
///
/// 供 `tools/import_results` 把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)
/// 批量回灌进 DCTS。与 `/task/report` 的关键区别:
/// - **跳过任务归属校验**`verify_task_claim`):历史数据无领用语义,导入端点不经过
/// claim/report 队列,直接幂等落库。
/// - **`point_name` 取旧 `conv.json` 的 `name` 字段**Python `gen_input5.model_name`
/// 生成的源精度真名,如 `t20000_g5.0_...`),而非从数值重推——保证迁移逐字符保真。
/// - **真实 `max_relc`** 取自 `summary.final_max_relc`(旧版已记录),不硬编码。
///
/// 幂等:`upsert_grid_point` 用 `ON CONFLICT DO NOTHING``.7`/`conv.json` 原子覆盖写,
/// 可重复运行。
pub async fn import_seed(
State(state): State<AppState>,
Query(query): Query<ImportSeedQuery>,
mut multipart: Multipart,
) -> Result<impl IntoResponse, crate::api::AppError> {
let mut summary_json: Option<String> = None;
let mut seed_file_data: Option<Vec<u8>> = None;
// 大气收敛途径(cold_run/seed_step):由 import_results 工具依据旧 conv.json 的 stages 是否
// 含 seed_nc 判定后透传。缺失或非法时兜底 cold_run(容错旧版工具 / 防注入)。
// 语义为 TLUSTY 阶段策略,写入 grid_points.tlusty_success_method。
let mut success_method: Option<String> = None;
let mut multipart_error = false;
// 显式遍历全部字段,记录读取错误。旧实现 `while let Ok(...)` 在首字段出错时静默停止,
// 可能丢失后续 seed_file/report 字段导致半截请求被处理。
loop {
match multipart.next_field().await {
Ok(Some(field)) => {
let field_name = field.name().unwrap_or("").to_string();
if field_name == "report" {
match field.bytes().await {
Ok(bytes) => {
summary_json = Some(String::from_utf8_lossy(&bytes).to_string());
}
Err(e) => {
warn!("历史种子导入:读取 report 字段失败: {}", e);
multipart_error = true;
}
}
} else if field_name == "seed_file" {
match field.bytes().await {
Ok(bytes) => {
seed_file_data = Some(bytes.to_vec());
}
Err(e) => {
warn!("历史种子导入:读取 seed_file 字段失败: {}", e);
multipart_error = true;
}
}
} else if field_name == "tlusty_success_method" {
match field.text().await {
Ok(text) => {
success_method = Some(text);
}
Err(e) => {
warn!("历史种子导入:读取 tlusty_success_method 字段失败: {}", e);
multipart_error = true;
}
}
}
}
Ok(None) => break,
Err(e) => {
warn!("历史种子导入:解析 multipart 字段时出错: {}", e);
multipart_error = true;
break;
}
}
}
if multipart_error {
return Err(crate::api::AppError::BadRequest(
"multipart 请求体解析不完整(字段读取失败)".to_string(),
));
}
let summary_json = match summary_json {
Some(s) => s,
None => {
return Err(crate::api::AppError::BadRequest(
"请求中缺少 report 字段(旧版 conv.json 内容)".to_string(),
));
}
};
// 解析旧版 conv.jsonModelSummary 结构)取 name / params / 收敛状态 / 真实 max_relc。
let summary: ModelSummary = match serde_json::from_str(&summary_json) {
Ok(s) => s,
Err(e) => {
warn!("历史种子导入:conv.json 解析失败: {}", e);
return Err(crate::api::AppError::BadRequest(
"conv.json 解析失败,非合法 ModelSummary".to_string(),
));
}
};
// point_name 优先用旧 conv.json 的 name(源精度真名);回退到 params 规范名。
let name = if !summary.name.is_empty() {
summary.name.clone()
} else {
summary.params.model_name()
};
// 名称合法性校验(防路径穿越),与 report_task 同口径。
if !super::workflow::is_valid_point_name(&name) {
warn!("历史种子导入:拒绝非法网格点名称: {}", name);
return Err(crate::api::AppError::BadRequest(
"非法的网格点名称参数".to_string(),
));
}
let workflow_name = query.workflow;
let params = summary.params.clone();
let converged = summary.result_valid && !summary.atmosphere_has_nan;
let max_relc = summary.final_max_relc;
// 1. 幂等写入 grid_pointsON CONFLICT DO NOTHING):无需事先 start 工作流。
// 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的
// params 来自旧 JSON(无源文本,model_name() 会失真)。
// wave 修复(2026-08-04):此前硬编码 wave=0,导入点全部被错误归入第一波,
// 前端难度波次推进显示错误。现按 initialize_grid 同口径计算波次(该工作流内
// cno_sum 严格小于本点的去重值个数),新导入点落库即归入正确波次。
let wave = state
.db
.compute_wave_for_cno_sum(&workflow_name, params.cno_sum())
.await
.unwrap_or(0);
if let Err(e) = state
.db
.upsert_grid_point_named(&name, &params, wave, &workflow_name)
.await
{
tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e);
return Err(crate::api::AppError::Internal(e));
}
// 2. 落地 conv.json(原子 tmp→rename)。
let model_dir = Path::new(&state.seeds_dir).join(&name);
if fs::create_dir_all(&model_dir).await.is_ok() {
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
let conv_path = model_dir.join("conv.json");
if fs::write(&conv_tmp, &summary_json).await.is_ok() {
let _ = fs::rename(&conv_tmp, &conv_path).await;
}
// 3. 收敛且干净才写 .7 + 入种子库(与 report_task 同口径)。
if converged {
if let Some(bytes) = seed_file_data {
let seed_tmp =
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
let seed_path = model_dir.join(format!("{}.7", name));
if fs::write(&seed_tmp, bytes).await.is_ok()
&& fs::rename(&seed_tmp, &seed_path).await.is_ok()
{
info!(
"历史种子导入:网格点 {} 收敛种子已落地: {} (max_relc={:?})",
name,
seed_path.display(),
max_relc
);
let _ = state
.db
.insert_seed_named(&name, &params, &seed_path.to_string_lossy())
.await;
}
} else {
warn!(
"历史种子导入:网格点 {} 声称收敛但未上传 seed_file,跳过种子写入",
name
);
}
}
}
// 4. 更新 grid_points 状态:收敛→converged(大气归因 tlusty_success_method=工具判定的途径);否则维持 pending
// 让正常调度处理(导入未收敛点无意义,但记录其尝试)。
// 途径缺失或非法时兜底 cold_run(容错旧版工具 / 防注入),由 db 层再次白名单校验。
if converged {
// 归一化:仅接受 cold_run / seed_step,其余(含 None)一律兜底 cold_run。
let method = match success_method.as_deref() {
Some("seed_step") => "seed_step",
_ => "cold_run",
};
if let Err(e) = state
.db
.mark_grid_point_imported(&name, &workflow_name, Some(summary.elapsed_sec), method)
.await
{
warn!("历史种子导入:标记 {} 为 converged 失败: {}", name, e);
}
}
info!(
"历史种子导入完成:网格点 {} (workflow={}, converged={}, success_method={}, max_relc={:?})",
name,
workflow_name,
converged,
success_method.as_deref().unwrap_or("(default cold_run)"),
max_relc
);
Ok((
StatusCode::OK,
Json(json!({
"status": "ok",
"point_name": name,
// 注:此 "converged" 键是大气收敛标志(由 summary.result_valid 派生),与 grid_points.status
// 的 completed 重命名无关,勿改(改键名会破坏历史种子导入客户端)。
"converged": converged,
"max_relc": max_relc,
})),
))
}
+19 -32
View File
@@ -615,12 +615,11 @@ pub async fn get_workflow_points(
}
}
/// 单网格点详情:点行 + 全部尝试历史 + conv.json 逐阶段诊断。
/// 单网格点详情:点行 + 全部尝试历史 + summary_json 逐阶段诊断。
///
/// conv.json 读自 `seeds_dir/<point>/conv.json`(单层目录)。点名经 `is_valid_point_name`
/// 白名单(无 `/`、`\`,拒前导 `.`)——即路径穿越的前置闸门;读盘后再做 canonicalize
/// 归属兜底校验(纵深防御)。缺失/读失败/解析失败一律 `conv: null`(仍 200),
/// 前端降级显示"诊断文件不可用"。
/// conv 诊断面板读自 `grid_points.summary_json`(点级诊断快照,DB 权威源)。
/// synspec-only 重跑时由 `record_task_report` 的增量合并保证 TLUSTY 字段不丢失。
/// 缺失/读失败/解析失败一律 `conv: null`(仍 200),前端降级显示"诊断文件不可用"。
pub async fn get_workflow_point_detail(
State(state): State<AppState>,
AxumPath((name, point)): AxumPath<(String, String)>,
@@ -643,33 +642,21 @@ pub async fn get_workflow_point_detail(
};
let attempts = state.db.list_point_attempts(&name, &point).await?;
let conv_path = std::path::Path::new(&state.seeds_dir)
.join(&point)
.join("conv.json");
let conv: Option<common::models::ModelSummary> =
match tokio::fs::read_to_string(&conv_path).await {
Ok(s) => {
let confined = std::path::Path::new(&state.seeds_dir)
.canonicalize()
.ok()
.zip(conv_path.canonicalize().ok())
.map(|(root, f)| f.starts_with(root))
.unwrap_or(false);
if confined {
match serde_json::from_str(&s) {
Ok(summary) => Some(summary),
Err(e) => {
tracing::warn!("网格点 {} 的 conv.json 解析失败: {}", point, e);
None
}
}
} else {
tracing::warn!("网格点 {} 的 conv.json 路径越界,拒绝读取", point);
None
}
}
Err(_) => None,
};
// conv 诊断面板数据源:grid_points.summary_json(点级诊断快照)。
// 此前读磁盘 conv.jsonsynspec-only 重跑会覆盖 TLUSTY 字段;改为查 DB 后由
// record_task_report 的增量合并保证 TLUSTY 诊断不丢失。
let conv: Option<common::models::ModelSummary> = match state
.db
.get_point_summary_json(&name, &point)
.await
{
Ok(Some(json)) => serde_json::from_str(&json).ok(),
Ok(None) => None,
Err(e) => {
tracing::warn!("网格点 {} 的 summary_json 读取失败: {}", point, e);
None
}
};
Ok((
StatusCode::OK,
+101 -48
View File
@@ -52,10 +52,10 @@ impl Database {
/// 的去重值个数,即 `cno_sum` 升序中的桶序号——与 `initialize_grid` 的波次分组口径一致
/// `scheduler.rs` 沿排序序扫描,cno_sum 变化 >1e-5 则 wave+1)。
///
/// 历史种子导入(`import_seed`)用它替代此前硬编码的 `wave=0`:导入点按现有规则归入
/// 正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流(所有
/// cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合 import_results
/// 工具按 cno_sum 升序导入即可保证最终波次一致。
/// 离线导入工具(`tools/import_db`)用它替代此前硬编码的 `wave=0`:导入点按现有规则
/// 归入正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流
/// (所有 cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合
/// 导入工具按 cno_sum 升序导入即可保证最终波次一致。
pub async fn compute_wave_for_cno_sum(&self, workflow_name: &str, cno_sum: f64) -> Result<i32> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
@@ -527,50 +527,6 @@ impl Database {
Ok(changed)
}
/// 历史种子导入专用:把网格点标记为 converged 并记录大气收敛途径 `tlusty_success_method`。
///
/// 与正常 `record_task_report` 路径的区别:导入不走 task 队列,无 task_type 可取,
/// 故由导入工具(import_results)依据旧 conv.json 的 stages 是否含 seed_nc 判定该点
/// 当初是冷启动收敛(cold_run)还是种子步进收敛(seed_step),经 multipart 字段透传至此。
/// 导入点因此融入冷启动/种子步进统计,而非独立为 imported 分类。
///
/// - `success_method`:须为 "cold_run" 或 "seed_step",非法值兜底为 "cold_run"(防注入)。
/// 语义为 TLUSTY 阶段策略,写入 `tlusty_success_method` 列。
/// - `elapsed_sec`:旧版 conv.json 的单点墙钟耗时(`summary.elapsed_sec`),落入
/// `last_elapsed_sec` 列使迁移点在详情页/点表保留真实耗时;无此数据传 None。
pub async fn mark_grid_point_imported(
&self,
name: &str,
workflow_name: &str,
elapsed_sec: Option<f64>,
success_method: &str,
) -> Result<()> {
// 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。
let method = match success_method {
"seed_step" => "seed_step",
_ => "cold_run",
};
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 阶段列(5b):导入点是历史 TLUSTY 大气收敛 → tlusty_status='converged'
// synspec_status 保持 NULL(历史结果仅大气,光谱未运行/未记录)。与正常结算路径的
// 状态一致性(审查 #2 修正:原实现不设阶段列,导入点与正常点状态口径不一致)。
conn.execute(
"UPDATE grid_points SET status = 'completed', tlusty_success_method = ?1, last_elapsed_sec = ?2, tlusty_status = 'converged' \
WHERE name = ?3 AND workflow_name = ?4",
params![method, elapsed_sec, name_owned, wf],
)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn get_grid_point_status(
&self,
name: &str,
@@ -934,4 +890,101 @@ impl Database {
})
.await?
}
/// 取网格点的 summary_json(点级诊断快照),供详情页 conv 诊断面板渲染。
///
/// 与 `get_workflow_point_row` 分离:summary_json 可能很大(含 stages/itek_history
/// 等嵌套诊断),列表查询(`list_workflow_points`)不需要它,故独立查询避免每行
/// 拖带大 JSON。返回 None = 该点无诊断数据(未跑过或尚未结算)。
pub async fn get_point_summary_json(
&self,
wf: &str,
point: &str,
) -> Result<Option<String>> {
let pool = self.pool.clone();
let wf = wf.to_string();
let point = point.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<String>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let res = conn.query_row(
"SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
params![point, wf],
|r| r.get::<_, Option<String>>(0),
);
match res {
Ok(v) => Ok(v),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await?
}
/// 离线/批量导入专用:把一个点级 [`ModelSummary`] 增量合并写入 grid_points。
///
/// 供离线导入工具(`tools/import_db`)逐份合并多节点 conv.json 时复用。与在线
/// `record_task_report` 路径共享 `merge_point_summary` 合并逻辑,保证一致性:
/// incoming.stages 非空 → 整体替换;为空 → 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。
///
/// 同时更新 status / method / elapsed_sec / tlusty_status 等调度列(等价于旧
/// `mark_grid_point_imported` 的职责并入此方法)。调用方须先 `upsert_grid_point_named`
/// 建点(保证行存在)。
///
/// - `method`TLUSTY 收敛策略(`cold_run` / `seed_step`),由 stages 是否含 `seed_nc` 判定。
/// - 返回合并后的 summary 文本(供调用方日志/验证)。
pub async fn upsert_point_summary(
&self,
name: &str,
workflow_name: &str,
incoming: &common::models::ModelSummary,
method: &str,
) -> Result<String> {
// 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。
let method = match method {
"seed_step" => "seed_step",
_ => "cold_run",
};
let converged = incoming.result_valid && !incoming.atmosphere_has_nan;
// 增量合并:读旧 summary_json → merge_point_summary → 写回。
let prior_json = self.get_point_summary_json(workflow_name, name).await?;
let prior = prior_json
.as_deref()
.and_then(|s| serde_json::from_str::<common::models::ModelSummary>(s).ok());
let merged = common::summary_merge::merge_point_summary(prior.as_ref(), incoming);
let merged_json = serde_json::to_string(&merged).unwrap_or_default();
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let merged_json_clone = merged_json.clone();
let elapsed_sec = merged.elapsed_sec;
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 收敛 → completed(与 record_task_report success 分支同口径:tlusty_status='converged')。
// 未收敛 → pending(让调度器后续处理,不在此翻 failed——离线导入无失败语义)。
if converged {
conn.execute(
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, \
tlusty_success_method = ?2, tlusty_status = 'converged', summary_json = ?3 \
WHERE name = ?4 AND workflow_name = ?5",
params![elapsed_sec, method, merged_json_clone, name_owned, wf],
)?;
} else {
// 未收敛:仍写入 summary_json(保留诊断供排查),但状态不变更。
conn.execute(
"UPDATE grid_points SET last_elapsed_sec = ?1, summary_json = ?2 \
WHERE name = ?3 AND workflow_name = ?4",
params![elapsed_sec, merged_json_clone, name_owned, wf],
)?;
}
Ok(())
})
.await??;
Ok(merged_json)
}
}
+726 -13
View File
@@ -69,7 +69,12 @@ const GRID_POINTS_SCHEMA: &str = "CREATE TABLE grid_points (
-- H1 M11退 pending JSON
-- cold_run YAML
-- scheduler.rs H1 NULL =
pending_strategies TEXT
pending_strategies TEXT,
-- Worker None
last_elapsed_sec REAL,
-- ModelSummary JSON conv
-- synspec-only merge_point_summary TLUSTY
summary_json TEXT
)";
#[derive(Debug)]
@@ -431,15 +436,20 @@ impl Database {
if !has_tasks_elapsed {
let _ = conn.execute("ALTER TABLE tasks ADD COLUMN elapsed_sec REAL", []);
}
let has_gp_elapsed = conn
.prepare("PRAGMA table_info(grid_points)")?
.query_map([], |r| r.get::<_, String>(1))?
.any(|r| r.map(|n| n == "last_elapsed_sec").unwrap_or(false));
if !has_gp_elapsed {
let _ = conn.execute(
"ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL",
[],
);
// grid_points 幂等补列:last_elapsed_sec(列表展示用)与 summary_json(点级诊断快照)。
// 新库由 GRID_POINTS_SCHEMA 建表时即含此二列;此 ALTER 仅兜底旧库(表已存在但缺列)。
// 与上方 tasks 列迁移同模式:PRAGMA 检测 → 缺列才 ALTER。
for (col, sql) in [
("last_elapsed_sec", "ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL"),
("summary_json", "ALTER TABLE grid_points ADD COLUMN summary_json TEXT"),
] {
let has_col = conn
.prepare("PRAGMA table_info(grid_points)")?
.query_map([], |r| r.get::<_, String>(1))?
.any(|r| r.map(|n| n == col).unwrap_or(false));
if !has_col {
let _ = conn.execute(sql, []);
}
}
// 阶段独立配置迁移(见 docs/task_engine_decoupling_design.md §4.1):
@@ -1143,9 +1153,30 @@ mod tests {
db.update_grid_status(&p.model_name(), GridPointStatus::Queued, "wf_b")
.await
.unwrap();
db.mark_grid_point_imported(&p2.model_name(), "wf_a", None, "seed_step")
.await
.unwrap();
{
let summary = common::models::ModelSummary {
name: p2.model_name(),
params: p2.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: Some(0.001),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 0.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
db.upsert_point_summary(&summary.name, "wf_a", &summary, "seed_step")
.await
.unwrap();
}
// 全局(None):3 个点,pending/queued/converged 分开计数;
// 导入点按 seed_step 途径计入 seed_step_converged(不再有独立 imported 分类)
@@ -1782,8 +1813,17 @@ mod tests {
synspec_config: common::models::PhaseConfig::default_synspec(),
synspec_params: None,
tlusty_chain_params: None,
seed_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
let syn_task = Uuid::new_v4();
let old_task = Uuid::new_v4();
@@ -3553,4 +3593,677 @@ mod tests {
})
);
}
/// synspec-only 重跑后 tlusty_success_method / tlusty_status 须保留 prior 值,
/// 不能被 NULL 覆写(CASE 守卫修复验证)。
///
/// 场景:先以 TLUSTY 启用(cold_run)跑成功 → tlusty_success_method = "cold_run"。
/// 再以 TLUSTY 关闭(仅 SYNSPEC,场景 B)重跑成功 → tlusty_success_method 仍须为
/// "cold_run",不能被覆写为 NULL。synspec_success_method 应更新为 "standard"。
#[tokio::test]
async fn test_synspec_only_rerun_preserves_tlusty_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("synrerun.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_synrerun";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// ── 第一轮:TLUSTY 启用 + cold_run,成功 ──
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
// 验证第一轮:tlusty_success_method = cold_run, tlusty_status = converged
let row1 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row1.status, "completed");
assert_eq!(row1.tlusty_success_method.as_deref(), Some("cold_run"));
assert_eq!(row1.tlusty_status.as_deref(), Some("converged"));
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
// ── 模拟场景 Breset_terminal_points_for_recompute 翻回 pending ──
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// ── 第二轮:TLUSTY 关闭 + SYNSPEC 启用(synspec-only),成功 ──
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: false,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// synspec-only 的 summarystages 为空 → merge_point_summary 走字段级合并
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: None,
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.25),
elapsed_sec: 0.25,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: None,
atmosphere_has_nan: false,
elapsed_sec: 0.25,
error_message: None,
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: None,
};
db.record_task_report(&report2, wf).await.unwrap();
// ── 核心断言:synspec-only 重跑后 tlusty 侧归因须保留 ──
let row2 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row2.status, "completed", "重跑成功后应为 completed");
// ★ 修复前:tsm2 = None(裸赋值覆写)。修复后:保留 "cold_run"。
assert_eq!(
row2.tlusty_success_method.as_deref(),
Some("cold_run"),
"synspec-only 重跑后 tlusty_success_method 须保留,不能覆写为 NULL"
);
assert_eq!(
row2.tlusty_status.as_deref(),
Some("converged"),
"synspec-only 重跑后 tlusty_status 须保留"
);
// synspec 侧应更新为新值
assert_eq!(
row2.synspec_success_method.as_deref(),
Some("standard"),
"synspec_success_method 应更新为 standard"
);
assert_eq!(
row2.synspec_status.as_deref(),
Some("converged"),
"synspec_status 应为 converged"
);
// summary_json 的 TLUSTY 诊断也须保留(merge_point_summary 字段级合并)
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
assert_eq!(ms.stages.len(), 1, "stages 须保留 prior 的 TLUSTY 链");
assert_eq!(ms.stages[0].label, "nl");
assert_eq!(ms.final_max_relc, Some(0.0005), "final_max_relc 须保留");
assert_eq!(ms.synspec_rc, Some(0), "synspec_rc 应为新值");
assert_eq!(ms.elapsed_sec, 0.25, "elapsed_sec 应为新值");
// last_elapsed_sec 语义:最近一次尝试耗时。synspec-only 重跑后为 0.25ssynspec 耗时),
// 原 TLUSTY 耗时保留在 stages[].elapsed_sec。ETA 不依赖此列(用 AVG(tasks.elapsed_sec))。
let last_elapsed = read_grid_last_elapsed(&db, &name, wf).await;
assert_eq!(
last_elapsed, Some(0.25),
"last_elapsed_sec 应为 synspec-only 耗时(最近一次尝试),非原 TLUSTY 总耗时"
);
}
/// 辅助:读取 grid_points 的阶段归因列。
async fn read_grid_attrs(db: &Database, name: &str, wf: &str) -> GridAttrs {
let pool = db.pool.clone();
let name = name.to_string();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> GridAttrs {
let conn = pool.get().unwrap();
conn.query_row(
"SELECT status, tlusty_success_method, synspec_success_method, tlusty_status, synspec_status \
FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
rusqlite::params![name, wf],
|r| {
Ok(GridAttrs {
status: r.get(0)?,
tlusty_success_method: r.get(1)?,
synspec_success_method: r.get(2)?,
tlusty_status: r.get(3)?,
synspec_status: r.get(4)?,
})
},
)
.unwrap()
})
.await
.unwrap()
}
struct GridAttrs {
status: String,
tlusty_success_method: Option<String>,
synspec_success_method: Option<String>,
tlusty_status: Option<String>,
synspec_status: Option<String>,
}
/// synspec-only 重跑**失败**后 tlusty_success_method / tlusty_status 仍须保留。
///
/// 失败分支的 UPDATE 不写 success_method 列,但 tlusty_status / synspec_status
/// 有 CASE 守卫。验证失败报告不会清空 prior 的 TLUSTY 归因。
#[tokio::test]
async fn test_synspec_only_rerun_failure_preserves_tlusty_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("synfail.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_synfail";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// 第一轮:TLUSTY 启用 + cold_run,成功。
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
assert_eq!(read_grid_attrs(&db, &name, wf).await.tlusty_success_method.as_deref(), Some("cold_run"));
// 翻回 pending 模拟场景 B 重跑。
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// 第二轮:synspec-only,失败(synspec 产出脏谱)。
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// synspec 失败:result_valid=false, stages 为空(synspec-only),synspec_rc=1。
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: Vec::new(),
result_valid: false,
final_max_relc: None,
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(1),
synspec_error: Some("spec 含 NaN".into()),
synspec_sec: Some(0.2),
elapsed_sec: 0.2,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: Some("synspec 失败".into()),
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Failed,
result_valid: false,
max_relc: None,
atmosphere_has_nan: false,
elapsed_sec: 0.2,
error_message: Some("synspec 失败".to_string()),
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: Some("synspec".to_string()),
};
db.record_task_report(&report2, wf).await.unwrap();
// 失败后 tlusty 侧归因仍须保留。
let row = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row.status, "failed", "失败后状态应为 failed");
assert_eq!(
row.tlusty_success_method.as_deref(),
Some("cold_run"),
"synspec-only 失败后 tlusty_success_method 须保留"
);
assert_eq!(
row.tlusty_status.as_deref(),
Some("converged"),
"synspec-only 失败后 tlusty_status 须保留(CASE 守卫)"
);
// synspec 侧应反映失败。
assert_eq!(row.synspec_status.as_deref(), Some("failed"));
}
/// 辅助:读取 grid_points.last_elapsed_sec。
async fn read_grid_last_elapsed(db: &Database, name: &str, wf: &str) -> Option<f64> {
let pool = db.pool.clone();
let name = name.to_string();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> Option<f64> {
let conn = pool.get().unwrap();
conn.query_row(
"SELECT last_elapsed_sec FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
rusqlite::params![name, wf],
|r| r.get(0),
)
.ok()
})
.await
.unwrap()
}
/// TLUSTY-only 重跑成功后 synspec 归因列应被显式清空(clear_synspec=true),
/// summary_json 保留 prior synspec 字段(merge_point_summary TLUSTY-only 路径)。
///
/// 场景:先正常管线(TLUSTY+SYNSPEC)成功 → 再 TLUSTY-onlysynspec 关闭)重跑成功
/// → 新大气使旧光谱失效 → synspec_success_method/synspec_status 清 NULL
/// summary_json 中 synspec_rc/synspec_sec 保留自 prior。
#[tokio::test]
async fn test_tlusty_only_rerun_clears_synspec_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("tlonly.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_tlonly";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// ── 第一轮:正常管线(TLUSTY + SYNSPEC 双开),成功 ──
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
let row1 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
// ── 翻回 pending 模拟 TLUSTY-only 重跑 ──
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// ── 第二轮:TLUSTY-onlysynspec 关闭),成功 ──
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_synspec()
},
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// TLUSTY-only summarystages 非空(TLUSTY 跑了),synspec_rc=Nonesynspec 没跑)
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.0008),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0003),
elapsed_sec: 280.0,
note: None,
last_iter: Some(15),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0003),
final_chmax: Some(0.0008),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None, // synspec 未运行
synspec_error: None,
synspec_sec: None,
elapsed_sec: 280.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0003),
atmosphere_has_nan: false,
elapsed_sec: 280.0,
error_message: None,
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: None,
};
db.record_task_report(&report2, wf).await.unwrap();
// ── 核心断言:synspec 列应被显式清空(clear_synspec=true)──
let row2 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row2.status, "completed");
assert_eq!(
row2.tlusty_success_method.as_deref(),
Some("cold_run"),
"tlusty_success_method 应更新为 cold_run"
);
assert_eq!(
row2.tlusty_status.as_deref(),
Some("converged"),
"tlusty_status 应为 converged"
);
// ★ synspec 列被 clear_synspec 显式置 NULL(新大气使旧光谱失效)
assert_eq!(
row2.synspec_success_method,
None,
"TLUSTY-only 重跑后 synspec_success_method 须清 NULLclear_synspec"
);
assert_eq!(
row2.synspec_status,
None,
"TLUSTY-only 重跑后 synspec_status 须清 NULLclear_synspec"
);
// summary_jsonTLUSTY 诊断来自 incomingsynspec 字段保留自 prior
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
assert_eq!(ms.stages.len(), 1, "stages 来自 incoming");
assert_eq!(ms.final_max_relc, Some(0.0003), "final_max_relc 来自 incoming");
assert_eq!(
ms.synspec_rc,
Some(0),
"synspec_rc 保留 prior 值(merge_point_summary TLUSTY-only 路径)"
);
assert_eq!(ms.synspec_sec, Some(0.3), "synspec_sec 保留 prior 值");
}
/// `get_task_tlusty_enabled`:正常任务返回 truesynspec-only 任务返回 false
/// 不存在的 task_id 返回 None。
#[tokio::test]
async fn test_get_task_tlusty_enabled() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("tlusty_enabled.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let params = GridPointParams {
teff: 20000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-4.0).into(),
logo: (-4.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, "wf_tle").await.unwrap();
// 正常任务(tlusty_enabled=true
let task_normal = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some("wf_tle".to_string()),
tlusty_config: PhaseConfig::default_tlusty(),
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task_normal).await.unwrap();
let enabled = db.get_task_tlusty_enabled(&task_normal.task_id).await.unwrap();
assert_eq!(enabled, Some(true), "正常任务 tlusty_enabled 应为 true");
// synspec-only 任务(tlusty_enabled=false
let task_synonly = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some("wf_tle".to_string()),
tlusty_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task_synonly).await.unwrap();
let enabled = db.get_task_tlusty_enabled(&task_synonly.task_id).await.unwrap();
assert_eq!(enabled, Some(false), "synspec-only 任务 tlusty_enabled 应为 false");
// 不存在的 task_id → None
let fake_id = Uuid::new_v4();
let enabled = db.get_task_tlusty_enabled(&fake_id).await.unwrap();
assert_eq!(enabled, None, "不存在的 task_id 应返回 None");
}
}
+93 -7
View File
@@ -254,6 +254,33 @@ impl Database {
Ok(())
}
/// 查询某 task 的 `tlusty_enabled` 标志。
///
/// 用于 `report_task` API 在写 `.7` 种子文件前判断本次任务是否为 synspec-only
/// `tlusty_enabled=false`)。synspec-only 任务未重算大气,节点上传的 `.7` 是从
/// 服务端下载的原样字节 → 服务端跳过写入,避免冗余 I/O 和潜在的误覆盖。
///
/// 返回 `None` 表示 task 行不存在(已被清理或 task_id 无效),调用方应 fallback 为
/// `true`(保守,不阻断种子写入主流程)。tasks 行在 queue 清理后仍保留(仅 task_queue
/// 被清),故在首次上报与幂等重放两条路径均有效。
pub async fn get_task_tlusty_enabled(&self, task_id: &uuid::Uuid) -> Result<Option<bool>> {
let pool = self.pool.clone();
let id = task_id.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<bool>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare("SELECT tlusty_enabled FROM tasks WHERE task_id = ?1")?;
let res = stmt.query_row(params![id], |r| r.get::<_, bool>(0));
match res {
Ok(enabled) => Ok(Some(enabled)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await?
}
/// 列出指定网格点在当前工作流内仍处于 `pending` 的 tasks 行 task_id。
///
/// tasks 行的 pending 意为"已创建但从未上报",可能是真在途(队列行仍 pending/
@@ -486,10 +513,13 @@ impl Database {
} else if converged {
// 半失败:大气收敛 + 光谱失败。tlusty 阶段确已收敛(tlusty_enabled 防御性守卫——
// 该分支在 synspec-only 下不可达,但避免未来代码路径把禁用阶段误标为 converged)。
(tlusty_enabled.then_some("converged"), Some("failed"))
// synspec_status gate on synspec_enabledsynspec 禁用时为 None → CASE 守卫保留 prior
// 不把禁用阶段误标为 "failed"TLUSTY-only 重跑失败不应清空既有 synspec 状态)。
(tlusty_enabled.then_some("converged"), synspec_enabled.then_some("failed"))
} else if tlusty_enabled {
// 大气未收敛(TLUSTY 启用)→ TLUSTY 失败、SYNSPEC 未运行。
(Some("failed"), Some("pending"))
// synspec_status gate on synspec_enabled:同上,禁用时保留 prior。
(Some("failed"), synspec_enabled.then_some("pending"))
} else {
// synspec-only 失败(converged 由 synspec_rc 决定)。
(None, Some("failed"))
@@ -506,11 +536,60 @@ impl Database {
// - 阶段列(5b):tlusty_status 用 CASE 守卫保留既有终态——半失败重试的 synspec-only
// 任务(tlusty 关闭)不覆盖已收敛的 tlusty_status(设计 §7.3 打开项 #2:仅 synspec
// 侧流转);synspec_status 自由流转。
// clear_synspecTLUSTY-only 重跑成功(tlusty_enabled && !synspec_enabled)时,
// 新大气使旧光谱失效 → 显式清空 synspec 归因列为 NULL(而非保留旧值)。
// 与 synspec-only 重跑(tlusty_enabled=false → 大气未变 → 保留 tlusty 列)对称:
// 另一阶段"跑了且产生了新产物"时,当前阶段的旧归因应作废。
let clear_synspec = tlusty_enabled && !synspec_enabled;
// summary_json 增量合并:synspec-only 重跑(tlusty_enabled=false)时只覆盖 synspec
// 字段,保留 grid_points 既有 summary 中的 TLUSTY 诊断(stages/final_max_relc/各 check)。
// 合并逻辑共享 common::summary_merge,与离线导入工具保持一致。
let merged_summary_json: Option<String> = {
let prior_json: Option<String> = tx
.query_row(
"SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
params![point_name, wf],
|r| r.get::<_, Option<String>>(0),
)
.ok()
.flatten();
let prior = prior_json
.as_deref()
.and_then(|s| serde_json::from_str::<common::models::ModelSummary>(s).ok());
let incoming = serde_json::from_str::<common::models::ModelSummary>(
&report_cloned.summary_json,
)
.ok();
// incoming 解析失败(错误路径 {"error":...} 或空字符串)时跳过 summary 写入,
// 只更新原有列——与此前「server 只透传不解析 summary_json」的容错语义一致。
incoming.map(|inc| {
let merged = common::summary_merge::merge_point_summary(prior.as_ref(), &inc);
serde_json::to_string(&merged).unwrap_or_default()
})
};
let grid_changed = if pipe_ok {
tx.execute(
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, tlusty_success_method = ?2, synspec_success_method = ?3,
// 阶段列 CASE 守卫(对称修复):
// - synspec-only 重跑(tlusty_enabled=0)→ tlusty 列为 NULL → CASE 守卫保留 prior。
// - TLUSTY-only 重跑(synspec_enabled=0, clear_synspec=true)→ synspec 列显式置 NULL
// (新大气使旧光谱失效),summary_merge 同步保留 prior synspec 字段。
// - 正常管线(双开)→ 全部 CASE 的 THEN 分支命中,写入新值。
// 此前 tlusty_success_method/synspec_success_method/synspec_status 为裸赋值,
// synspec-only 成功会把 tlusty_success_method 从 "cold_run" 覆写为 NULL
// 污染 stats 统计(workflows.rs:99 / grid.rs:579 按 tlusty_success_method 归因)。
//
// last_elapsed_sec 语义:最近一次尝试的墙钟耗时。synspec-only 重跑后该列反映
// synspec-only 耗时(~0.25s),TLUSTY 分项耗时保留在 summary_json.stages[].elapsed_sec。
// ETA 不依赖此列(用 AVG(tasks.elapsed_sec)),conv 面板用 summary_json.elapsed_sec。
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1,
tlusty_success_method = CASE WHEN ?2 IS NOT NULL THEN ?2 ELSE tlusty_success_method END,
synspec_success_method = CASE WHEN ?9 THEN NULL WHEN ?3 IS NOT NULL THEN ?3 ELSE synspec_success_method END,
tlusty_status = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE tlusty_status END,
synspec_status = ?7
synspec_status = CASE WHEN ?9 THEN NULL WHEN ?7 IS NOT NULL THEN ?7 ELSE synspec_status END,
summary_json = CASE WHEN ?8 IS NOT NULL THEN ?8 ELSE summary_json END
WHERE name = ?4 AND workflow_name = ?5 AND status != 'completed'",
params![
report_cloned.elapsed_sec,
@@ -519,16 +598,23 @@ impl Database {
point_name,
wf,
tlusty_status,
synspec_status
synspec_status,
merged_summary_json,
clear_synspec,
],
)?
} else {
tx.execute(
// synspec_status 同步加 CASE 守卫(对称修复):synspec_enabled=0 时
// synspec_status 为 None(上方 if-else 已 gate)→ 保留 prior。
// 与 success 分支、tlusty_status 守卫口径一致。
// last_elapsed_sec 语义见 success 分支注释。
"UPDATE grid_points SET status = 'failed', last_elapsed_sec = ?1,
tlusty_status = CASE WHEN ?4 IS NOT NULL THEN ?4 ELSE tlusty_status END,
synspec_status = ?5
synspec_status = CASE WHEN ?5 IS NOT NULL THEN ?5 ELSE synspec_status END,
summary_json = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE summary_json END
WHERE name = ?2 AND workflow_name = ?3 AND status NOT IN ('completed', 'failed')",
params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status],
params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status, merged_summary_json],
)?
};
+3 -2
View File
@@ -325,8 +325,6 @@ async fn main() -> Result<()> {
let report_router = Router::new()
.route("/task/report", post(api::task::report_task))
// 历史种子导入同样上传 .7 大气文件,并入宽松 body limit / 并发限流组。
.route("/admin/import_seed", post(api::task::import_seed))
.layer(DefaultBodyLimit::max(REPORT_BODY_LIMIT))
.layer(tower::ServiceBuilder::new().concurrency_limit(REPORT_MAX_CONCURRENCY));
@@ -426,6 +424,9 @@ async fn main() -> Result<()> {
"/admin/nodes/:node_id/quota",
post(api::admin::set_node_quota),
)
// 临时迁移端点:扫 seeds_dir 的 conv.json → 写 grid_points.summary_json。
// 迁移完成后删除本路由 + api/migrate.rs 即可。
.route("/admin/migrate_conv", post(api::migrate::migrate_conv))
.layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT));
// 合并两个子 router:各自携带自己的 body limit,互不覆盖。
+129 -1
View File
@@ -261,7 +261,7 @@ impl GridScheduler {
/// 读取指定工作流的 TLUSTY 物理迭代步进链(`config::GridConfig.tlusty_chain`),
/// 序列化为 JSON Value 供 TaskSpec 携带。节点 executor 反序列化为 `Vec<ChainStep>`
/// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax/metals
/// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax
/// 等阶段参数真正生效(此前 executor 硬编码用 default 链,忽略用户配置)。
/// 工作流未配置 tlusty_chain(空数组)→ Noneexecutor 用 default 链兜底)。
async fn get_workflow_tlusty_chain(&self, workflow_name: &str) -> Option<serde_json::Value> {
@@ -273,6 +273,19 @@ impl GridScheduler {
serde_json::to_value(&cfg.tlusty_chain).ok()
}
/// 读取指定工作流的种子热启动链(`config::GridConfig.seed_chain`),序列化为 JSON
/// Value 供 TaskSpec 携带。仅 seed_step 策略下由 executor 读取。
/// 与 `get_workflow_tlusty_chain` 对称。工作流未配置 seed_chain(空数组)→ None
///executor 用 `default_seed_chain()` 兜底)。
async fn get_workflow_seed_chain(&self, workflow_name: &str) -> Option<serde_json::Value> {
let wf = self.db.get_workflow(workflow_name).await.ok()??;
let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "seed_chain")?;
if cfg.seed_chain.is_empty() {
return None;
}
serde_json::to_value(&cfg.seed_chain).ok()
}
/// 读取指定工作流的 TLUSTY 输入文件全局参数(`config::GridConfig.tlusty_input`),
/// 序列化为 JSON Value 供 TaskSpec 携带。包含 NFREAD 频率网格、ions 能级表、
/// nst extra_keys 等不随阶段变化的参数。节点 executor 反序列化为 `TlustyInput`
@@ -286,6 +299,38 @@ impl GridScheduler {
.and_then(|t| serde_json::to_value(t).ok())
}
/// 一次性读取工作流的全部物理校验阈值(能量守恒 / 温度结构 / emflux)。
/// 统一读取避免对同一 YAML 多次解析。返回 8 元组,对应 TaskSpec 的 8 个标量字段:
/// (energy_tolerance, temp_max_factor, temp_floor, temp_ceiling, emflux_tolerance,
/// convergence_min_ratio, bfac_max, bfac_min)。
async fn get_workflow_validation_thresholds(
&self,
workflow_name: &str,
) -> Option<(
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
)> {
let wf = self.db.get_workflow(workflow_name).await.ok()??;
let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "validation_thresholds")?;
Some((
cfg.energy_tolerance,
cfg.temp_max_factor,
cfg.temp_floor,
cfg.temp_ceiling,
cfg.emflux_tolerance,
cfg.convergence_min_ratio,
cfg.bfac_max,
cfg.bfac_min,
))
}
/// 从策略链解析出「首个可派发」的顺位(见 docs/task_engine_decoupling_design.md §4.2)。
///
/// 判定:
@@ -404,7 +449,21 @@ impl GridScheduler {
let (tlusty_cfg, synspec_cfg) = self.get_workflow_stage_configs(workflow_name).await;
let synspec_params = self.get_workflow_synspec_params(workflow_name).await;
let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await;
let seed_chain = self.get_workflow_seed_chain(workflow_name).await;
let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await;
let (
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
) = self
.get_workflow_validation_thresholds(workflow_name)
.await
.unwrap_or((None, None, None, None, None, None, None, None));
// 双阶段全关是退化配置(save_workflow 已拦截,此处兜底防御):无可执行阶段,
// 整工作流跳过派发(修复审查 #5)。
@@ -538,6 +597,7 @@ impl GridScheduler {
synspec_config: synspec_cfg.clone(),
synspec_params: synspec_params.clone(),
tlusty_chain_params: tlusty_chain.clone(),
seed_chain_params: seed_chain.clone(),
tlusty_input_params: tlusty_input.clone(),
// 显式绑定大气来源(设计 §5.2,修复审查 #3):仅 SYNSPEC-onlyTLUSTY 关闭)
// 场景需要外部大气——节点凭 atmosphere_ref(或 point_name 兜底)从本地归档
@@ -547,6 +607,14 @@ impl GridScheduler {
} else {
Some(name.clone())
},
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
};
self.db.insert_task(&task_spec).await?;
@@ -834,7 +902,21 @@ impl GridScheduler {
let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await;
let synspec_params = self.get_workflow_synspec_params(workflow_name).await;
let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await;
let seed_chain = self.get_workflow_seed_chain(workflow_name).await;
let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await;
let (
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
) = self
.get_workflow_validation_thresholds(workflow_name)
.await
.unwrap_or((None, None, None, None, None, None, None, None));
// SYNSPEC 链回退:重试光谱合成。无邻居种子门控(大气来自目标点自身既有产物,
// 见 docs/task_engine_decoupling_design.md §5)——旧实现把 synspec 失败误归因到
@@ -874,8 +956,18 @@ impl GridScheduler {
synspec_params,
// TLUSTY 已关闭(半失败重试只重跑光谱),不执行 chain/input → None。
tlusty_chain_params: None,
seed_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: Some(name.to_string()),
// 不重算大气 → 不做物理正确性校验。
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
self.db.insert_task(&task_spec).await?;
self.db
@@ -959,8 +1051,17 @@ impl GridScheduler {
synspec_config: synspec_cfg.clone(),
synspec_params,
tlusty_chain_params: tlusty_chain.clone(),
seed_chain_params: seed_chain.clone(),
tlusty_input_params: tlusty_input.clone(),
atmosphere_ref: None,
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
};
self.db.insert_task(&task_spec).await?;
@@ -1018,6 +1119,7 @@ mod tests {
logo: vec![(-2.0).into()],
},
tlusty_chain: vec![],
seed_chain: vec![],
tlusty_input: None,
synspec_input: None,
nworkers: 4,
@@ -1030,6 +1132,14 @@ mod tests {
linelist: None,
tlusty_stage: None,
synspec_stage: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
scheduler.initialize_grid(&cfg, "test_wf").await.unwrap();
@@ -1075,6 +1185,7 @@ mod tests {
logo: vec![(-2.0).into()],
},
tlusty_chain: vec![],
seed_chain: vec![],
tlusty_input: None,
synspec_input: None,
nworkers: 4,
@@ -1087,6 +1198,14 @@ mod tests {
linelist: None,
tlusty_stage: None,
synspec_stage: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
// wf_a 初始化并推入队列
@@ -1195,6 +1314,7 @@ mod tests {
logo: vec![(-2.0).into()],
},
tlusty_chain: vec![],
seed_chain: vec![],
tlusty_input: None,
synspec_input: None,
nworkers: 4,
@@ -1207,6 +1327,14 @@ mod tests {
linelist: None,
tlusty_stage: None,
synspec_stage: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
cfg
}
+66 -357
View File
@@ -2,11 +2,39 @@ use axum::{
body::Body,
http::{Request, StatusCode},
};
use common::models::{GridPointParams, ModelSummary};
use mq::sqlite_queue::SqliteTaskQueue;
use server::{api::AppState, db::Database, scheduler::GridScheduler};
use std::sync::Arc;
use tower::ServiceExt; // for oneshot
/// 测试辅助:把一个已 upsert 的点标记为导入收敛(写 summary_json + status=completed)。
/// 等价旧 mark_grid_point_imported。
async fn mark_imported(db: &Database, name: &str, wf: &str, params: &GridPointParams, method: &str) {
let summary = ModelSummary {
name: name.to_string(),
params: params.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: Some(0.001),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 0.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
db.upsert_point_summary(name, wf, &summary, method)
.await
.unwrap();
}
#[tokio::test]
async fn test_server_api_flow() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -1256,343 +1284,6 @@ async fn test_cors_same_origin_and_local_policy() {
assert!(res.headers().get("access-control-allow-origin").is_none());
}
/// 构造一个合法的旧版 conv.json (ModelSummary) 文本,用于 import_seed 测试。
/// 关键:name 用源精度真名(`g5.0`),验证服务端逐字符保真落库。
fn make_legacy_conv_json(name: &str, converged: bool) -> String {
let summary = serde_json::json!({
"name": name,
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"stages": [],
"converged": converged,
"final_max_relc": 0.000321,
"final_chmax": null,
"seed": null,
"atmosphere_has_nan": false,
"synspec_rc": 0,
"synspec_error": null,
"synspec_sec": 1.5,
"elapsed_sec": 42.0,
"note": null,
});
serde_json::to_string(&summary).unwrap()
}
/// 构造**旧版 Python run_one.py 形态**的 conv.jsonstage 含 itek_attempts/final 嵌套,
/// 顶层含 elapsed_sec/synspec_*)——验证 import_seed 对真实历史数据的解析兼容性。
fn make_python_legacy_conv_json(name: &str) -> String {
serde_json::json!({
"name": name,
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"stages": [
{"label": "lte", "chmax": null, "lte": "T",
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0},
"best_max_relc": 0.0, "elapsed_sec": 2.1},
{"label": "nl", "chmax": null, "lte": "F",
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321,
"worst_depth": 1, "last_iter": 17, "n_depths": 50}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321,
"worst_depth": 1, "last_iter": 17, "n_depths": 50},
"best_max_relc": 0.000321, "elapsed_sec": 640.0}
],
"converged": true,
"final_max_relc": 0.000321,
"final_chmax": null,
"seed": null,
"atmosphere_has_nan": false,
"synspec_rc": 0,
"synspec_sec": 3.1,
"elapsed_sec": 715.0,
})
.to_string()
}
/// 构造一个 multipart/form-data body,含 report(JSON 文本) + seed_file(二进制)。
fn make_import_multipart(
boundary: &str,
report_json: &str,
seed_bytes: &[u8],
seed_name: &str,
success_method: &str,
) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"report\"\r\n");
body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
body.extend_from_slice(report_json.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"seed_file\"; filename=\"{}\"\r\n",
seed_name
)
.as_bytes(),
);
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(seed_bytes);
body.extend_from_slice(b"\r\n");
// 大气收敛途径字段(cold_run/seed_step):模拟 import_results 工具判定后透传的途径。
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"tlusty_success_method\"\r\n");
body.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
body.extend_from_slice(success_method.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
body
}
#[tokio::test]
async fn test_import_seed_admin_endpoint() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("import_db.db");
let queue_db_path = temp_dir.path().join("import_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
let state = AppState {
db: db.clone(),
queue,
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let api_router = axum::Router::new().route(
"/admin/import_seed",
axum::routing::post(server::api::task::import_seed),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
// 1. 无 admin token → 401
let conv = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4", true);
let body_bytes = make_import_multipart(
"boundary1",
&conv,
b"FAKE_ATMOS_7",
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
"cold_run",
);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("content-type", "multipart/form-data; boundary=boundary1")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
// 2. admin token + 收敛点 → 200conv.json + .7 落地,grid_points=converged
let body_bytes = make_import_multipart(
"boundary2",
&conv,
b"FAKE_ATMOS_7",
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
"cold_run",
);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundary2")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// 关键断言:磁盘目录名用旧 conv.json 的源精度真名(g5.0),conv.json 与 .7 均落地。
let point_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4");
assert!(point_dir.join("conv.json").is_file(), "conv.json 应落地");
assert!(
point_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4.7").is_file(),
".7 种子文件应落地"
);
// grid_points 应被幂等 upsert 且标记为 converged。
let gp = db
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import")
.await
.unwrap()
.expect("grid_points 应存在");
assert_eq!(gp.0, "completed", "导入的收敛点应为 converged 状态");
// 3. 幂等:重复导入同名点不应报错,状态仍 converged。
let body_bytes = make_import_multipart(
"boundary3",
&conv,
b"FAKE_ATMOS_7_AGAIN",
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
"cold_run",
);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundary3")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let gp = db
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import")
.await
.unwrap()
.unwrap();
assert_eq!(gp.0, "completed");
// 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。
let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false);
let body_bytes =
make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run");
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundary4")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let fail_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4_fail");
assert!(
fail_dir.join("conv.json").is_file(),
"未收敛点仍应记录 conv.json"
);
assert!(
!fail_dir
.join("t20000_g5.0_he-2_c-4_n-4_o-4_fail.7")
.exists(),
"未收敛点不应写 .7 种子"
);
}
/// 旧版 Python run_one.py conv.json 的完整导入链路(import_results 工具的服务端侧):
/// 嵌套 stagesitek_attempts/final)应被正常解析,收敛标记落地,且旧版的
/// elapsed_sec 经 P3 迁移链路进入 grid_points.last_elapsed_sec(迁移完整性)。
#[tokio::test]
async fn test_import_seed_python_legacy_conv_json() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("import_legacy_db.db");
let queue_db_path = temp_dir.path().join("import_legacy_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
let state = AppState {
db: db.clone(),
queue,
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let api_router = axum::Router::new().route(
"/admin/import_seed",
axum::routing::post(server::api::task::import_seed),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let conv = make_python_legacy_conv_json(name);
let body_bytes = make_import_multipart(
"boundaryL",
&conv,
b"FAKE_ATMOS_7",
&format!("{name}.7"),
"cold_run",
);
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_legacy")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundaryL")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"旧版嵌套 stages 的 conv.json 应被接受"
);
// grid_pointsconverged + cold_run 手段(旧版 conv.json 无 seed_nc 阶段)+ 旧版 elapsed_sec 已落库
let row = db
.get_workflow_point_row("wf_legacy", name)
.await
.unwrap()
.expect("grid_points 应存在");
assert_eq!(row.status, "completed");
assert_eq!(row.tlusty_success_method.as_deref(), Some("cold_run"));
assert_eq!(
row.last_elapsed_sec,
Some(715.0),
"旧版 conv.json 的单点耗时应完整迁移到 last_elapsed_sec"
);
// conv.json 原文落盘(嵌套诊断数据 itek_attempts/final 不丢失,详情页解析走兼容路径)
assert!(seeds_dir.join(name).join("conv.json").is_file());
assert!(seeds_dir.join(name).join(format!("{name}.7")).is_file());
}
#[tokio::test]
async fn test_node_disable_enable_flow() {
@@ -2145,7 +1836,7 @@ async fn dispatch_and_report(
task_id,
point_name: p.model_name(),
params: p.clone(),
seed_point_name: seed,
seed_point_name: seed.clone(),
timeout_sec: 7200,
workflow_name: Some(wf.to_string()),
wave: 0,
@@ -2157,6 +1848,26 @@ async fn dispatch_and_report(
..Default::default()
};
db.insert_task(&spec).await.unwrap();
// 构造合法 ModelSummary 作为 summary_json(让 record_task_report 能解析合并写入 grid_points)。
let summary = common::models::ModelSummary {
name: p.model_name(),
params: p.clone(),
stages: Vec::new(),
result_valid: converged,
final_max_relc: if converged { Some(0.0005) } else { Some(9.5e5) },
final_chmax: Some(0.001),
seed,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 120.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report = common::models::TaskReport {
task_id,
point_name: p.model_name(),
@@ -2176,7 +1887,7 @@ async fn dispatch_and_report(
} else {
Some("nl stage diverged".to_string())
},
summary_json: "{}".to_string(),
summary_json: serde_json::to_string(&summary).unwrap(),
failed_stage: None,
};
db.record_task_report(&report, wf).await.unwrap();
@@ -2292,9 +2003,7 @@ async fn test_wf_stats_endpoint() {
)
.await;
dispatch_and_report(&db, "wf_stats", &p_failed, "cold_run", None, false).await;
db.mark_grid_point_imported(&p_imported.model_name(), "wf_stats", None, "cold_run")
.await
.unwrap();
mark_imported(&db, &p_imported.model_name(), "wf_stats", &p_imported, "cold_run").await;
// 回拨任务创建时间:测试内 insert/report 同秒完成,墙钟差为 0 会被 ETA 估算
// 过滤(avg 必须 > 0);造 120s 的真实感样本,使 avg_point_sec/eta_sec 非空。
@@ -2456,9 +2165,7 @@ async fn seed_obs_fixture(db: &Database, db_path: &std::path::Path, wf: &str) ->
true,
)
.await;
db.mark_grid_point_imported(&p_imported.model_name(), wf, None, "cold_run")
.await
.unwrap();
mark_imported(&db, &p_imported.model_name(), wf, &p_imported, "cold_run").await;
let conn = rusqlite::Connection::open(db_path).unwrap();
conn.execute(
@@ -2709,14 +2416,9 @@ async fn test_point_detail_endpoint() {
let n = seed_obs_fixture(&db, &db_path, "wf_pd").await;
// 给 cold 点写一份合法 conv.jsonrescued 刻意不写,验证 null 降级)
let cold_dir = seeds_dir.join(&n.cold);
std::fs::create_dir_all(&cold_dir).unwrap();
std::fs::write(
cold_dir.join("conv.json"),
make_legacy_conv_json(&n.cold, true),
)
.unwrap();
// conv 诊断面板数据源已改为 grid_points.summary_jsondispatch_and_report 写入),
// 不再需要磁盘 conv.json。rescued 点最终 seed_step 成功 → 也有 summary_json
// 但此处验证 null 降级用 pending 点(未 report,无 summary)。
async fn get_detail(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
let res = app
@@ -2757,10 +2459,11 @@ async fn test_point_detail_endpoint() {
assert_eq!(attempts.len(), 1);
assert!(attempts[0]["seed_point_name"].is_null(), "冷启动无种子来源");
assert_eq!(attempts[0]["status"], "completed");
assert_eq!(data["conv"]["result_valid"], true, "conv.json 应被解析");
assert_eq!(data["conv"]["final_max_relc"], 0.000321);
assert_eq!(data["conv"]["result_valid"], true, "summary_json 应被解析");
assert_eq!(data["conv"]["final_max_relc"], 0.0005);
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回)conv 为 null ----
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回)----
// 最终 seed_step 成功 → record_task_report 写入 summary_jsonconv 非 null。
let uri = format!("/api/workflows/wf_pd/points/{}", n.rescued);
let (st, data) = get_detail(&app, &uri).await;
assert_eq!(st, StatusCode::OK);
@@ -2775,7 +2478,13 @@ async fn test_point_detail_endpoint() {
attempts[1]["seed_point_name"], n.cold,
"种子来源应为 cold 点"
);
assert!(data["conv"].is_null(), "无 conv.json 应返回 null 而非报错");
assert_eq!(data["conv"]["result_valid"], true, "rescued 最终成功应有 summary");
// ---- 3b. pending 点:从未 report → 无 summary_json → conv 为 null(降级不报错)----
let uri = format!("/api/workflows/wf_pd/points/{}", n.pending);
let (st, data) = get_detail(&app, &uri).await;
assert_eq!(st, StatusCode::OK);
assert!(data["conv"].is_null(), "未结算点无 summary 应返回 null");
// ---- 4. 路径穿越 / 非法字符 → 400 ----
for bad in ["..%2Fevil", "a%2Fb", ".hidden", ".."] {
+51 -30
View File
@@ -1,10 +1,10 @@
//! 验证「历史种子导入的工作流名」与「正式工作流名」的隔离关系。
//!
//! 用户意图:import_results 把旧 Python 计算结果导入,标记为已完成,避免重算。
//! 用户意图:离线导入工具把旧计算结果导入,标记为已完成,避免重算。
//! 关键问题:导入到工作流 A,之后正式启动工作流 B(同名/异名),B 能否看到 A 标记的 converged
use common::config::GridConfig;
use common::models::GridPointParams;
use common::models::{GridPointParams, ModelSummary};
use mq::sqlite_queue::SqliteTaskQueue;
use server::{db::Database, scheduler::GridScheduler};
use std::sync::Arc;
@@ -21,6 +21,47 @@ fn make_params() -> GridPointParams {
}
}
/// 构造一个收敛的 ModelSummaryresult_valid=true, atmosphere_has_nan=false),
/// 用 point_name 作权威名。供测试模拟离线导入写入 summary_json。
fn make_converged_summary(name: &str, params: &GridPointParams) -> ModelSummary {
ModelSummary {
name: name.to_string(),
params: params.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: Some(0.001),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 0.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
}
}
/// 测试辅助:模拟离线导入——upsert 点 + 写收敛 summary(等价旧 mark_grid_point_imported)。
async fn mark_imported(
db: &Database,
name: &str,
workflow: &str,
params: &GridPointParams,
method: &str,
) {
db.upsert_grid_point_named(name, params, 0, workflow)
.await
.unwrap();
let summary = make_converged_summary(name, params);
db.upsert_point_summary(name, workflow, &summary, method)
.await
.unwrap();
}
/// 构造只含一个网格点(t20000_g5.0_he-2_c-4_n-4_o-4)的 config。
fn make_grid_cfg() -> GridConfig {
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
@@ -49,13 +90,8 @@ async fn test_same_workflow_name_preserves_converged() {
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let p = make_params();
// 模拟 import_seedupsert + mark_imported,工作流名 = sdB_cno
db.upsert_grid_point_named(name, &p, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported(name, "sdB_cno", None, "cold_run")
.await
.unwrap();
// 模拟离线导入:upsert + 写收敛 summary,工作流名 = sdB_cno
mark_imported(&db, name, "sdB_cno", &p, "cold_run").await;
// 之后正式启动同名工作流:initialize_grid(sdB_cno)
sched
@@ -80,13 +116,8 @@ async fn test_different_workflow_name_causes_recompute() {
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let p = make_params();
// 模拟 import_seed:导入到 "imported" 工作流
db.upsert_grid_point_named(name, &p, 0, "imported")
.await
.unwrap();
db.mark_grid_point_imported(name, "imported", None, "cold_run")
.await
.unwrap();
// 模拟离线导入:导入到 "imported" 工作流
mark_imported(&db, name, "imported", &p, "cold_run").await;
// 之后正式启动 "sdB_cno" 工作流
sched
@@ -118,13 +149,8 @@ async fn test_mixed_grid_import_then_init_avoids_recompute() {
let (db, sched) = setup().await;
let p_old = make_params(); // t20000_g5.0_...
// 模拟 import_seed:旧网格里这个点已收敛,导入到 sdB_cno
db.upsert_grid_point_named("t20000_g5.0_he-2_c-4_n-4_o-4", &p_old, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", None, "cold_run")
.await
.unwrap();
// 模拟离线导入:旧网格里这个点已收敛,导入到 sdB_cno
mark_imported(&db, "t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", &p_old, "cold_run").await;
// 正式启动 sdB_cno,config 比旧网格多了一个新点(t25000)
let yaml = "grid:\n teff: [20000, 25000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
@@ -172,13 +198,8 @@ async fn test_precision_diff_import_then_init_preserves_converged() {
logo: GridAxisValue::from_value(-4.0),
};
// 模拟 import_results 重写 name 后入库:grid_points.name = canonical(g5.0)
db.upsert_grid_point_named(canonical, &p, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported(canonical, "sdB_cno", None, "cold_run")
.await
.unwrap();
// 模拟离线导入重写 name 后入库:grid_points.name = canonical(g5.0)
mark_imported(&db, canonical, "sdB_cno", &p, "cold_run").await;
// 启动同名工作流:initialize_grid 用配置 model_name()(=g5.0) 插入
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";