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:
+167
-33
@@ -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,必须 > 0(conv_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 为 true(NaN 比较恒 false),
|
||||
// 应拒绝 NaN;改写为 `chmax <= 0.0` 会漏掉 NaN(NaN<=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=2,Li/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:连续频率网格点数。
|
||||
/// 取值 >0 → 从预设频率表读取(高精度、慢);≤0 → 由 frmin/frmax 自动生成对数网格(快)。
|
||||
/// 默认 2000(gen_input5.rs 原硬编码值)。
|
||||
#[serde(default = "default_nfread")]
|
||||
pub nfread: i32,
|
||||
|
||||
/// atoms 块特定元素的 mode/modpf 覆盖。
|
||||
/// key = 元素符号(`"H"`/`"He"`/`"C"`/`"N"`/`"O"`/`"Li"`/`"Be"`/`"B"`)。
|
||||
/// 缺省的元素走代码默认 mode(H/He/CNO=2,Li/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_params,executor 反序列化后透传给 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_params,executor 反序列化后
|
||||
/// 透传给 runner → make_input5 / generate_nst_content。
|
||||
#[serde(default)]
|
||||
pub tlusty_input: Option<TlustyInput>,
|
||||
/// SYNSPEC 输入文件(fort.55)的数值参数。与 `tlusty_input` 语义对称。
|
||||
/// 由 scheduler 序列化进 TaskSpec.synspec_params,executor 反序列化后透传给 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/result(Phase 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())
|
||||
|
||||
@@ -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` 光谱文件内容是否有效。
|
||||
///
|
||||
/// # 背景(漏洞 1,P0)
|
||||
/// 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.6(TLUSTY stdout 日志)提取失败诊断提示。
|
||||
///
|
||||
/// # 背景(漏洞 5,P2)
|
||||
/// 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-2(depth2)、iter2 最深 max_relc=5.0E-4(depth1)。
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 block:H/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(¶ms, "F", "F", "cno", 100);
|
||||
let input5 = make_input5(¶ms, "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(¶ms, "T", "T", "", 100);
|
||||
let input5 = make_input5(¶ms, "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=2,Li/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(¶ms, "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(¶ms, "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(¶ms, "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
@@ -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-only(TLUSTY 关闭)时 = 光谱输入大气来源点(由 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-only(TLUSTY 关闭)被重写为管线成功。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-only(TLUSTY 关闭)被 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 6(P8)删除 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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/IELCOR(input_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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 6(P8)起取代废弃的 task_type 匹配:`"seed_step"` → 种子热启动链,其余策略
|
||||
/// (`cold_run` 等)→ 冷启动链。executor 现优先使用 TaskSpec.tlusty_chain_params
|
||||
/// (用户 YAML `tlusty_chain:` 配置),None/空才回退本函数的默认链。
|
||||
pub fn default_chain_for_strategy(current_strategy: &str) -> Vec<ChainStep> {
|
||||
if current_strategy == "seed_step" {
|
||||
default_seed_chain()
|
||||
} else {
|
||||
default_cold_chain()
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行子进程,带超时与优雅退出(shutdown)感知。
|
||||
///
|
||||
/// 三种终止路径:
|
||||
@@ -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 6(P8):`current_strategy` 取代废弃的 `task_type`——执行链由
|
||||
/// `custom_chain`(executor 按 `tlusty_config.strategies[0]` 显式推导)决定;
|
||||
/// 该参数仅用于日志与 custom_chain=None 时的兜底("seed_step"→种子链,否则冷启动链)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_model_with_timeout(
|
||||
&self,
|
||||
params: &GridPointParams,
|
||||
name: &str,
|
||||
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_name(DB 的 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 start(lte 阶段不迭代,TLUSTY 不写 fort.9)。
|
||||
// converged=true 保留 grey start 语义;best_max_relc=None 不虚构
|
||||
// (避免污染 final_max_relc/种子选择)。
|
||||
// - niter>0:异常——配了迭代却无 fort.9,通常是 TLUSTY 启动失败
|
||||
// (call quit,如 temp 越界)或 IO 异常。判 converged=false,
|
||||
// 避免把崩溃误判为收敛。此前两种场景共用无校验分支无法区分。
|
||||
if stage_def.niter == 0 {
|
||||
stage_summary.converged = true;
|
||||
stage_summary.best_max_relc = None;
|
||||
stage_summary.note = Some("NITER=0 grey start".to_string());
|
||||
} else {
|
||||
stage_summary.converged = false;
|
||||
stage_summary.best_max_relc = None;
|
||||
// 补 fort.6 失败诊断(call quit 留言),便于排查启动失败原因。
|
||||
let fort6 = model_dir.join(format!("{}.6", name));
|
||||
let hint = extract_failure_hint(&fort6);
|
||||
stage_summary.note = Some(match hint {
|
||||
Some(h) => format!(
|
||||
"stage {} 配置 NITER={} 但 fort.9 缺失 [{}]",
|
||||
stage_def.label, stage_def.niter, h
|
||||
),
|
||||
None => format!(
|
||||
"stage {} 配置 NITER={} 但 fort.9 缺失(TLUSTY 未完成迭代)",
|
||||
stage_def.label, stage_def.niter
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Copy fort.7 as stage seed
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@ pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
|
||||
/// **数据标定依据**——对历史 1191 个真实 seed_step(种子,目标)配对的成败统计:
|
||||
/// - 贫金属方向(种子更富、目标往贫走,delta=目标−种子 < 0):成功率 **42–54%**
|
||||
/// - 富金属方向(目标更富、delta > 0):成功率仅 **3–11%**
|
||||
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
|
||||
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
|
||||
///
|
||||
/// **物理解释**:从高金属丰度的收敛解出发**减少**金属(贫方向)是稳定微扰;
|
||||
/// 反过来从贫金属种子**增加**金属(富方向),新增的紫外谱线辐射驱动会破坏已建立的
|
||||
|
||||
Reference in New Issue
Block a user