feat(all): 数据库模块化拆分与版本化迁移、任务引擎命名体系收敛、物理输出校验加固与用户配置接通

- 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
This commit is contained in:
fmq
2026-08-06 20:51:21 +08:00
parent cd370d88e7
commit d16b3d3cdc
61 changed files with 10268 additions and 5881 deletions
+167 -33
View File
@@ -1,11 +1,13 @@
use crate::models::{EngineStageConfig, GridAxisValue};
use crate::models::{GridAxisValue, PhaseConfig};
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GridAxesConfig {
pub teff: Vec<GridAxisValue>,
pub logg: Vec<GridAxisValue>,
@@ -136,24 +138,77 @@ impl GridConfig {
if let Some(raw_axes) = parse_grid_axes_raw(yaml) {
cfg.grid = raw_axes;
}
cfg.validate()?;
Ok(cfg)
}
/// 配置合法性校验(审查修复 #M2/#N3):把配置错误从运行时逐点失败提前到加载时,
/// 避免笔误(如 chmax: 0、logc: 400)静默产出错误输入文件、浪费算力。
///
/// 校验项:
/// - `tlusty_chain` 中每个 ChainStep 的 `chmax` 若为 Some,必须 > 0conv_check.rs
/// 的守卫会把 chmax <= 0 判为非法、整 stage 判发散;runner.rs 的 unwrap_or(0.001)
/// 只在字段缺失时兜底,显式写 0 不会触发)。
/// - grid 六轴数值范围合理性(teff > 0;丰度 logc/logn/logo 物理上 ∈ [-20, 10]
/// 超出几乎必为笔误,如 logc: 400 会让 gen_input5 的 10^logx 溢出为 Inf 污染输入文件)。
fn validate(&self) -> Result<()> {
for (i, step) in self.tlusty_chain.iter().enumerate() {
if let Some(chmax) = step.chmax {
// 用 partial_cmp 显式判断:`!(chmax > 0.0)` 对 NaN 为 trueNaN 比较恒 false),
// 应拒绝 NaN;改写为 `chmax <= 0.0` 会漏掉 NaNNaN<=0 也是 false),故不用。
if !matches!(chmax.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
anyhow::bail!(
"tlusty_chain[{}] (label={}) 的 chmax={} 非法:必须 > 0<=0 会被 conv_check 判为整 stage 发散)",
i, step.label, chmax
);
}
}
// 注:niter 不校验 > 0——LTE grey start 步骤 niter=0 是合法设计
//runner.rs:455 显式处理 niter==0 为「不迭代,直接用 grey atmosphere 作初值」)。
}
// teff 必须为正(物理温度)。
for (i, t) in self.grid.teff.iter().enumerate() {
let v = t.value();
// 同 chmax`!(v > 0.0)` 保持对 NaN 的拒绝语义,用 partial_cmp 显式表达。
if !matches!(v.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
anyhow::bail!("grid.teff[{}] = {} 非法:温度必须 > 0", i, v);
}
}
// 丰度对数轴范围校验(超出 [-20, 10] 几乎必为笔误,且会令 10^logx 溢出为 Inf)。
for (axis_name, vals) in [
("logc", &self.grid.logc),
("logn", &self.grid.logn),
("logo", &self.grid.logo),
("loghe", &self.grid.loghe),
] {
for (i, av) in vals.iter().enumerate() {
let v = av.value();
if !(-20.0..=10.0).contains(&v) {
anyhow::bail!(
"grid.{}[{}] = {} 超出物理合理范围 [-20, 10]:请检查是否笔误(超出会让 10^logx 溢出为 Inf 污染输入文件)",
axis_name, i, v
);
}
}
}
Ok(())
}
/// 解析 TLUSTY 阶段配置。
///
/// 优先级(见 docs/task_engine_decoupling_design.md §3):
/// 1. 新版顶层 `tlusty:` 块(EngineStageConfig)—— 显式覆盖;
/// 1. 新版顶层 `tlusty_stage:` 块(PhaseConfig)—— 显式覆盖;
/// 2. 旧版 `seed_step_fallback: bool` —— true → `[cold_run, seed_step]`
/// false → `[cold_run]`(不回退种子步进);
/// 3. 兜底 `default_tlusty()`。
///
/// 注:旧版只控制是否回退种子步进,无 enabled/policy 维度,故回退路径固定
/// enabled=true / policy=SkipConverged(与新默认一致)。
pub fn resolve_tlusty_config(&self) -> EngineStageConfig {
if let Some(cfg) = &self.tlusty {
pub fn resolve_tlusty_config(&self) -> PhaseConfig {
if let Some(cfg) = &self.tlusty_stage {
return cfg.clone();
}
let mut cfg = EngineStageConfig::default_tlusty();
let mut cfg = PhaseConfig::default_tlusty();
if !self.seed_step_fallback {
cfg.strategies = vec!["cold_run".to_string()];
}
@@ -163,22 +218,90 @@ impl GridConfig {
/// 解析 SYNSPEC 阶段配置。
///
/// 优先级:
/// 1. 新版顶层 `synspec_stage:` 块(EngineStageConfig)—— 显式覆盖(含 enabled 开关);
/// 1. 新版顶层 `synspec_stage:` 块(PhaseConfig)—— 显式覆盖(含 enabled 开关);
/// 2. 兜底 `default_synspec()`enabled=true,保持旧行为:有大气就跑光谱)。
///
/// 注:旧版 `synspec: SynspecConfig`(数值参数)不影响阶段启用/策略——它只携带
/// 注:旧版 `synspec: SynspecInput`(数值参数)不影响阶段启用/策略——它只携带
/// 波长范围等数值,由调度器透传到 TaskSpec.synspec_params。如需禁用 SYNSPEC
/// 必须用新版 `synspec_stage: { enabled: false }`。
pub fn resolve_synspec_config(&self) -> EngineStageConfig {
pub fn resolve_synspec_config(&self) -> PhaseConfig {
if let Some(cfg) = &self.synspec_stage {
return cfg.clone();
}
EngineStageConfig::default_synspec()
PhaseConfig::default_synspec()
}
}
// ===== TLUSTY 输入文件(.5 + nst)全局物理参数 =====
// 这些参数不随收敛阶段(lte/nc/nl)变化——NFREAD 频率网格、ions 能级数据表等是
// 物理建模选择,一旦确定对整个大气计算全局生效。阶段差异参数(lte/ltgray/niter/
// chmax/metals 等)保留在 ChainStep 里。
// 缺省 None → 走代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量),与改动前行为一致。
/// 单个元素的 atoms 块配置(.5 文件的 `mode abn modpf` 行)。
/// abn(丰度)不在此配——每网格点不同,由 GridPointParams.loghe/logc/logn/logo 计算。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageConfig {
pub struct AtomConfig {
/// 0=不计算, 1=隐式(仅 LTE Saha 算电荷,不出现在 ions 块), 2=显式(统计平衡)。
/// None → 走代码默认(H/He/CNO=2Li/Be/B=0)。
pub mode: Option<i32>,
/// partition function 模式(.5 atoms 行第 3 列 modpf)。None → 默认 0。
#[serde(default)]
pub modpf: Option<i32>,
}
/// 单个离子的能级数据配置(.5 文件 ions 块的一行)。
/// ilast 由 nlevs 推导(nlevs==1 → ilast=1 裸核终止标志,否则 0),不暴露。
/// ilvlin 由 ChainStep.ilvlin 提供(nlevs==1 时强制 0),不在此配。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IonConfig {
/// 原子序数(1=H, 2=He, 6=C, 7=N, 8=O...)。
pub iat: i32,
/// 电离级(0=中性, 1=一次电离, 2=二次电离...)。
pub iz: i32,
/// 能级数(该离子的能级模型复杂度)。
pub nlevs: i32,
/// 4 字符离子标识(如 `" H 1"`、`"He 2"`),用于 tlusty 日志与诊断。
pub typion: String,
/// 能级数据文件路径(如 `"data/h1.dat"`),裸核(nlevs==1)用 `" "`。
pub filei: String,
}
/// TLUSTY 输入文件的全局物理参数(.5 + nst 的非阶段差异部分)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlustyInput {
/// .5 frequencies 块的 NFREAD:连续频率网格点数。
/// 取值 &gt;0 → 从预设频率表读取(高精度、慢);≤0 → 由 frmin/frmax 自动生成对数网格(快)。
/// 默认 2000gen_input5.rs 原硬编码值)。
#[serde(default = "default_nfread")]
pub nfread: i32,
/// atoms 块特定元素的 mode/modpf 覆盖。
/// key = 元素符号(`"H"`/`"He"`/`"C"`/`"N"`/`"O"`/`"Li"`/`"Be"`/`"B"`)。
/// 缺省的元素走代码默认 modeH/He/CNO=2Li/Be/B=0)。
#[serde(default)]
pub atoms: HashMap<String, AtomConfig>,
/// ions 能级数据表(完全替换默认的 H/He/C/N/O 23 行表)。
/// 空Vec → 用 gen_input5.rs 的默认常量表。
/// 非空时由 metals 参数按 iat 筛选参与元素。
#[serde(default)]
pub ions: Vec<IonConfig>,
/// nst 文件的额外关键字(逃逸口):自由传入任意 `KEY=VALUE` 对。
/// 生成 nst 时追加到末尾(每行一个),用于暴露未结构化的 220+ nst 关键字
/// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。
#[serde(default)]
pub nst_extra_keys: Vec<(String, String)>,
}
fn default_nfread() -> i32 {
2000
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChainStep {
pub label: String,
#[serde(default = "default_false_str")]
pub lte: String,
@@ -207,8 +330,10 @@ fn default_niter() -> i32 {
50
}
/// SYNSPEC 输入文件(fort.55)的数值参数。与 `TlustyInput` 语义对称——
/// 分别承载各阶段输入文件的物理参数(tlusty 的 .5/nst vs synspec 的 fort.55)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SynspecConfig {
pub struct SynspecInput {
#[serde(default = "default_wstart")]
pub wstart: f64,
#[serde(default = "default_wend")]
@@ -249,12 +374,28 @@ fn default_abs_cutoff() -> f64 {
0.01
}
/// 审查修复:`#[serde(deny_unknown_fields)]` 让旧字段名(如已重命名的 `chain`/`synspec`/
/// `tlusty`/`results`/`itek_fallback`)在反序列化时**立即报错**,而非静默丢弃导致配置失效。
/// 用户明确不需要向后兼容,故用严格模式让配置错误尽早暴露。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GridConfig {
pub grid: GridAxesConfig,
/// TLUSTY 物理迭代步进链(lte/nc/nl 多阶段 ChainStep 数组)。
/// 每阶段独立配置 niter/chmax/metals/ilvlin 等参数,由 scheduler 序列化进
/// TaskSpec.tlusty_chain_paramsexecutor 反序列化后透传给 runner.custom_chain。
/// 空(缺省)→ executor 用 `default_chain_for_strategy` 兜底(按策略名选默认链)。
#[serde(default)]
pub chain: Vec<StageConfig>,
pub synspec: Option<SynspecConfig>,
pub tlusty_chain: Vec<ChainStep>,
/// TLUSTY 输入文件(.5 + nst)的全局物理参数。缺省 None → 走代码内硬编码默认。
/// 由 scheduler 序列化进 TaskSpec.tlusty_input_paramsexecutor 反序列化后
/// 透传给 runner → make_input5 / generate_nst_content。
#[serde(default)]
pub tlusty_input: Option<TlustyInput>,
/// SYNSPEC 输入文件(fort.55)的数值参数。与 `tlusty_input` 语义对称。
/// 由 scheduler 序列化进 TaskSpec.synspec_paramsexecutor 反序列化后透传给 runner。
#[serde(default)]
pub synspec_input: Option<SynspecInput>,
#[serde(default = "default_nworkers")]
pub nworkers: usize,
#[serde(default = "default_timeout")]
@@ -263,13 +404,6 @@ pub struct GridConfig {
pub resume: bool,
#[serde(default = "default_true")]
pub seed_step_fallback: bool,
/// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。保留以兼容旧 workflow YAML。
#[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")]
#[serde(default)]
pub results: Option<String>,
#[serde(default)]
pub itek_fallback: Vec<StageConfig>,
#[serde(default = "default_grid_niter")]
pub niter: Option<i32>,
pub template: Option<String>,
@@ -277,12 +411,14 @@ pub struct GridConfig {
pub linelist: Option<String>,
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
/// 缺省 None → `resolve_tlusty_config()` 据旧 `seed_step_fallback` 推断默认链。
/// 命名为 `tlusty_stage` 以与同级 `synspec_stage` 对称(均带 `_stage` 后缀,
/// 表示阶段启用/策略配置,区别于 `synspec: SynspecInput` 数值参数)。
#[serde(default)]
pub tlusty: Option<EngineStageConfig>,
/// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecConfig`
pub tlusty_stage: Option<PhaseConfig>,
/// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecInput`
///(光谱合成数值参数)区分。缺省 None → `resolve_synspec_config()` 给默认 `[standard]`。
#[serde(default)]
pub synspec_stage: Option<EngineStageConfig>,
pub synspec_stage: Option<PhaseConfig>,
}
fn default_grid_niter() -> Option<i32> {
@@ -356,17 +492,16 @@ fn default_node_stale_sec() -> u64 {
impl Default for ServerConfig {
fn default() -> Self {
// Phase 7b:清理旧 CNO_* / DCTS_RESULTS_DIR 回退(TLUSTY-first 遗留命名)。
let port = std::env::var("DCTS_PORT")
.or_else(|_| std::env::var("CNO_PORT"))
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "8090".to_string());
let db_path = std::env::var("DCTS_DB_PATH").unwrap_or_else(|_| "data/dcts.db".to_string());
let queue_db_path = std::env::var("DCTS_QUEUE_DB_PATH")
.unwrap_or_else(|_| "data/dcts_queue.db".to_string());
// 种子库目录:优先 DCTS_SEEDS_DIR回退旧 DCTS_RESULTS_DIR(已弃用,保留兼容)
let seeds_dir = std::env::var("DCTS_SEEDS_DIR")
.or_else(|_| std::env::var("DCTS_RESULTS_DIR"))
.unwrap_or_else(|_| "data/seeds".to_string());
// 种子库目录:DCTS_SEEDS_DIR缺省 data/seeds
let seeds_dir =
std::env::var("DCTS_SEEDS_DIR").unwrap_or_else(|_| "data/seeds".to_string());
let backup_dir =
std::env::var("DCTS_BACKUP_DIR").unwrap_or_else(|_| "data/backups".to_string());
let grid_config = std::env::var("DCTS_GRID_CONFIG")
@@ -474,9 +609,9 @@ impl std::fmt::Debug for NodeConfig {
impl Default for NodeConfig {
fn default() -> Self {
// Phase 7b:清理旧 CNO_SERVER_URL 回退。
let server_url = std::env::var("DCTS_SERVER_URL")
.or_else(|_| std::env::var("SERVER_URL"))
.or_else(|_| std::env::var("CNO_SERVER_URL"))
.unwrap_or_else(|_| "http://127.0.0.1:8090".to_string());
let node_id = std::env::var("DCTS_NODE_ID")
.or_else(|_| std::env::var("NODE_ID"))
@@ -496,10 +631,9 @@ impl Default for NodeConfig {
let runtime_dir =
std::env::var("DCTS_RUNTIME_DIR").unwrap_or_else(|_| "data/runtime".to_string());
let work_dir = std::env::var("DCTS_WORK_DIR").unwrap_or_else(|_| "data/work".to_string());
// 结果归档目录:优先 DCTS_RESULT_DIR回退旧 DCTS_ARCHIVE_DIR(已弃用,保留兼容)。
let result_dir = std::env::var("DCTS_RESULT_DIR")
.or_else(|_| std::env::var("DCTS_ARCHIVE_DIR"))
.unwrap_or_else(|_| "data/result".to_string());
// 结果归档目录:DCTS_RESULT_DIR缺省 data/resultPhase 7b 清理旧 DCTS_ARCHIVE_DIR 回退)。
let result_dir =
std::env::var("DCTS_RESULT_DIR").unwrap_or_else(|_| "data/result".to_string());
let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
+412 -8
View File
@@ -1,4 +1,4 @@
use crate::models::ConvCheckResult;
use crate::models::{ConvCheckResult, IterCheck};
use regex::Regex;
use std::fs::File;
use std::io::{BufRead, BufReader};
@@ -6,9 +6,20 @@ use std::path::Path;
use std::sync::OnceLock;
static FORT9_RE: OnceLock<Regex> = OnceLock::new();
/// 无效数值正则模式(NaN / Inf / Infinity / Fortran 字段溢出 `***` / 超高正指数 E+300+)。
///
/// 抽为常量是因为 `NAN_RE` 这个 `OnceLock` 被 `atmosphere_has_nan` 与 `spec_is_valid`
/// 共用——若两处 `get_or_init` 闭包传不同的字符串,`OnceLock` 全局只采用首次初始化的
/// 版本,第二处闭包被静默丢弃,导致两函数行为不一致且依赖调用顺序(并发测试间歇性失败)。
/// 常量化保证两处字面一致。超高正指数分支物理论据见 `atmosphere_has_nan` 文档注释。
const NAN_RE_PATTERN: &str = r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,}|[eE]\+(?:3\d{2}|[4-9]\d{2,}))";
static NAN_RE: OnceLock<Regex> = OnceLock::new();
/// 匹配 Fortran 无-E 科学记数法的尾数+指数部分(归一化用,见 parse_fortran_float)。
static NO_E_EXP_RE: OnceLock<Regex> = OnceLock::new();
/// 匹配 fort.6 中求解器发散 STOP 行(如 `**** STOP in SOLVE after ITER 8`)。
static SOLVER_STOP_RE: OnceLock<Regex> = OnceLock::new();
/// 匹配 call quit / stop 留言关键字。
static QUIT_RE: OnceLock<Regex> = OnceLock::new();
/// 解析 fort.9 / fort.7 中的数值字符串为 f64,兼容 Fortran 的**无-E 科学记数法**。
///
@@ -63,6 +74,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter: None,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some(format!(
"非法 chmax={}(须为正有限数):请检查工作流 YAML 中该 stage 的 chmax 配置",
chmax
@@ -80,6 +92,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter: None,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some(format!("Failed to open fort.9: {}", e)),
}
}
@@ -95,6 +108,9 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
let mut last_iter: Option<i32> = None;
let mut cur_iter: Option<i32> = None;
let mut cur_rows: Vec<Fort9Row> = Vec::new();
// 逐次迭代诊断(Phase 5b itek 全量保真):每次迭代记录该次最大 |maximum|。
let mut itek_history: Vec<IterCheck> = Vec::new();
let mut iter_max_relc: f64 = 0.0;
for line in reader.lines().map_while(Result::ok) {
if let Some(caps) = re.captures(&line) {
@@ -112,14 +128,35 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
};
if cur_iter != Some(iter) {
// 迭代切换:结算上一拍(若有),并开启新拍。
if let Some(prev_iter) = cur_iter {
itek_history.push(IterCheck {
iter: prev_iter,
max_relc: iter_max_relc,
n_depths: cur_rows.len(),
});
}
cur_iter = Some(iter);
cur_rows.clear();
iter_max_relc = 0.0;
}
cur_rows.push(Fort9Row { depth, maximum });
let abs_max = maximum.abs();
if abs_max > iter_max_relc {
iter_max_relc = abs_max;
}
last_iter = Some(iter);
}
}
// 收尾:结算最后一拍。
if let Some(prev_iter) = cur_iter {
itek_history.push(IterCheck {
iter: prev_iter,
max_relc: iter_max_relc,
n_depths: cur_rows.len(),
});
}
if cur_rows.is_empty() || last_iter.is_none() {
return ConvCheckResult {
@@ -129,6 +166,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter: None,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some("No valid iteration data found in fort.9".to_string()),
};
}
@@ -149,6 +187,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some(
"No valid iteration rows found when calculating maximum change".to_string(),
),
@@ -166,6 +205,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter,
n_depths: cur_rows.len(),
chmax,
itek_history,
error: if is_valid_num {
None
} else {
@@ -182,11 +222,24 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
/// - Fortran 字段宽度溢出标记 `***`(如 `********`):Tlusty 数值溢出发散时常以星号填满
/// 字段而非写 NaN。历史上只检 `\bnan\b`,全溢出发散的大气会被判"无 NaN"→converged
/// 产出物理上完全错误的大气。
/// - 超高指数科学记数法 `[Ee]\+(3\d{2}|[4-9]\d{2,})`(漏洞4补充盲区):匹配 E+300 以上的
/// 正指数值(如 `1.0E+308`、`9.99E+307`)。这是数值发散的产物——恒星大气物理量天花板
/// 在 E+07 量级(温度/密度/布居数),E+300 在物理上无意义。gfortran 实际溢出时多写 `***`
/// (已被上一条覆盖),但"未溢出但接近 f64 上限"的窄窗口需此分支兜底。锁定正号 `\+`
/// 避免误伤合法的极小值(如 E-300)。真实数据集最高仅 E+07,零误报。
///
/// 超过 10% 的行命中任一标记即判定无效
/// **任意一行**命中任一标记即判定无效(0 行容忍)。物理论据:大气模型每个深度点的
/// 物理量都是耦合求解的,任意一个深度点 NaN/Inf/溢出意味着该层解已破坏,整个大气不可用。
///
/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与“含 NaN 导致无效”是不同语义;
/// 调用方需先自行确认文件存在性,不应将“缺失”与“含 NaN”混为一谈
/// 历史:曾用 10% 阈值(`bad_lines > total*0.1`),但单行 NaN(如表层发散)会被放过,
/// 导致部分坏大气被误判为可用。见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 4
///
/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与"含 NaN 导致无效"是不同语义;
/// 调用方需先自行确认文件存在性,不应将"缺失"与"含 NaN"混为一谈。
///
/// **空文件(存在但 0 行数据)返回 `true`**(语义:无效大气)。0 行意味着大气数据
/// 缺失/损坏(如 TLUSTY 启动后立即崩溃、写出的文件截断),不应被当作"无 NaN 的可用
/// 大气"。runner 凭此判定 final_converged=false,行为安全。
pub fn atmosphere_has_nan(path: &Path) -> bool {
let file = match File::open(path) {
Ok(f) => f,
@@ -195,8 +248,7 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
let reader = BufReader::new(file);
let mut total_lines = 0;
let mut bad_lines = 0;
let nan_re =
NAN_RE.get_or_init(|| Regex::new(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap());
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
for line in reader.lines().map_while(Result::ok) {
total_lines += 1;
@@ -209,7 +261,120 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
return true;
}
(bad_lines as f64) > (total_lines as f64 * 0.1)
// 漏洞4修复:0 行容忍——任意一行 NaN/Inf/溢出/超高指数即判定大气不可用。
bad_lines > 0
}
/// 校验 SYNSPEC 产出的 `.spec` 光谱文件内容是否有效。
///
/// # 背景(漏洞 1P0
/// gfortran 下 SYNSPEC 几乎所有错误路径都用裸 `STOP`(rc=0),且即便发散也会写出
/// 一个"看起来存在"的 `.spec`。旧代码只做 `is_file()` 存在性检查,导致脏谱(含
/// NaN/Inf、行数极少、流量全零)被当作 `Completed` 归档——这是全链路最大的科学
/// 正确性风险。见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 1。
///
/// # 校验规则(按序短路)
/// 1. 文件缺失/无法读取 → `Some("...")`
/// 2. 逐行扫描累计:含 NaN/Inf/`***` 的 bad_lines、含 ≥2 个可解析数值 token 的有效行、
/// 流量列(第 2 列)非零的有效行
/// 3. 空文件 → `Some("spec 为空")`
/// 4. bad_lines > 0 → `Some("spec 含 NaN/Inf/溢出行 (共 N 行)")`
/// 5. 有效行数 < 10 → `Some("spec 有效行数不足 (N<10)")`
/// 6. 非零流量行数 == 0 → `Some("spec 流量全为零")`
/// 7. 全通过 → `None`
///
/// 返回 `None` 表示有效,`Some(原因)` 表示无效(调用方据此置 `synspec_rc`/`synspec_error`)。
pub fn spec_is_valid(path: &Path) -> Option<String> {
let file = match File::open(path) {
Ok(f) => f,
Err(_) => return Some("spec 文件缺失或无法读取".to_string()),
};
let reader = BufReader::new(file);
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
let mut total_lines = 0;
let mut bad_lines = 0;
let mut valid_lines = 0; // 含 ≥2 个可解析数值 token 的行
let mut nonzero_flux_lines = 0; // 流量列(第 2 个 token)非零的有效行
for line in reader.lines().map_while(Result::ok) {
total_lines += 1;
if nan_re.is_match(&line) {
bad_lines += 1;
continue;
}
// 拆 token,尝试解析为数值。.spec 每行 2 列:波长、流量(FLAM)。
let tokens: Vec<&str> = line.split_whitespace().collect();
let parsed: Vec<f64> = tokens
.iter()
.filter_map(|t| parse_fortran_float(t))
.filter(|v| v.is_finite())
.collect();
if parsed.len() >= 2 {
valid_lines += 1;
// 第 2 个数值列是流量;流量非零才算有效行(避免全零谱)。
if parsed[1].abs() > 0.0 {
nonzero_flux_lines += 1;
}
}
}
if total_lines == 0 {
return Some("spec 为空".to_string());
}
if bad_lines > 0 {
return Some(format!("spec 含 NaN/Inf/溢出行 (共 {} 行)", bad_lines));
}
if valid_lines < 10 {
return Some(format!("spec 有效行数不足 ({}<10)", valid_lines));
}
if nonzero_flux_lines == 0 {
return Some("spec 流量全为零".to_string());
}
None
}
/// 从 fort.6TLUSTY stdout 日志)提取失败诊断提示。
///
/// # 背景(漏洞 5P2
/// TLUSTY 失败时旧 note 统一记 `"tlusty rc=N or missing fort.7"`,丢失"为何未收敛"线索。
/// 求解器发散时 fort.6 会印 `**** STOP in SOLVE/SOLVES/RYBSOL after ITER N`(来自
/// `tlusty208.f:14731/15077/47598`FORMAT 610);fort.7 缺失(输入错误、temp 越界等
/// `call quit`)时 fort.6 尾部会有 `stop 'msg'` 留言。两类信息都能显著提升归因质量。
/// 见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 5。
///
/// # 规则
/// 1. 优先全文匹配 `STOP in (SOLVE|SOLVES|RYBSOL) after ITER N`(发散求解器名),返回该行。
/// 2. 否则取最后 5 行,找含 `stop|quit|error`(忽略大小写)的行返回。
/// 3. 都没有 → `None`。
pub fn extract_failure_hint(fort6_path: &Path) -> Option<String> {
let file = File::open(fort6_path).ok()?;
let reader = BufReader::new(file);
let solver_re = SOLVER_STOP_RE.get_or_init(|| {
Regex::new(r"(?i)STOP\s+in\s+(SOLVES?|RYBSOL)\s+after\s+ITER\s+\d+").unwrap()
});
// 单词边界 \b 避免子串误报(如 "stopping criterion"/"no error detected")。
// 仅在已确认失败的语境下提取 hint,误报后果仅是 note 多一行提示,不影响 converged 判定。
let quit_re = QUIT_RE.get_or_init(|| Regex::new(r"(?i)\b(stop|quit|error)\b").unwrap());
let mut all_lines: Vec<String> = Vec::new();
for line in reader.lines().map_while(Result::ok) {
all_lines.push(line);
}
// 1. 全文找发散求解器 STOP 行(取最后一次出现)。
for line in all_lines.iter().rev() {
if solver_re.is_match(line) {
return Some(line.trim().to_string());
}
}
// 2. 尾部 5 行找 call quit / stop / error 留言(取最后一条)。
for line in all_lines.iter().rev().take(5) {
if quit_re.is_match(line) {
return Some(line.trim().to_string());
}
}
None
}
#[cfg(test)]
@@ -245,6 +410,215 @@ mod tests {
let inf_file_path = dir.path().join("inf.7");
std::fs::write(&inf_file_path, "Inf 2 3\nInfinity 5 6\n7 8 9\n").unwrap();
assert!(atmosphere_has_nan(&inf_file_path));
// 漏洞4修复:0 行容忍——单行 NaN(1/3 行)也应判定无效。
let single_nan_path = dir.path().join("single_nan.7");
std::fs::write(&single_nan_path, "NaN 2 3\n4 5 6\n7 8 9\n").unwrap();
assert!(
atmosphere_has_nan(&single_nan_path),
"单行 NaN(表层发散的典型形态)应被判为无效"
);
// 漏洞4补充盲区:超高指数科学记数法(E+300 以上)是数值发散产物,物理上无意义。
// 正常大气物理量天花板在 E+07,应被判无效。
let huge_exp_path = dir.path().join("huge_exp.7");
std::fs::write(
&huge_exp_path,
"1.0E+07 2.0 3.0\n9.99E+308 5.0 6.0\n7.0 8.0 9.0\n",
)
.unwrap();
assert!(
atmosphere_has_nan(&huge_exp_path),
"超高指数 E+308 应被判为无效(数值发散产物)"
);
// 边界:合法的极高正指数(E+50,仍远超物理上限但未被超高指数分支命中)
// —— 此测试确认正则只匹配 E+300+,不误伤。
// 注:E+50 在物理上无意义但 regex 不拦,由物理论据留给将来收紧。
let high_but_ok_path = dir.path().join("high_ok.7");
std::fs::write(&high_but_ok_path, "1.0E+50 2.0 3.0\n4.0 5.0 6.0\n").unwrap();
assert!(
!atmosphere_has_nan(&high_but_ok_path),
"E+50 不在 E+300+ 检测范围,不应被超高指数分支误判"
);
// 边界:合法的极小值(E-300)不得被误伤(正则锁定正号 \\+)。
let tiny_path = dir.path().join("tiny.7");
std::fs::write(&tiny_path, "1.0E-300 2.0 3.0\n4.0 5.0 6.0\n").unwrap();
assert!(
!atmosphere_has_nan(&tiny_path),
"极小值 E-300(合法)不得被超高指数分支误伤"
);
}
/// 漏洞1修复:SYNSPEC `.spec` 内容校验。
#[test]
fn test_spec_validation() {
let dir = tempfile::tempdir().unwrap();
// 1. 正常谱:≥10 行、每行 2 数值列、流量非零 → None(有效)
let good_spec = dir.path().join("good.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(
" {:.5} {:.6E}\n",
1400.0 + i as f64 * 0.5,
1e-12
));
}
std::fs::write(&good_spec, &content).unwrap();
assert!(
spec_is_valid(&good_spec).is_none(),
"正常多行数值谱应判有效"
);
// 2. 脏谱(仓库内真实失败样本形态):仅 2 行 + Infinity/NaN → Some
let dirty_spec = dir.path().join("dirty.spec");
std::fs::write(
&dirty_spec,
" Infinity NaN\n 1410.00005 NaN\n",
)
.unwrap();
let reason = spec_is_valid(&dirty_spec).expect("脏谱应判无效");
assert!(
reason.contains("NaN") || reason.contains("Inf"),
"脏谱原因应提及 NaN/Inf,实际:{}",
reason
);
// 3. 行数不足(< 10)→ Some
let short_spec = dir.path().join("short.spec");
std::fs::write(
&short_spec,
" 1400.0 1.0E-12\n 1401.0 1.0E-12\n 1402.0 1.0E-12\n",
)
.unwrap();
let reason = spec_is_valid(&short_spec).expect("行数不足应判无效");
assert!(
reason.contains("行数不足"),
"行数不足原因,实际:{}",
reason
);
// 4. 全零流量 → Some
let zero_spec = dir.path().join("zero.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(" {:.5} {:.6E}\n", 1400.0 + i as f64 * 0.5, 0.0));
}
std::fs::write(&zero_spec, &content).unwrap();
let reason = spec_is_valid(&zero_spec).expect("全零谱应判无效");
assert!(reason.contains("全为零"), "全零谱原因,实际:{}", reason);
// 5. 文件缺失 → Some
let missing_spec = dir.path().join("missing.spec");
let reason = spec_is_valid(&missing_spec).expect("文件缺失应判无效");
assert!(reason.contains("缺失"), "文件缺失原因,实际:{}", reason);
// 6. Fortran 字段溢出 *** → Some
let overflow_spec = dir.path().join("overflow.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(
" {:.5} {:.6E}\n",
1400.0 + i as f64 * 0.5,
1e-12
));
}
// 第 3 行混入溢出行
let mut lines: Vec<&str> = content.lines().collect();
if lines.len() > 2 {
lines[2] = " 1401.0 ********";
}
std::fs::write(&overflow_spec, lines.join("\n") + "\n").unwrap();
let reason = spec_is_valid(&overflow_spec).expect("含溢出标记的谱应判无效");
assert!(reason.contains("溢出"), "溢出标记原因,实际:{}", reason);
// 7. 超高指数(E+308,数值发散产物)→ Some。
// 双重作用:(a) 验证 spec_is_valid 拦截超高指数;
// (b) 守护 NAN_RE OnceLock 一致性——spec_is_valid 与 atmosphere_has_nan
// 共用 NAN_RE,若两处正则字符串不一致(OnceLock 竞态),此断言会
// 在多线程测试时间歇性失败。
let huge_exp_spec = dir.path().join("huge_exp.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(
" {:.5} {:.6E}\n",
1400.0 + i as f64 * 0.5,
1e-12
));
}
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
if lines.len() > 2 {
lines[2] = " 1401.0 9.99E+308".to_string();
}
std::fs::write(&huge_exp_spec, lines.join("\n") + "\n").unwrap();
let reason = spec_is_valid(&huge_exp_spec).expect("含超高指数的谱应判无效");
assert!(
reason.contains("NaN") || reason.contains("Inf") || reason.contains("溢出"),
"超高指数应被识别为无效数值,实际原因:{}",
reason
);
}
/// 漏洞5修复:fort.6 失败诊断提示提取。
#[test]
fn test_extract_failure_hint() {
let dir = tempfile::tempdir().unwrap();
// 1. 发散求解器 STOP 行 → 提取该行
let solve_log = dir.path().join("solve.6");
std::fs::write(
&solve_log,
" KANTOROVICH acceleration: ITER 7\n\
**** STOP in SOLVE after ITER 8\n\
Max change: 3.59E+20\n",
)
.unwrap();
let hint = extract_failure_hint(&solve_log).expect("发散日志应返回 hint");
assert!(
hint.contains("STOP in SOLVE"),
"应提取求解器 STOP 行,实际:{}",
hint
);
// RYBSOL 路径
let rybsol_log = dir.path().join("rybsol.6");
std::fs::write(&rybsol_log, "**** STOP in RYBSOL after ITER 3\n").unwrap();
let hint = extract_failure_hint(&rybsol_log).expect("RYBSOL 日志应返回 hint");
assert!(hint.contains("RYBSOL"));
// 2. call quit 留言(fort.7 缺失场景,如 temp 越界)→ 提取尾部
let quit_log = dir.path().join("quit.6");
std::fs::write(
&quit_log,
" some normal output\n\
partf; temp<1000 K\n\
stop 'partf; temp<1000 K'\n",
)
.unwrap();
let hint = extract_failure_hint(&quit_log).expect("call quit 日志应返回 hint");
assert!(
hint.contains("partf") || hint.to_lowercase().contains("stop"),
"应提取 quit 留言,实际:{}",
hint
);
// 3. 无关日志(正常收敛,无 STOP/quit)→ None
let clean_log = dir.path().join("clean.6");
std::fs::write(
&clean_log,
" KANTOROVICH acceleration: ITER 10\n Converged.\n",
)
.unwrap();
assert!(
extract_failure_hint(&clean_log).is_none(),
"正常日志不应返回 hint"
);
// 4. 文件缺失 → None
let missing = dir.path().join("missing.6");
assert!(extract_failure_hint(&missing).is_none());
}
/// 验证 parse_fortran_float 对各种数值格式(含 Fortran 无-E 记数法)的解析。
@@ -253,7 +627,7 @@ mod tests {
// 标准 parse 能覆盖的
assert_eq!(parse_fortran_float("100"), Some(100.0));
assert_eq!(parse_fortran_float("-0.001"), Some(-0.001));
assert_eq!(parse_fortran_float("3.14"), Some(3.14));
assert_eq!(parse_fortran_float("2.5"), Some(2.5));
assert_eq!(parse_fortran_float("-5.42E+72"), Some(-5.42e72));
assert_eq!(parse_fortran_float("1.5e-99"), Some(1.5e-99));
assert_eq!(parse_fortran_float("0"), Some(0.0));
@@ -336,5 +710,35 @@ mod tests {
let res = check_fort9(&fort9, 0.001);
assert!(res.converged, "正常收敛行应判为收敛");
assert!((res.max_relc - 1.0e-5).abs() < 1e-15);
assert_eq!(res.itek_history.len(), 1, "单迭代应只有一拍");
assert_eq!(res.itek_history[0].iter, 10);
}
/// Phase 5b itek 全量保真:多迭代 fort.9 → itek_history 逐拍记录(iter → 该次最大 |maximum|)。
#[test]
fn test_fort9_itek_history_per_iteration() {
let dir = tempfile::tempdir().unwrap();
let fort9 = dir.path().join("multi_iter.9");
// 两拍:iter1 最深 max_relc=2.0E-2depth2)、iter2 最深 max_relc=5.0E-4depth1)。
std::fs::write(
&fort9,
" 1 1 0 1.0E-3 1.0E-3 1.0E-3 1.0E-2 5 10\n\
1 2 0 1.0E-3 1.0E-3 1.0E-3 2.0E-2 5 10\n\
2 1 0 1.0E-3 1.0E-3 1.0E-3 5.0E-4 5 10\n\
2 2 0 1.0E-3 1.0E-3 1.0E-3 3.0E-4 5 10\n",
)
.unwrap();
let res = check_fort9(&fort9, 0.001);
// 末拍(iter2)收敛。
assert!(res.converged);
assert!((res.max_relc - 5.0e-4).abs() < 1e-15);
// itek_history 逐拍完整。
assert_eq!(res.itek_history.len(), 2);
assert_eq!(res.itek_history[0].iter, 1);
assert!((res.itek_history[0].max_relc - 2.0e-2).abs() < 1e-15);
assert_eq!(res.itek_history[0].n_depths, 2);
assert_eq!(res.itek_history[1].iter, 2);
assert!((res.itek_history[1].max_relc - 5.0e-4).abs() < 1e-15);
assert_eq!(res.itek_history[1].n_depths, 2);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
use crate::config::SynspecConfig;
use crate::config::SynspecInput;
/// Dynamic generator for SYNSPEC fort.55 parameter control file
pub fn generate_fort55_content(cfg: &SynspecConfig) -> String {
pub fn generate_fort55_content(cfg: &SynspecInput) -> String {
let line1 = format!(" {} {} {}", cfg.imode, cfg.idrv, cfg.ifreq);
let line2 = " 1 0 0 0";
let line3 = " 0 0 0 0 0";
@@ -25,7 +25,7 @@ mod tests {
#[test]
fn test_fort55_generation() {
let cfg = SynspecConfig {
let cfg = SynspecInput {
wstart: 3000.0,
wend: 7000.0,
imode: 0,
+290 -37
View File
@@ -1,4 +1,6 @@
use crate::config::{AtomConfig, IonConfig, TlustyInput};
use crate::models::GridPointParams;
use std::collections::HashMap;
struct IonDef {
iat: i32,
@@ -177,63 +179,193 @@ const IONS_O: &[IonDef] = &[
},
];
/// 把对数丰度 logx 格式化为 10^logx 的科学计数法字符串。
///
/// 审查修复 #N3:深度防御 powf 溢出——config.validate 已把丰度轴限制在 [-20, 10]
/// 但 fmt_abn 也可能被 tlusty_chain 里的元素丰度等其它路径调用。若 logx 超出约 [-308, 308]
/// `10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5 输入文件导致 TLUSTY 行为未定义。
/// 配置层校验在前,此处仅作最终防线:溢出时 saturate 到 f64 可表示范围并按原格式输出。
fn fmt_abn(logx: f64) -> String {
format!("{:.4E}", 10.0f64.powf(logx))
let val = if logx.abs() < 300.0 {
10.0f64.powf(logx)
} else {
// 超出安全范围:clamp 到 0(丰度对数极小)或 f64::MAX(丰度对数极大),
// 避免写出 inf。配置层应已拦截,这里不会成为正常路径。
if logx < 0.0 {
0.0
} else {
f64::MAX
}
};
format!("{:.4E}", val)
}
/// 元素符号首字母大写归一("h"→"H", "he"→"He", "HE"→"He")。
/// 容忍用户在 YAML atoms 块用小写/全大写写元素符号。
fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str().to_lowercase().as_str(),
None => String::new(),
}
}
/// 默认 ions 能级数据表(与历史 IONS_* 常量等价)。
/// 用户未在 `tlusty_input.ions` 配置时使用此表,按 metals 筛选参与元素。
fn default_ions(metals: &str) -> Vec<IonConfig> {
let mt = metals.to_lowercase();
let has_c = mt.contains('c');
let has_n = mt.contains('n');
let has_o = mt.contains('o');
let mut ions: Vec<IonConfig> = Vec::new();
// H(恒在)
for d in IONS_H {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
}
// He(恒在)
for d in IONS_HE {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
}
let extend = |ions: &mut Vec<IonConfig>, table: &[IonDef]| {
for d in table {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
}
};
if has_c {
extend(&mut ions, IONS_C);
}
if has_n {
extend(&mut ions, IONS_N);
}
if has_o {
extend(&mut ions, IONS_O);
}
ions
}
/// Constructs the complete text of a `.5` input file for TLUSTY
///
/// `input_cfg`:全局输入参数(NFREAD / atoms.mode / ions 表)。
/// None → 走代码内硬编码默认(与改动前行为完全一致,向后兼容)。
pub fn make_input5(
params: &GridPointParams,
lte: &str,
ltgray: &str,
metals: &str,
ilvlin: i32,
input_cfg: Option<&TlustyInput>,
) -> String {
let mt = metals.to_lowercase();
let has_c = mt.contains('c');
let has_n = mt.contains('n');
let has_o = mt.contains('o');
// Atoms block
let mut atom_rows: Vec<(i32, String)> = vec![
(2, "0.".to_string()), // 1 H
(2, fmt_abn(*params.loghe)), // 2 He
(0, "0.".to_string()), // 3 Li
(0, "0.".to_string()), // 4 Be
(0, "0.".to_string()), // 5 B
];
// 元素符号 → (mode, abn)。mode 优先取 input_cfg.atoms 覆写,否则走默认。
// abn(丰度)永远由 GridPointParams 计算——每网格点不同,不可全局配置。
// 查找时对 key 做首字母大写归一(H/He/Li/Be/B/C/N/O),容忍用户写小写 "h"/"he"。
//
// M1 修复:config.rs 的 `atoms` 是 Serde 反序列化的 `HashMap<String, AtomConfig>`
// 键按 YAML 原样存储(无规范化)。若用户写 `he:`/`HE:`,原实现只归一探针、不归一存储键,
// 查不到规范键 "He" → mode 覆写静默回落默认(错误物理:想要的 LTE-only 变统计平衡)。
// 这里把用户键也按同一 capitalize_first 规则归一一次,保证任意大小写都能命中。
let atoms_normalized: Option<HashMap<String, &AtomConfig>> = input_cfg.map(|c| {
let mut m = HashMap::with_capacity(c.atoms.len());
for (k, v) in &c.atoms {
let canon = capitalize_first(k);
// 归一撞键检测:用户同时写 "C" 与 "c"(或 "He"/"HE")等大小写变体时,两键归一为
// 同一规范键。原 collect() 静默覆盖(迭代序非确定 → 取哪个任选)。现显式提示
// 让歧义可见,避免 mode 覆写悄悄取到任意一个变体。
if let Some(_prev) = m.insert(canon.clone(), v) {
tracing::warn!(
"atoms 键 {:?} 与已有键(归一后同为 {:?})冲突:取最后读取项,请改用单一规范键",
k,
canon
);
}
}
m
});
let atom_mode = |sym: &str, default_mode: i32| -> i32 {
atoms_normalized
.as_ref()
.and_then(|m| m.get(&capitalize_first(sym)))
.and_then(|a| a.mode)
.unwrap_or(default_mode)
};
let atom_modpf = |sym: &str| -> i32 {
atoms_normalized
.as_ref()
.and_then(|m| m.get(&capitalize_first(sym)))
.and_then(|a| a.modpf)
.unwrap_or(0)
};
// Atoms blockH/He 恒在;Li/Be/B 恒在(mode=0 不参与);C/N/O 按 metals。
let mut atom_rows: Vec<(i32, String, i32)> = vec![
(atom_mode("H", 2), "0.".to_string(), atom_modpf("H")), // 1 H
(atom_mode("He", 2), fmt_abn(*params.loghe), atom_modpf("He")), // 2 He
(atom_mode("Li", 0), "0.".to_string(), atom_modpf("Li")), // 3 Li
(atom_mode("Be", 0), "0.".to_string(), atom_modpf("Be")), // 4 Be
(atom_mode("B", 0), "0.".to_string(), atom_modpf("B")), // 5 B
];
if has_c {
atom_rows.push((2, fmt_abn(*params.logc))); // 6 C
atom_rows.push((atom_mode("C", 2), fmt_abn(*params.logc), atom_modpf("C")));
// 6 C
}
if has_n {
atom_rows.push((2, fmt_abn(*params.logn))); // 7 N
atom_rows.push((atom_mode("N", 2), fmt_abn(*params.logn), atom_modpf("N")));
// 7 N
}
if has_o {
atom_rows.push((2, fmt_abn(*params.logo))); // 8 O
atom_rows.push((atom_mode("O", 2), fmt_abn(*params.logo), atom_modpf("O")));
// 8 O
}
let natoms =
5 + (if has_c { 1 } else { 0 }) + (if has_n { 1 } else { 0 }) + (if has_o { 1 } else { 0 });
let natoms = atom_rows.len() as i32;
let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms);
for (mode, abn) in &atom_rows {
atoms_block.push_str(&format!(" {} {} 0\n", mode, abn));
for (mode, abn, modpf) in &atom_rows {
atoms_block.push_str(&format!(" {} {} {}\n", mode, abn, modpf));
}
// Ions block
let mut ions: Vec<&IonDef> = Vec::new();
ions.extend(IONS_H.iter());
ions.extend(IONS_HE.iter());
if has_c {
ions.extend(IONS_C.iter());
}
if has_n {
ions.extend(IONS_N.iter());
}
if has_o {
ions.extend(IONS_O.iter());
}
// Ions block:用户配置非空时完全替换默认表,仍按 metals 筛选 iat。
let ions: Vec<IonConfig> = if let Some(cfg) = input_cfg {
if cfg.ions.is_empty() {
default_ions(metals)
} else {
// 用户自定义表——按 metals 筛选参与元素(H/He 恒在,C/N/O 按 metals)。
cfg.ions
.iter()
.filter(|ion| match ion.iat {
1 | 2 => true, // H, He 恒在
6 => has_c,
7 => has_n,
8 => has_o,
_ => true, // 用户自定义元素不筛
})
.cloned()
.collect()
}
} else {
default_ions(metals)
};
let mut ions_block = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string();
for ion in &ions {
@@ -243,9 +375,6 @@ pub fn make_input5(
// (tests/tlusty/hhe/fort.5) 逐字节一致:
// iat 结束于 col4(|iat|=4), iz col10(+6), nlevs col16(+6),
// ilast col23(+7), ilvl col30(+7), nonstd col37(+7)。
// 实测:此宽列宽与窄列宽对 tlusty 输出(fort.7/9 等)完全相同(list-directed I/O
// 列宽无关),但对齐真实文件便于与历史参考 diff、符合 tlusty 官方输入惯例。
// 历史上曾用 `" {} {:2} {:5}..."`(窄列宽,与 Python 旧实现一致但偏离真实 fort.5)。
ions_block.push_str(&format!(
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, 0, ion.typion, ion.filei
@@ -257,13 +386,16 @@ pub fn make_input5(
0, 0, 0, -1, 0, 0, " ", " "
));
// NFREAD:从配置读,None → 默认 2000。
let nfread = input_cfg.map(|c| c.nfread).unwrap_or(2000);
format!(
"{:.1} {:.1} ! TEFF, GRAV\n \
{} {} ! LTE, LTGRAY\n \
'nst' ! name of file containing non-standard flags\n\
*-----------------------------------------------------------------\n\
* frequencies\n \
2000 ! NFREAD\n\
{} ! NFREAD\n\
*-----------------------------------------------------------------\n\
* data for atoms\n\
{}\
@@ -271,7 +403,7 @@ pub fn make_input5(
* data for ions\n*\n\
{}\
*\n* end\n",
params.teff, params.logg, lte, ltgray, atoms_block, ions_block
params.teff, params.logg, lte, ltgray, nfread, atoms_block, ions_block
)
}
@@ -289,7 +421,7 @@ mod tests {
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let input5 = make_input5(&params, "F", "F", "cno", 100);
let input5 = make_input5(&params, "F", "F", "cno", 100, None);
assert!(input5.contains("35000.0 5.5"));
assert!(input5.contains("data/h1.dat"));
assert!(input5.contains("data/c1.dat"));
@@ -312,7 +444,7 @@ mod tests {
logn: (-1.0).into(),
logo: (-1.0).into(),
};
let input5 = make_input5(&params, "T", "T", "", 100);
let input5 = make_input5(&params, "T", "T", "", 100, None);
let lines: Vec<&str> = input5.lines().collect();
// 真实 fort.5 的 ions 数据行(数值部分 + typion/filei)。
@@ -346,4 +478,125 @@ mod tests {
"ions 行数值部分必须与真实 fort.5 逐字节一致(宽列宽)"
);
}
/// 回归守护:None 配置时 NFREAD=2000、atoms.mode 走默认(H/He=2Li/Be/B=0)。
#[test]
fn test_make_input5_none_cfg_defaults() {
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let input5 = make_input5(&params, "F", "F", "cno", 100, None);
// NFREAD 默认 2000
assert!(input5.contains("2000"), "None 配置时 NFREAD 应为默认 2000");
// H 的 mode=2(显式)
assert!(input5.contains(" 2 0. 0"), "H 的 mode 应为默认 2");
}
/// 用户配置生效:nfread 覆写、atoms.mode 覆写、ions 自定义表。
#[test]
fn test_make_input5_user_cfg_override() {
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let mut atoms = std::collections::HashMap::new();
atoms.insert(
"C".to_string(),
crate::config::AtomConfig {
mode: Some(1),
modpf: None,
},
);
let cfg = TlustyInput {
nfread: 500,
atoms,
ions: vec![],
nst_extra_keys: vec![],
};
let input5 = make_input5(&params, "F", "F", "cno", 100, Some(&cfg));
// NFREAD 被覆写为 500
assert!(
input5.contains("500") && !input5.contains("2000"),
"NFREAD 应被用户配置覆写为 500"
);
// C 的 mode 被覆写为 1(隐式),而非默认 2
// atoms 行格式 " {mode} {abn} {modpf}"C 的 abn 是 fmt_abn(logc)
let c_line = input5
.lines()
.find(|l| l.contains(&format!("{:.4E}", 10.0f64.powf(-2.0))))
.expect("应找到 C 的 atoms 行");
assert!(
c_line.trim_start().starts_with("1"),
"C 的 mode 应被覆写为 1(隐式),实际: {}",
c_line
);
}
/// M1 回归:`atoms` 键大小写不敏感查找。用户写小写/全大写元素键(`c:`/`HE:`)时,
/// 与规范键(`C`/`He`)必须同样命中覆写,否则 mode 覆写会静默回落默认(错误物理)。
#[test]
fn test_atoms_key_case_insensitive() {
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
// 用户用小写 "c" 与全大写 "HE" 覆写 mode。
let mut atoms = std::collections::HashMap::new();
atoms.insert(
"c".to_string(),
crate::config::AtomConfig {
mode: Some(1),
modpf: None,
},
);
atoms.insert(
"HE".to_string(),
crate::config::AtomConfig {
mode: Some(0),
modpf: None,
},
);
let cfg = TlustyInput {
nfread: 2000,
atoms,
ions: vec![],
nst_extra_keys: vec![],
};
let input5 = make_input5(&params, "F", "F", "cno", 100, Some(&cfg));
// C(键 "c" 小写)应命中得到 mode=1,而非默认 2。
let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0));
let c_line = input5
.lines()
.find(|l| l.contains(&c_abn))
.expect("应找到 C 的 atoms 行");
assert!(
c_line.trim_start().starts_with("1"),
"小写键 c 应覆写 C 的 mode 为 1,实际: {}",
c_line
);
// He(键 "HE" 全大写)应命中得到 mode=0,而非默认 2。
let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0));
let he_line = input5
.lines()
.find(|l| l.contains(&he_abn))
.expect("应找到 He 的 atoms 行");
assert!(
he_line.trim_start().starts_with("0"),
"全大写键 HE 应覆写 He 的 mode 为 0,实际: {}",
he_line
);
}
}
+149 -125
View File
@@ -242,7 +242,10 @@ pub struct GridPoint {
pub wave: i32,
pub status: GridPointStatus,
pub attempt_count: i32,
pub success_method: Option<String>,
/// TLUSTY 阶段收敛策略(TLUSTY 禁用为 NULL)。
pub tlusty_success_method: Option<String>,
/// 光谱阶段收敛策略(TLUSTY-only 为 NULL)。
pub synspec_success_method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -251,7 +254,9 @@ pub enum GridPointStatus {
Pending,
Queued,
Running,
Converged,
/// Phase 7c:由 `Converged` 改名——点级"管线完成"(大气收敛 + 光谱合成),
/// 消除 TLUSTY-first 的"大气收敛"误读。DB 值 'completed' 经 M9 迁为 'completed'。
Completed,
Failed,
}
@@ -261,7 +266,7 @@ impl std::fmt::Display for GridPointStatus {
GridPointStatus::Pending => "pending",
GridPointStatus::Queued => "queued",
GridPointStatus::Running => "running",
GridPointStatus::Converged => "converged",
GridPointStatus::Completed => "completed",
GridPointStatus::Failed => "failed",
};
write!(f, "{}", s)
@@ -273,7 +278,8 @@ impl From<&str> for GridPointStatus {
match s {
"queued" => GridPointStatus::Queued,
"running" => GridPointStatus::Running,
"converged" | "done" => GridPointStatus::Converged,
// 7c'completed' 是权威值;'converged'/'done' 为 legacy 别名(M9 迁移前旧数据/旧代码)。
"completed" | "converged" | "done" => GridPointStatus::Completed,
"failed" => GridPointStatus::Failed,
_ => GridPointStatus::Pending,
}
@@ -288,7 +294,7 @@ impl From<&str> for GridPointStatus {
/// 的处理;失败后的策略链回退**只由启动时的策略链(回退优先级排序)驱动**,不受策略门控。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StagePolicy {
pub enum ResumePolicy {
/// 跳过已收敛、重试已失败:启动时把已失败点打回 pending 重试,收敛点保留(增量+重试失败)。
/// 默认值。
#[default]
@@ -299,22 +305,22 @@ pub enum StagePolicy {
SkipFailed,
}
impl StagePolicy {
impl ResumePolicy {
/// 序列化为 DB 文本列存储用的 snake_case 字符串。
pub fn as_str(&self) -> &'static str {
match self {
StagePolicy::SkipConverged => "skip_converged",
StagePolicy::ForceRecompute => "force_recompute",
StagePolicy::SkipFailed => "skip_failed",
ResumePolicy::SkipConverged => "skip_converged",
ResumePolicy::ForceRecompute => "force_recompute",
ResumePolicy::SkipFailed => "skip_failed",
}
}
/// 从 DB 文本列回读;非法值兜底为默认 SkipConverged(防注入与脏数据)。
pub fn from_str_lossy(s: &str) -> Self {
match s {
"force_recompute" => StagePolicy::ForceRecompute,
"skip_failed" => StagePolicy::SkipFailed,
_ => StagePolicy::SkipConverged,
"force_recompute" => ResumePolicy::ForceRecompute,
"skip_failed" => ResumePolicy::SkipFailed,
_ => ResumePolicy::SkipConverged,
}
}
}
@@ -324,16 +330,16 @@ impl StagePolicy {
/// 见 docs/task_engine_decoupling_design.md §3:嵌套式单阶段配置模型,
/// 包含三个正交维度:enabled / policy / strategies。
///
/// 为避免与 `common::config::StageConfig`(迭代步进参数)同名冲突,命名为
/// `EngineStageConfig`。
/// 为避免与 `common::config::ChainStep`(迭代步进参数)同名冲突,命名为
/// `PhaseConfig`。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EngineStageConfig {
pub struct PhaseConfig {
/// 是否在当前计算流中启用该阶段。
#[serde(default = "default_engine_stage_enabled")]
pub enabled: bool,
/// 决定如何处理历史记录。
#[serde(default)]
pub policy: StagePolicy,
pub policy: ResumePolicy,
/// 策略链队列(按回退优先级排序),如 `["cold_run", "seed_step"]`。
/// 节点总是执行 `strategies[0]`;失败后由服务端弹出首项,下一顺位顶上。
#[serde(default)]
@@ -344,12 +350,12 @@ fn default_engine_stage_enabled() -> bool {
true
}
impl EngineStageConfig {
impl PhaseConfig {
/// TLUSTY 阶段默认配置:启用、增量、策略链 `[cold_run, seed_step]`。
pub fn default_tlusty() -> Self {
Self {
enabled: true,
policy: StagePolicy::SkipConverged,
policy: ResumePolicy::SkipConverged,
strategies: vec!["cold_run".to_string(), "seed_step".to_string()],
}
}
@@ -358,7 +364,7 @@ impl EngineStageConfig {
pub fn default_synspec() -> Self {
Self {
enabled: true,
policy: StagePolicy::SkipConverged,
policy: ResumePolicy::SkipConverged,
strategies: vec!["standard".to_string()],
}
}
@@ -382,20 +388,18 @@ impl EngineStageConfig {
/// Task execution specification sent to Node
///
/// 注:`EngineStageConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY
/// 注:`PhaseConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY
/// `[cold_run, seed_step]` vs SYNSPEC `[standard]`),无中立的默认语义。构造某阶段的配置请用
/// `..EngineStageConfig::default_tlusty()` / `..EngineStageConfig::default_synspec()`
/// `..PhaseConfig::default_tlusty()` / `..PhaseConfig::default_synspec()`
/// 避免把 TLUSTY 默认链误用到 synspec。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSpec {
pub task_id: Uuid,
pub point_name: String,
pub params: GridPointParams,
/// **已废弃**:保留以兼容历史 MQ 在途消息与旧节点。新代码应读
/// `tlusty_config.strategies[0]` 判定当前 TLUSTY 策略。
/// 字段仍是必填(serde 反序列化要求),调度器在派发时会据
/// `tlusty_config.strategies[0]` 同步设置它,保证旧节点能正常工作。
pub task_type: TaskType,
/// **双义**Phase 7a 标注):TLUSTY 启用时 = seed_step 热启动的近邻种子点;
/// SYNSPEC-onlyTLUSTY 关闭)时 = 光谱输入大气来源点(由 atmosphere_ref/point_name 决定,
/// 字段此时恒 None)。executor 以 `tlusty_config.enabled` 区分两种语义。
pub seed_point_name: Option<String>,
pub timeout_sec: u64,
/// 所属工作流名称,用于按工作流隔离队列清理(stop_workflow 只清当前工作流的任务)。
@@ -408,46 +412,37 @@ pub struct TaskSpec {
pub wave: i32,
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
/// 旧 payload 反序列化时缺省为 `default_tlusty()`。
#[serde(default = "EngineStageConfig::default_tlusty")]
pub tlusty_config: EngineStageConfig,
#[serde(default = "PhaseConfig::default_tlusty")]
pub tlusty_config: PhaseConfig,
/// SYNSPEC 阶段独立配置。旧 payload 反序列化时缺省为 `default_synspec()`。
#[serde(default = "EngineStageConfig::default_synspec")]
pub synspec_config: EngineStageConfig,
/// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecConfig`)。
#[serde(default = "PhaseConfig::default_synspec")]
pub synspec_config: PhaseConfig,
/// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecInput`)。
/// 以 `serde_json::Value` 携带避免 models ↔ config 循环依赖;executor 侧
/// 反序列化为 `SynspecConfig` 后透传给 runner。None → runner 用硬编码默认。
/// 反序列化为 `SynspecInput` 后透传给 runner。None → runner 用硬编码默认。
/// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。
#[serde(default)]
pub synspec_params: Option<serde_json::Value>,
/// TLUSTY 物理迭代步进链(lte/nc/nl 多阶段 `config::ChainStep` 数组)。
/// 同样以 `serde_json::Value` 携带避免循环依赖;executor 反序列化为
/// `Vec<ChainStep>` 后透传给 runner 的 custom_chain 参数。
/// None/空 → executor 用 `default_chain_for_strategy` 兜底(按策略名选默认链)。
/// 旧 payload 反序列化时缺省为 None(旧节点本就用 default 链,无回归)。
#[serde(default)]
pub tlusty_chain_params: Option<serde_json::Value>,
/// TLUSTY 输入文件(.5 + nst)的全局物理参数(`config::TlustyInput`)。
/// NFREAD 频率网格、ions 能级表、nst extra_keys 等不随阶段变化的参数。
/// None → runner 用代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量)。
/// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。
#[serde(default)]
pub tlusty_input_params: Option<serde_json::Value>,
/// 仅 SYNSPEC-only 场景(TLUSTY 关闭)拉取大气用:显式关联大气网格点名。
#[serde(default)]
pub atmosphere_ref: Option<String>,
}
impl TaskSpec {
/// 旧版兼容归一化:据废弃的 `task_type` 回填 `tlusty_config.strategies` 首项。
///
/// 修复(审查 #8):serde default 已把 strategies 填为完整默认链 `[cold_run, seed_step]`
/// 故仅判 `is_empty` 无法覆盖「旧 seed_step 消息被误判为 cold_run」的场景。
/// 现据 task_type 把首项校正为对应的单策略链(旧消息的 task_type 是权威来源):
/// - task_type=SeedStep → `[seed_step]`(旧热启动消息不应被当冷启动重跑);
/// - task_type=ColdRun → 保持默认链(cold_run 本就是默认首项)。
pub fn normalize_compat(&mut self) {
let legacy_first = match self.task_type {
TaskType::ColdRun => "cold_run",
TaskType::SeedStep => "seed_step",
};
// 仅当当前 strategies 首项与 task_type 不一致时校正(避免覆盖显式配置)。
let needs_fix =
self.tlusty_config.strategies.first().map(|s| s.as_str()) != Some(legacy_first);
if needs_fix {
self.tlusty_config.strategies = vec![legacy_first.to_string()];
}
}
}
/// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、
/// 空点/参数、ColdRun、默认阶段配置)。生产代码应显式构造所有字段,避免依赖占位。
/// 空点/参数、默认阶段配置)。生产代码应显式构造所有字段,避免依赖占位。
impl Default for TaskSpec {
fn default() -> Self {
TaskSpec {
@@ -461,26 +456,20 @@ impl Default for TaskSpec {
logn: 0.0.into(),
logo: 0.0.into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 3600,
workflow_name: None,
wave: 0,
tlusty_config: EngineStageConfig::default_tlusty(),
synspec_config: EngineStageConfig::default_synspec(),
tlusty_config: PhaseConfig::default_tlusty(),
synspec_config: PhaseConfig::default_synspec(),
synspec_params: None,
tlusty_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskType {
ColdRun,
SeedStep,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
@@ -499,10 +488,20 @@ pub struct TaskReport {
#[serde(default)]
pub params: Option<GridPointParams>,
pub node_id: String,
/// 任务整体成败(`derive_report_status`converged && 无 synspec 错误 → Completed
/// 半失败 = Failed)。整体语义,非阶段成败。
pub status: TaskStatus,
pub converged: bool,
/// **双义**Phase 7a/7b 标注):TLUSTY 启用时 = 大气收敛标志("本次大气产物是否可用");
/// SYNSPEC-onlyTLUSTY 关闭)被重写为管线成功。7b 改名 `result_valid` 消除字段名误读——
/// 阶段成败请用 `failed_stage` / `summary_json.synspec_*`。
///
/// 旧节点仍以字段名 `converged` 上报,serde alias 兼容(支持滚动升级)。
#[serde(alias = "converged")]
pub result_valid: bool,
/// 仅 TLUSTY 大气迭代有效(SYNSPEC-only 任务此量为 None 或大气来源值)。
pub max_relc: Option<f64>,
pub atmosphere_has_nan: bool,
/// 单点总墙钟耗时(秒,含 TLUSTY + SYNSPEC`synspec_sec` 是其子集)。
pub elapsed_sec: f64,
pub error_message: Option<String>,
pub summary_json: String,
@@ -570,11 +569,25 @@ pub struct ConvCheckResult {
pub n_depths: usize,
pub chmax: f64,
pub error: Option<String>,
/// 逐次迭代诊断(iter → 该次最大相对变化;Phase 5b 起由 fort.9 全量解析)。
/// 完整收敛轨迹:17 次迭代缓降 vs 顶着 NITER 上限勉强的发散轨迹一眼可辨。
#[serde(default)]
pub itek_history: Vec<IterCheck>,
}
/// 单次迭代的收敛诊断(fort.9 每迭代一拍)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IterCheck {
pub iter: i32,
/// 该次迭代的最大相对变化(所有深度点 |maximum| 的最大值)。
pub max_relc: f64,
/// 该次迭代参与解析的深度点行数。
pub n_depths: usize,
}
/// Convergence stage summary recorded in conv.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageSummary {
pub struct StepSummary {
pub label: String,
pub chmax: Option<f64>,
pub lte: String,
@@ -592,6 +605,10 @@ pub struct StageSummary {
/// 深度点总数,诊断用。
#[serde(default)]
pub n_depths: Option<usize>,
/// 逐次迭代诊断(iter → 该次最大相对变化)。**全量保真**summary_json 与 conv.json
/// 同源于 runner 的 StepSummary,故完整收敛轨迹随 summary_json 落库,不再仅存磁盘。
#[serde(default)]
pub itek_history: Vec<IterCheck>,
}
/// Full execution summary for a grid point
@@ -599,8 +616,15 @@ pub struct StageSummary {
pub struct ModelSummary {
pub name: String,
pub params: GridPointParams,
pub stages: Vec<StageSummary>,
pub converged: bool,
/// TLUSTY 收敛链子步骤摘要(lte/nc/nl 或 seed_nc/nl),**不是** TLUSTY/SYNSPEC 管线大阶段。
pub stages: Vec<StepSummary>,
/// 本次结果是否可用(P9 拆分,与 `TaskReport.result_valid` 对齐):
/// TLUSTY 启用时 = 大气收敛;SYNSPEC-onlyTLUSTY 关闭)被 reporter 重写为管线成功。
/// 读方不能仅凭字段名判断是哪个阶段——整体成败请用 `TaskReport.status`。
/// 旧 `conv.json`/`summary_json` 序列化的键名是 `converged``#[serde(alias)]` 兼容读取。
#[serde(alias = "converged")]
pub result_valid: bool,
/// 仅 TLUSTY 大气迭代有效(最大相对修正;SYNSPEC 阶段无此量)。
pub final_max_relc: Option<f64>,
pub final_chmax: Option<f64>,
pub seed: Option<String>,
@@ -608,8 +632,9 @@ pub struct ModelSummary {
pub synspec_rc: Option<i32>,
pub synspec_error: Option<String>,
pub synspec_sec: Option<f64>,
/// 单点总墙钟耗时(秒)。极旧版 conv.json 可能缺此字段,default 0.0 兜底
/// (展示层把 ≤0 视为"无数据");现版 run_one.py 总是写入
/// 单点总墙钟耗时(秒,含 TLUSTY + SYNSPEC)。**包含** `synspec_sec`(子集):
/// `elapsed_sec ≥ synspec_sec` 恒成立(synspec 为空时 synspec_sec=None
/// 极旧版 conv.json 可能缺此字段,default 0.0 兜底(展示层把 ≤0 视为"无数据")。
#[serde(default)]
pub elapsed_sec: f64,
pub note: Option<String>,
@@ -619,7 +644,7 @@ pub struct ModelSummary {
mod tests {
use super::*;
/// StageSummary 新增迭代诊断字段的向后兼容:
/// StepSummary 新增迭代诊断字段的向后兼容:
/// 旧 conv.json(无 last_iter/worst_depth/n_depths)必须能正常反序列化为 null,
/// 新写入的 conv.json 往返保真。
#[test]
@@ -628,12 +653,12 @@ mod tests {
"label": "nl", "chmax": 0.001, "lte": "F", "converged": true,
"best_max_relc": 0.0005, "elapsed_sec": 64.0, "note": null
}"#;
let st: StageSummary = serde_json::from_str(legacy).unwrap();
let st: StepSummary = serde_json::from_str(legacy).unwrap();
assert_eq!(st.last_iter, None);
assert_eq!(st.worst_depth, None);
assert_eq!(st.n_depths, None);
let full = StageSummary {
let full = StepSummary {
label: "nl".to_string(),
chmax: Some(0.001),
lte: "F".to_string(),
@@ -644,8 +669,13 @@ mod tests {
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![IterCheck {
iter: 1,
max_relc: 0.5,
n_depths: 50,
}],
};
let round: StageSummary =
let round: StepSummary =
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
assert_eq!(round.last_iter, Some(17));
assert_eq!(round.worst_depth, Some(1));
@@ -657,7 +687,7 @@ mod tests {
///
/// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套
/// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。
/// Rust StageSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。
/// Rust StepSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。
#[test]
fn test_model_summary_parses_python_legacy_conv_json() {
let legacy = r#"{
@@ -699,7 +729,8 @@ mod tests {
let s: ModelSummary = serde_json::from_str(legacy).expect("旧版 conv.json 应可解析");
assert_eq!(s.name, "t20000_g5.0_he-2_c-4_n-4_o-4");
assert_eq!(*s.params.teff, 20000.0);
assert!(s.converged);
// 旧版键名 "converged" 经 #[serde(alias)] 兼容读入 result_valid。
assert!(s.result_valid);
assert!(!s.atmosphere_has_nan);
assert_eq!(s.final_max_relc, Some(0.0069));
assert_eq!(s.elapsed_sec, 715.0);
@@ -852,75 +883,80 @@ mod tests {
assert_eq!(GridPointStatus::Pending.to_string(), "pending");
assert_eq!(GridPointStatus::Queued.to_string(), "queued");
assert_eq!(GridPointStatus::Running.to_string(), "running");
assert_eq!(GridPointStatus::Converged.to_string(), "converged");
assert_eq!(GridPointStatus::Completed.to_string(), "completed");
assert_eq!(GridPointStatus::Failed.to_string(), "failed");
assert_eq!(GridPointStatus::from("queued"), GridPointStatus::Queued);
// 7c'completed' 权威值;'converged'/'done' 为 legacy 别名。
assert_eq!(
GridPointStatus::from("completed"),
GridPointStatus::Completed
);
assert_eq!(
GridPointStatus::from("converged"),
GridPointStatus::Converged
GridPointStatus::Completed
);
assert_eq!(GridPointStatus::from("done"), GridPointStatus::Converged);
assert_eq!(GridPointStatus::from("done"), GridPointStatus::Completed);
assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed);
assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending);
}
/// `StagePolicy` 的 snake_case serde 往返 + DB 文本兜底。
/// `ResumePolicy` 的 snake_case serde 往返 + DB 文本兜底。
#[test]
fn test_stage_policy_serde_roundtrip() {
for p in [
StagePolicy::SkipConverged,
StagePolicy::ForceRecompute,
StagePolicy::SkipFailed,
ResumePolicy::SkipConverged,
ResumePolicy::ForceRecompute,
ResumePolicy::SkipFailed,
] {
let s = serde_json::to_string(&p).unwrap();
let back: StagePolicy = serde_json::from_str(&s).unwrap();
let back: ResumePolicy = serde_json::from_str(&s).unwrap();
assert_eq!(p, back);
}
// snake_case 形态锁定(前端 payload 与 DB 列口径)
assert_eq!(
serde_json::to_string(&StagePolicy::SkipConverged).unwrap(),
serde_json::to_string(&ResumePolicy::SkipConverged).unwrap(),
"\"skip_converged\""
);
assert_eq!(
serde_json::to_string(&StagePolicy::ForceRecompute).unwrap(),
serde_json::to_string(&ResumePolicy::ForceRecompute).unwrap(),
"\"force_recompute\""
);
assert_eq!(
serde_json::to_string(&StagePolicy::SkipFailed).unwrap(),
serde_json::to_string(&ResumePolicy::SkipFailed).unwrap(),
"\"skip_failed\""
);
// as_str/from_str_lossy 互逆(非法值兜底 SkipConverged
assert_eq!(
StagePolicy::from_str_lossy("skip_converged"),
StagePolicy::SkipConverged
ResumePolicy::from_str_lossy("skip_converged"),
ResumePolicy::SkipConverged
);
assert_eq!(
StagePolicy::from_str_lossy("garbage"),
StagePolicy::SkipConverged
ResumePolicy::from_str_lossy("garbage"),
ResumePolicy::SkipConverged
);
assert_eq!(
StagePolicy::from_str_lossy(StagePolicy::ForceRecompute.as_str()),
StagePolicy::ForceRecompute
ResumePolicy::from_str_lossy(ResumePolicy::ForceRecompute.as_str()),
ResumePolicy::ForceRecompute
);
}
/// `EngineStageConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。
/// `PhaseConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。
#[test]
fn test_engine_stage_config_serde_and_defaults() {
let cfg = EngineStageConfig {
let cfg = PhaseConfig {
enabled: false,
policy: StagePolicy::ForceRecompute,
policy: ResumePolicy::ForceRecompute,
strategies: vec!["cold_run".to_string(), "seed_step".to_string()],
};
let json = serde_json::to_string(&cfg).unwrap();
let back: EngineStageConfig = serde_json::from_str(&json).unwrap();
let back: PhaseConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back);
// 空 payload 应产出默认值(enabled=true, policy=skip_converged, strategies=[]
let empty: EngineStageConfig = serde_json::from_str("{}").unwrap();
let empty: PhaseConfig = serde_json::from_str("{}").unwrap();
assert!(empty.enabled);
assert_eq!(empty.policy, StagePolicy::SkipConverged);
assert_eq!(empty.policy, ResumePolicy::SkipConverged);
assert!(empty.strategies.is_empty());
// current_strategy 空链兜底
@@ -930,43 +966,31 @@ mod tests {
assert!(!cfg.has_strategy("standard"));
}
/// 旧版 MQ 在途消息(仅含 task_type,无 tlusty_config)经 `#[serde(default)]`
/// 反序列化后,`normalize_compat()` 应据 task_type 回填 strategies。
/// Phase 6P8)删除 task_type 后,`normalize_compat` 与旧版单策略链校正机制整体移除。
/// 升级前需确认队列为空(docs/database_refactor_design.md §8.8):残留旧 payload 的
/// strategies 会按 serde default 填成默认链 [cold_run, seed_step],无 task_type 可校正。
#[test]
fn test_task_spec_normalize_compat_from_legacy_task_type() {
fn test_legacy_payload_without_task_type_uses_default_strategy_chain() {
let legacy_json = r#"{
"task_id": "00000000-0000-0000-0000-000000000001",
"point_name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"task_type": "seed_step",
"seed_point_name": "neighbor",
"timeout_sec": 7200,
"workflow_name": "wf_a",
"wave": 0
}"#;
let mut spec: TaskSpec = serde_json::from_str(legacy_json).unwrap();
// 修复后 normalize_compat 据 task_type 校正首项:旧 seed_step 消息的 strategies
// 首项应被校正为 seed_step(而非保留默认链的 cold_run 首项,否则会被误当冷启动)。
spec.normalize_compat();
// task_type 字段已被移除;旧 payload 即使仍携带该键也会被 serde 忽略(未知字段)。
// tlusty_config 缺省回落到默认链,首项 cold_run。
let spec: TaskSpec = serde_json::from_str(legacy_json).unwrap();
assert_eq!(
spec.tlusty_config.current_strategy("cold_run"),
"cold_run",
"无显式 strategies 的旧 payload 回落到默认链首项"
);
assert_eq!(
spec.tlusty_config.strategies,
vec!["seed_step".to_string()],
"旧 seed_step 消息应校正为 [seed_step] 单策略链"
);
assert_eq!(spec.tlusty_config.current_strategy("cold_run"), "seed_step");
// 对照:旧 cold_run 消息 → 首项已是 cold_run(默认链首项),无需校正。
let mut cold_spec = TaskSpec::default();
cold_spec.task_type = TaskType::ColdRun;
cold_spec.normalize_compat();
assert_eq!(
cold_spec
.tlusty_config
.strategies
.first()
.map(|s| s.as_str()),
Some("cold_run"),
"旧 cold_run 消息保持默认链"
vec!["cold_run".to_string(), "seed_step".to_string()]
);
}
}
+114 -5
View File
@@ -1,6 +1,17 @@
use crate::config::StageConfig;
use crate::config::{ChainStep, TlustyInput};
pub fn generate_nst_content(stage: &StageConfig) -> String {
/// 生成 TLUSTY 的 nst(非标准标志)文件内容。
///
/// nst 文件由 `KEY=VALUE` 对组成,TLUSTY 的 NSTPAR 子程序逐行解析(无行数限制)。
/// 内容来源(优先级从高到低):
/// 1. `input_cfg.nst_extra_keys`:用户自由传入的任意 KEY=VALUE(逃逸口),追加到末尾。
/// 2. `stage`ChainStep):CHMAX/ITEK/NITER/ORELAX/IDLTE/IACC/ICHANG 等阶段差异参数。
/// 3. 硬编码默认:ND/NLAMBD/VTB/ISPODF/DDNU/CNU1/IELCORinput_cfg 无对应字段时)。
///
/// `input_cfg` 为 None 时走全默认(与改动前行为完全一致,向后兼容)。
pub fn generate_nst_content(stage: &ChainStep, input_cfg: Option<&TlustyInput>) -> String {
// 第 1 行:深度点数/角度数/湍速/ODF 等物理网格参数 + 收敛控制。
// ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 当前无结构化字段,保留硬编码(如需覆写用 extra_keys)。
let mut line1_parts = vec![
"ND=50".to_string(),
"NLAMBD=3".to_string(),
@@ -18,6 +29,7 @@ pub fn generate_nst_content(stage: &StageConfig) -> String {
}
line1_parts.push(format!("NITER={}", stage.niter));
// 第 2 行:加速/收敛控制开关。
let mut line2_parts = Vec::new();
if let Some(orelax) = stage.orelax {
line2_parts.push(format!("ORELAX={}", orelax));
@@ -33,7 +45,38 @@ pub fn generate_nst_content(stage: &StageConfig) -> String {
}
line2_parts.push("IELCOR=-1".to_string());
format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(","))
let mut out = format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(","));
// 第 3 行起:用户自由传入的额外 nst 关键字(逃逸口)。
// 每行一个 KEY=VALUE,追加到末尾。用于暴露未结构化的 220+ nst 关键字
// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。
// 多行格式经 TLUSTY 源码验证(tlusty208.f:1819 NSTPAR 用 `READ(INPFI,500,END=70)`
// + `GO TO 10` 逐行循环读至 EOF,每行用 GETWRD 解析 KEY=VALUE),追加行可被正确解析。
// 安全校验:key/value 含逗号/换行/等号会破坏 nst 的逗号分隔或 KEY=VALUE 解析,
// 跳过非法项并记 warn(避免生成损坏的 nst 导致 tlusty 行为异常)。
// L1 修复:**key 与 value 都校验**——key 含 `=`/`,`/空白/换行会产出畸形 `KEY=VALUE`
// 行,TLUSTY 的 GETWRD 解析器可能误读。key 必须是合法标识符(非空、无上述分隔符)。
if let Some(cfg) = input_cfg {
for (key, value) in &cfg.nst_extra_keys {
let bad_key = key.is_empty()
|| key.contains(',')
|| key.contains('\n')
|| key.contains('=')
|| key.chars().any(char::is_whitespace);
let bad_value = value.contains(',') || value.contains('\n') || value.contains('=');
if bad_key || bad_value {
tracing::warn!(
"跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析",
key,
value
);
continue;
}
out.push_str(&format!("{}={}\n", key, value));
}
}
out
}
#[cfg(test)]
@@ -42,7 +85,7 @@ mod tests {
#[test]
fn test_nst_generation() {
let stage = StageConfig {
let stage = ChainStep {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -57,9 +100,75 @@ mod tests {
iacc: None,
orelax: None,
};
let content = generate_nst_content(&stage);
let content = generate_nst_content(&stage, None);
assert!(content.contains("ND=50"));
assert!(content.contains("NITER=10"));
assert!(content.contains("IELCOR=-1"));
}
/// extra_keys 追加到 nst 末尾(每行一个 KEY=VALUE)。
#[test]
fn test_nst_extra_keys() {
let stage = ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: Some(0.001),
itek: None,
metals: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
};
let cfg = TlustyInput {
nfread: 2000,
atoms: Default::default(),
ions: vec![],
nst_extra_keys: vec![
("FRCMAX".to_string(), "0.01".to_string()),
("CUTBAL".to_string(), "0.3".to_string()),
],
};
let content = generate_nst_content(&stage, Some(&cfg));
assert!(content.contains("FRCMAX=0.01"), "extra_keys 应追加到 nst");
assert!(content.contains("CUTBAL=0.3"));
// 原有内容仍存在
assert!(content.contains("NITER=100"));
assert!(content.contains("CHMAX=0.001"));
}
/// None 配置时与改动前行为一致(无 extra_keys 行)。
#[test]
fn test_nst_none_cfg_backward_compat() {
let stage = 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: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
};
let content = generate_nst_content(&stage, None);
// None 配置时只有 2 行(第1行 ND/NITER 等 + 第2行 IELCOR 等),无 extra_keys 追加行。
let non_empty_lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
non_empty_lines.len(),
2,
"None 配置时 nst 应只有 2 行,实际 {} 行: {:?}",
non_empty_lines.len(),
non_empty_lines
);
assert!(!content.contains("FRCMAX"));
}
}
+41 -24
View File
@@ -15,7 +15,8 @@
//! `conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
//! 2. **科学核心**`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log`、
//! `<name>.bfac`TLUSTY 最终 b 因子/非 LTE 偏离因子)、`<name>.emflux`TLUSTY 最终出射谱 λ–Fλ)
//! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label ∈ lte/nc/nl/seed_nc
//! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label 为任意单一标识符,
//! 含默认链 lte/nc/nl/seed_nc 与用户 `tlusty_chain` 自定义标签——M3 起不再硬编码白名单)
//! 4. **收敛诊断**`<name>.<label>_chmax*.9`**唯一保留的 .9**;裸 `<name>.<label>.9`
//! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复)
//!
@@ -30,15 +31,6 @@ const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log", "bfac",
/// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。
const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"];
/// 合法阶段标签(来自 `default_cold_chain` / `default_seed_chain` 的 label)。
/// 阶段标签由 workflow 配置保证唯一,不会与科学后缀或 synspec 产物冲突。
///
/// **约束**:此处硬编码了默认链的 4 个标签。runner 的 `run_model_with_timeout`
/// 虽接受 `custom_chain`(可含任意 label),但当前唯一生产调用方(executor)传 `None`
/// 走默认链,故白名单覆盖安全。若将来启用自定义 chain 且引入新标签,需同步加入此处,
/// 否则带新标签的阶段快照(`.5/.6/.err/.nst/.7`)和 `_chmax*.9` 会被白名单静默丢弃。
const STAGE_LABELS: &[&str] = &["lte", "nc", "nl", "seed_nc"];
/// 有独立语义、保留的裸文件名(不以 model_name 为前缀)。
const BARE_KEEPS: &[&str] = &["conv.json", "fort.8", "fort.55"];
@@ -86,21 +78,25 @@ pub fn is_result_worthy(fname: &str, model_name: &str) -> bool {
}
// 3. 阶段快照:`<label>.<suffix>`(如 `nl.7`、`nc.nst`)。
// 用 split_once('.', label/suffix) 切一刀;label 必须在 STAGE_LABELS 内,
// suffix 必须在 STAGE_SNAPSHOT_SUFFIXES 内。这样能精确排除 `<name>.nl.9`
// (suffix=9 不在快照后缀集)等。
// M3 修复:不再硬编码 label 白名单——runner 的 `custom_chain` 允许用户在 YAML
// `tlusty_chain` 配置任意阶段标签,硬编码白名单(lte/nc/nl/seed_nc)会把自定义
// 标签的阶段快照静默丢弃。改按**结构**识别:`<label>.<suffix>`label 为不含
// '.' 的单一标识符,suffix 限定在快照后缀集内。这样自定义标签(如 `grey`/`base`
// 与默认标签同等归档;代价是 `<name>.<任意>.7` 这类罕见杂散文件也会被保留
// (低风险,宁可多留一份也不丢科学产物)。
if let Some((label, suffix)) = rest.split_once('.') {
if STAGE_LABELS.contains(&label) && STAGE_SNAPSHOT_SUFFIXES.contains(&suffix) {
if !label.is_empty() && !label.contains('.') && STAGE_SNAPSHOT_SUFFIXES.contains(&suffix) {
return true;
}
}
// 4. 收敛诊断:`<label>_chmax*.9`(如 `nl_chmax0.001.9`)。
// rest 以 `<label>_chmax` 开头,以 `.9` 结尾。这是唯一保留的 .9 形态
if rest.ends_with(".9") {
for label in STAGE_LABELS {
let tag = format!("{}_chmax", label);
if rest.starts_with(&tag) && rest.ends_with(".9") {
// 4. 收敛诊断:`<label>_chmax<value>.9`(如 `nl_chmax0.001.9`)。唯一保留的 .9 形态。
// 同样按结构识别:以 `.9` 结尾 + 去掉 `.9` 后含 `_chmax` + 标签为非空单 token
// 精确排除冗余的 `<name>.<label>.9`(无 `_chmax`,与此完全重复)。
if let Some(stem) = rest.strip_suffix(".9") {
if let Some(pos) = stem.rfind("_chmax") {
let label = &stem[..pos];
if !label.is_empty() && !label.contains('.') {
return true;
}
}
@@ -114,6 +110,8 @@ mod tests {
use super::*;
const NAME: &str = "t20000_g5.0_he-2_c-4_n-4_o-4";
// 默认链标签(测试沿用历史白名单;生产逻辑已改为结构识别,见 is_result_worthy)。
const STAGE_LABELS: &[&str] = &["lte", "nc", "nl", "seed_nc"];
#[test]
fn test_bare_keeps() {
@@ -201,10 +199,29 @@ mod tests {
assert!(!is_result_worthy(&format!("{}.nl.foo", NAME), NAME));
}
/// M3 回归:用户自定义阶段标签(非默认 lte/nc/nl/seed_nc)的阶段快照必须归档。
/// 硬编码 STAGE_LABELS 白名单会把自定义标签的 `.5/.6/.err/.nst/.7` 与 `_chmax*.9`
/// 静默丢弃;现改为结构识别,任意单一标识符标签均保留。
#[test]
fn test_unknown_stage_label_skipped() {
// 未知的阶段标签不归档(防御性:未来若引入新标签需显式加入 STAGE_LABELS
assert!(!is_result_worthy(&format!("{}.xxx.7", NAME), NAME));
assert!(!is_result_worthy(&format!("{}.xxx.nst", NAME), NAME));
fn test_custom_stage_label_kept() {
for label in ["grey", "base", "myscenario"] {
for s in ["5", "6", "err", "nst", "7"] {
let f = format!("{}.{}.{}", NAME, label, s);
assert!(
is_result_worthy(&f, NAME),
"自定义标签 {} 的阶段快照 {} 应归档",
label,
f
);
}
let chmax = format!("{}.{}_chmax0.001.9", NAME, label);
assert!(
is_result_worthy(&chmax, NAME),
"自定义标签 {} 的 chmax 诊断应归档",
label
);
}
// 冗余的 `<name>.<label>.9`(无 _chmax)仍不应归档(与 _chmax.9 重复)。
assert!(!is_result_worthy(&format!("{}.grey.9", NAME), NAME));
}
}
+142 -38
View File
@@ -1,9 +1,9 @@
use crate::config::{StageConfig, SynspecConfig};
use crate::conv_check::{atmosphere_has_nan, check_fort9};
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, StageSummary, TaskType};
use crate::models::{GridPointParams, ModelSummary, StepSummary};
use crate::nst_writer::generate_nst_content;
use anyhow::Result;
use std::path::{Path, PathBuf};
@@ -13,9 +13,9 @@ use tokio::fs::File;
use tokio::process::Command as AsyncCommand;
use tracing::{info, warn};
pub fn default_cold_chain() -> Vec<StageConfig> {
pub fn default_cold_chain() -> Vec<ChainStep> {
vec![
StageConfig {
ChainStep {
label: "lte".to_string(),
lte: "T".to_string(),
ltgray: "T".to_string(),
@@ -30,7 +30,7 @@ pub fn default_cold_chain() -> Vec<StageConfig> {
iacc: None,
orelax: None,
},
StageConfig {
ChainStep {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -45,7 +45,7 @@ pub fn default_cold_chain() -> Vec<StageConfig> {
iacc: None,
orelax: None,
},
StageConfig {
ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -63,9 +63,9 @@ pub fn default_cold_chain() -> Vec<StageConfig> {
]
}
pub fn default_seed_chain() -> Vec<StageConfig> {
pub fn default_seed_chain() -> Vec<ChainStep> {
vec![
StageConfig {
ChainStep {
label: "seed_nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -80,7 +80,7 @@ pub fn default_seed_chain() -> Vec<StageConfig> {
iacc: None,
orelax: None,
},
StageConfig {
ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -98,6 +98,19 @@ pub fn default_seed_chain() -> Vec<StageConfig> {
]
}
/// 按当前策略选默认执行链(`custom_chain` 为 None/空时的兜底)。
///
/// Phase 6P8)起取代废弃的 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)感知。
///
/// 三种终止路径:
@@ -199,19 +212,21 @@ impl<'a> ExecutionRunner<'a> {
Self { runtime, work_dir }
}
#[allow(clippy::too_many_arguments)] // 透传全参给 run_model_with_timeout(后者同 allow
pub async fn run_model(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
current_strategy: &str,
custom_chain: Option<Vec<ChainStep>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
synspec_cfg: Option<&SynspecInput>,
tlusty_input: Option<&TlustyInput>,
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
name,
task_type,
current_strategy,
custom_chain,
seed_atmos,
synspec_cfg,
@@ -219,6 +234,7 @@ impl<'a> ExecutionRunner<'a> {
true,
7200,
None,
tlusty_input,
)
.await
}
@@ -228,19 +244,24 @@ impl<'a> ExecutionRunner<'a> {
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
/// - TLUSTY 关闭:跳过 chain 循环,直接以 seed_atmos(或单独拉取的大气)作 final_7;
/// - SYNSPEC 关闭:跳过光谱合成块(即便 final_7 存在)。
///
/// Phase 6P8):`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,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
current_strategy: &str,
custom_chain: Option<Vec<ChainStep>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
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_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
@@ -255,7 +276,7 @@ impl<'a> ExecutionRunner<'a> {
let model_dir = self.work_dir.join(name);
tokio::fs::create_dir_all(&model_dir).await?;
info!("开始物理计算网格模型 {} (类型: {:?})", name, task_type);
info!("开始物理计算网格模型 {} (策略: {})", name, current_strategy);
let t0 = Instant::now();
// 1. Data directory symlink setup
@@ -296,10 +317,7 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::remove_file(&fort84).await;
}
let chain = custom_chain.unwrap_or_else(|| match task_type {
TaskType::ColdRun => default_cold_chain(),
TaskType::SeedStep => default_seed_chain(),
});
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());
@@ -329,13 +347,14 @@ impl<'a> ExecutionRunner<'a> {
&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);
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
@@ -386,7 +405,7 @@ impl<'a> ExecutionRunner<'a> {
let fort9 = model_dir.join("fort.9");
let fort7 = model_dir.join("fort.7");
let mut stage_summary = StageSummary {
let mut stage_summary = StepSummary {
label: stage_def.label.clone(),
chmax: stage_def.chmax,
lte: stage_def.lte.clone(),
@@ -397,6 +416,7 @@ impl<'a> ExecutionRunner<'a> {
last_iter: None,
worst_depth: None,
n_depths: None,
itek_history: Vec::new(),
};
if rc == 0 && fort7.is_file() {
@@ -410,15 +430,50 @@ impl<'a> ExecutionRunner<'a> {
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 {
// NITER=0 grey start without fort.9
stage_summary.converged = true;
stage_summary.best_max_relc = Some(0.0);
stage_summary.note = Some("NITER=0 grey start".to_string());
// fort.9 缺失:按 stage_def.niter 区分两种场景(漏洞2进阶修复)。
// - niter==0:合法 grey startlte 阶段不迭代,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
@@ -426,7 +481,14 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::copy(&fort7, &stage_seed_path).await;
current_seed = Some(stage_seed_path);
} else {
stage_summary.note = Some(format!("tlusty rc={} or missing fort.7", rc));
// 漏洞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),
});
}
// 快照本阶段的同名输入/输出文件,带阶段标签保留。
@@ -485,7 +547,15 @@ impl<'a> ExecutionRunner<'a> {
// fort.12/14 不存在,函数内按文件是否存在静默跳过。
snapshot_tlusty_outputs(&model_dir, name).await;
let atmo_has_nan = atmosphere_has_nan(&final_7);
// 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;
}
@@ -518,7 +588,7 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::remove_file(&fort55_path).await;
let _ = tokio::fs::remove_file(&fort19_path).await;
let default_cfg = SynspecConfig {
let default_cfg = SynspecInput {
wstart: 1400.0,
wend: 1410.0,
imode: 0,
@@ -577,11 +647,22 @@ impl<'a> ExecutionRunner<'a> {
// Copy/move outputs: fort.7 (Synspec spectrum) -> .spec, fort.17 -> .cont, fort.12 -> .iden
if model_dir.join("fort.7").is_file() {
let _ = tokio::fs::rename(
model_dir.join("fort.7"),
model_dir.join(format!("{}.spec", name)),
)
.await;
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(
@@ -642,7 +723,7 @@ impl<'a> ExecutionRunner<'a> {
let note = {
let mut notes: Vec<String> = Vec::new();
if atmo_has_nan {
notes.push("Invalidated: atmosphere contains >10% NaN lines".to_string());
notes.push("Invalidated: atmosphere contains NaN/Inf lines".to_string());
}
if let Some(ref err) = synspec_err {
notes.push(format!("synspec error: {}", err));
@@ -662,7 +743,7 @@ impl<'a> ExecutionRunner<'a> {
name: name.to_string(),
params: params.clone(),
stages: stage_summaries,
converged: final_converged,
result_valid: final_converged,
final_max_relc,
final_chmax,
seed: seed_atmos.map(|p| p.to_string_lossy().to_string()),
@@ -687,6 +768,29 @@ 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;
+1 -1
View File
@@ -15,7 +15,7 @@ pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
/// **数据标定依据**——对历史 1191 个真实 seed_step(种子,目标)配对的成败统计:
/// - 贫金属方向(种子更富、目标往贫走,delta=目标−种子 < 0):成功率 **4254%**
/// - 富金属方向(目标更富、delta > 0):成功率仅 **311%**
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
///
/// **物理解释**:从高金属丰度的收敛解出发**减少**金属(贫方向)是稳定微扰;
/// 反过来从贫金属种子**增加**金属(富方向),新增的紫外谱线辐射驱动会破坏已建立的