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

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

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

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

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

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

文档与分析
- 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档
- spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错
This commit is contained in:
fmq
2026-08-09 12:09:48 +08:00
parent d16b3d3cdc
commit 43b82b1ae2
45 changed files with 6059 additions and 3184 deletions
+726 -13
View File
@@ -69,7 +69,12 @@ const GRID_POINTS_SCHEMA: &str = "CREATE TABLE grid_points (
-- H1 修复(M11):运行时回退把点打回 pending 时记录的「剩余策略链」(JSON 数组)。
-- 供调度路径识别「该点已失败过 cold_run、正在等种子」→ 重派时用剩余链而非完整 YAML 链,
-- 避免重跑已失败策略导致的无界失败重试活锁(见 scheduler.rs H1 注释)。NULL = 无标记。
pending_strategies TEXT
pending_strategies TEXT,
-- 最近一次尝试的真实墙钟耗时(秒,Worker 回报值;旧数据为 None)。
last_elapsed_sec REAL,
-- 点级诊断快照(完整 ModelSummary JSON)。详情页 conv 诊断面板的数据源。
-- synspec-only 重跑时经 merge_point_summary 增量合并,保留 TLUSTY 字段不丢失。
summary_json TEXT
)";
#[derive(Debug)]
@@ -431,15 +436,20 @@ impl Database {
if !has_tasks_elapsed {
let _ = conn.execute("ALTER TABLE tasks ADD COLUMN elapsed_sec REAL", []);
}
let has_gp_elapsed = conn
.prepare("PRAGMA table_info(grid_points)")?
.query_map([], |r| r.get::<_, String>(1))?
.any(|r| r.map(|n| n == "last_elapsed_sec").unwrap_or(false));
if !has_gp_elapsed {
let _ = conn.execute(
"ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL",
[],
);
// grid_points 幂等补列:last_elapsed_sec(列表展示用)与 summary_json(点级诊断快照)。
// 新库由 GRID_POINTS_SCHEMA 建表时即含此二列;此 ALTER 仅兜底旧库(表已存在但缺列)。
// 与上方 tasks 列迁移同模式:PRAGMA 检测 → 缺列才 ALTER。
for (col, sql) in [
("last_elapsed_sec", "ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL"),
("summary_json", "ALTER TABLE grid_points ADD COLUMN summary_json TEXT"),
] {
let has_col = conn
.prepare("PRAGMA table_info(grid_points)")?
.query_map([], |r| r.get::<_, String>(1))?
.any(|r| r.map(|n| n == col).unwrap_or(false));
if !has_col {
let _ = conn.execute(sql, []);
}
}
// 阶段独立配置迁移(见 docs/task_engine_decoupling_design.md §4.1):
@@ -1143,9 +1153,30 @@ mod tests {
db.update_grid_status(&p.model_name(), GridPointStatus::Queued, "wf_b")
.await
.unwrap();
db.mark_grid_point_imported(&p2.model_name(), "wf_a", None, "seed_step")
.await
.unwrap();
{
let summary = common::models::ModelSummary {
name: p2.model_name(),
params: p2.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: Some(0.001),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 0.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
db.upsert_point_summary(&summary.name, "wf_a", &summary, "seed_step")
.await
.unwrap();
}
// 全局(None):3 个点,pending/queued/converged 分开计数;
// 导入点按 seed_step 途径计入 seed_step_converged(不再有独立 imported 分类)
@@ -1782,8 +1813,17 @@ mod tests {
synspec_config: common::models::PhaseConfig::default_synspec(),
synspec_params: None,
tlusty_chain_params: None,
seed_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
let syn_task = Uuid::new_v4();
let old_task = Uuid::new_v4();
@@ -3553,4 +3593,677 @@ mod tests {
})
);
}
/// synspec-only 重跑后 tlusty_success_method / tlusty_status 须保留 prior 值,
/// 不能被 NULL 覆写(CASE 守卫修复验证)。
///
/// 场景:先以 TLUSTY 启用(cold_run)跑成功 → tlusty_success_method = "cold_run"。
/// 再以 TLUSTY 关闭(仅 SYNSPEC,场景 B)重跑成功 → tlusty_success_method 仍须为
/// "cold_run",不能被覆写为 NULL。synspec_success_method 应更新为 "standard"。
#[tokio::test]
async fn test_synspec_only_rerun_preserves_tlusty_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("synrerun.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_synrerun";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// ── 第一轮:TLUSTY 启用 + cold_run,成功 ──
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
// 验证第一轮:tlusty_success_method = cold_run, tlusty_status = converged
let row1 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row1.status, "completed");
assert_eq!(row1.tlusty_success_method.as_deref(), Some("cold_run"));
assert_eq!(row1.tlusty_status.as_deref(), Some("converged"));
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
// ── 模拟场景 Breset_terminal_points_for_recompute 翻回 pending ──
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// ── 第二轮:TLUSTY 关闭 + SYNSPEC 启用(synspec-only),成功 ──
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: false,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// synspec-only 的 summarystages 为空 → merge_point_summary 走字段级合并
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: None,
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.25),
elapsed_sec: 0.25,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: None,
atmosphere_has_nan: false,
elapsed_sec: 0.25,
error_message: None,
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: None,
};
db.record_task_report(&report2, wf).await.unwrap();
// ── 核心断言:synspec-only 重跑后 tlusty 侧归因须保留 ──
let row2 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row2.status, "completed", "重跑成功后应为 completed");
// ★ 修复前:tsm2 = None(裸赋值覆写)。修复后:保留 "cold_run"。
assert_eq!(
row2.tlusty_success_method.as_deref(),
Some("cold_run"),
"synspec-only 重跑后 tlusty_success_method 须保留,不能覆写为 NULL"
);
assert_eq!(
row2.tlusty_status.as_deref(),
Some("converged"),
"synspec-only 重跑后 tlusty_status 须保留"
);
// synspec 侧应更新为新值
assert_eq!(
row2.synspec_success_method.as_deref(),
Some("standard"),
"synspec_success_method 应更新为 standard"
);
assert_eq!(
row2.synspec_status.as_deref(),
Some("converged"),
"synspec_status 应为 converged"
);
// summary_json 的 TLUSTY 诊断也须保留(merge_point_summary 字段级合并)
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
assert_eq!(ms.stages.len(), 1, "stages 须保留 prior 的 TLUSTY 链");
assert_eq!(ms.stages[0].label, "nl");
assert_eq!(ms.final_max_relc, Some(0.0005), "final_max_relc 须保留");
assert_eq!(ms.synspec_rc, Some(0), "synspec_rc 应为新值");
assert_eq!(ms.elapsed_sec, 0.25, "elapsed_sec 应为新值");
// last_elapsed_sec 语义:最近一次尝试耗时。synspec-only 重跑后为 0.25ssynspec 耗时),
// 原 TLUSTY 耗时保留在 stages[].elapsed_sec。ETA 不依赖此列(用 AVG(tasks.elapsed_sec))。
let last_elapsed = read_grid_last_elapsed(&db, &name, wf).await;
assert_eq!(
last_elapsed, Some(0.25),
"last_elapsed_sec 应为 synspec-only 耗时(最近一次尝试),非原 TLUSTY 总耗时"
);
}
/// 辅助:读取 grid_points 的阶段归因列。
async fn read_grid_attrs(db: &Database, name: &str, wf: &str) -> GridAttrs {
let pool = db.pool.clone();
let name = name.to_string();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> GridAttrs {
let conn = pool.get().unwrap();
conn.query_row(
"SELECT status, tlusty_success_method, synspec_success_method, tlusty_status, synspec_status \
FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
rusqlite::params![name, wf],
|r| {
Ok(GridAttrs {
status: r.get(0)?,
tlusty_success_method: r.get(1)?,
synspec_success_method: r.get(2)?,
tlusty_status: r.get(3)?,
synspec_status: r.get(4)?,
})
},
)
.unwrap()
})
.await
.unwrap()
}
struct GridAttrs {
status: String,
tlusty_success_method: Option<String>,
synspec_success_method: Option<String>,
tlusty_status: Option<String>,
synspec_status: Option<String>,
}
/// synspec-only 重跑**失败**后 tlusty_success_method / tlusty_status 仍须保留。
///
/// 失败分支的 UPDATE 不写 success_method 列,但 tlusty_status / synspec_status
/// 有 CASE 守卫。验证失败报告不会清空 prior 的 TLUSTY 归因。
#[tokio::test]
async fn test_synspec_only_rerun_failure_preserves_tlusty_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("synfail.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_synfail";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// 第一轮:TLUSTY 启用 + cold_run,成功。
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
assert_eq!(read_grid_attrs(&db, &name, wf).await.tlusty_success_method.as_deref(), Some("cold_run"));
// 翻回 pending 模拟场景 B 重跑。
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// 第二轮:synspec-only,失败(synspec 产出脏谱)。
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// synspec 失败:result_valid=false, stages 为空(synspec-only),synspec_rc=1。
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: Vec::new(),
result_valid: false,
final_max_relc: None,
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(1),
synspec_error: Some("spec 含 NaN".into()),
synspec_sec: Some(0.2),
elapsed_sec: 0.2,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: Some("synspec 失败".into()),
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Failed,
result_valid: false,
max_relc: None,
atmosphere_has_nan: false,
elapsed_sec: 0.2,
error_message: Some("synspec 失败".to_string()),
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: Some("synspec".to_string()),
};
db.record_task_report(&report2, wf).await.unwrap();
// 失败后 tlusty 侧归因仍须保留。
let row = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row.status, "failed", "失败后状态应为 failed");
assert_eq!(
row.tlusty_success_method.as_deref(),
Some("cold_run"),
"synspec-only 失败后 tlusty_success_method 须保留"
);
assert_eq!(
row.tlusty_status.as_deref(),
Some("converged"),
"synspec-only 失败后 tlusty_status 须保留(CASE 守卫)"
);
// synspec 侧应反映失败。
assert_eq!(row.synspec_status.as_deref(), Some("failed"));
}
/// 辅助:读取 grid_points.last_elapsed_sec。
async fn read_grid_last_elapsed(db: &Database, name: &str, wf: &str) -> Option<f64> {
let pool = db.pool.clone();
let name = name.to_string();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> Option<f64> {
let conn = pool.get().unwrap();
conn.query_row(
"SELECT last_elapsed_sec FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
rusqlite::params![name, wf],
|r| r.get(0),
)
.ok()
})
.await
.unwrap()
}
/// TLUSTY-only 重跑成功后 synspec 归因列应被显式清空(clear_synspec=true),
/// summary_json 保留 prior synspec 字段(merge_point_summary TLUSTY-only 路径)。
///
/// 场景:先正常管线(TLUSTY+SYNSPEC)成功 → 再 TLUSTY-onlysynspec 关闭)重跑成功
/// → 新大气使旧光谱失效 → synspec_success_method/synspec_status 清 NULL
/// summary_json 中 synspec_rc/synspec_sec 保留自 prior。
#[tokio::test]
async fn test_tlusty_only_rerun_clears_synspec_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("tlonly.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_tlonly";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// ── 第一轮:正常管线(TLUSTY + SYNSPEC 双开),成功 ──
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
let row1 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
// ── 翻回 pending 模拟 TLUSTY-only 重跑 ──
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// ── 第二轮:TLUSTY-onlysynspec 关闭),成功 ──
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_synspec()
},
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// TLUSTY-only summarystages 非空(TLUSTY 跑了),synspec_rc=Nonesynspec 没跑)
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.0008),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0003),
elapsed_sec: 280.0,
note: None,
last_iter: Some(15),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0003),
final_chmax: Some(0.0008),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None, // synspec 未运行
synspec_error: None,
synspec_sec: None,
elapsed_sec: 280.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0003),
atmosphere_has_nan: false,
elapsed_sec: 280.0,
error_message: None,
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: None,
};
db.record_task_report(&report2, wf).await.unwrap();
// ── 核心断言:synspec 列应被显式清空(clear_synspec=true)──
let row2 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row2.status, "completed");
assert_eq!(
row2.tlusty_success_method.as_deref(),
Some("cold_run"),
"tlusty_success_method 应更新为 cold_run"
);
assert_eq!(
row2.tlusty_status.as_deref(),
Some("converged"),
"tlusty_status 应为 converged"
);
// ★ synspec 列被 clear_synspec 显式置 NULL(新大气使旧光谱失效)
assert_eq!(
row2.synspec_success_method,
None,
"TLUSTY-only 重跑后 synspec_success_method 须清 NULLclear_synspec"
);
assert_eq!(
row2.synspec_status,
None,
"TLUSTY-only 重跑后 synspec_status 须清 NULLclear_synspec"
);
// summary_jsonTLUSTY 诊断来自 incomingsynspec 字段保留自 prior
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
assert_eq!(ms.stages.len(), 1, "stages 来自 incoming");
assert_eq!(ms.final_max_relc, Some(0.0003), "final_max_relc 来自 incoming");
assert_eq!(
ms.synspec_rc,
Some(0),
"synspec_rc 保留 prior 值(merge_point_summary TLUSTY-only 路径)"
);
assert_eq!(ms.synspec_sec, Some(0.3), "synspec_sec 保留 prior 值");
}
/// `get_task_tlusty_enabled`:正常任务返回 truesynspec-only 任务返回 false
/// 不存在的 task_id 返回 None。
#[tokio::test]
async fn test_get_task_tlusty_enabled() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("tlusty_enabled.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let params = GridPointParams {
teff: 20000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-4.0).into(),
logo: (-4.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, "wf_tle").await.unwrap();
// 正常任务(tlusty_enabled=true
let task_normal = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some("wf_tle".to_string()),
tlusty_config: PhaseConfig::default_tlusty(),
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task_normal).await.unwrap();
let enabled = db.get_task_tlusty_enabled(&task_normal.task_id).await.unwrap();
assert_eq!(enabled, Some(true), "正常任务 tlusty_enabled 应为 true");
// synspec-only 任务(tlusty_enabled=false
let task_synonly = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some("wf_tle".to_string()),
tlusty_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task_synonly).await.unwrap();
let enabled = db.get_task_tlusty_enabled(&task_synonly.task_id).await.unwrap();
assert_eq!(enabled, Some(false), "synspec-only 任务 tlusty_enabled 应为 false");
// 不存在的 task_id → None
let fake_id = Uuid::new_v4();
let enabled = db.get_task_tlusty_enabled(&fake_id).await.unwrap();
assert_eq!(enabled, None, "不存在的 task_id 应返回 None");
}
}