feat(all): 物理正确性五重硬门槛、输入文件结构化与 fort.55 错位修复、conv 诊断 DB 化与阶段归因修复、ORELAX 收敛修复与导入工具下线
物理正确性校验体系(common/conv_check.rs +494 行) - 新增 5 类硬门槛:能量守恒(.6)、温度结构(.7)、emflux 积分校验(.emflux,含全 NaN 判失败)、假收敛排查(itek 轨迹首末比)、b 因子合理性(.bfac) - runner 在 TLUSTY 阶段结束后执行全部校验,任一失败判 final_converged=false - GridConfig 新增 8 个可配阈值,经 scheduler→executor→runner 全链路透传 输入文件配置结构化重构(config.rs +1453 行) - TlustyInput 拆为 dot5/nst 分层结构,字段名严格映射 tlusty208.f READ 语句;SynspecInput 重构为 9 个 Fort55Line 子结构体 - 移除 ChainStep.metals 字段,元素集改由 dot5.atoms/ions 显式声明(gen_input5/nst_writer 同步重写为三源融合 / 分层覆盖) - fort.55 修复行结构 bug:补全分子表行(7→9 行),IDSTD 50→0 错位修正(影响全部光谱线强归一化,需重算 SYNSPEC 阶段) conv 诊断 DB 化与阶段归因修复(server) - 单点详情 conv 面板从磁盘 conv.json 改读 DB grid_points.summary_json;grid_points 新增 summary_json/last_elapsed_sec 两列(旧库幂等 ALTER) - record_task_report 阶段归因列加 CASE 守卫 + clear_synspec 对称处理,修复 synspec-only/TLUSTY-only 重跑污染统计 - 新增 summary_merge.rs 点级增量合并,避免重跑覆盖诊断字段 收敛性 ORELAX 修复与 seed_chain 可配(sdB_cno.yaml + node) - nl 阶段加 orelax=0.5、seed_nc 加 orelax=0.3,阻尼中温区 relc 振荡发散 - seed_chain 块可配,executor 优先采用用户配置而非内置默认链 导入工具下线 - 删除 import_results 客户端工具及 Windows 推送脚本;移除 /admin/import_seed 端点 - 改为服务端临时 migrate_conv 端点(扫 conv.json 增量合并入库,迁移后可删) 文档与分析 - 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档 - spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错
This commit is contained in:
+726
-13
@@ -69,7 +69,12 @@ const GRID_POINTS_SCHEMA: &str = "CREATE TABLE grid_points (
|
||||
-- H1 修复(M11):运行时回退把点打回 pending 时记录的「剩余策略链」(JSON 数组)。
|
||||
-- 供调度路径识别「该点已失败过 cold_run、正在等种子」→ 重派时用剩余链而非完整 YAML 链,
|
||||
-- 避免重跑已失败策略导致的无界失败重试活锁(见 scheduler.rs H1 注释)。NULL = 无标记。
|
||||
pending_strategies TEXT
|
||||
pending_strategies TEXT,
|
||||
-- 最近一次尝试的真实墙钟耗时(秒,Worker 回报值;旧数据为 None)。
|
||||
last_elapsed_sec REAL,
|
||||
-- 点级诊断快照(完整 ModelSummary JSON)。详情页 conv 诊断面板的数据源。
|
||||
-- synspec-only 重跑时经 merge_point_summary 增量合并,保留 TLUSTY 字段不丢失。
|
||||
summary_json TEXT
|
||||
)";
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -431,15 +436,20 @@ impl Database {
|
||||
if !has_tasks_elapsed {
|
||||
let _ = conn.execute("ALTER TABLE tasks ADD COLUMN elapsed_sec REAL", []);
|
||||
}
|
||||
let has_gp_elapsed = conn
|
||||
.prepare("PRAGMA table_info(grid_points)")?
|
||||
.query_map([], |r| r.get::<_, String>(1))?
|
||||
.any(|r| r.map(|n| n == "last_elapsed_sec").unwrap_or(false));
|
||||
if !has_gp_elapsed {
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL",
|
||||
[],
|
||||
);
|
||||
// grid_points 幂等补列:last_elapsed_sec(列表展示用)与 summary_json(点级诊断快照)。
|
||||
// 新库由 GRID_POINTS_SCHEMA 建表时即含此二列;此 ALTER 仅兜底旧库(表已存在但缺列)。
|
||||
// 与上方 tasks 列迁移同模式:PRAGMA 检测 → 缺列才 ALTER。
|
||||
for (col, sql) in [
|
||||
("last_elapsed_sec", "ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL"),
|
||||
("summary_json", "ALTER TABLE grid_points ADD COLUMN summary_json TEXT"),
|
||||
] {
|
||||
let has_col = conn
|
||||
.prepare("PRAGMA table_info(grid_points)")?
|
||||
.query_map([], |r| r.get::<_, String>(1))?
|
||||
.any(|r| r.map(|n| n == col).unwrap_or(false));
|
||||
if !has_col {
|
||||
let _ = conn.execute(sql, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段独立配置迁移(见 docs/task_engine_decoupling_design.md §4.1):
|
||||
@@ -1143,9 +1153,30 @@ mod tests {
|
||||
db.update_grid_status(&p.model_name(), GridPointStatus::Queued, "wf_b")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(&p2.model_name(), "wf_a", None, "seed_step")
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let summary = common::models::ModelSummary {
|
||||
name: p2.model_name(),
|
||||
params: p2.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.001),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: None,
|
||||
synspec_error: None,
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 0.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
db.upsert_point_summary(&summary.name, "wf_a", &summary, "seed_step")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 全局(None):3 个点,pending/queued/converged 分开计数;
|
||||
// 导入点按 seed_step 途径计入 seed_step_converged(不再有独立 imported 分类)
|
||||
@@ -1782,8 +1813,17 @@ mod tests {
|
||||
synspec_config: common::models::PhaseConfig::default_synspec(),
|
||||
synspec_params: None,
|
||||
tlusty_chain_params: None,
|
||||
seed_chain_params: None,
|
||||
tlusty_input_params: None,
|
||||
atmosphere_ref: None,
|
||||
energy_tolerance: None,
|
||||
temp_max_factor: None,
|
||||
temp_floor: None,
|
||||
temp_ceiling: None,
|
||||
emflux_tolerance: None,
|
||||
convergence_min_ratio: None,
|
||||
bfac_max: None,
|
||||
bfac_min: None,
|
||||
};
|
||||
let syn_task = Uuid::new_v4();
|
||||
let old_task = Uuid::new_v4();
|
||||
@@ -3553,4 +3593,677 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// synspec-only 重跑后 tlusty_success_method / tlusty_status 须保留 prior 值,
|
||||
/// 不能被 NULL 覆写(CASE 守卫修复验证)。
|
||||
///
|
||||
/// 场景:先以 TLUSTY 启用(cold_run)跑成功 → tlusty_success_method = "cold_run"。
|
||||
/// 再以 TLUSTY 关闭(仅 SYNSPEC,场景 B)重跑成功 → tlusty_success_method 仍须为
|
||||
/// "cold_run",不能被覆写为 NULL。synspec_success_method 应更新为 "standard"。
|
||||
#[tokio::test]
|
||||
async fn test_synspec_only_rerun_preserves_tlusty_attribution() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("synrerun.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let wf = "wf_synrerun";
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 25000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, wf).await.unwrap();
|
||||
|
||||
// ── 第一轮:TLUSTY 启用 + cold_run,成功 ──
|
||||
let task1 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task1).await.unwrap();
|
||||
|
||||
let summary1 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0005),
|
||||
elapsed_sec: 300.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0005),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.3),
|
||||
elapsed_sec: 300.3,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report1 = TaskReport {
|
||||
task_id: task1.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0005),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 300.3,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary1).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report1, wf).await.unwrap();
|
||||
|
||||
// 验证第一轮:tlusty_success_method = cold_run, tlusty_status = converged
|
||||
let row1 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row1.status, "completed");
|
||||
assert_eq!(row1.tlusty_success_method.as_deref(), Some("cold_run"));
|
||||
assert_eq!(row1.tlusty_status.as_deref(), Some("converged"));
|
||||
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
|
||||
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
|
||||
|
||||
// ── 模拟场景 B:reset_terminal_points_for_recompute 翻回 pending ──
|
||||
db.reset_terminal_points_for_recompute(wf).await.unwrap();
|
||||
|
||||
// ── 第二轮:TLUSTY 关闭 + SYNSPEC 启用(synspec-only),成功 ──
|
||||
let task2 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: false,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
atmosphere_ref: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task2).await.unwrap();
|
||||
|
||||
// synspec-only 的 summary:stages 为空 → merge_point_summary 走字段级合并
|
||||
let summary2 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: true,
|
||||
final_max_relc: None,
|
||||
final_chmax: None,
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.25),
|
||||
elapsed_sec: 0.25,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report2 = TaskReport {
|
||||
task_id: task2.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: None,
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 0.25,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary2).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report2, wf).await.unwrap();
|
||||
|
||||
// ── 核心断言:synspec-only 重跑后 tlusty 侧归因须保留 ──
|
||||
let row2 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row2.status, "completed", "重跑成功后应为 completed");
|
||||
// ★ 修复前:tsm2 = None(裸赋值覆写)。修复后:保留 "cold_run"。
|
||||
assert_eq!(
|
||||
row2.tlusty_success_method.as_deref(),
|
||||
Some("cold_run"),
|
||||
"synspec-only 重跑后 tlusty_success_method 须保留,不能覆写为 NULL"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.tlusty_status.as_deref(),
|
||||
Some("converged"),
|
||||
"synspec-only 重跑后 tlusty_status 须保留"
|
||||
);
|
||||
// synspec 侧应更新为新值
|
||||
assert_eq!(
|
||||
row2.synspec_success_method.as_deref(),
|
||||
Some("standard"),
|
||||
"synspec_success_method 应更新为 standard"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.synspec_status.as_deref(),
|
||||
Some("converged"),
|
||||
"synspec_status 应为 converged"
|
||||
);
|
||||
|
||||
// summary_json 的 TLUSTY 诊断也须保留(merge_point_summary 字段级合并)
|
||||
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
|
||||
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(ms.stages.len(), 1, "stages 须保留 prior 的 TLUSTY 链");
|
||||
assert_eq!(ms.stages[0].label, "nl");
|
||||
assert_eq!(ms.final_max_relc, Some(0.0005), "final_max_relc 须保留");
|
||||
assert_eq!(ms.synspec_rc, Some(0), "synspec_rc 应为新值");
|
||||
assert_eq!(ms.elapsed_sec, 0.25, "elapsed_sec 应为新值");
|
||||
|
||||
// last_elapsed_sec 语义:最近一次尝试耗时。synspec-only 重跑后为 0.25s(synspec 耗时),
|
||||
// 原 TLUSTY 耗时保留在 stages[].elapsed_sec。ETA 不依赖此列(用 AVG(tasks.elapsed_sec))。
|
||||
let last_elapsed = read_grid_last_elapsed(&db, &name, wf).await;
|
||||
assert_eq!(
|
||||
last_elapsed, Some(0.25),
|
||||
"last_elapsed_sec 应为 synspec-only 耗时(最近一次尝试),非原 TLUSTY 总耗时"
|
||||
);
|
||||
}
|
||||
|
||||
/// 辅助:读取 grid_points 的阶段归因列。
|
||||
async fn read_grid_attrs(db: &Database, name: &str, wf: &str) -> GridAttrs {
|
||||
let pool = db.pool.clone();
|
||||
let name = name.to_string();
|
||||
let wf = wf.to_string();
|
||||
tokio::task::spawn_blocking(move || -> GridAttrs {
|
||||
let conn = pool.get().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT status, tlusty_success_method, synspec_success_method, tlusty_status, synspec_status \
|
||||
FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
|
||||
rusqlite::params![name, wf],
|
||||
|r| {
|
||||
Ok(GridAttrs {
|
||||
status: r.get(0)?,
|
||||
tlusty_success_method: r.get(1)?,
|
||||
synspec_success_method: r.get(2)?,
|
||||
tlusty_status: r.get(3)?,
|
||||
synspec_status: r.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct GridAttrs {
|
||||
status: String,
|
||||
tlusty_success_method: Option<String>,
|
||||
synspec_success_method: Option<String>,
|
||||
tlusty_status: Option<String>,
|
||||
synspec_status: Option<String>,
|
||||
}
|
||||
|
||||
/// synspec-only 重跑**失败**后 tlusty_success_method / tlusty_status 仍须保留。
|
||||
///
|
||||
/// 失败分支的 UPDATE 不写 success_method 列,但 tlusty_status / synspec_status
|
||||
/// 有 CASE 守卫。验证失败报告不会清空 prior 的 TLUSTY 归因。
|
||||
#[tokio::test]
|
||||
async fn test_synspec_only_rerun_failure_preserves_tlusty_attribution() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("synfail.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let wf = "wf_synfail";
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 25000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, wf).await.unwrap();
|
||||
|
||||
// 第一轮:TLUSTY 启用 + cold_run,成功。
|
||||
let task1 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task1).await.unwrap();
|
||||
let summary1 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0005),
|
||||
elapsed_sec: 300.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0005),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.3),
|
||||
elapsed_sec: 300.3,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report1 = TaskReport {
|
||||
task_id: task1.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0005),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 300.3,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary1).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report1, wf).await.unwrap();
|
||||
assert_eq!(read_grid_attrs(&db, &name, wf).await.tlusty_success_method.as_deref(), Some("cold_run"));
|
||||
|
||||
// 翻回 pending 模拟场景 B 重跑。
|
||||
db.reset_terminal_points_for_recompute(wf).await.unwrap();
|
||||
|
||||
// 第二轮:synspec-only,失败(synspec 产出脏谱)。
|
||||
let task2 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: false,
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
atmosphere_ref: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task2).await.unwrap();
|
||||
// synspec 失败:result_valid=false, stages 为空(synspec-only),synspec_rc=1。
|
||||
let summary2 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: Vec::new(),
|
||||
result_valid: false,
|
||||
final_max_relc: None,
|
||||
final_chmax: None,
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(1),
|
||||
synspec_error: Some("spec 含 NaN".into()),
|
||||
synspec_sec: Some(0.2),
|
||||
elapsed_sec: 0.2,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: Some("synspec 失败".into()),
|
||||
};
|
||||
let report2 = TaskReport {
|
||||
task_id: task2.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Failed,
|
||||
result_valid: false,
|
||||
max_relc: None,
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 0.2,
|
||||
error_message: Some("synspec 失败".to_string()),
|
||||
summary_json: serde_json::to_string(&summary2).unwrap(),
|
||||
failed_stage: Some("synspec".to_string()),
|
||||
};
|
||||
db.record_task_report(&report2, wf).await.unwrap();
|
||||
|
||||
// 失败后 tlusty 侧归因仍须保留。
|
||||
let row = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row.status, "failed", "失败后状态应为 failed");
|
||||
assert_eq!(
|
||||
row.tlusty_success_method.as_deref(),
|
||||
Some("cold_run"),
|
||||
"synspec-only 失败后 tlusty_success_method 须保留"
|
||||
);
|
||||
assert_eq!(
|
||||
row.tlusty_status.as_deref(),
|
||||
Some("converged"),
|
||||
"synspec-only 失败后 tlusty_status 须保留(CASE 守卫)"
|
||||
);
|
||||
// synspec 侧应反映失败。
|
||||
assert_eq!(row.synspec_status.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
/// 辅助:读取 grid_points.last_elapsed_sec。
|
||||
async fn read_grid_last_elapsed(db: &Database, name: &str, wf: &str) -> Option<f64> {
|
||||
let pool = db.pool.clone();
|
||||
let name = name.to_string();
|
||||
let wf = wf.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Option<f64> {
|
||||
let conn = pool.get().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT last_elapsed_sec FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
|
||||
rusqlite::params![name, wf],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// TLUSTY-only 重跑成功后 synspec 归因列应被显式清空(clear_synspec=true),
|
||||
/// summary_json 保留 prior synspec 字段(merge_point_summary TLUSTY-only 路径)。
|
||||
///
|
||||
/// 场景:先正常管线(TLUSTY+SYNSPEC)成功 → 再 TLUSTY-only(synspec 关闭)重跑成功
|
||||
/// → 新大气使旧光谱失效 → synspec_success_method/synspec_status 清 NULL,
|
||||
/// summary_json 中 synspec_rc/synspec_sec 保留自 prior。
|
||||
#[tokio::test]
|
||||
async fn test_tlusty_only_rerun_clears_synspec_attribution() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("tlonly.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let wf = "wf_tlonly";
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 25000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, wf).await.unwrap();
|
||||
|
||||
// ── 第一轮:正常管线(TLUSTY + SYNSPEC 双开),成功 ──
|
||||
let task1 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task1).await.unwrap();
|
||||
let summary1 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0005),
|
||||
elapsed_sec: 300.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0005),
|
||||
final_chmax: Some(0.001),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(0.3),
|
||||
elapsed_sec: 300.3,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report1 = TaskReport {
|
||||
task_id: task1.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0005),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 300.3,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary1).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report1, wf).await.unwrap();
|
||||
let row1 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
|
||||
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
|
||||
|
||||
// ── 翻回 pending 模拟 TLUSTY-only 重跑 ──
|
||||
db.reset_terminal_points_for_recompute(wf).await.unwrap();
|
||||
|
||||
// ── 第二轮:TLUSTY-only(synspec 关闭),成功 ──
|
||||
let task2 = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
timeout_sec: 7200,
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: true,
|
||||
strategies: vec!["cold_run".to_string()],
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig {
|
||||
enabled: false,
|
||||
..PhaseConfig::default_synspec()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task2).await.unwrap();
|
||||
// TLUSTY-only summary:stages 非空(TLUSTY 跑了),synspec_rc=None(synspec 没跑)
|
||||
let summary2 = common::models::ModelSummary {
|
||||
name: name.clone(),
|
||||
params: params.clone(),
|
||||
stages: vec![common::models::StepSummary {
|
||||
label: "nl".into(),
|
||||
chmax: Some(0.0008),
|
||||
lte: "F".into(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0003),
|
||||
elapsed_sec: 280.0,
|
||||
note: None,
|
||||
last_iter: Some(15),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
itek_history: vec![],
|
||||
conv_trace_check: None,
|
||||
}],
|
||||
result_valid: true,
|
||||
final_max_relc: Some(0.0003),
|
||||
final_chmax: Some(0.0008),
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: None, // synspec 未运行
|
||||
synspec_error: None,
|
||||
synspec_sec: None,
|
||||
elapsed_sec: 280.0,
|
||||
energy_check: None,
|
||||
temp_check: None,
|
||||
emflux_check: None,
|
||||
bfac_check: None,
|
||||
note: None,
|
||||
};
|
||||
let report2 = TaskReport {
|
||||
task_id: task2.task_id,
|
||||
point_name: name.clone(),
|
||||
params: Some(params.clone()),
|
||||
node_id: "test-node".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
result_valid: true,
|
||||
max_relc: Some(0.0003),
|
||||
atmosphere_has_nan: false,
|
||||
elapsed_sec: 280.0,
|
||||
error_message: None,
|
||||
summary_json: serde_json::to_string(&summary2).unwrap(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report2, wf).await.unwrap();
|
||||
|
||||
// ── 核心断言:synspec 列应被显式清空(clear_synspec=true)──
|
||||
let row2 = read_grid_attrs(&db, &name, wf).await;
|
||||
assert_eq!(row2.status, "completed");
|
||||
assert_eq!(
|
||||
row2.tlusty_success_method.as_deref(),
|
||||
Some("cold_run"),
|
||||
"tlusty_success_method 应更新为 cold_run"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.tlusty_status.as_deref(),
|
||||
Some("converged"),
|
||||
"tlusty_status 应为 converged"
|
||||
);
|
||||
// ★ synspec 列被 clear_synspec 显式置 NULL(新大气使旧光谱失效)
|
||||
assert_eq!(
|
||||
row2.synspec_success_method,
|
||||
None,
|
||||
"TLUSTY-only 重跑后 synspec_success_method 须清 NULL(clear_synspec)"
|
||||
);
|
||||
assert_eq!(
|
||||
row2.synspec_status,
|
||||
None,
|
||||
"TLUSTY-only 重跑后 synspec_status 须清 NULL(clear_synspec)"
|
||||
);
|
||||
|
||||
// summary_json:TLUSTY 诊断来自 incoming,synspec 字段保留自 prior
|
||||
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
|
||||
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(ms.stages.len(), 1, "stages 来自 incoming");
|
||||
assert_eq!(ms.final_max_relc, Some(0.0003), "final_max_relc 来自 incoming");
|
||||
assert_eq!(
|
||||
ms.synspec_rc,
|
||||
Some(0),
|
||||
"synspec_rc 保留 prior 值(merge_point_summary TLUSTY-only 路径)"
|
||||
);
|
||||
assert_eq!(ms.synspec_sec, Some(0.3), "synspec_sec 保留 prior 值");
|
||||
}
|
||||
|
||||
/// `get_task_tlusty_enabled`:正常任务返回 true,synspec-only 任务返回 false,
|
||||
/// 不存在的 task_id 返回 None。
|
||||
#[tokio::test]
|
||||
async fn test_get_task_tlusty_enabled() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("tlusty_enabled.db");
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
|
||||
let params = GridPointParams {
|
||||
teff: 20000.0.into(),
|
||||
logg: 5.0.into(),
|
||||
loghe: 2.0.into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-4.0).into(),
|
||||
logo: (-4.0).into(),
|
||||
};
|
||||
let name = params.model_name();
|
||||
db.upsert_grid_point(¶ms, 0, "wf_tle").await.unwrap();
|
||||
|
||||
// 正常任务(tlusty_enabled=true)
|
||||
let task_normal = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some("wf_tle".to_string()),
|
||||
tlusty_config: PhaseConfig::default_tlusty(),
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task_normal).await.unwrap();
|
||||
let enabled = db.get_task_tlusty_enabled(&task_normal.task_id).await.unwrap();
|
||||
assert_eq!(enabled, Some(true), "正常任务 tlusty_enabled 应为 true");
|
||||
|
||||
// synspec-only 任务(tlusty_enabled=false)
|
||||
let task_synonly = common::models::TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
params: params.clone(),
|
||||
workflow_name: Some("wf_tle".to_string()),
|
||||
tlusty_config: PhaseConfig {
|
||||
enabled: false,
|
||||
..PhaseConfig::default_tlusty()
|
||||
},
|
||||
synspec_config: PhaseConfig::default_synspec(),
|
||||
atmosphere_ref: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&task_synonly).await.unwrap();
|
||||
let enabled = db.get_task_tlusty_enabled(&task_synonly.task_id).await.unwrap();
|
||||
assert_eq!(enabled, Some(false), "synspec-only 任务 tlusty_enabled 应为 false");
|
||||
|
||||
// 不存在的 task_id → None
|
||||
let fake_id = Uuid::new_v4();
|
||||
let enabled = db.get_task_tlusty_enabled(&fake_id).await.unwrap();
|
||||
assert_eq!(enabled, None, "不存在的 task_id 应返回 None");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user