- server/db: 拆 4929 行 db.rs 单体为 db/ 目录,migrations.rs 引入 PRAGMA user_version
版本化迁移运行器(M1~M13)
- 任务引擎 Phase 6/7b/7c 改名收敛:EngineStageConfig→PhaseConfig、StagePolicy→ResumePolicy、
Converged→Completed、删除 task_type 列、success_method 拆 tlusty_/synspec_ 双列、
新增 tlusty_status/synspec_status 半失败阶段守卫
- 科学正确性加固:conv_check 任意行 NaN/Inf/溢出判无效(0 行容忍)、新增 spec_is_valid
校验 SYNSPEC 脏谱、itek_history 逐次迭代全量保真、fmt_abn powf 溢出饱和
- 用户配置真正接通:tlusty_chain/tlusty_input 由死字段经 调度器→TaskSpec→executor→runner
透传生效;config 加载期 validate + deny_unknown_fields + 解析失败记 warn
- 调度修复:H1 活锁(pending_strategies 跳过已失败策略)、种子查找错误不再静默降级冷启动
- dashboard: 阶段配置面板 tlusty_stage/synspec_stage、"已完成"标签、迭代诊断展示
- docs: 新增 database_refactor_design.md,同步 database/api/PIPELINE/workflow_detail
900 lines
40 KiB
Rust
900 lines
40 KiB
Rust
use crate::config::{ChainStep, SynspecInput, TlustyInput};
|
||
use crate::conv_check::{atmosphere_has_nan, check_fort9, 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};
|
||
|
||
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,
|
||
metals: Some("cno".to_string()),
|
||
ichang: None,
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: 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,
|
||
metals: Some("cno".to_string()),
|
||
ichang: None,
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: 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,
|
||
metals: Some("cno".to_string()),
|
||
ichang: None,
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: 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,
|
||
metals: Some("cno".to_string()),
|
||
ichang: Some(0),
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: 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,
|
||
metals: Some("cno".to_string()),
|
||
ichang: Some(0),
|
||
idlte: None,
|
||
iacc: None,
|
||
orelax: None,
|
||
},
|
||
]
|
||
}
|
||
|
||
/// 按当前策略选默认执行链(`custom_chain` 为 None/空时的兜底)。
|
||
///
|
||
/// Phase 6(P8)起取代废弃的 task_type 匹配:`"seed_step"` → 种子热启动链,其余策略
|
||
/// (`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 {
|
||
default_cold_chain()
|
||
}
|
||
}
|
||
|
||
/// 运行子进程,带超时与优雅退出(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>,
|
||
synspec_cfg: Option<&SynspecInput>,
|
||
tlusty_input: Option<&TlustyInput>,
|
||
) -> Result<ModelSummary> {
|
||
self.run_model_with_timeout(
|
||
params,
|
||
name,
|
||
current_strategy,
|
||
custom_chain,
|
||
seed_atmos,
|
||
synspec_cfg,
|
||
true,
|
||
true,
|
||
7200,
|
||
None,
|
||
tlusty_input,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// 阶段独立配置执行入口(见 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>,
|
||
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>,
|
||
) -> 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;
|
||
|
||
// 阶段独立配置(见 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;
|
||
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_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);
|
||
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
|
||
|
||
// Prepare fort.8 for this stage
|
||
if stage_def.ltgray == "T" {
|
||
if fort8.exists() {
|
||
let _ = tokio::fs::remove_file(&fort8).await;
|
||
}
|
||
} else if let Some(ref s_path) = current_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.clone()).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(),
|
||
};
|
||
|
||
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;
|
||
|
||
// 漏洞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
|
||
),
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
current_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),不会与 synspec 产物冲突。
|
||
//
|
||
// 不快照 fort.9:上方 L292 已把 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;
|
||
}
|
||
}
|
||
|
||
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
|
||
);
|
||
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;
|
||
}
|
||
|
||
// 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;
|
||
|
||
let default_cfg = SynspecInput {
|
||
wstart: 1400.0,
|
||
wend: 1410.0,
|
||
imode: 0,
|
||
idrv: 50,
|
||
ifreq: 1,
|
||
rel_cutoff: 0.0001,
|
||
abs_cutoff: 0.01,
|
||
};
|
||
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 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,
|
||
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]
|
||
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());
|
||
}
|
||
}
|