收敛攻坚(81/400 失败点根因与实测,见 docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md): - runner: 新增 seed_step_stab 策略链——同物理族(同 Teff/logg/logHe)CNO 邻居种子 + DPSILG=3.0 λ 算子欠松弛 + POPZER=1E-10 微布居置零(联动 POPZR2/RADZER 同值 + NITZER=1),针对 20kK He 富大气 He I/II 电离前沿布居极限环 - runner: 三级链内延拓回退(主链与 nl_direct 全败后自动触发): · 固定 ladder 步进——plan_ladder_steps 按归一化间隔选轴,Δlogg≤0.25/ΔTeff≤2.5kK,≤4 步 · 自适应 Teff 延拓——步长 1250K 起、成功 ×1.5 恢复、失败二分至 25K 折叠墙,≤48 步 · C/N 丰度轴延拓——高温域(Teff>30kK)专用,严格同族种子沿 C(优先)/N 轴 0.2 dex 起步 延拓阶段 NITER 下限提至 300(慢收敛 waypoint 迭代饥饿误判修复) - runner: 稳定化多档回退(DPSILG/POPZER 三档互补,联合回收 41/65); 域门控 Teff≤30kK——高温高金属域实测旋钮致散(17 拍爆至 4e16),域外跳过 - 梯级种子持久化: 收敛中间模型登记 ladder_seeds 随上报落 server seeds 表 (任务失败也上传,合成名 _ladder 与真实网格点零冲突),簇内相邻失败点自动复用 调度与执行: - scheduler: 策略解析新增 seed_step_stab 臂——find_exact_family_seed_from_db 严格 同物理族判定(不做 global 退化,防 ladder 中间种子 ΔTeff≤5000K 误命中), 排除本点历史已用种子实现重试轮换;链在 stab 耗尽时轮换未试过同族邻居重派 - executor: seed_step_stab 补种子下载(漏列曾致 78 任务假失败,seed_nc 无 fort.8 崩溃); Teff>30kK 域外自动降级普通种子链 收敛判据: - conv_check: 热启动豁免——首拍 max_relc<1(种子已近解)时首末比 1e3 判据数学上 不可达,豁免后交五重物理硬门槛裁决(修复 nl_ladder 0.038→6e-4 物理全过被误杀); 冷启动仍受判据门控 workflow 生命周期: - workflows/tasks: 完成 flip 增加「未消费回退链」阻塞子句——failed 点策略链未耗尽 或链尾 seed_step_stab 尚有未试过同族种子时不得置 completed(修复最后活跃点 cold 失败上报抢先 flip、still_running 守卫拦截后续策略永不派发);按 failed_stage 归因 (synspec 失败行只看 synspec 链,防 stale 审计副本永久卡死)+ json_valid 脏行防护 统计与前端: - 统计新增权威口径 tlusty_converged(不按策略拆)与 seed_step_stab_converged 分项, 前端详情页色带/概览卡消费权威总数并新增稳定化青色段(修复 stab 收敛点漏计, 生产 9137/9216 差额);M14 迁移回填历史 completed 点的 tlusty_status 文档: - 新增 failed81 POPZER/DPSILG 制胜配方根因分析、Windows 节点经跳板 RDP 运维手册; failed400 增补 ladder 生产化实现与第二轮 121 残点实测矩阵
2484 lines
117 KiB
Rust
2484 lines
117 KiB
Rust
use crate::config::{ChainStep, SynspecInput, TlustyInput};
|
||
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;
|
||
use crate::models::{GridPointParams, ModelSummary, StepSummary};
|
||
use crate::nst_writer::generate_nst_content;
|
||
use anyhow::Result;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Stdio;
|
||
use std::time::Instant;
|
||
use tokio::fs::File;
|
||
use tokio::process::Command as AsyncCommand;
|
||
use tracing::{info, warn};
|
||
|
||
/// 阶梯/延拓阶段(Teff 自适应延拓、固定梯、丰度轴延拓)的迭代下限。
|
||
///
|
||
/// 这些阶段克隆自 nl 步(NITER=100),而慢收敛 waypoint 常在 chmax=0.001 门槛前
|
||
/// 耗尽迭代——生产实证(2026-08-21,t60000_g5.0_he-4_c-4_n-4_o-1):N 轴延拓
|
||
/// 在 n=-3.53 处 best=0.002/0.005 卡满 100 迭代且仍在单调下降(迭代饥饿而非
|
||
/// 发散),二分触底 0.025 dex 后被误判为轴折叠。TLUSTY 收敛即停,本上限只给
|
||
/// 慢阶段 3× 余量,不影响快阶段;真发散步会提前 STOP in SOLVE,也不受损。
|
||
const LADDER_STAGE_NITER: i32 = 300;
|
||
|
||
pub fn default_cold_chain() -> Vec<ChainStep> {
|
||
vec![
|
||
ChainStep {
|
||
label: "lte".to_string(),
|
||
lte: "T".to_string(),
|
||
ltgray: "T".to_string(),
|
||
ilvlin: 0,
|
||
require_converged: false,
|
||
niter: 0,
|
||
chmax: None,
|
||
itek: None,
|
||
ichang: None,
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: None,
|
||
dpsilg: None,
|
||
popzer: None,
|
||
},
|
||
ChainStep {
|
||
label: "nc".to_string(),
|
||
lte: "F".to_string(),
|
||
ltgray: "F".to_string(),
|
||
ilvlin: 0,
|
||
require_converged: false,
|
||
niter: 10,
|
||
chmax: None,
|
||
itek: None,
|
||
ichang: None,
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: None,
|
||
dpsilg: None,
|
||
popzer: None,
|
||
},
|
||
ChainStep {
|
||
label: "nl".to_string(),
|
||
lte: "F".to_string(),
|
||
ltgray: "F".to_string(),
|
||
ilvlin: 100,
|
||
require_converged: true,
|
||
niter: 100,
|
||
chmax: None,
|
||
itek: None,
|
||
ichang: None,
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: None,
|
||
dpsilg: None,
|
||
popzer: None,
|
||
},
|
||
]
|
||
}
|
||
|
||
pub fn default_seed_chain() -> Vec<ChainStep> {
|
||
vec![
|
||
ChainStep {
|
||
label: "seed_nc".to_string(),
|
||
lte: "F".to_string(),
|
||
ltgray: "F".to_string(),
|
||
ilvlin: 0,
|
||
require_converged: false,
|
||
niter: 20,
|
||
chmax: None,
|
||
itek: None,
|
||
ichang: Some(0),
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: Some(0.3),
|
||
dpsilg: None,
|
||
popzer: None,
|
||
},
|
||
ChainStep {
|
||
label: "nl".to_string(),
|
||
lte: "F".to_string(),
|
||
ltgray: "F".to_string(),
|
||
ilvlin: 100,
|
||
require_converged: true,
|
||
niter: 100,
|
||
chmax: None,
|
||
itek: None,
|
||
ichang: Some(0),
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: Some(0.5),
|
||
dpsilg: None,
|
||
popzer: None,
|
||
},
|
||
]
|
||
}
|
||
|
||
/// 稳定化种子步进链(策略 `seed_step_stab`)。
|
||
///
|
||
/// 面向难收敛角落(2026-08-18 实测 20kK He 富大气 He I/II 电离前沿布居极限环):
|
||
/// 同物理族(同 Teff/logg/logHe)不同 CNO 的已收敛邻居种子 + POPZER 微布居置零 +
|
||
/// DPSILG λ 算子欠松弛。代表点 t20000_g6.5_he2_c-4_n-3_o-4 其余配置组合全部发散,
|
||
/// 本配方收敛且五重物理硬门全过(emflux 1.0013、首末比 4.5e5)。
|
||
pub fn default_seed_stab_chain() -> Vec<ChainStep> {
|
||
default_seed_chain()
|
||
.into_iter()
|
||
.map(|mut s| {
|
||
s.dpsilg = Some(3.0);
|
||
s.popzer = Some(1e-10);
|
||
s
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 按当前策略选默认执行链(`custom_chain` 为 None/空时的兜底)。
|
||
///
|
||
/// Phase 6(P8)起取代废弃的 task_type 匹配:`"seed_step"` → 种子热启动链,
|
||
/// `"seed_step_stab"` → 稳定化种子链(POPZER+DPSILG),其余策略(`cold_run` 等)
|
||
/// → 冷启动链。executor 现优先使用 TaskSpec.tlusty_chain_params
|
||
/// (用户 YAML `tlusty_chain:` 配置),None/空才回退本函数的默认链。
|
||
pub fn default_chain_for_strategy(current_strategy: &str) -> Vec<ChainStep> {
|
||
if current_strategy == "seed_step" {
|
||
default_seed_chain()
|
||
} else if current_strategy == "seed_step_stab" {
|
||
default_seed_stab_chain()
|
||
} else {
|
||
default_cold_chain()
|
||
}
|
||
}
|
||
|
||
/// ladder 步进规划(纯函数):给定种子参数与目标参数,产出中间步坐标。
|
||
///
|
||
/// 规则(2026-08-17 实测验证:test/20260817_failed400/ladder_*):
|
||
/// - 只沿与种子差值更大的轴步进(归一化:Δlogg/0.25 vs Δteff/2500);
|
||
/// - 步长上限 Δlogg=0.25、ΔTeff=2500K,中间步数 ≤4;
|
||
/// - 间隔已在单步收敛域内(Δlogg≤0.25 且 ΔTeff≤2500)→ 返回空(无需 ladder,
|
||
/// 该场景本就不该触发);
|
||
/// - 均匀切分:n = ceil(归一化间隔),每步走 gap/n。
|
||
///
|
||
/// 返回 (teff, logg, label) 列表,label 如 `ladder_g6.25` / `ladder_t57.5k`。
|
||
/// 目标步不在其中(由调用方用原 ChainStep 跑目标参数)。
|
||
pub fn plan_ladder_steps(
|
||
from_teff: f64,
|
||
from_logg: f64,
|
||
to_teff: f64,
|
||
to_logg: f64,
|
||
) -> Vec<(f64, f64, String)> {
|
||
let d_logg = (to_logg - from_logg).abs();
|
||
let d_teff = (to_teff - from_teff).abs();
|
||
// 归一化到"单步上限"的单位数
|
||
let n_logg = (d_logg / 0.25).ceil() as usize;
|
||
let n_teff = (d_teff / 2500.0).ceil() as usize;
|
||
// 双轴都在单步收敛域内(含恰好等于上限)→ 无需 ladder(direct nl 即可覆盖)
|
||
if n_logg <= 1 && n_teff <= 1 {
|
||
return Vec::new();
|
||
}
|
||
// 轴选择:需要更多步的轴(间隔更远 = 收敛域外的主因)
|
||
let use_logg = n_logg >= n_teff;
|
||
let n = if use_logg { n_logg } else { n_teff }.clamp(1, 4);
|
||
(1..=n)
|
||
.map(|k| {
|
||
let f = k as f64 / n as f64;
|
||
let (t, g) = if use_logg {
|
||
(to_teff, from_logg + f * (to_logg - from_logg))
|
||
} else {
|
||
(from_teff + f * (to_teff - from_teff), to_logg)
|
||
};
|
||
let label = if use_logg {
|
||
format!("ladder_g{}", (g * 100.0).round() / 100.0)
|
||
} else {
|
||
format!("ladder_t{}k", (t / 1000.0 * 10.0).round() / 10.0)
|
||
};
|
||
(t, g, label)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 运行子进程,带超时与优雅退出(shutdown)感知。
|
||
///
|
||
/// 三种终止路径:
|
||
/// 1. 子进程正常结束 → 返回 ExitStatus。
|
||
/// 2. 超时(timeout_sec)→ SIGKILL 子进程 + 二级 30s 等待 reap,超时则放弃 Child(kill_on_drop 兜底)。
|
||
/// 3. shutdown 信号(节点收到 SIGTERM/SIGINT)→ 立即 SIGKILL 子进程并快速返回 Err,
|
||
/// 让上层尽快退出(在途任务的结果会丢失,由服务端 stale 重投兜底)。
|
||
///
|
||
/// 历史 bug:超时 kill 后 `child.wait().await` 无二级超时,Fortran 进程若卡死
|
||
/// (OpenMP hang / ptrace)会使 wait 永久阻塞,超时机制名存实亡、slot 永久泄漏。
|
||
async fn run_child_async_with_timeout(
|
||
mut child: tokio::process::Child,
|
||
timeout_sec: u64,
|
||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||
) -> Result<std::process::ExitStatus> {
|
||
let timeout_fut =
|
||
tokio::time::timeout(tokio::time::Duration::from_secs(timeout_sec), child.wait());
|
||
|
||
// 若提供了 shutdown 标志,则与超时/正常结束三路 select;否则只等超时/正常结束。
|
||
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown
|
||
{
|
||
let shutdown_watcher = async move {
|
||
// 轮询 shutdown 标志(10ms 粒度足够灵敏,开销可忽略)。
|
||
loop {
|
||
if flag.load(std::sync::atomic::Ordering::Acquire) {
|
||
return;
|
||
}
|
||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||
}
|
||
};
|
||
tokio::select! {
|
||
biased; // 优先响应 shutdown
|
||
_ = shutdown_watcher => Err(ShutdownOrTimeout::Shutdown),
|
||
r = timeout_fut => match r {
|
||
Ok(res) => Ok(res?),
|
||
Err(_) => Err(ShutdownOrTimeout::Timeout),
|
||
},
|
||
}
|
||
} else {
|
||
match timeout_fut.await {
|
||
Ok(res) => Ok(res?),
|
||
Err(_) => Err(ShutdownOrTimeout::Timeout),
|
||
}
|
||
};
|
||
|
||
match outcome {
|
||
Ok(status) => Ok(status),
|
||
Err(ShutdownOrTimeout::Shutdown) => {
|
||
let _ = child.start_kill();
|
||
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
|
||
anyhow::bail!("节点收到退出信号,子进程已被终止");
|
||
}
|
||
Err(ShutdownOrTimeout::Timeout) => {
|
||
let _ = child.start_kill();
|
||
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
|
||
anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
enum ShutdownOrTimeout {
|
||
Shutdown,
|
||
Timeout,
|
||
}
|
||
|
||
/// 快照 TLUSTY 最终模型的 b 因子与出射谱,防止被 SYNSPEC 覆盖丢失。
|
||
///
|
||
/// TLUSTY 在最终迭代(`LFIN=.TRUE.`)经 `OUTPRI` 写出(见 tlusty208.f):
|
||
/// - `fort.12`:b 因子 / 非 LTE 偏离因子表(头 2I5 + 每深度 TEMP/ELEC/DENS/BFAC,格式 701/702/703)。
|
||
/// 随后 SYNSPEC 会复用 unit 12 写谱线证认表并覆盖它(runner 再将其存为 `<name>.iden`),
|
||
/// 故 TLUSTY 的 b 因子若不在此快照即静默丢失。
|
||
/// - `fort.14`:出射谱(波长 Å + Fλ,格式 614),同样会被 SYNSPEC 的谱线数据覆盖。
|
||
///
|
||
/// 在收敛链循环结束(链上最后一次 TLUSTY 运行即最终模型)、SYNSPEC 启动前调用,
|
||
/// 快照为 `<name>.bfac` / `<name>.emflux`,与科学核心产物一并进入归档白名单
|
||
/// (见 `result_filter::is_result_worthy` 的 `bfac`/`emflux` 后缀)。
|
||
/// 文件不存在时静默跳过(TLUSTY 未运行/未写出);IO 错误降级为 warn,不阻断主流程。
|
||
async fn snapshot_tlusty_outputs(model_dir: &Path, name: &str) {
|
||
for (src, suffix) in [("fort.12", "bfac"), ("fort.14", "emflux")] {
|
||
let src_path = model_dir.join(src);
|
||
if !src_path.is_file() {
|
||
continue;
|
||
}
|
||
let dst = model_dir.join(format!("{}.{}", name, suffix));
|
||
if let Err(e) = tokio::fs::copy(&src_path, &dst).await {
|
||
warn!("快照 TLUSTY {} 到 {} 失败: {}", src, dst.display(), e);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub struct ExecutionRunner<'a> {
|
||
pub runtime: &'a RuntimePaths,
|
||
pub work_dir: PathBuf,
|
||
}
|
||
|
||
impl<'a> ExecutionRunner<'a> {
|
||
pub fn new(runtime: &'a RuntimePaths, work_dir: PathBuf) -> Self {
|
||
Self { runtime, work_dir }
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)] // 透传全参给 run_model_with_timeout(后者同 allow)
|
||
pub async fn run_model(
|
||
&self,
|
||
params: &GridPointParams,
|
||
name: &str,
|
||
current_strategy: &str,
|
||
custom_chain: Option<Vec<ChainStep>>,
|
||
seed_atmos: Option<&Path>,
|
||
seed_params: Option<&GridPointParams>,
|
||
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,
|
||
name,
|
||
current_strategy,
|
||
custom_chain,
|
||
seed_atmos,
|
||
seed_params,
|
||
synspec_cfg,
|
||
true,
|
||
true,
|
||
7200,
|
||
None,
|
||
tlusty_input,
|
||
energy_tolerance,
|
||
temp_max_factor,
|
||
temp_floor,
|
||
temp_ceiling,
|
||
emflux_tolerance,
|
||
convergence_min_ratio,
|
||
bfac_max,
|
||
bfac_min,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// 执行收敛链中的单个 TLUSTY 阶段(2026-08-14 从 run_model_with_timeout 的
|
||
/// 阶段循环体抽取,供主链与 nl_direct 回退共用)。
|
||
///
|
||
/// 职责:写 .5/nst 输入 → 按 seed 铺 fort.8 → 运行 tlusty → fort.9 收敛诊断
|
||
/// (含假收敛排查与 STOP 留言提取)→ 快照阶段产物(.5/.6/.err/nst/_chmax*.9)。
|
||
///
|
||
/// 返回 `(StepSummary, 产出的阶段种子路径)`:rc==0 且 fort.7 存在时,
|
||
/// fort.7 被快照为 `<name>.<label>.7` 并作为返回种子路径(供下一阶段 fort.8);
|
||
/// 否则返回 None(调用方保留上一阶段种子)。
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn execute_tlusty_stage(
|
||
&self,
|
||
model_dir: &std::path::Path,
|
||
name: &str,
|
||
params: &GridPointParams,
|
||
stage_def: &ChainStep,
|
||
input_cfg: &TlustyInput,
|
||
seed: Option<&std::path::Path>,
|
||
convergence_min_ratio: Option<f64>,
|
||
timeout_sec: u64,
|
||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||
) -> Result<(StepSummary, Option<std::path::PathBuf>)> {
|
||
let stage_t0 = Instant::now();
|
||
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, input_cfg);
|
||
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
|
||
|
||
// Prepare fort.8 for this stage
|
||
let fort8 = model_dir.join("fort.8");
|
||
if stage_def.ltgray == "T" {
|
||
if fort8.exists() {
|
||
let _ = tokio::fs::remove_file(&fort8).await;
|
||
}
|
||
} else if let Some(s_path) = seed {
|
||
if s_path.is_file() {
|
||
if let Err(e) = tokio::fs::copy(s_path, &fort8).await {
|
||
warn!(
|
||
"阶段 {} 重载候选近邻推算种子模型期间发生文件复制异常: {}",
|
||
stage_def.label, e
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Run tlusty.exe
|
||
let fin = File::open(&input5_path).await?.into_std().await;
|
||
let fout = File::create(model_dir.join(format!("{}.6", name)))
|
||
.await?
|
||
.into_std()
|
||
.await;
|
||
let ferr = File::create(model_dir.join(format!("{}.err", name)))
|
||
.await?
|
||
.into_std()
|
||
.await;
|
||
|
||
let child = AsyncCommand::new(&self.runtime.tlusty_exe)
|
||
.current_dir(model_dir)
|
||
.stdin(Stdio::from(fin))
|
||
.stdout(Stdio::from(fout))
|
||
.stderr(Stdio::from(ferr))
|
||
.kill_on_drop(true)
|
||
.spawn()?;
|
||
|
||
let status_res = run_child_async_with_timeout(child, timeout_sec, shutdown).await;
|
||
let rc = match status_res {
|
||
Ok(st) => st.code().unwrap_or(-1),
|
||
Err(e) => {
|
||
warn!("tlusty 运行失败/超时: {}", e);
|
||
-1
|
||
}
|
||
};
|
||
|
||
let fort9 = model_dir.join("fort.9");
|
||
let fort7 = model_dir.join("fort.7");
|
||
|
||
let mut stage_summary = StepSummary {
|
||
label: stage_def.label.clone(),
|
||
chmax: stage_def.chmax,
|
||
lte: stage_def.lte.clone(),
|
||
converged: false,
|
||
best_max_relc: None,
|
||
elapsed_sec: stage_t0.elapsed().as_secs_f64(),
|
||
note: None,
|
||
last_iter: None,
|
||
worst_depth: None,
|
||
n_depths: None,
|
||
itek_history: Vec::new(),
|
||
conv_trace_check: None,
|
||
};
|
||
|
||
// 产出的阶段种子:rc==0 且 fort.7 存在时快照为阶段种子文件并返回路径。
|
||
let mut produced_seed: Option<std::path::PathBuf> = None;
|
||
|
||
if rc == 0 && fort7.is_file() {
|
||
let eff_chmax = stage_def.chmax.unwrap_or(0.001);
|
||
if fort9.is_file() {
|
||
let res = check_fort9(&fort9, eff_chmax);
|
||
stage_summary.converged = res.converged;
|
||
stage_summary.best_max_relc = Some(res.max_relc);
|
||
// 携带迭代诊断量进 conv.json(旧版在此处丢弃):
|
||
// 迭代数/最差深度点供详情页阶段链展示收敛难度。
|
||
stage_summary.last_iter = res.last_iter;
|
||
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.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 时补(避免覆盖错误信息)。
|
||
if !res.converged && stage_summary.note.is_none() {
|
||
let fort6 = model_dir.join(format!("{}.6", name));
|
||
if let Some(h) = extract_failure_hint(&fort6) {
|
||
stage_summary.note = Some(format!("未收敛 [{}]", h));
|
||
}
|
||
}
|
||
|
||
// Save fort.9 snapshot
|
||
let snap_name = format!("{}.{}_chmax{}.9", name, stage_def.label, eff_chmax);
|
||
let _ = tokio::fs::copy(&fort9, model_dir.join(snap_name)).await;
|
||
} else {
|
||
// fort.9 缺失:按 stage_def.niter 区分两种场景(漏洞2进阶修复)。
|
||
// - niter==0:合法 grey start(lte 阶段不迭代,TLUSTY 不写 fort.9)。
|
||
// converged=true 保留 grey start 语义;best_max_relc=None 不虚构
|
||
// (避免污染 final_max_relc/种子选择)。
|
||
// - niter>0:异常——配了迭代却无 fort.9,通常是 TLUSTY 启动失败
|
||
// (call quit,如 temp 越界)或 IO 异常。判 converged=false,
|
||
// 避免把崩溃误判为收敛。此前两种场景共用无校验分支无法区分。
|
||
if stage_def.niter == 0 {
|
||
stage_summary.converged = true;
|
||
stage_summary.best_max_relc = None;
|
||
stage_summary.note = Some("NITER=0 grey start".to_string());
|
||
} else {
|
||
stage_summary.converged = false;
|
||
stage_summary.best_max_relc = None;
|
||
// 补 fort.6 失败诊断(call quit 留言),便于排查启动失败原因。
|
||
let fort6 = model_dir.join(format!("{}.6", name));
|
||
let hint = extract_failure_hint(&fort6);
|
||
stage_summary.note = Some(match hint {
|
||
Some(h) => format!(
|
||
"stage {} 配置 NITER={} 但 fort.9 缺失 [{}]",
|
||
stage_def.label, stage_def.niter, h
|
||
),
|
||
None => format!(
|
||
"stage {} 配置 NITER={} 但 fort.9 缺失(TLUSTY 未完成迭代)",
|
||
stage_def.label, stage_def.niter
|
||
),
|
||
});
|
||
}
|
||
}
|
||
|
||
// NaN 伪收敛防护:TLUSTY 从 NaN 污染种子启动时会立即崩溃,fort.9 写出
|
||
// 全 0.00E+00(NaN 参与的相对变化算不出,写出零),max_relc=0 < chmax
|
||
// 会被误判收敛(生产实测 261 个点因此跳过 nl_direct 回退直接进门槛失败)。
|
||
// 阶段级防线:converged=true 时复查本阶段 fort.7,含 NaN/Inf/溢出即否决,
|
||
// 使 require_converged 中止逻辑与 nl_direct 回退得以触发;且不产出污染种子。
|
||
if stage_summary.converged && atmosphere_has_nan(&fort7) {
|
||
warn!(
|
||
"阶段 {} fort.9 达标但 fort.7 含 NaN/Inf/溢出,判未收敛(NaN 伪收敛防护)",
|
||
stage_def.label
|
||
);
|
||
stage_summary.converged = false;
|
||
stage_summary.note = Some(
|
||
"未收敛 [阶段大气含 NaN/Inf/溢出(NaN 伪收敛防护:fort.9 全零假达标)]"
|
||
.to_string(),
|
||
);
|
||
}
|
||
|
||
// Copy fort.7 as stage seed
|
||
let stage_seed_path = model_dir.join(format!("{}.{}.7", name, stage_def.label));
|
||
let _ = tokio::fs::copy(&fort7, &stage_seed_path).await;
|
||
if stage_summary.converged {
|
||
produced_seed = Some(stage_seed_path);
|
||
}
|
||
} else {
|
||
// 漏洞5修复:fort.7 缺失分支(输入错误、temp 越界等 call quit 场景),
|
||
// 从 fort.6 尾部提取 call quit / stop 留言补进 note,便于排查。
|
||
let fort6 = model_dir.join(format!("{}.6", name));
|
||
let hint = extract_failure_hint(&fort6);
|
||
stage_summary.note = Some(match hint {
|
||
Some(h) => format!("tlusty rc={} or missing fort.7 [{}]", rc, h),
|
||
None => format!("tlusty rc={} or missing fort.7", rc),
|
||
});
|
||
}
|
||
|
||
// 快照本阶段的同名输入/输出文件,带阶段标签保留。
|
||
// 背景:.5/.6/.err/nst 在每阶段用同名文件覆盖,若不快照则只有最后阶段(nl)
|
||
// 的版本能存活到归档,nc 等中间阶段的日志/输入会丢失。失败阶段的日志对排错
|
||
// 尤其重要,因此此处无条件(不论 rc 是否为 0)快照。
|
||
// 命名风格与上方 .7/.9 快照一致:<name>.<label>.<后缀>(单 name,不重复)。
|
||
// 注意:stage_def.label 由配置保证唯一(lte/nc/nl/seed_nc/nl_direct),
|
||
// 不会与 synspec 产物冲突。
|
||
//
|
||
// 不快照 fort.9:上方已把 fort.9 收敛诊断存为 `<name>.<label>_chmax*.9`
|
||
// (带 chmax 阈值语义),再快照成 `<name>.<label>.9` 会与它内容完全重复。
|
||
// 故 .9 收敛诊断只保留 `_chmax*.9` 一份,不留重复快照。
|
||
// (suffix, full_src_name) —— suffix 用于快照名后缀,full_src_name 用于定位源文件
|
||
for (suffix, full_name) in [
|
||
("5", format!("{}.5", name)),
|
||
("6", format!("{}.6", name)),
|
||
("err", format!("{}.err", name)),
|
||
("nst", "nst".to_string()),
|
||
] {
|
||
let src = model_dir.join(&full_name);
|
||
if src.is_file() {
|
||
let snap = model_dir.join(format!("{}.{}.{}", name, stage_def.label, suffix));
|
||
let _ = tokio::fs::copy(&src, &snap).await;
|
||
}
|
||
}
|
||
|
||
Ok((stage_summary, produced_seed))
|
||
}
|
||
|
||
/// 阶段独立配置执行入口(见 docs/task_engine_decoupling_design.md §5)。
|
||
///
|
||
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
|
||
/// - TLUSTY 关闭:跳过 chain 循环,直接以 seed_atmos(或单独拉取的大气)作 final_7;
|
||
/// - SYNSPEC 关闭:跳过光谱合成块(即便 final_7 存在)。
|
||
///
|
||
/// Phase 6(P8):`current_strategy` 取代废弃的 `task_type`——执行链由
|
||
/// `custom_chain`(executor 按 `tlusty_config.strategies[0]` 显式推导)决定;
|
||
/// 该参数仅用于日志与 custom_chain=None 时的兜底("seed_step"→种子链,否则冷启动链)。
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn run_model_with_timeout(
|
||
&self,
|
||
params: &GridPointParams,
|
||
name: &str,
|
||
current_strategy: &str,
|
||
custom_chain: Option<Vec<ChainStep>>,
|
||
seed_atmos: Option<&Path>,
|
||
seed_params: Option<&crate::models::GridPointParams>,
|
||
synspec_cfg: Option<&SynspecInput>,
|
||
tlusty_enabled: bool,
|
||
synspec_enabled: bool,
|
||
timeout_sec: u64,
|
||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||
tlusty_input: Option<&TlustyInput>,
|
||
energy_tolerance: Option<f64>,
|
||
temp_max_factor: Option<f64>,
|
||
temp_floor: Option<f64>,
|
||
temp_ceiling: Option<f64>,
|
||
emflux_tolerance: Option<f64>,
|
||
convergence_min_ratio: Option<f64>,
|
||
bfac_max: Option<f64>,
|
||
bfac_min: Option<f64>,
|
||
) -> Result<ModelSummary> {
|
||
// `name` 取自权威的 TaskSpec.point_name(DB 的 grid_points.name 列,源精度正确),
|
||
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
|
||
// 回读时用 from_value() 反推文本会丢精度("5.0"→"5"),导致 params.model_name()
|
||
// 产出错误名(g5 而非 g5.0)。point_name 走独立 TEXT 列,精度全程保留。
|
||
// 下游(沙盒子目录、各阶段快照、conv.json.name、归档目录)全部用此 name,
|
||
// 故只需在此处用权威 name 即可让整条链精度正确。
|
||
//
|
||
// 历史:此处曾把 params.model_name() 与 name 对比并 warn 不一致。但该不一致是
|
||
// DB REAL 列回读丢精度的已知现象(runner 端无法修复,根治需改 DB schema 存原文),
|
||
// 且 runner 已全程采用权威 name,对比结果不参与任何决策——故移除这段噪音 warn。
|
||
let model_dir = self.work_dir.join(name);
|
||
tokio::fs::create_dir_all(&model_dir).await?;
|
||
|
||
info!("开始物理计算网格模型 {} (策略: {})", name, current_strategy);
|
||
let t0 = Instant::now();
|
||
|
||
// 1. Data directory symlink setup
|
||
let link_data = model_dir.join("data");
|
||
if tokio::fs::symlink_metadata(&link_data).await.is_ok() || link_data.exists() {
|
||
let _ = tokio::fs::remove_file(&link_data).await;
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
let abs_data_dir = tokio::fs::canonicalize(&self.runtime.data_dir)
|
||
.await
|
||
.unwrap_or_else(|_| self.runtime.data_dir.clone());
|
||
if let Err(e) = std::os::unix::fs::symlink(&abs_data_dir, &link_data) {
|
||
warn!("构建 data 数据集软链时发生提示性告警: {}", e);
|
||
}
|
||
}
|
||
|
||
// 2. Initial fort.8 seed setup
|
||
let fort8 = model_dir.join("fort.8");
|
||
if fort8.exists() {
|
||
let _ = tokio::fs::remove_file(&fort8).await;
|
||
}
|
||
if let Some(seed_path) = seed_atmos {
|
||
if seed_path.is_file() {
|
||
if let Err(e) = tokio::fs::copy(seed_path, &fort8).await {
|
||
warn!(
|
||
"向工作沙盒引导填载首期收敛模型种子 fort.8 发生复制错误: {}",
|
||
e
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean fort.84 residue to prevent NATOMS Fortran crash
|
||
let fort84 = model_dir.join("fort.84");
|
||
if fort84.exists() {
|
||
let _ = tokio::fs::remove_file(&fort84).await;
|
||
}
|
||
|
||
let chain = custom_chain.unwrap_or_else(|| default_chain_for_strategy(current_strategy));
|
||
|
||
let mut stage_summaries = Vec::new();
|
||
let mut current_seed: Option<PathBuf> = seed_atmos.map(|p| p.to_path_buf());
|
||
let mut final_converged = false;
|
||
let mut final_chmax: Option<f64> = None;
|
||
let mut final_max_relc: Option<f64> = None;
|
||
// ladder 中间梯级(收敛 + 无 NaN 双过才登记),随 ModelSummary 上报持久化
|
||
let mut ladder_seeds: Vec<crate::models::LadderSeedInfo> = Vec::new();
|
||
|
||
// 阶段独立配置(见 docs/task_engine_decoupling_design.md §5):
|
||
// TLUSTY 关闭时跳过整个 chain 循环——current_seed 直接作为 final_7 来源,
|
||
// 适配「仅 SYNSPEC」场景(用既有大气合成光谱,不重算大气结构)。
|
||
if tlusty_enabled {
|
||
info!("TLUSTY 阶段启用:执行 {} 步收敛链", chain.len());
|
||
} else {
|
||
info!("TLUSTY 阶段关闭:跳过大气结构计算,直接进入 SYNSPEC 阶段");
|
||
}
|
||
|
||
let tlusty_skipped = !tlusty_enabled;
|
||
// tlusty_input 为 None(YAML 未配 tlusty_input 块)时用默认值。
|
||
// 默认值定义集中在 config.rs 的 TlustyInput::default(),此处不再重复维护。
|
||
let default_input = TlustyInput::default();
|
||
let input_cfg: &TlustyInput = tlusty_input.unwrap_or(&default_input);
|
||
let mut failed_stage_idx: Option<usize> = None;
|
||
for (stage_idx, stage_def) in chain.iter().enumerate() {
|
||
if tlusty_skipped {
|
||
break;
|
||
}
|
||
let (stage_summary, produced_seed) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
params,
|
||
stage_def,
|
||
input_cfg,
|
||
current_seed.as_deref(),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
if let Some(p) = produced_seed {
|
||
current_seed = Some(p);
|
||
}
|
||
|
||
final_chmax = stage_def.chmax;
|
||
final_converged = stage_summary.converged;
|
||
if let Some(r) = stage_summary.best_max_relc {
|
||
final_max_relc = Some(r);
|
||
}
|
||
stage_summaries.push(stage_summary);
|
||
|
||
if !final_converged && stage_def.require_converged {
|
||
warn!(
|
||
"阶段 {} 要求收敛但未达标,中止后续收敛链阶段",
|
||
stage_def.label
|
||
);
|
||
failed_stage_idx = Some(stage_idx);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── nl_direct 回退(2026-08-14)──
|
||
// 背景:种子链 seed_nc→nl 中,seed_nc 发散(Kantorovich 雪崩)时其被污染的
|
||
// fort.7 会被无条件传给 nl 并毒死 nl;而 nl 自身从原始干净种子出发通常能
|
||
// 直接收敛(实测 5 个最难失败点中 3 个仅靠此回退恢复,另 2 个经
|
||
// seed_nc 预热后收敛——两条路径失败集互补,故仅作回退、不替换主链)。
|
||
// 详见 docs/tlusty_coldstart_nc_trace_illcond_2026_08_13.md。
|
||
// 触发条件:require_converged 阶段失败 + 失败阶段非链首(存在中间阶段
|
||
// 污染可能)+ 存在原始种子文件。回退以 `<label>_direct` 作为独立阶段
|
||
// 记录进 conv.json,成功则采纳其收敛结果。
|
||
if tlusty_enabled && !final_converged {
|
||
if let Some(idx) = failed_stage_idx {
|
||
if idx > 0 {
|
||
if let Some(orig_seed) = seed_atmos {
|
||
if orig_seed.is_file() {
|
||
let failed_label = chain[idx].label.clone();
|
||
info!(
|
||
"阶段 {} 未收敛:尝试 nl_direct 回退(用原始种子直接重跑该阶段)",
|
||
failed_label
|
||
);
|
||
let mut direct_stage = chain[idx].clone();
|
||
direct_stage.label = format!("{}_direct", failed_label);
|
||
let (mut direct_summary, direct_seed) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
params,
|
||
&direct_stage,
|
||
input_cfg,
|
||
Some(orig_seed),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
if direct_summary.converged {
|
||
info!("nl_direct 回退成功:{} 自原始种子直接收敛", failed_label);
|
||
final_converged = true;
|
||
final_chmax = direct_stage.chmax;
|
||
if let Some(r) = direct_summary.best_max_relc {
|
||
final_max_relc = Some(r);
|
||
}
|
||
if let Some(p) = direct_seed {
|
||
current_seed = Some(p);
|
||
}
|
||
if direct_summary.note.is_none() {
|
||
direct_summary.note = Some(
|
||
"nl_direct 回退收敛(跳过被污染的中间阶段,用原始种子直接求解)"
|
||
.to_string(),
|
||
);
|
||
}
|
||
} else if direct_summary.note.is_none() {
|
||
direct_summary.note =
|
||
Some("nl_direct 回退未收敛".to_string());
|
||
}
|
||
stage_summaries.push(direct_summary);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── ladder 步进回退(2026-08-17,真发散硬核专用)──
|
||
// 背景:~131 个生产失败点从任何网格邻居种子直接求解都在 Newton 收敛域外
|
||
// (首 6-13 迭代即 1e16 STOP,ORELAX 无效)。实测(ladder_60k_he4 /
|
||
// ladder_20k_he2):把种子→目标的参数间隔切成 ≤4 段(Δlogg≤0.25 或
|
||
// ΔTeff≤2.5kK)逐步热启动,两个最难簇代表点均完全收敛(物理三项过 +
|
||
// emflux 达标)。触发条件:此前全部失败 + 有原始种子 + 知道种子参数
|
||
// (ladder 规划需要种子与目标的差值)。
|
||
// 每个中间步收敛即登记进 ladder_seeds(node→server 持久化为可复用梯级,
|
||
// 簇内相邻失败点可共享),链在中途断掉已完成的梯级仍有价值。
|
||
if tlusty_enabled && !final_converged {
|
||
if let (Some(orig_seed), Some(sp)) = (seed_atmos, seed_params) {
|
||
if orig_seed.is_file() {
|
||
if let Some(last_stage) = chain.last() {
|
||
let steps = plan_ladder_steps(
|
||
sp.teff.value(),
|
||
sp.logg.value(),
|
||
params.teff.value(),
|
||
params.logg.value(),
|
||
);
|
||
// 2026-08-21 自适应 Teff 延拓(60k/g5.0 攻坚,10/13 点唯一
|
||
// 制胜路径,docs/failed81_...md §十一):Teff 轴为主且间隔
|
||
// 超一档时,固定 ≤2500K 均分档在 58–59.5k 折叠墙前全灭
|
||
// (实测一步 2500K 必发散、二分到 25–150K 才能穿过)。
|
||
// 改为自适应步长控制:初始 1250K,成功 ×1.5(上限 1250K)
|
||
// 恢复、失败二分(下限 25K),最多 48 步。logg 轴与短间隔
|
||
// 维持原固定档(logg 轴未经自适应验证)。
|
||
let d_teff_gap =
|
||
(params.teff.value() - sp.teff.value()).abs();
|
||
let teff_axis_adaptive = d_teff_gap > 2500.0 && {
|
||
let n_logg = ((params.logg.value() - sp.logg.value()).abs()
|
||
/ 0.25)
|
||
.ceil() as usize;
|
||
let n_teff = (d_teff_gap / 2500.0).ceil() as usize;
|
||
n_teff > n_logg
|
||
};
|
||
if teff_axis_adaptive {
|
||
info!(
|
||
"ladder 回退(自适应 Teff 延拓):种子 t{}/g{} → 目标 t{}/g{},步长 1250K 起、失败二分至 25K",
|
||
sp.teff.value(),
|
||
sp.logg.value(),
|
||
params.teff.value(),
|
||
params.logg.value()
|
||
);
|
||
let mut ladder_seed_now: Option<PathBuf> =
|
||
Some(orig_seed.to_path_buf());
|
||
let teff_target = params.teff.value();
|
||
let mut t_now = sp.teff.value();
|
||
let mut d_t = 1250.0_f64;
|
||
let mut steps_done = 0usize;
|
||
const MAX_WALK_STAGES: usize = 48;
|
||
const MIN_DT: f64 = 25.0;
|
||
while (teff_target - t_now).abs() > 1.0
|
||
&& steps_done < MAX_WALK_STAGES
|
||
{
|
||
let t_next = if teff_target > t_now {
|
||
(t_now + d_t).min(teff_target)
|
||
} else {
|
||
(t_now - d_t).max(teff_target)
|
||
};
|
||
let label = format!(
|
||
"ladder_t{:.2}k",
|
||
(t_next / 1000.0 * 100.0).round() / 100.0
|
||
);
|
||
let step_params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(t_next),
|
||
logg: params.logg.clone(),
|
||
loghe: params.loghe.clone(),
|
||
logc: params.logc.clone(),
|
||
logn: params.logn.clone(),
|
||
logo: params.logo.clone(),
|
||
};
|
||
let mut step_def = last_stage.clone();
|
||
step_def.label = label.clone();
|
||
step_def.require_converged = true;
|
||
step_def.niter = step_def.niter.max(LADDER_STAGE_NITER);
|
||
let (step_summary, step_produced) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
&step_params,
|
||
&step_def,
|
||
input_cfg,
|
||
ladder_seed_now.as_deref(),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
steps_done += 1;
|
||
let ok = step_summary.converged;
|
||
if ok {
|
||
if let Some(p) = step_produced {
|
||
ladder_seeds.push(crate::models::LadderSeedInfo {
|
||
label: label.clone(),
|
||
point_name: format!(
|
||
"{}_ladder",
|
||
step_params.model_name()
|
||
),
|
||
teff: t_next,
|
||
logg: params.logg.value(),
|
||
loghe: params.loghe.value(),
|
||
logc: params.logc.value(),
|
||
logn: params.logn.value(),
|
||
logo: params.logo.value(),
|
||
});
|
||
ladder_seed_now = Some(p);
|
||
}
|
||
t_now = t_next;
|
||
d_t = (d_t * 1.5).min(1250.0);
|
||
} else {
|
||
// 失败步产物不传递(沿用上一收敛态),步长二分;
|
||
// 低于下限视为折叠墙,中止链。
|
||
d_t /= 2.0;
|
||
if d_t < MIN_DT {
|
||
warn!(
|
||
"自适应 Teff 延拓在 {} 处遇到折叠墙(步长 <{MIN_DT}K 仍失败)",
|
||
label
|
||
);
|
||
stage_summaries.push(step_summary);
|
||
break;
|
||
}
|
||
}
|
||
stage_summaries.push(step_summary);
|
||
}
|
||
// 延拓末步通常已 clamp 到目标参数(step_def 即 chain.last()
|
||
// 的 nl 定义,与目标一致)→ 直接采纳,不再跑冗余的 nl_ladder
|
||
// 同参数重跑(审查修复 2026-08-21)。仅当中途断链
|
||
// (步数上限/折叠墙)时保持未收敛,交给后续回退顺位。
|
||
if !final_converged
|
||
&& (teff_target - t_now).abs() <= 1.0
|
||
&& stage_summaries.last().map(|s| s.converged).unwrap_or(false)
|
||
&& ladder_seed_now.is_some()
|
||
&& ladder_seed_now.as_deref() != Some(orig_seed)
|
||
{
|
||
final_converged = true;
|
||
final_chmax = last_stage.chmax;
|
||
if let Some(s) = stage_summaries.last() {
|
||
if let Some(r) = s.best_max_relc {
|
||
final_max_relc = Some(r);
|
||
}
|
||
if s.label == "nl_ladder" {
|
||
// 不可能:本分支不跑 nl_ladder;占位防误标
|
||
}
|
||
}
|
||
current_seed = ladder_seed_now.clone();
|
||
info!("自适应 Teff 延拓成功:末步已达目标并收敛");
|
||
}
|
||
} else if !steps.is_empty() {
|
||
info!(
|
||
"ladder 回退:{} 步中间参数步进(种子 t{}/g{} → 目标 t{}/g{})",
|
||
steps.len(),
|
||
sp.teff.value(),
|
||
sp.logg.value(),
|
||
params.teff.value(),
|
||
params.logg.value()
|
||
);
|
||
let mut ladder_seed_now: Option<PathBuf> =
|
||
Some(orig_seed.to_path_buf());
|
||
for (t, g, label) in steps {
|
||
let step_params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(t),
|
||
logg: crate::models::GridAxisValue::from_value(g),
|
||
loghe: params.loghe.clone(),
|
||
logc: params.logc.clone(),
|
||
logn: params.logn.clone(),
|
||
logo: params.logo.clone(),
|
||
};
|
||
let mut step_def = last_stage.clone();
|
||
step_def.label = label.clone();
|
||
step_def.require_converged = true;
|
||
step_def.niter = step_def.niter.max(LADDER_STAGE_NITER);
|
||
let (step_summary, step_produced) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
&step_params,
|
||
&step_def,
|
||
input_cfg,
|
||
ladder_seed_now.as_deref(),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
let ok = step_summary.converged;
|
||
if let Some(p) = step_produced {
|
||
// 收敛 + NaN 否决双过(execute_tlusty_stage 保证
|
||
// converged ⇒ fort.7 干净)→ 登记为可复用梯级
|
||
if ok {
|
||
ladder_seeds.push(crate::models::LadderSeedInfo {
|
||
label: label.clone(),
|
||
point_name: format!(
|
||
"{}_ladder",
|
||
step_params.model_name()
|
||
),
|
||
teff: t,
|
||
logg: g,
|
||
loghe: params.loghe.value(),
|
||
logc: params.logc.value(),
|
||
logn: params.logn.value(),
|
||
logo: params.logo.value(),
|
||
});
|
||
}
|
||
ladder_seed_now = Some(p);
|
||
}
|
||
stage_summaries.push(step_summary);
|
||
if !ok {
|
||
warn!("ladder 步进在 {} 处未收敛,中止步进链", label);
|
||
break;
|
||
}
|
||
}
|
||
// 全部中间步通过 → 用目标参数跑最终步
|
||
if !final_converged
|
||
&& stage_summaries.last().map(|s| s.converged).unwrap_or(false)
|
||
&& ladder_seed_now.is_some()
|
||
&& ladder_seed_now.as_deref() != Some(orig_seed)
|
||
{
|
||
let mut final_step = last_stage.clone();
|
||
final_step.label = "nl_ladder".to_string();
|
||
final_step.require_converged = true;
|
||
// 与中间梯级同口径(LADDER_STAGE_NITER):末步是从最后
|
||
// 梯级到目标的逼近,越近目标越慢收敛——恰恰是最可能
|
||
// 迭代饥饿的一步(2026-08-22 审查补充)。
|
||
final_step.niter = final_step.niter.max(LADDER_STAGE_NITER);
|
||
let (mut final_summary, final_produced) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
params,
|
||
&final_step,
|
||
input_cfg,
|
||
ladder_seed_now.as_deref(),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
if final_summary.converged {
|
||
final_summary.note = Some(
|
||
"nl_ladder 回退收敛(连续步进链最终步)".to_string(),
|
||
);
|
||
final_converged = true;
|
||
final_chmax = final_step.chmax;
|
||
if let Some(r) = final_summary.best_max_relc {
|
||
final_max_relc = Some(r);
|
||
}
|
||
if let Some(p) = final_produced {
|
||
current_seed = Some(p);
|
||
}
|
||
info!("nl_ladder 回退成功:目标点经步进链收敛");
|
||
} else if final_summary.note.is_none() {
|
||
final_summary.note =
|
||
Some("nl_ladder 回退未收敛".to_string());
|
||
}
|
||
stage_summaries.push(final_summary);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 稳定化多档回退(2026-08-18/19)──
|
||
// 背景:20kK He 富大气布居极限环对 (DPSILG, POPZER) 组合逐点敏感,实测
|
||
// (test/20260818_failed81/full20k* 全 65 点批量)三档互补,联合回收 41/65:
|
||
// 一档 DPSILG=3.0/POPZER=1E-10(17 点,seed_step_stab 策略链自带)
|
||
// 二档 DPSILG=2.0/POPZER=3E-11(+14 点)
|
||
// 三档 DPSILG=2.0/POPZER=1E-11(+10 点,含全部 g5.5)
|
||
// 收敛点全部通过温度结构物理判据。此回退在全部常规路径失败后逐档自原始
|
||
// 种子重跑最后的 require_converged 阶段,任一档收敛即采纳。
|
||
// 详见 docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md。
|
||
const STAB_TIERS: [(f64, f64); 3] = [(2.0, 3e-11), (2.0, 1e-11), (2.5, 1e-11)];
|
||
// 2026-08-21 域门控(60k/g5.0 攻坚实测,docs/failed81_...md §十一):
|
||
// DPSILG/POPZER 族旋钮仅在低温 He 富域(Teff≤30kK)有效;60k 高金属域
|
||
// 实测自复现收敛种子加档后 17 拍爆到 4e16(致散而非稳定)。域外跳过
|
||
// 全部档位,避免白跑与二次伤害。
|
||
if tlusty_enabled && !final_converged && params.teff.value() <= 30000.0 {
|
||
if let Some(idx) = failed_stage_idx {
|
||
if let Some(orig_seed) = seed_atmos {
|
||
if orig_seed.is_file() {
|
||
for (tier_no, (dg, pz)) in STAB_TIERS.iter().enumerate() {
|
||
let mut tier_stage = chain[idx].clone();
|
||
// 链自带一档参数时跳过同档重跑
|
||
if tier_stage.dpsilg == Some(*dg)
|
||
&& tier_stage.popzer == Some(*pz)
|
||
{
|
||
continue;
|
||
}
|
||
tier_stage.label = format!("nl_stab{}", tier_no + 2);
|
||
tier_stage.dpsilg = Some(*dg);
|
||
tier_stage.popzer = Some(*pz);
|
||
info!(
|
||
"尝试 {} 回退:DPSILG={}/POPZER={:.0E} 自原始种子重跑 {}",
|
||
tier_stage.label,
|
||
dg,
|
||
pz,
|
||
chain[idx].label
|
||
);
|
||
let (mut tier_summary, tier_seed) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
params,
|
||
&tier_stage,
|
||
input_cfg,
|
||
Some(orig_seed),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
if tier_summary.converged {
|
||
info!("{} 回退成功:稳定化参数收敛", tier_stage.label);
|
||
final_converged = true;
|
||
final_chmax = tier_stage.chmax;
|
||
if let Some(r) = tier_summary.best_max_relc {
|
||
final_max_relc = Some(r);
|
||
}
|
||
if let Some(p) = tier_seed {
|
||
current_seed = Some(p);
|
||
}
|
||
if tier_summary.note.is_none() {
|
||
tier_summary.note = Some(format!(
|
||
"{} 回退收敛(DPSILG={}/POPZER={:.0E} 稳定化)",
|
||
tier_stage.label, dg, pz
|
||
));
|
||
}
|
||
stage_summaries.push(tier_summary);
|
||
break;
|
||
} else if tier_summary.note.is_none() {
|
||
tier_summary.note =
|
||
Some(format!("{} 回退未收敛", tier_stage.label));
|
||
}
|
||
stage_summaries.push(tier_summary);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 丰度轴延拓回退(2026-08-21,60k/g5.0 折叠角攻坚)──
|
||
// 背景:60k/g5.0 高金属角(o-1 × 高 C+N)三点上,Teff 轴(自适应二分至 25K)、
|
||
// logg 轴、O 丰度轴全部在目标前折叠,但 **C 或 N 丰度轴延拓全部走通**
|
||
// (test/20260820_residual27/p60/*.ax_*.log,3/3 点物理收敛)——静力解存在,
|
||
// 只是可达方向在 C/N。配方:同 (Teff,logg,logHe) 的 CNO 邻居收敛种子
|
||
// (seed_step_stab 的 exact_family 种子),沿 C 轴(3/3 验证,优先)或
|
||
// N 轴自适应小步爬向目标丰度:初始 0.2 dex,成功 ×1.4(上限 0.25)、
|
||
// 失败二分(下限 0.025 dex)、每轴最多 40 步;失败步产物不传递。
|
||
// 与稳定化域门控互补:本回退仅高温域(Teff>30kK)启用(低温域由
|
||
// seed_step_stab 稳定化档覆盖,见上方域门控注释)。
|
||
if tlusty_enabled && !final_converged && params.teff.value() > 30000.0 {
|
||
if let (Some(orig_seed), Some(sp), Some(last_stage)) =
|
||
(seed_atmos, seed_params, chain.last())
|
||
{
|
||
// 仅严格同物理族种子(同 Teff/logg/logHe、仅 CNO 不同)——延拓前提
|
||
// 是结构与背景大气已处于目标 T/g/He 的解分支附近。
|
||
let same_family = (sp.teff.value() - params.teff.value()).abs() < 1e-9
|
||
&& (sp.logg.value() - params.logg.value()).abs() < 1e-9
|
||
&& (sp.loghe.value() - params.loghe.value()).abs() < 1e-9;
|
||
let cno_differs = (sp.logc.value() - params.logc.value()).abs() > 1e-9
|
||
|| (sp.logn.value() - params.logn.value()).abs() > 1e-9
|
||
|| (sp.logo.value() - params.logo.value()).abs() > 1e-9;
|
||
if orig_seed.is_file() && same_family && cno_differs {
|
||
// 轴顺序:C 优先(3/3 验证),N 兜底(he-2 验证)
|
||
for (axis_name, axis_from, axis_to) in [
|
||
("c", sp.logc.value(), params.logc.value()),
|
||
("n", sp.logn.value(), params.logn.value()),
|
||
] {
|
||
if (axis_to - axis_from).abs() < 1e-9 || final_converged {
|
||
continue;
|
||
}
|
||
info!(
|
||
"丰度轴延拓回退:沿 {axis_name} 轴 {axis_from} → {axis_to}(种子 {} → 目标 {})",
|
||
sp.model_name(),
|
||
params.model_name()
|
||
);
|
||
let mut seed_now: Option<PathBuf> = Some(orig_seed.to_path_buf());
|
||
let mut v_now = axis_from;
|
||
let mut d_v = 0.2_f64;
|
||
let mut steps_done = 0usize;
|
||
const MAX_AX_STAGES: usize = 40;
|
||
const MIN_DV: f64 = 0.025;
|
||
while (axis_to - v_now).abs() > 1e-6 && steps_done < MAX_AX_STAGES {
|
||
let v_next = if axis_to > v_now {
|
||
(v_now + d_v).min(axis_to)
|
||
} else {
|
||
(v_now - d_v).max(axis_to)
|
||
};
|
||
let label = format!("axlad_{}{:.3}", axis_name, v_next);
|
||
let step_params = crate::models::GridPointParams {
|
||
teff: params.teff.clone(),
|
||
logg: params.logg.clone(),
|
||
loghe: params.loghe.clone(),
|
||
logc: if axis_name == "c" {
|
||
crate::models::GridAxisValue::from_value(v_next)
|
||
} else {
|
||
params.logc.clone()
|
||
},
|
||
logn: if axis_name == "n" {
|
||
crate::models::GridAxisValue::from_value(v_next)
|
||
} else {
|
||
params.logn.clone()
|
||
},
|
||
logo: params.logo.clone(),
|
||
};
|
||
let mut step_def = last_stage.clone();
|
||
step_def.label = label.clone();
|
||
step_def.require_converged = true;
|
||
step_def.niter = step_def.niter.max(LADDER_STAGE_NITER);
|
||
let (step_summary, step_produced) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
&step_params,
|
||
&step_def,
|
||
input_cfg,
|
||
seed_now.as_deref(),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
steps_done += 1;
|
||
if step_summary.converged {
|
||
if let Some(p) = step_produced {
|
||
ladder_seeds.push(crate::models::LadderSeedInfo {
|
||
label: label.clone(),
|
||
point_name: format!("{}_ladder", step_params.model_name()),
|
||
teff: step_params.teff.value(),
|
||
logg: step_params.logg.value(),
|
||
loghe: step_params.loghe.value(),
|
||
logc: step_params.logc.value(),
|
||
logn: step_params.logn.value(),
|
||
logo: step_params.logo.value(),
|
||
});
|
||
seed_now = Some(p);
|
||
}
|
||
v_now = v_next;
|
||
d_v = (d_v * 1.4).min(0.25);
|
||
} else {
|
||
d_v /= 2.0;
|
||
if d_v < MIN_DV {
|
||
warn!(
|
||
"丰度轴 {axis_name} 延拓在 {v_next} 处折叠(步长 <{MIN_DV} dex 仍失败)"
|
||
);
|
||
stage_summaries.push(step_summary);
|
||
break;
|
||
}
|
||
}
|
||
stage_summaries.push(step_summary);
|
||
}
|
||
// 走到目标丰度且末步收敛 → 采纳(末步参数即目标)
|
||
if !final_converged
|
||
&& (axis_to - v_now).abs() <= 1e-6
|
||
&& stage_summaries.last().map(|s| s.converged).unwrap_or(false)
|
||
&& seed_now.is_some()
|
||
&& seed_now.as_deref() != Some(orig_seed)
|
||
{
|
||
final_converged = true;
|
||
final_chmax = last_stage.chmax;
|
||
if let Some(s) = stage_summaries.last() {
|
||
if let Some(r) = s.best_max_relc {
|
||
final_max_relc = Some(r);
|
||
}
|
||
}
|
||
current_seed = seed_now.clone();
|
||
info!("丰度轴 {axis_name} 延拓成功:末步已达目标丰度并收敛");
|
||
}
|
||
if final_converged {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Final atmosphere file .7
|
||
let final_7 = model_dir.join(format!("{}.7", name));
|
||
if let Some(ref s_path) = current_seed {
|
||
if s_path.is_file() {
|
||
let _ = tokio::fs::copy(s_path, &final_7).await;
|
||
}
|
||
} else if model_dir.join("fort.7").is_file() {
|
||
let _ = tokio::fs::copy(model_dir.join("fort.7"), &final_7).await;
|
||
}
|
||
|
||
// TLUSTY 最终 b 因子 + 出射谱快照。必须在此处(SYNSPEC 覆盖 fort.12/fort.14 之前)
|
||
// 完成:synspec 会复用 unit 12/14 写谱线数据,覆盖 TLUSTY 的最终产物(见上方
|
||
// snapshot_tlusty_outputs 的注释)。仅 SYNSPEC 场景(tlusty_enabled=false)下
|
||
// fort.12/14 不存在,函数内按文件是否存在静默跳过。
|
||
snapshot_tlusty_outputs(&model_dir, name).await;
|
||
|
||
// L2 修复:`atmosphere_has_nan` 对**缺失**文件返回 false(语义是"无 NaN"而非"有效"),
|
||
// 与**空文件返回 true** 语义不对称。缺失最终大气 = 无可判定收敛的干净大气 →
|
||
// 在此显式判定为无效(NaN),与空文件语义对齐。调用前提:final_7 应在收敛链产出;
|
||
// 若缺失(如种子拷贝失败、TLUSTY 崩溃未写 fort.7),本守卫强制最终不收敛。
|
||
let atmo_has_nan = if final_7.is_file() {
|
||
atmosphere_has_nan(&final_7)
|
||
} else {
|
||
true
|
||
};
|
||
if atmo_has_nan {
|
||
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 mut 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;
|
||
}
|
||
}
|
||
|
||
// ── nl_tight 回退(2026-08-17,能量边际专用)──
|
||
// 背景:7 个生产失败点的唯一失败项是最深层(ID=ND)能量残差 1.0–2.3% 超
|
||
// 1% 阈值,其余门槛(emflux/温度/bfac)全过。此类残差源于 CHMAX=1e-3 停机
|
||
// 时统计平衡未完全达成。实测(test/20260817_failed400/mg_tight):从自身
|
||
// 最终模型续迭代、CHMAX 收紧 10×(1e-4),~19 次迭代后残差降至 0.001–0.002
|
||
// (降幅 ~10×)。触发条件:能量门槛失败 + 最终大气存在且无 NaN + 数值收敛
|
||
// (非发散模型)。回退以 `<label>_tight` 记录进 conv.json,成功则刷新最终
|
||
// 大气与能量判定,并重快照 fort.12/14(bfac/emflux 检查读快照文件)。
|
||
if tlusty_enabled && energy_failed && !atmo_has_nan && final_7.is_file() {
|
||
if let Some(last_stage) = chain.last() {
|
||
let tight_chmax = last_stage.chmax.map(|c| c / 10.0);
|
||
let mut tight_stage = last_stage.clone();
|
||
tight_stage.label = format!("{}_tight", last_stage.label);
|
||
tight_stage.chmax = tight_chmax;
|
||
info!(
|
||
"能量边际回退:以 CHMAX={} 从自身最终模型续迭代 {}",
|
||
tight_chmax.map(|c| c.to_string()).unwrap_or_default(),
|
||
tight_stage.label
|
||
);
|
||
let tight_seed = final_7.clone();
|
||
let (mut tight_summary, tight_produced) = self
|
||
.execute_tlusty_stage(
|
||
&model_dir,
|
||
name,
|
||
params,
|
||
&tight_stage,
|
||
input_cfg,
|
||
Some(&tight_seed),
|
||
convergence_min_ratio,
|
||
timeout_sec,
|
||
shutdown.clone(),
|
||
)
|
||
.await?;
|
||
let tight_ok = tight_summary.converged;
|
||
if tight_ok {
|
||
tight_summary.note = Some(
|
||
"nl_tight 回退收敛(能量边际:CHMAX 收紧 10× 从自身模型续迭代)"
|
||
.to_string(),
|
||
);
|
||
if let Some(p) = tight_produced {
|
||
// 刷新最终大气 + 出射谱快照(后续 bfac/emflux 门槛读快照文件)
|
||
let _ = tokio::fs::copy(&p, &final_7).await;
|
||
snapshot_tlusty_outputs(&model_dir, name).await;
|
||
// 重新判定能量守恒:tight 阶段的 stdout 直接写 <name>.6
|
||
//(每阶段覆盖),此处读到的是 tight 阶段的能量表。
|
||
if let Some(tol) = energy_tolerance {
|
||
let fort6 = model_dir.join(format!("{}.6", name));
|
||
if fort6.is_file() {
|
||
energy_check = check_energy_conservation(&fort6, tol);
|
||
}
|
||
}
|
||
energy_failed = matches!(&energy_check, Some(ec) if !ec.valid);
|
||
if !energy_failed {
|
||
final_converged = true;
|
||
final_chmax = tight_stage.chmax;
|
||
if let Some(r) = tight_summary.best_max_relc {
|
||
final_max_relc = Some(r);
|
||
}
|
||
info!("nl_tight 回退成功:能量守恒达标");
|
||
}
|
||
}
|
||
} else {
|
||
tight_summary.note =
|
||
Some("nl_tight 回退未收敛".to_string());
|
||
}
|
||
stage_summaries.push(tight_summary);
|
||
}
|
||
}
|
||
|
||
// 温度结构边界校验(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;
|
||
let mut synspec_sec = None;
|
||
|
||
if !synspec_enabled {
|
||
// SYNSPEC 关闭是合法配置(TLUSTY-only 大气计算),不写入 synspec_error
|
||
// 以免污染 conv.json 的错误归因——下游把非空 synspec_error 当「光谱有缺陷」。
|
||
info!("SYNSPEC 阶段关闭:跳过光谱合成(TLUSTY-only 模式)");
|
||
} else if final_7.is_file() {
|
||
let syn_t0 = Instant::now();
|
||
// H10:synspec 输入文件(fort.8 大气 / fort.55 控制卡)写入失败不可静默吞掉。
|
||
// 历史上用 `let _ =` 忽略错误,磁盘满/inode 耗尽时 synspec 会读到旧/缺失的
|
||
// fort.8 产出垃圾光谱,却仍生成 .spec 并被归档为"成功"。现改为写入失败即记
|
||
// synspec_err 并跳过 synspec 阶段,避免产出物理上错误的谱。
|
||
if let Err(e) = tokio::fs::copy(&final_7, model_dir.join("fort.8")).await {
|
||
warn!("synspec 输入 fort.8 (大气) 复制失败,跳过 synspec: {}", e);
|
||
synspec_err = Some(format!("fort.8 copy failed: {}", e));
|
||
} else {
|
||
let _ = tokio::fs::remove_file(model_dir.join("fort.7")).await;
|
||
|
||
// Fort.55 parameter generation or symlink
|
||
let fort55_path = model_dir.join("fort.55");
|
||
let fort19_path = model_dir.join("fort.19");
|
||
|
||
let _ = tokio::fs::remove_file(&fort55_path).await;
|
||
let _ = tokio::fs::remove_file(&fort19_path).await;
|
||
|
||
// 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!(
|
||
"synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}",
|
||
e
|
||
);
|
||
synspec_err = Some(format!("fort.55 write failed: {}", e));
|
||
} else {
|
||
#[cfg(unix)]
|
||
{
|
||
let abs_linelist = tokio::fs::canonicalize(&self.runtime.linelist)
|
||
.await
|
||
.unwrap_or_else(|_| self.runtime.linelist.clone());
|
||
let _ = std::os::unix::fs::symlink(&abs_linelist, &fort19_path);
|
||
}
|
||
}
|
||
}
|
||
|
||
let input5_path = model_dir.join(format!("{}.5", name));
|
||
if synspec_err.is_none() && input5_path.is_file() {
|
||
let fin = File::open(&input5_path).await?.into_std().await;
|
||
let fout = File::create(model_dir.join(format!("{}.log", name)))
|
||
.await?
|
||
.into_std()
|
||
.await;
|
||
|
||
let child = AsyncCommand::new(&self.runtime.synspec_exe)
|
||
.current_dir(&model_dir)
|
||
.stdin(Stdio::from(fin))
|
||
.stdout(Stdio::from(fout))
|
||
.stderr(Stdio::null())
|
||
.kill_on_drop(true)
|
||
.spawn()?;
|
||
|
||
let synspec_timeout_sec = 600_u64.min(timeout_sec);
|
||
let status_res =
|
||
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone())
|
||
.await;
|
||
let rc = match status_res {
|
||
Ok(st) => st.code().unwrap_or(-1),
|
||
Err(e) => {
|
||
warn!("synspec 运行失败/超时: {}", e);
|
||
-1
|
||
}
|
||
};
|
||
synspec_rc = Some(rc);
|
||
synspec_sec = Some(syn_t0.elapsed().as_secs_f64());
|
||
|
||
// Copy/move outputs: fort.7 (Synspec spectrum) -> .spec, fort.17 -> .cont, fort.12 -> .iden
|
||
if model_dir.join("fort.7").is_file() {
|
||
let spec_path = model_dir.join(format!("{}.spec", name));
|
||
let _ = tokio::fs::rename(model_dir.join("fort.7"), &spec_path).await;
|
||
|
||
// 漏洞1修复(P0):SYNSPEC .spec 内容校验。
|
||
// gfortran 下 SYNSPEC 几乎所有错误路径 rc=0,旧代码只做 is_file() 存在性
|
||
// 检查,导致脏谱(NaN/Inf/行数不足/全零)被当作 Completed 归档——全链路
|
||
// 最大的科学正确性风险。命中无效则置 synspec_rc 非零 + synspec_error 描述,
|
||
// 让 reporter 判 Failed 并触发 synspec 策略链回退。
|
||
// 守卫 synspec_err.is_none():避免覆盖上游 fort.8/fort.55 复制失败的既有 err。
|
||
if synspec_err.is_none() {
|
||
if let Some(reason) = spec_is_valid(&spec_path) {
|
||
warn!("spec 校验失败: {}", reason);
|
||
synspec_rc = Some(1);
|
||
synspec_err = Some(reason);
|
||
}
|
||
}
|
||
}
|
||
if model_dir.join("fort.17").is_file() {
|
||
let _ = tokio::fs::copy(
|
||
model_dir.join("fort.17"),
|
||
model_dir.join(format!("{}.cont", name)),
|
||
)
|
||
.await;
|
||
}
|
||
if model_dir.join("fort.12").is_file() {
|
||
let _ = tokio::fs::copy(
|
||
model_dir.join("fort.12"),
|
||
model_dir.join(format!("{}.iden", name)),
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
} else {
|
||
synspec_err = Some("No atmosphere .7 produced".to_string());
|
||
}
|
||
|
||
// 收敛判定(见 docs/task_engine_decoupling_design.md §5):
|
||
// - TLUSTY 启用:final_converged 由 chain 循环内各阶段收敛状态累积得出(既有逻辑)。
|
||
// - TLUSTY 关闭(仅 SYNSPEC 场景):final_converged 不能恒为 false——否则成功的
|
||
// 光谱合成任务被误判失败并触发策略回退。此时收敛 = 大气加载干净(无 NaN)且
|
||
// SYNSPEC 成功(rc=0)或 SYNSPEC 也关闭(TLUSTY-only 等价的纯校验场景,虽罕见)。
|
||
// 仅 SYNSPEC 场景下大气来自既有产物(非本任务重算),NaN 检查仍必要(产物可能损坏)。
|
||
if !tlusty_enabled && !atmo_has_nan {
|
||
final_converged = match synspec_rc {
|
||
Some(rc) => rc == 0,
|
||
None => !synspec_enabled, // SYNSPEC 也关闭 → 仅校验大气,干净即收敛
|
||
};
|
||
}
|
||
|
||
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
|
||
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
|
||
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
|
||
// 注意:fort.8(synspec 输入大气)和 fort.55(synspec 控制卡)有独立语义,予以保留。
|
||
for redundant in [
|
||
format!("{}.5", name), // 同 <name>.<最后阶段label>.5
|
||
format!("{}.6", name), // 同 <name>.<最后阶段label>.6
|
||
format!("{}.err", name), // 同 <name>.<最后阶段label>.err
|
||
"nst".to_string(), // 同 <name>.<最后阶段label>.nst
|
||
"fort.9".to_string(), // 内容已被 <name>.<label>_chmax*.9 收敛诊断覆盖
|
||
"fort.12".to_string(), // 同 <name>.iden(synspec 谱线证认)
|
||
"fort.17".to_string(), // 同 <name>.cont(synspec 连续谱)
|
||
] {
|
||
let p = model_dir.join(&redundant);
|
||
if p.is_file() {
|
||
let _ = tokio::fs::remove_file(&p).await;
|
||
}
|
||
}
|
||
|
||
let elapsed_sec = t0.elapsed().as_secs_f64();
|
||
|
||
// 汇总 note(修复审查 #2 后续):半失败点(大气已收敛 + 光谱失败)须让
|
||
// synspec 的错误可见——此前 synspec rc≠0 时 note 恒为 None,上报的
|
||
// error_message 为空,attempts 表与详情面板无从排查失败原因。
|
||
let note = {
|
||
let mut notes: Vec<String> = Vec::new();
|
||
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 {
|
||
if rc != 0 {
|
||
notes.push(format!("synspec rc={}", rc));
|
||
}
|
||
}
|
||
if notes.is_empty() {
|
||
None
|
||
} else {
|
||
Some(notes.join("; "))
|
||
}
|
||
};
|
||
|
||
let summary = ModelSummary {
|
||
name: name.to_string(),
|
||
params: params.clone(),
|
||
stages: stage_summaries,
|
||
result_valid: final_converged,
|
||
final_max_relc,
|
||
final_chmax,
|
||
seed: seed_atmos.map(|p| p.to_string_lossy().to_string()),
|
||
atmosphere_has_nan: atmo_has_nan,
|
||
synspec_rc,
|
||
synspec_error: synspec_err,
|
||
synspec_sec,
|
||
elapsed_sec,
|
||
energy_check,
|
||
temp_check,
|
||
emflux_check,
|
||
bfac_check,
|
||
ladder_seeds,
|
||
note,
|
||
};
|
||
|
||
// Write conv.json
|
||
let json_text = serde_json::to_string_pretty(&summary)?;
|
||
tokio::fs::write(model_dir.join("conv.json"), json_text).await?;
|
||
|
||
Ok(summary)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::snapshot_tlusty_outputs;
|
||
use crate::models::{GridAxisValue, GridPointParams};
|
||
|
||
/// Phase 6(P8):执行链按当前策略派生——seed_step 走种子热启动链,其余走冷启动链。
|
||
#[test]
|
||
/// seed_step_stab 策略链:种子链形状 + 全步带 POPZER=1e-10/DPSILG=3.0 稳定化旋钮。
|
||
#[test]
|
||
fn test_default_seed_stab_chain() {
|
||
let chain = super::default_seed_stab_chain();
|
||
assert_eq!(
|
||
chain.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(),
|
||
vec!["seed_nc", "nl"],
|
||
"stab 链应保持种子链形状"
|
||
);
|
||
for step in &chain {
|
||
assert_eq!(step.dpsilg, Some(3.0), "步 {} 应带 DPSILG=3.0", step.label);
|
||
assert_eq!(step.popzer, Some(1e-10), "步 {} 应带 POPZER=1e-10", step.label);
|
||
}
|
||
// 策略映射
|
||
let via_strategy = super::default_chain_for_strategy("seed_step_stab");
|
||
assert_eq!(via_strategy.len(), chain.len());
|
||
assert!(via_strategy.iter().all(|s| s.popzer == Some(1e-10)));
|
||
}
|
||
|
||
fn test_default_chain_for_strategy() {
|
||
let labels = |chain: Vec<super::ChainStep>| -> Vec<String> {
|
||
chain.into_iter().map(|s| s.label).collect()
|
||
};
|
||
// seed_step → 种子热启动链(seed_nc/nl)。
|
||
assert_eq!(
|
||
labels(super::default_chain_for_strategy("seed_step")),
|
||
vec!["seed_nc".to_string(), "nl".to_string()]
|
||
);
|
||
// 其余策略(cold_run / 未知如 standard)→ 冷启动链(lte/nc/nl)。
|
||
assert_eq!(
|
||
labels(super::default_chain_for_strategy("cold_run")),
|
||
vec!["lte".to_string(), "nc".to_string(), "nl".to_string()]
|
||
);
|
||
assert_eq!(
|
||
labels(super::default_chain_for_strategy("standard")),
|
||
vec!["lte".to_string(), "nc".to_string(), "nl".to_string()],
|
||
"未知策略兜底冷启动链(synspec-only strategies[0] 等)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_synspec_timeout_calculation() {
|
||
let long_tlusty_timeout: u64 = 7200;
|
||
let synspec_timeout = 600_u64.min(long_tlusty_timeout);
|
||
assert_eq!(synspec_timeout, 600);
|
||
|
||
let short_tlusty_timeout: u64 = 300;
|
||
let synspec_timeout_short = 600_u64.min(short_tlusty_timeout);
|
||
assert_eq!(synspec_timeout_short, 300);
|
||
}
|
||
|
||
/// 回归测试:复现命名精度丢失场景,并锁定「runner 用 point_name 作权威名」的契约。
|
||
///
|
||
/// 背景:服务端 grid_points 表把 GridPointParams 存成 6 个 REAL 列,回读时用
|
||
/// `GridAxisValue::from_value()` 反推文本(`format_float_minimal`),整数-valued
|
||
/// 浮点数会丢小数(5.0 → "5")。于是 `params.model_name()` 产出 `g5` 而非 `g5.0`。
|
||
/// 而 `TaskSpec.point_name`(DB 的 name TEXT 列,源精度)始终是 `g5.0`。
|
||
///
|
||
/// runner 的 `run_model_with_timeout` 现接收外部 `name: &str`(由 executor 传入
|
||
/// `task.point_name`),不再用降级的 `params.model_name()`。本测试构造降级后的
|
||
/// params,证明二者确实不同,从而确认「必须用 point_name」的修复是必要的。
|
||
#[test]
|
||
fn test_point_name_bypasses_degraded_params_model_name() {
|
||
// 模拟 DB REAL 列回读后的 params:logg 经 from_value(5.0) 丢精度
|
||
let degraded = GridPointParams {
|
||
teff: GridAxisValue::from_value(20000.0),
|
||
logg: GridAxisValue::from_value(5.0), // text 退化为 "5"
|
||
loghe: GridAxisValue::from_value(-2.0),
|
||
logc: GridAxisValue::from_value(-4.0),
|
||
logn: GridAxisValue::from_value(-4.0),
|
||
logo: GridAxisValue::from_value(-4.0),
|
||
};
|
||
// 权威 point_name(DB name 列,保留源精度)
|
||
let point_name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||
|
||
// 降级的 params 重推出的名字丢了 ".0"
|
||
assert_ne!(
|
||
degraded.model_name(),
|
||
point_name,
|
||
"降级 params.model_name() 应与权威 point_name 不同(这是 bug 的可观测证据)"
|
||
);
|
||
assert_eq!(degraded.model_name(), "t20000_g5_he-2_c-4_n-4_o-4");
|
||
// runner 现在直接采用 point_name(不再调 params.model_name()),故归档/产物名正确
|
||
let authoritative_name = point_name; // 即 executor 传入的 task.point_name
|
||
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
|
||
}
|
||
|
||
/// 快照 TLUSTY b 因子与出射谱:fort.12→`<name>.bfac`、fort.14→`<name>.emflux`,
|
||
/// 缺失的源文件静默跳过,未列入快照的 fort.13 不受影响。
|
||
#[tokio::test]
|
||
async fn test_snapshot_tlusty_outputs() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||
let model_dir = dir.path().join(name);
|
||
tokio::fs::create_dir_all(&model_dir).await.unwrap();
|
||
|
||
// TLUSTY 最终迭代产物:b 因子(fort.12)与出射谱(fort.14)
|
||
tokio::fs::write(model_dir.join("fort.12"), "bfac payload")
|
||
.await
|
||
.unwrap();
|
||
tokio::fs::write(model_dir.join("fort.14"), "emflux payload")
|
||
.await
|
||
.unwrap();
|
||
// 不参与快照的文件(出射辐射场 fort.13、大气 fort.7)
|
||
tokio::fs::write(model_dir.join("fort.13"), "emrad payload")
|
||
.await
|
||
.unwrap();
|
||
tokio::fs::write(model_dir.join("fort.7"), "atmo payload")
|
||
.await
|
||
.unwrap();
|
||
|
||
snapshot_tlusty_outputs(&model_dir, name).await;
|
||
|
||
assert_eq!(
|
||
tokio::fs::read_to_string(model_dir.join(format!("{}.bfac", name)))
|
||
.await
|
||
.unwrap(),
|
||
"bfac payload"
|
||
);
|
||
assert_eq!(
|
||
tokio::fs::read_to_string(model_dir.join(format!("{}.emflux", name)))
|
||
.await
|
||
.unwrap(),
|
||
"emflux payload"
|
||
);
|
||
// fort.13 未列入快照,不应生成 <name>.emrad
|
||
assert!(!model_dir.join(format!("{}.emrad", name)).exists());
|
||
// 原 fort.12/fort.14 保留(后续 synspec 覆盖前仍作为单元文件存在)
|
||
assert!(model_dir.join("fort.12").is_file());
|
||
assert!(model_dir.join("fort.14").is_file());
|
||
}
|
||
|
||
/// 缺失源文件(仅 SYNSPEC 场景,tlusty 未运行)时快照应是无害 no-op
|
||
#[tokio::test]
|
||
async fn test_snapshot_tlusty_outputs_noop_when_missing() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||
let model_dir = dir.path().join(name);
|
||
tokio::fs::create_dir_all(&model_dir).await.unwrap();
|
||
|
||
snapshot_tlusty_outputs(&model_dir, name).await;
|
||
|
||
assert!(!model_dir.join(format!("{}.bfac", name)).exists());
|
||
assert!(!model_dir.join(format!("{}.emflux", name)).exists());
|
||
}
|
||
|
||
/// 快速单元回归(2026-08-14):nl_direct 回退接线。用假 tlusty 脚本模拟
|
||
/// 「seed_nc 发散 → nl 失败 → 回退 nl_direct 收敛」三步,验证:
|
||
/// 1) nl 失败后触发 <label>_direct 阶段并用原始种子重跑;
|
||
/// 2) 回退收敛被采纳为最终结果(final 阶段链含 nl_direct 且 converged)。
|
||
#[tokio::test]
|
||
async fn unit_nl_direct_fallback_wiring() {
|
||
let work = std::env::temp_dir().join(format!("dcts_unit_fallback_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
tokio::fs::create_dir_all(&work).await.unwrap();
|
||
// 假 tlusty:按调用序号产出 fort.9 —— #1(seed_nc) 发散、#2(nl) 失败、
|
||
// #3(nl_direct) 收敛。fort.7 写哑内容(无 NaN 字样)。
|
||
let fake = work.join("fake_tlusty.sh");
|
||
let script = r#"#!/usr/bin/env bash
|
||
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
|
||
cat /dev/stdin > /dev/null
|
||
echo "fake model" > fort.7
|
||
case $n in
|
||
1|2) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
|
||
3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9 ;;
|
||
esac
|
||
exit 0
|
||
"#;
|
||
tokio::fs::write(&fake, script).await.unwrap();
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
}
|
||
let runtime = crate::embedded::RuntimePaths {
|
||
tlusty_exe: fake.clone(),
|
||
synspec_exe: work.join("synspec_absent"),
|
||
data_dir: work.clone(),
|
||
linelist: work.join("linelist_absent"),
|
||
};
|
||
// 原始种子:普通文件存在即可(fake 不读内容)
|
||
let seed = work.join("orig_seed.7");
|
||
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
|
||
|
||
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
|
||
let params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(60000.0),
|
||
logg: crate::models::GridAxisValue::from_value(5.5),
|
||
loghe: crate::models::GridAxisValue::from_value(-2.0),
|
||
logc: crate::models::GridAxisValue::from_value(-4.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-4.0),
|
||
};
|
||
let summary = runner
|
||
.run_model_with_timeout(
|
||
¶ms,
|
||
"t60000_g5.5_he-2_c-4_n-4_o-4",
|
||
"seed_step",
|
||
Some(super::default_seed_chain()),
|
||
Some(&seed),
|
||
None,
|
||
None,
|
||
true,
|
||
false,
|
||
60,
|
||
None,
|
||
None, None, None, None, None, None, None, None, None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let labels: Vec<(String, bool)> = summary
|
||
.stages
|
||
.iter()
|
||
.map(|s| (s.label.clone(), s.converged))
|
||
.collect();
|
||
assert_eq!(
|
||
labels,
|
||
vec![
|
||
("seed_nc".to_string(), false),
|
||
("nl".to_string(), false),
|
||
("nl_direct".to_string(), true),
|
||
],
|
||
"应触发 nl_direct 回退并采纳其收敛"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
}
|
||
|
||
/// nl_tight 回退接线回归(2026-08-17):收敛模型但能量边际失败(最深层残差
|
||
/// 2% > 1%)→ 应以 CHMAX 收紧 10× 从自身模型续迭代,回退后能量达标。
|
||
#[tokio::test]
|
||
async fn unit_nl_tight_fallback_wiring() {
|
||
let work =
|
||
std::env::temp_dir().join(format!("dcts_unit_nltight_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
tokio::fs::create_dir_all(&work).await.unwrap();
|
||
// 假 tlusty:#1(nl) 数值收敛但能量表残差 0.02;#2(nl_tight) 残差 0.005。
|
||
let fake = work.join("fake_tlusty.sh");
|
||
let script = r#"#!/usr/bin/env bash
|
||
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
|
||
cat /dev/stdin > /dev/null
|
||
echo "clean model" > fort.7
|
||
printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9
|
||
case $n in
|
||
1) printf ' depth TOT (RAD+CON)/TOT\n 1 1.00\n 50 1.02\n' ;;
|
||
2) printf ' depth TOT (RAD+CON)/TOT\n 1 1.00\n 50 1.005\n' ;;
|
||
esac
|
||
exit 0
|
||
"#;
|
||
tokio::fs::write(&fake, script).await.unwrap();
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
}
|
||
let runtime = crate::embedded::RuntimePaths {
|
||
tlusty_exe: fake.clone(),
|
||
synspec_exe: work.join("synspec_absent"),
|
||
data_dir: work.clone(),
|
||
linelist: work.join("linelist_absent"),
|
||
};
|
||
let seed = work.join("orig_seed.7");
|
||
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
|
||
|
||
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
|
||
let params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(60000.0),
|
||
logg: crate::models::GridAxisValue::from_value(5.0),
|
||
loghe: crate::models::GridAxisValue::from_value(-2.0),
|
||
logc: crate::models::GridAxisValue::from_value(-2.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-3.0),
|
||
};
|
||
// 单阶段 nl 链
|
||
let chain = vec![super::ChainStep {
|
||
label: "nl".to_string(),
|
||
lte: "F".to_string(),
|
||
ltgray: "F".to_string(),
|
||
niter: 100,
|
||
chmax: Some(0.001),
|
||
orelax: Some(0.5),
|
||
dpsilg: None,
|
||
popzer: None,
|
||
ilvlin: 100,
|
||
require_converged: true,
|
||
itek: None,
|
||
ichang: None,
|
||
idlte: None,
|
||
iacc: None,
|
||
}];
|
||
let summary = runner
|
||
.run_model_with_timeout(
|
||
¶ms,
|
||
"t60000_g5.0_he-2_c-2_n-4_o-3",
|
||
"cold_run",
|
||
Some(chain),
|
||
Some(&seed),
|
||
None, // seed_params
|
||
None,
|
||
true,
|
||
false,
|
||
60,
|
||
None,
|
||
None, // tlusty_input
|
||
Some(0.01), // energy_tolerance
|
||
None, None, None, None, None, None, None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let labels: Vec<(String, bool)> = summary
|
||
.stages
|
||
.iter()
|
||
.map(|s| (s.label.clone(), s.converged))
|
||
.collect();
|
||
assert_eq!(
|
||
labels,
|
||
vec![
|
||
("nl".to_string(), true),
|
||
("nl_tight".to_string(), true),
|
||
],
|
||
"能量边际应触发 nl_tight 回退并收敛(阶段: {:?})",
|
||
labels
|
||
);
|
||
let ec = summary.energy_check.as_ref().expect("应有能量检查结果");
|
||
assert!(ec.valid, "回退后能量应达标: {:?}", ec.error);
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
}
|
||
|
||
/// NaN 伪收敛防护回归(2026-08-17):TLUSTY 从 NaN 污染种子启动会立即崩溃,
|
||
/// fort.9 全 0.00E+00 假达标(max_relc=0 < chmax)——生产实测 261 个点因此
|
||
/// 被误判 nl 收敛、跳过 nl_direct 回退。防护后:fort.7 含 NaN 即否决 converged,
|
||
/// 且不产出污染种子(下一阶段回退用原始种子)。
|
||
#[tokio::test]
|
||
async fn unit_nan_pseudo_convergence_veto() {
|
||
let work =
|
||
std::env::temp_dir().join(format!("dcts_unit_nanveto_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
tokio::fs::create_dir_all(&work).await.unwrap();
|
||
// 假 tlusty:#1(seed_nc) 写全零 fort.9(假达标)+ 含 NaN 的 fort.7;
|
||
// #2(nl) 从原始种子正常收敛。
|
||
let fake = work.join("fake_tlusty.sh");
|
||
let script = r#"#!/usr/bin/env bash
|
||
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
|
||
cat /dev/stdin > /dev/null
|
||
case $n in
|
||
1) printf ' 50 5.0E+03 NaN NaN NaN\n' > fort.7
|
||
printf ' 1 50 0.00E+00 0.00E+00 0.00E+00 0.00E+00 0.00E+00 0 9\n' > fort.9 ;;
|
||
2) echo "clean model" > fort.7
|
||
printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9 ;;
|
||
esac
|
||
exit 0
|
||
"#;
|
||
tokio::fs::write(&fake, script).await.unwrap();
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
}
|
||
let runtime = crate::embedded::RuntimePaths {
|
||
tlusty_exe: fake.clone(),
|
||
synspec_exe: work.join("synspec_absent"),
|
||
data_dir: work.clone(),
|
||
linelist: work.join("linelist_absent"),
|
||
};
|
||
let seed = work.join("orig_seed.7");
|
||
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
|
||
|
||
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
|
||
let params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(60000.0),
|
||
logg: crate::models::GridAxisValue::from_value(6.0),
|
||
loghe: crate::models::GridAxisValue::from_value(-4.0),
|
||
logc: crate::models::GridAxisValue::from_value(-4.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-4.0),
|
||
};
|
||
let summary = runner
|
||
.run_model_with_timeout(
|
||
¶ms,
|
||
"t60000_g6.0_he-4_c-4_n-4_o-4",
|
||
"seed_step",
|
||
Some(super::default_seed_chain()),
|
||
Some(&seed),
|
||
None,
|
||
None,
|
||
true,
|
||
false,
|
||
60,
|
||
None,
|
||
None, None, None, None, None, None, None, None, None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let labels: Vec<(String, bool)> = summary
|
||
.stages
|
||
.iter()
|
||
.map(|s| (s.label.clone(), s.converged))
|
||
.collect();
|
||
assert_eq!(
|
||
labels,
|
||
vec![
|
||
("seed_nc".to_string(), false),
|
||
("nl".to_string(), true),
|
||
],
|
||
"全零 fort.9 + NaN fort.7 应被否决收敛;nl 应回退到原始种子并收敛(阶段: {:?})",
|
||
labels
|
||
);
|
||
let seed_nc = summary.stages.iter().find(|s| s.label == "seed_nc").unwrap();
|
||
assert!(
|
||
seed_nc.note.as_deref().unwrap_or("").contains("NaN"),
|
||
"note 应标注 NaN 伪收敛防护,实际: {:?}",
|
||
seed_nc.note
|
||
);
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
}
|
||
/// 端到端回归(2026-08-14,#[ignore]:真实运行 TLUSTY,约 15-40 分钟):
|
||
/// nl_direct 回退。场景取自生产实测失败点 t60000_g5.5_he-2_c-4_n-4_o-4:
|
||
/// seed_nc 发散 → 其输出毒死 nl → 回退用原始种子直接跑 nl 应收敛。
|
||
/// 对应修复验证数据见 docs/tlusty_coldstart_nc_trace_illcond_2026_08_13.md。
|
||
#[tokio::test]
|
||
#[ignore = "真实运行 TLUSTY(~40min),验证修复时用 cargo test -p common nl_direct -- --ignored --nocapture"]
|
||
async fn e2e_nl_direct_fallback_recovers_poisoned_seed_chain() {
|
||
use crate::models::GridAxisValue;
|
||
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||
let repo_root: &std::path::Path = manifest.parent().and_then(|p| p.parent()).unwrap();
|
||
let seed_path = repo_root.join(
|
||
"test/20260813_cold_nc_trace_fix/hevalidation/p4_nldirect_cno1/inputs/fort.8",
|
||
);
|
||
if !seed_path.is_file() {
|
||
eprintln!("跳过:种子模型不存在({}),需先准备 p4 场景种子", seed_path.display());
|
||
return;
|
||
}
|
||
let runtime = crate::embedded::RuntimePaths {
|
||
tlusty_exe: repo_root.join("assets/tlusty_static"),
|
||
synspec_exe: repo_root.join("assets/synspec_static"),
|
||
data_dir: repo_root.join("assets/data"),
|
||
linelist: repo_root.join("assets/data/gfATO.dat"),
|
||
};
|
||
let work = std::env::temp_dir().join(format!("dcts_e2e_nl_direct_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
tokio::fs::create_dir_all(&work).await.unwrap();
|
||
let runner = super::ExecutionRunner::new(&runtime, work.clone());
|
||
|
||
let params = GridPointParams {
|
||
teff: GridAxisValue::from_value(60000.0),
|
||
logg: GridAxisValue::from_value(5.5),
|
||
loghe: GridAxisValue::from_value(-2.0),
|
||
logc: GridAxisValue::from_value(-4.0),
|
||
logn: GridAxisValue::from_value(-4.0),
|
||
logo: GridAxisValue::from_value(-4.0),
|
||
};
|
||
let name = "t60000_g5.5_he-2_c-4_n-4_o-4";
|
||
let chain = super::default_seed_chain();
|
||
let summary = runner
|
||
.run_model_with_timeout(
|
||
¶ms,
|
||
name,
|
||
"seed_step",
|
||
Some(chain),
|
||
Some(&seed_path),
|
||
None, // seed_params
|
||
None, // synspec_cfg
|
||
true, // tlusty_enabled
|
||
false, // synspec_enabled
|
||
3600,
|
||
None,
|
||
None,
|
||
None, None, None, None, None, None, None, None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
// 成功判据(两条路径之一,均为修复目标):
|
||
// a) 主链 nl 自收敛(nst 截断修复后 IFALI/IFPOPR/JALI 等真正生效,nl 可自愈),或
|
||
// b) nl 失败触发 nl_direct 回退且回退收敛(p4 场景实测 13it/6.3e-4)。
|
||
let labels: Vec<(&str, bool)> = summary
|
||
.stages
|
||
.iter()
|
||
.map(|s| (s.label.as_str(), s.converged))
|
||
.collect();
|
||
eprintln!("stages: {:?}", labels);
|
||
let main_nl_ok = summary.stages.iter().any(|s| s.label == "nl" && s.converged);
|
||
let direct_ok = summary.stages.iter().any(|s| s.label == "nl_direct" && s.converged);
|
||
assert!(
|
||
main_nl_ok || direct_ok,
|
||
"种子链应经主链 nl 或 nl_direct 回退之一收敛,实际阶段: {:?}",
|
||
labels
|
||
);
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
}
|
||
|
||
/// plan_ladder_steps 步进规划(纯函数)回归:
|
||
/// - logg 间隔 0.5(> 单步上限 0.25)→ logg 轴 2 步,每步 0.25;
|
||
/// - teff 间隔 5000(= 2×2500)→ teff 轴 2 步,每步 2500;
|
||
/// - 双轴都在单步域内 → 空(不该触发 ladder)。
|
||
#[test]
|
||
fn test_plan_ladder_steps() {
|
||
// logg 轴主导:Δlogg=0.5、Δteff=0
|
||
let s = super::plan_ladder_steps(60000.0, 5.5, 60000.0, 6.0);
|
||
assert_eq!(s.len(), 2);
|
||
assert!((s[0].1 - 5.75).abs() < 1e-9, "{:?}", s);
|
||
assert!((s[1].1 - 6.0).abs() < 1e-9);
|
||
assert!(s[0].2.starts_with("ladder_g"));
|
||
// teff 轴主导:Δteff=5000、Δlogg=0.2
|
||
let s = super::plan_ladder_steps(55000.0, 5.0, 60000.0, 5.2);
|
||
assert_eq!(s.len(), 2);
|
||
assert!((s[0].0 - 57500.0).abs() < 1e-9, "{:?}", s);
|
||
assert!((s[0].1 - 5.2).abs() < 1e-9);
|
||
// 单步域内 → 空
|
||
assert!(super::plan_ladder_steps(60000.0, 5.0, 57500.0, 5.1).is_empty());
|
||
assert!(super::plan_ladder_steps(60000.0, 5.0, 60000.0, 5.25).is_empty());
|
||
}
|
||
|
||
/// ladder 回退接线回归(2026-08-17):种子链 + nl_direct 全失败 + 种子参数
|
||
/// Δlogg=0.5 → 触发 2 步 logg 步进 + 目标步 nl_ladder;中间步登记 ladder_seeds。
|
||
#[tokio::test]
|
||
async fn unit_ladder_fallback_wiring() {
|
||
let work =
|
||
std::env::temp_dir().join(format!("dcts_unit_ladder_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
tokio::fs::create_dir_all(&work).await.unwrap();
|
||
// 假 tlusty:#1(seed_nc)/#2(nl)/#3(nl_direct) 发散;#4/#5(中间步)/#6(nl_ladder)
|
||
// 收敛。fort.7 干净(含数字行,无 NaN 字样)。
|
||
let fake = work.join("fake_tlusty.sh");
|
||
let script = r#"#!/usr/bin/env bash
|
||
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
|
||
cat /dev/stdin > /dev/null
|
||
echo " 50 1.0E+03 2.0E+03" > fort.7
|
||
case $n in
|
||
1|2|3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
|
||
*) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9 ;;
|
||
esac
|
||
exit 0
|
||
"#;
|
||
tokio::fs::write(&fake, script).await.unwrap();
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
}
|
||
let runtime = crate::embedded::RuntimePaths {
|
||
tlusty_exe: fake.clone(),
|
||
synspec_exe: work.join("synspec_absent"),
|
||
data_dir: work.clone(),
|
||
linelist: work.join("linelist_absent"),
|
||
};
|
||
let seed = work.join("orig_seed.7");
|
||
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
|
||
|
||
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
|
||
let params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(60000.0),
|
||
logg: crate::models::GridAxisValue::from_value(6.0),
|
||
loghe: crate::models::GridAxisValue::from_value(-4.0),
|
||
logc: crate::models::GridAxisValue::from_value(-4.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-4.0),
|
||
};
|
||
// 种子参数 Δlogg=0.5(同 teff/丰度)→ 2 步 logg 步进
|
||
let seed_params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(60000.0),
|
||
logg: crate::models::GridAxisValue::from_value(5.5),
|
||
loghe: crate::models::GridAxisValue::from_value(-4.0),
|
||
logc: crate::models::GridAxisValue::from_value(-4.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-4.0),
|
||
};
|
||
let summary = runner
|
||
.run_model_with_timeout(
|
||
¶ms,
|
||
"t60000_g6.0_he-4_c-4_n-4_o-4",
|
||
"seed_step",
|
||
Some(super::default_seed_chain()),
|
||
Some(&seed),
|
||
Some(&seed_params),
|
||
None,
|
||
true,
|
||
false,
|
||
60,
|
||
None,
|
||
None, None, None, None, None, None, None, None, None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let labels: Vec<(String, bool)> = summary
|
||
.stages
|
||
.iter()
|
||
.map(|s| (s.label.clone(), s.converged))
|
||
.collect();
|
||
assert_eq!(
|
||
labels,
|
||
vec![
|
||
("seed_nc".to_string(), false),
|
||
("nl".to_string(), false),
|
||
("nl_direct".to_string(), false),
|
||
("ladder_g5.75".to_string(), true),
|
||
("ladder_g6".to_string(), true),
|
||
("nl_ladder".to_string(), true),
|
||
],
|
||
"应触发 ladder 步进链并经 nl_ladder 收敛(阶段: {:?})",
|
||
labels
|
||
);
|
||
assert_eq!(summary.ladder_seeds.len(), 2, "两个中间步都应登记梯级");
|
||
assert_eq!(
|
||
summary.ladder_seeds[0].point_name,
|
||
"t60000_g5.75_he-4_c-4_n-4_o-4_ladder"
|
||
);
|
||
assert!((summary.ladder_seeds[0].logg - 5.75).abs() < 1e-9);
|
||
assert!((summary.ladder_seeds[1].logg - 6.0).abs() < 1e-9);
|
||
assert!(summary.result_valid);
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
}
|
||
|
||
/// nl_stab2 回退接线(2026-08-18):种子链 seed_nc/nl/nl_direct 全发散、
|
||
/// 无种子参数(ladder 不触发)→ nl_stab2 以二档稳定化参数自原始种子收敛。
|
||
#[tokio::test]
|
||
async fn unit_nl_stab2_fallback_wiring() {
|
||
let work = std::env::temp_dir().join(format!("dcts_unit_stab2_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
tokio::fs::create_dir_all(&work).await.unwrap();
|
||
// 假 tlusty:#1(seed_nc)/#2(nl)/#3(nl_direct) 发散;#4(nl_stab2) 收敛。
|
||
let fake = work.join("fake_tlusty.sh");
|
||
let script = r#"#!/usr/bin/env bash
|
||
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
|
||
cat /dev/stdin > /dev/null
|
||
echo " 50 1.0E+03 2.0E+03" > fort.7
|
||
case $n in
|
||
1|2|3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
|
||
*) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9 ;;
|
||
esac
|
||
exit 0
|
||
"#;
|
||
tokio::fs::write(&fake, script).await.unwrap();
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
}
|
||
let runtime = crate::embedded::RuntimePaths {
|
||
tlusty_exe: fake.clone(),
|
||
synspec_exe: work.join("synspec_absent"),
|
||
data_dir: work.clone(),
|
||
linelist: work.join("linelist_absent"),
|
||
};
|
||
let seed = work.join("orig_seed.7");
|
||
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
|
||
|
||
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
|
||
let params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(20000.0),
|
||
logg: crate::models::GridAxisValue::from_value(6.0),
|
||
loghe: crate::models::GridAxisValue::from_value(2.0),
|
||
logc: crate::models::GridAxisValue::from_value(-2.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-4.0),
|
||
};
|
||
let summary = runner
|
||
.run_model_with_timeout(
|
||
¶ms,
|
||
"t20000_g6.0_he2_c-2_n-4_o-4",
|
||
"seed_step",
|
||
Some(super::default_seed_chain()),
|
||
Some(&seed),
|
||
None, // 种子参数未知 → ladder 回退不触发,直达 nl_stab2
|
||
None,
|
||
true,
|
||
false,
|
||
60,
|
||
None,
|
||
None, None, None, None, None, None, None, None, None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let labels: Vec<(String, bool)> = summary
|
||
.stages
|
||
.iter()
|
||
.map(|s| (s.label.clone(), s.converged))
|
||
.collect();
|
||
assert_eq!(
|
||
labels,
|
||
vec![
|
||
("seed_nc".to_string(), false),
|
||
("nl".to_string(), false),
|
||
("nl_direct".to_string(), false),
|
||
("nl_stab2".to_string(), true),
|
||
],
|
||
"全链失败后应触发 nl_stab2 二档回退(阶段: {:?})",
|
||
labels
|
||
);
|
||
assert!(summary.result_valid);
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
}
|
||
|
||
/// 丰度轴延拓接线回归(2026-08-21,60k 折叠角攻坚):高温域(Teff>30kK)
|
||
/// 下常规链全失败(stab 档因域门控跳过)→ 从同族 CNO 邻居种子沿 C 轴
|
||
/// 自适应延拓,第 4 次调用收敛。种子参数与目标同 T/g/He、仅 C 不同。
|
||
#[tokio::test]
|
||
async fn unit_axlad_c_axis_fallback_wiring() {
|
||
let work = std::env::temp_dir().join(format!("dcts_unit_axlad_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
tokio::fs::create_dir_all(&work).await.unwrap();
|
||
// 假 tlusty:#1(seed_nc)/#2(nl)/#3(nl_direct) 发散;#4 起收敛。
|
||
let fake = work.join("fake_tlusty.sh");
|
||
let script = r#"#!/usr/bin/env bash
|
||
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
|
||
cat /dev/stdin > /dev/null
|
||
echo " 50 1.0E+03 2.0E+03" > fort.7
|
||
case $n in
|
||
1|2|3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
|
||
*) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9 ;;
|
||
esac
|
||
exit 0
|
||
"#;
|
||
tokio::fs::write(&fake, script).await.unwrap();
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
}
|
||
let runtime = crate::embedded::RuntimePaths {
|
||
tlusty_exe: fake.clone(),
|
||
synspec_exe: work.join("synspec_absent"),
|
||
data_dir: work.clone(),
|
||
linelist: work.join("linelist_absent"),
|
||
};
|
||
let seed = work.join("orig_seed.7");
|
||
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
|
||
// 种子参数:同 T/g/He、仅 logC 与目标不同 → C 轴延拓触发
|
||
let seed_params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(60000.0),
|
||
logg: crate::models::GridAxisValue::from_value(5.0),
|
||
loghe: crate::models::GridAxisValue::from_value(-4.0),
|
||
logc: crate::models::GridAxisValue::from_value(-1.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-1.0),
|
||
};
|
||
|
||
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
|
||
let params = crate::models::GridPointParams {
|
||
teff: crate::models::GridAxisValue::from_value(60000.0),
|
||
logg: crate::models::GridAxisValue::from_value(5.0),
|
||
loghe: crate::models::GridAxisValue::from_value(-4.0),
|
||
logc: crate::models::GridAxisValue::from_value(-4.0),
|
||
logn: crate::models::GridAxisValue::from_value(-4.0),
|
||
logo: crate::models::GridAxisValue::from_value(-1.0),
|
||
};
|
||
let summary = runner
|
||
.run_model_with_timeout(
|
||
¶ms,
|
||
"t60000_g5.0_he-4_c-4_n-4_o-1",
|
||
"seed_step",
|
||
Some(super::default_seed_chain()),
|
||
Some(&seed),
|
||
Some(&seed_params),
|
||
None,
|
||
true,
|
||
false,
|
||
120,
|
||
None,
|
||
None, None, None, None, None, None, None, None, None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let labels: Vec<String> = summary.stages.iter().map(|s| s.label.clone()).collect();
|
||
assert!(
|
||
labels.iter().any(|l| l.starts_with("axlad_c")),
|
||
"高温域全链失败后应触发 C 轴丰度延拓(阶段: {:?})",
|
||
labels
|
||
);
|
||
assert!(summary.result_valid, "延拓收敛后结果应有效");
|
||
assert!(summary
|
||
.stages
|
||
.last()
|
||
.map(|s| s.converged)
|
||
.unwrap_or(false));
|
||
let _ = std::fs::remove_dir_all(&work);
|
||
}
|
||
|
||
/// parse_point_name 回归:规范名与 _ladder 后缀名均可解析,乱名返回 None。
|
||
#[test]
|
||
fn test_parse_point_name() {
|
||
let p = crate::models::GridPointParams::parse_point_name("t20000_g5.0_he-2_c-1_n-3_o-4");
|
||
assert!(p.is_some());
|
||
let p = p.unwrap();
|
||
assert_eq!(p.teff.value(), 20000.0);
|
||
assert_eq!(p.logg.value(), 5.0);
|
||
assert_eq!(p.loghe.value(), -2.0);
|
||
// _ladder 合成名(ladder 持久化路径)
|
||
let p = crate::models::GridPointParams::parse_point_name(
|
||
"t60000_g5.75_he-4_c-4_n-4_o-4_ladder",
|
||
);
|
||
assert!(p.is_some());
|
||
assert!((p.unwrap().logg - 5.75).abs() < 1e-9);
|
||
assert!(crate::models::GridPointParams::parse_point_name("garbage").is_none());
|
||
assert!(crate::models::GridPointParams::parse_point_name("").is_none());
|
||
}
|
||
}
|