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
+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,
};