feat(all): 源精度命名体系、工作流可观测台、节点停用管理与白名单归档
核心变更:
1. GridAxisValue 源精度命名
- 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术)
- config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0)
- runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name,
修复 REAL 列回读丢精度导致的 model_name 错配
2. 工作流执行可观测台
- 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、
停滞预警、逐点明细分页、收敛性热力图数据)
- 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列
- runner 携带 last_iter/worst_depth/n_depths 进 conv.json
- 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器
3. 节点停用/启用管理
- 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发,
worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖;
移除 host_name 字段
4. 白名单结果归档
- 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型)
- executor 原子写入归档 + 200 点 LRU 上限
5. 历史数据导入
- sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged +
按新版命名迁移产物树
6. 部署与目录重规划
- data/results→seeds、data/archive→result + migrate_data_dirs.sh
- deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身
7. 文档同步更新 api/database/architecture/deployment
This commit is contained in:
@@ -0,0 +1,958 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use common::config::GridConfig;
|
||||
use common::conv_check::atmosphere_has_nan;
|
||||
use common::models::{GridPointParams, ModelSummary};
|
||||
use common::result_filter::is_result_worthy;
|
||||
use regex::Regex;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// DCTS 历史计算结果导入工具。
|
||||
///
|
||||
/// 双职责:
|
||||
/// 1. **入库 + 种子库**:经 `/api/admin/import_seed` 把旧版 `run_grid.py` 的收敛结果
|
||||
/// (`conv.json` + `.7` 大气)上传服务端,标记 grid_points 为 converged,避免重算。
|
||||
/// 2. **完整产物树迁移**:把旧目录的全部产物按**新版 node result 格式 + workflow 配置
|
||||
/// 的小数精度命名**,拷贝到本地 `data/result/<name>/`(与 node 的 `DCTS_RESULT_DIR`
|
||||
/// 同卷,docker-compose 里 server/node 都挂载 `./data:/app/data`)。
|
||||
///
|
||||
/// 命名以 **workflow 配置为准**(`GridConfig::from_yaml_str` 保留源小数,如 `logg: 5.0`
|
||||
/// → `g5.0`),而非旧 conv.json 里的 name —— 配置是当前项目权威。
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
author,
|
||||
version,
|
||||
about = "DCTS 历史计算结果导入工具(旧版 run_grid.py 产物 → 标记为已完成 + 迁移产物树)"
|
||||
)]
|
||||
struct Args {
|
||||
/// 存放历史计算结果的目录路径(旧版 run_grid.py 的 results 根目录)
|
||||
#[arg(short, long, default_value = "data/results")]
|
||||
dir: PathBuf,
|
||||
|
||||
/// workflow 配置文件路径(YAML)。用于推导权威命名(保留小数精度)+ 校验网格点。
|
||||
/// 命名以配置为准,而非旧 conv.json 的 name。
|
||||
#[arg(short, long)]
|
||||
config: PathBuf,
|
||||
|
||||
/// 产物树写入目标目录(默认: data/result,与 node 的 DCTS_RESULT_DIR 同卷)。
|
||||
/// 旧长名 --archive-dir 作为别名保留(仅长选项,无 -a 短选项),向后兼容。
|
||||
#[arg(
|
||||
short = 'r',
|
||||
long = "result-dir",
|
||||
visible_alias = "archive-dir",
|
||||
default_value = "data/result"
|
||||
)]
|
||||
result_dir: PathBuf,
|
||||
|
||||
/// Master 服务端 API 地址(默认: http://127.0.0.1:8090)。
|
||||
/// 若服务端未运行或不想入库,可指向不可达地址,工具会继续完成本地产物树迁移。
|
||||
#[arg(short, long, default_value = "http://127.0.0.1:8090")]
|
||||
server: String,
|
||||
|
||||
/// 目标工作流名称:grid_points 导入到该工作流分区。
|
||||
/// **必须等于将来要启动的工作流名**,否则导入的 converged 标记会被工作流强隔离
|
||||
/// 看不到,导致重算(grid_points 唯一键是复合 (workflow_name, name))。
|
||||
#[arg(short, long)]
|
||||
workflow: String,
|
||||
|
||||
/// 服务端 Admin 鉴权令牌(/api/admin/import_seed 需 Admin 角色)。
|
||||
#[arg(short, long)]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// 一个经校验的待导入历史结果点。
|
||||
#[derive(Debug)]
|
||||
struct CandidateResult {
|
||||
/// 旧 conv.json 中的 name(旧 Python 命名,仅用于定位旧目录文件)。
|
||||
legacy_name: String,
|
||||
/// 旧版 conv.json 原文:服务端解析出 params / 收敛状态 / final_max_relc。
|
||||
summary_json: String,
|
||||
/// 对应的 .7 大气种子文件路径。
|
||||
seed_path: PathBuf,
|
||||
/// 旧结果所在子目录(产物树迁移的源)。
|
||||
src_dir: PathBuf,
|
||||
/// 权威新名(来自 workflow 配置的小数精度命名)。
|
||||
canonical_name: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let args = Args::parse();
|
||||
info!("=== DCTS 历史计算结果导入工具启动 ===");
|
||||
info!("扫描结果目录: {}", args.dir.display());
|
||||
info!("workflow 配置: {}", args.config.display());
|
||||
info!("产物树写入: {}", args.result_dir.display());
|
||||
info!("目标工作流: {}", args.workflow);
|
||||
info!("目标服务端: {}", args.server);
|
||||
|
||||
if !args.dir.is_dir() {
|
||||
anyhow::bail!("指定的结果目录不存在或不是文件夹: {}", args.dir.display());
|
||||
}
|
||||
|
||||
// 1. 解析 workflow 配置,构建「旧名 → 权威新名」映射(保留小数精度)。
|
||||
let config_yaml = std::fs::read_to_string(&args.config)
|
||||
.with_context(|| format!("读取 workflow 配置失败: {}", args.config.display()))?;
|
||||
let grid_cfg = GridConfig::from_yaml_str(&config_yaml)
|
||||
.with_context(|| format!("解析 workflow 配置失败: {}", args.config.display()))?;
|
||||
let name_map = build_canonical_name_map(&grid_cfg);
|
||||
info!(
|
||||
"workflow 配置含 {} 个网格点,命名保留源小数精度",
|
||||
name_map.len()
|
||||
);
|
||||
// 收敛链最后阶段标签:用于把旧版「每阶段覆盖、只留最后阶段」的 .5/.6 归到正确标签。
|
||||
// 从配置 chain 取末段;chain 为空则回退到默认收敛链的末段(都是 nl)。
|
||||
let last_stage_label = derive_last_stage_label(&grid_cfg);
|
||||
info!(
|
||||
"收敛链最后阶段标签: {}(用于 .5/.6 归档)",
|
||||
last_stage_label
|
||||
);
|
||||
|
||||
// 2. 扫描旧版结果目录,匹配到配置里的网格点。
|
||||
let candidates = scan_dir_for_results(&args.dir, &name_map).await?;
|
||||
info!(
|
||||
"扫描完成,共找到 {} 个经校验无 NaN 且物理收敛、且命中配置网格点的合格结果!",
|
||||
candidates.len()
|
||||
);
|
||||
|
||||
if candidates.is_empty() {
|
||||
info!("未查找到符合条件的合格结果。");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. 先迁移本地完整产物树(新版 result 格式 + 配置命名)。
|
||||
info!("=== 启动本地产物树迁移(→ result 格式)===");
|
||||
let mut migrated = 0usize;
|
||||
for c in &candidates {
|
||||
migrate_result_tree(
|
||||
&args.result_dir,
|
||||
&c.src_dir,
|
||||
&c.legacy_name,
|
||||
&c.canonical_name,
|
||||
&last_stage_label,
|
||||
&c.summary_json,
|
||||
)
|
||||
.await;
|
||||
migrated += 1;
|
||||
}
|
||||
info!("产物树迁移完成:{}/{}", migrated, candidates.len());
|
||||
|
||||
// 4. 经 /api/admin/import_seed 批量入库(标记 converged,避免重算)。
|
||||
info!("=== 启动 HTTP 结果入库(→ grid_points=converged)===");
|
||||
let ingested = upload_results_to_server(
|
||||
&args.server,
|
||||
&args.workflow,
|
||||
&candidates,
|
||||
args.token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 软一致性提示:result 树已迁移但 DB 入库失败时,提示重跑(DB 幂等,重跑会补齐)。
|
||||
if ingested < migrated {
|
||||
warn!(
|
||||
"产物树已迁移 {} 个,但仅 {} 个成功入库 DB。未入库的 {} 个点 result 已有文件但 grid_points 未标记 converged,\
|
||||
请检查服务端/网络后重跑本工具(DB 幂等,重跑只补未入库点,不会重复)",
|
||||
migrated, ingested, migrated - ingested
|
||||
);
|
||||
}
|
||||
info!("=== 导入全量完成(迁移 {migrated},入库 {ingested})===");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 workflow 配置展开全部网格点,构建「参数数值指纹 → 权威名」映射。
|
||||
///
|
||||
/// 权威名取自 `GridPointParams::model_name()`,该方法拼接各轴的 `.text()`(源精度原文),
|
||||
/// 故 `logg: 5.0` → `g5.0`。键用 6 个数值四舍五入到 1e-6 后拼成的字符串指纹(f64 不实现
|
||||
/// Eq/Hash,故转字符串),以便和旧 conv.json 里的数值 params 匹配(旧 params 无源文本,
|
||||
/// 只能比数值)。
|
||||
fn build_canonical_name_map(cfg: &GridConfig) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
for teff in &cfg.grid.teff {
|
||||
for logg in &cfg.grid.logg {
|
||||
for loghe in &cfg.grid.loghe {
|
||||
for logc in &cfg.grid.logc {
|
||||
for logn in &cfg.grid.logn {
|
||||
for logo in &cfg.grid.logo {
|
||||
let p = GridPointParams {
|
||||
teff: teff.clone(),
|
||||
logg: logg.clone(),
|
||||
loghe: loghe.clone(),
|
||||
logc: logc.clone(),
|
||||
logn: logn.clone(),
|
||||
logo: logo.clone(),
|
||||
};
|
||||
let key = param_fingerprint(&[
|
||||
teff.value(),
|
||||
logg.value(),
|
||||
loghe.value(),
|
||||
logc.value(),
|
||||
logn.value(),
|
||||
logo.value(),
|
||||
]);
|
||||
map.insert(key, p.model_name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// 把 6 个 f64 四舍五入到 1e-6,拼成字符串指纹作为 map 键(消除浮点尾差,绕过 f64 无 Hash)。
|
||||
fn param_fingerprint(k: &[f64; 6]) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for v in k {
|
||||
let mut r = (v * 1e6).round() / 1e6;
|
||||
// 归一化 -0.0 → 0.0:否则 format!("{}", -0.0)="-0" 与 "0" 不匹配,导致静默跳点。
|
||||
if r == 0.0 {
|
||||
r = 0.0;
|
||||
}
|
||||
s.push_str(&format!("{}|", r));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 从 workflow 配置派生收敛链最后阶段标签。
|
||||
///
|
||||
/// 旧版 run_one.py 每阶段覆盖同名 .5/.6/.err/nst,只留最后阶段的版本。迁移时需把这些
|
||||
/// 裸文件归到正确的阶段标签下。标签取自配置 chain 的末段;chain 为空则回退到默认收敛链
|
||||
/// 的末段(default_cold_chain 末段是 nl)。
|
||||
fn derive_last_stage_label(cfg: &GridConfig) -> String {
|
||||
if let Some(last) = cfg.chain.last() {
|
||||
return last.label.clone();
|
||||
}
|
||||
// chain 为空(仅 grid,无 chain 配置):回退到默认收敛链末段。
|
||||
common::runner::default_cold_chain()
|
||||
.last()
|
||||
.map(|s| s.label.clone())
|
||||
.unwrap_or_else(|| "nl".to_string())
|
||||
}
|
||||
|
||||
/// 扫描旧版结果目录,匹配到 workflow 配置里的网格点。
|
||||
///
|
||||
/// 跳过 `.OLD`/`.FAILED`/`.coldfail` 备份目录;要求 conv.json 存在且
|
||||
/// `converged && !atmosphere_has_nan`,并对 .7 文件二次 NaN 校验。
|
||||
/// 仅保留能匹配到配置网格点(按 params 数值)的结果 —— 配置外的点不入库。
|
||||
async fn scan_dir_for_results(
|
||||
dir_path: &Path,
|
||||
name_map: &HashMap<String, String>,
|
||||
) -> Result<Vec<CandidateResult>> {
|
||||
let mut results = Vec::new();
|
||||
let entries = std::fs::read_dir(dir_path)?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.')
|
||||
|| name.contains(".OLD")
|
||||
|| name.contains(".FAILED")
|
||||
|| name.contains(".coldfail")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let sub_path = entry.path();
|
||||
if !sub_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let conv_json = sub_path.join("conv.json");
|
||||
// .7 候选:旧版 run_one.py 写 <name>.7;也兼容 fort.7 / 阶段快照。
|
||||
let candidates_7 = [
|
||||
sub_path.join(format!("{}.7", name)),
|
||||
sub_path.join(format!("{}.nl.7", name)),
|
||||
sub_path.join(format!("{}.nc.7", name)),
|
||||
sub_path.join("fort.7"),
|
||||
];
|
||||
let atmo_7 = candidates_7.into_iter().find(|p| p.is_file());
|
||||
|
||||
if !conv_json.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(seed_file) = atmo_7 else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&conv_json) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("目录 {} 的 conv.json 读取失败,跳过: {}", name, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let summary = match serde_json::from_str::<ModelSummary>(&content) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"目录 {} 的 conv.json 解析失败(非合法 ModelSummary),跳过: {}",
|
||||
name, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !(summary.converged && !summary.atmosphere_has_nan && !atmosphere_has_nan(&seed_file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 诊断:目录名应与 conv.json.name 一致(旧 run_one.py 用同一 model_name 命名两者)。
|
||||
// 若不一致,map_filename 用 summary.name 做前缀匹配会找不到以目录名命名的文件,导致迁移丢文件。
|
||||
if name != summary.name {
|
||||
warn!(
|
||||
"目录名 {} 与 conv.json.name ({}) 不一致:迁移时以 conv.json.name 为准定位文件,\
|
||||
若旧目录里的产物文件用目录名命名会无法映射而跳过",
|
||||
name, summary.name
|
||||
);
|
||||
}
|
||||
|
||||
// 用旧 conv.json 的数值 params 匹配配置网格点,拿到权威新名。
|
||||
let key = param_fingerprint(&[
|
||||
*summary.params.teff,
|
||||
*summary.params.logg,
|
||||
*summary.params.loghe,
|
||||
*summary.params.logc,
|
||||
*summary.params.logn,
|
||||
*summary.params.logo,
|
||||
]);
|
||||
let Some(canonical_name) = name_map.get(&key) else {
|
||||
warn!(
|
||||
"网格点 {} 的参数不在 workflow 配置中,跳过(teff={} logg={} ...)",
|
||||
summary.name, *summary.params.teff, *summary.params.logg,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
results.push(CandidateResult {
|
||||
legacy_name: summary.name.clone(),
|
||||
summary_json: content,
|
||||
seed_path: seed_file,
|
||||
src_dir: sub_path,
|
||||
canonical_name: canonical_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 把旧版结果目录的产物按**新版 node result 格式**拷贝到 `result_dir/<new_name>/`。
|
||||
///
|
||||
/// 文件名映射规则(旧 → 新):
|
||||
/// - `conv.json` → `conv.json`(**内容重写 name 为 canonical_name**,与目录名一致)
|
||||
/// - `<old>.7/.spec/.cont/.iden/.log` → `<new>.7/.spec/.cont/.iden/.log`(前缀重命名)
|
||||
/// - `<old>.<label>.7`(lte/nc/nl 阶段大气快照)→ `<new>.<label>.7`(前缀重命名)
|
||||
/// - `<old>.<label>_chmaxX_itekY.9` → `<new>.<label>_chmaxX.9`(正则重命名:
|
||||
/// 去 itek 段;旧版 `stagename.replace(".","p")` 里的 `p` 还原为 `.`)
|
||||
/// - `<old>.5/.6`(旧版每阶段覆盖、只留最后阶段)→ `<new>.<last_stage>.5/.6`
|
||||
/// (补最后阶段标签,从配置 chain 派生)
|
||||
/// - `fort.8`(synspec 输入大气)→ `fort.8`(直接拷,保留独立语义)
|
||||
///
|
||||
/// 跳过:软链(data/fort.19)、fort.84、*.tmp、以及新版已清理的裸文件
|
||||
/// (nst/fort.9/fort.12/fort.17 —— 其内容已被 .iden/.cont 覆盖)。
|
||||
///
|
||||
/// 原子写入(tmp→rename),IO 错误降级 warn 不阻断。
|
||||
async fn migrate_result_tree(
|
||||
result_dir: &Path,
|
||||
src_dir: &Path,
|
||||
legacy_name: &str,
|
||||
canonical_name: &str,
|
||||
last_stage_label: &str,
|
||||
summary_json: &str,
|
||||
) {
|
||||
let dest_dir = result_dir.join(canonical_name);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dest_dir).await {
|
||||
warn!(
|
||||
"产物树迁移 {} 失败:创建归档目录 {} 失败: {}",
|
||||
canonical_name,
|
||||
dest_dir.display(),
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rd = match tokio::fs::read_dir(src_dir).await {
|
||||
Ok(rd) => rd,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"产物树迁移 {} 失败:读取源目录 {} 失败: {}",
|
||||
canonical_name,
|
||||
src_dir.display(),
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 匹配旧版 .9 收敛快照:<rest>_chmax<chmax_raw>_itek<itek>.9
|
||||
// 旧版 stagename = "{label}_chmax{eff_chmax}_itek{it}",再 .replace(".","p"),
|
||||
// 故 eff_chmax 的小数点变 p(如 0.001→0p001),但科学计数法(1e-05)无小数点则原样保留。
|
||||
// 用宽泛的 [^_]+ 捕获 chmax 段(含 p 还原与科学计数法两种形态),重写时把 p 还原为 .。
|
||||
let re_chmax9 =
|
||||
Regex::new(r"(?P<rest>.+?)_chmax(?P<chmax>[^_]+)_itek[^.]*\.9$").expect("正则编译失败");
|
||||
|
||||
let mut copied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let path = entry.path();
|
||||
let Some(fname) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// 跳过软链(data/fort.19 指向共享 runtime 资源,归档无意义)。
|
||||
if tokio::fs::symlink_metadata(&path)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 推导新文件名(旧格式 → 新格式)。
|
||||
let new_fname = map_filename(
|
||||
fname,
|
||||
legacy_name,
|
||||
canonical_name,
|
||||
&re_chmax9,
|
||||
last_stage_label,
|
||||
);
|
||||
let Some(new_fname) = new_fname else {
|
||||
warn!(
|
||||
"产物树迁移 {}:跳过无法映射的文件: {}",
|
||||
canonical_name, fname
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
// 白名单复核(与 node 实时归档口径一致):映射后的文件名必须落在归档白名单内。
|
||||
// 这会统一过滤 .tmp/fort.84/nst/fort.9/12/17(新版已清理的裸文件)、以及所有
|
||||
// Tlusty 中间单元(fort.1/13/18/22 等)、无 _chmax 的重复 .9 快照。旧 Python
|
||||
// 目录通常无这些中间单元,但白名单兜底保证与 node 归档产物树逐文件一致。
|
||||
if !is_result_worthy(&new_fname, canonical_name) {
|
||||
warn!(
|
||||
"产物树迁移 {}:跳过非白名单文件: {} → {}",
|
||||
canonical_name, fname, new_fname
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let dest_path = dest_dir.join(&new_fname);
|
||||
// 原子写入:先拷到 .migrate.tmp.<uuid> 再 rename。
|
||||
let tmp_path = dest_dir.join(format!(
|
||||
"{}.migrate.tmp.{}",
|
||||
new_fname,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
|
||||
// conv.json 特殊处理:内容里的 name 重写为 canonical_name(与目录名一致)。
|
||||
let write_result = if fname == "conv.json" {
|
||||
match rewrite_summary_name(summary_json, canonical_name) {
|
||||
Ok(rewritten) => tokio::fs::write(&tmp_path, rewritten).await,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"产物树迁移 {}:重写 conv.json name 失败,原样拷贝: {}",
|
||||
canonical_name, e
|
||||
);
|
||||
tokio::fs::copy(&path, &tmp_path).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokio::fs::copy(&path, &tmp_path).await.map(|_| ())
|
||||
};
|
||||
|
||||
match write_result {
|
||||
Ok(_) => {
|
||||
if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
warn!(
|
||||
"产物树迁移 {}:文件 {} rename 失败: {}",
|
||||
canonical_name, new_fname, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
copied += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
warn!("产物树迁移 {}:拷贝 {} 失败: {}", canonical_name, fname, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"产物树迁移 {}:拷贝 {} 个文件,跳过 {} 个",
|
||||
canonical_name, copied, skipped
|
||||
);
|
||||
}
|
||||
|
||||
/// 把旧文件名映射为新版 result 文件名。返回 None 表示该文件不迁移。
|
||||
///
|
||||
/// 规则(按优先级):
|
||||
/// 1. `conv.json` / `fort.8` → 原名保留(conv.json 的内容重写在 migrate_result_tree 里做)。
|
||||
/// 2. 旧版 .9 收敛快照 `<old>.<label>_chmaxX_itekY.9` → `<new>.<label>_chmaxX.9`
|
||||
/// (X 里的 `p` 还原为 `.`;去 itek 段)。
|
||||
/// 3. `<old>.5` / `<old>.6`(旧版只留最后阶段)→ `<new>.<last_stage>.5` / `.<last_stage>.6`。
|
||||
/// 4. `<old>.<rest>` → `<new>.<rest>`(前缀重命名,覆盖 .7/.spec/.cont/.iden/.log/.lte.7 等)。
|
||||
/// 5. 其它(不以 legacy_name 开头的非保留名)→ None。
|
||||
fn map_filename(
|
||||
fname: &str,
|
||||
legacy_name: &str,
|
||||
canonical_name: &str,
|
||||
re_chmax9: &Regex,
|
||||
last_stage: &str,
|
||||
) -> Option<String> {
|
||||
// 1. 保留名(无前缀,直接保留)。
|
||||
if fname == "conv.json" || fname == "fort.8" {
|
||||
return Some(fname.to_string());
|
||||
}
|
||||
|
||||
// 2. 旧版 .9 收敛快照重命名(优先于通用前缀规则,因为正则更具体)。
|
||||
// rest 形如 "<old>.<label>",chmax 段是旧版 stagename.replace(".","p") 后的形态。
|
||||
if let Some(caps) = re_chmax9.captures(fname) {
|
||||
let rest = caps.name("rest").unwrap().as_str();
|
||||
let chmax_raw = caps.name("chmax").unwrap().as_str();
|
||||
// rest 前缀换成 canonical_name。
|
||||
let new_rest = replace_legacy_prefix(rest, legacy_name, canonical_name);
|
||||
// 若 rest 不以 legacy_name 开头(异常),跳过。
|
||||
if new_rest == rest && !rest.starts_with(canonical_name) {
|
||||
return None;
|
||||
}
|
||||
// chmax 段:旧版把小数点替换成了 p(0.001→0p001),这里还原。
|
||||
// 科学计数法(1e-05)无小数点则不含 p,原样保留。
|
||||
let chmax_restored = chmax_raw.replace('p', ".");
|
||||
return Some(format!("{}_chmax{}.9", new_rest, chmax_restored));
|
||||
}
|
||||
|
||||
// 3. 旧版只留最后阶段的 .5/.6(裸文件,无阶段标签)→ 补最后阶段标签。
|
||||
if fname == format!("{}.5", legacy_name) {
|
||||
return Some(format!("{}.{}.5", canonical_name, last_stage));
|
||||
}
|
||||
if fname == format!("{}.6", legacy_name) {
|
||||
return Some(format!("{}.{}.6", canonical_name, last_stage));
|
||||
}
|
||||
|
||||
// 4. 通用前缀重命名:<old>.<rest> → <new>.<rest>。
|
||||
let prefix = format!("{}.", legacy_name);
|
||||
if let Some(rest) = fname.strip_prefix(&prefix) {
|
||||
return Some(format!("{}.{}", canonical_name, rest));
|
||||
}
|
||||
|
||||
// 5. 其它文件(不以 legacy_name 开头)→ 不迁移。
|
||||
None
|
||||
}
|
||||
|
||||
/// 若 s 形如 "<legacy>.<suffix>",把前缀换成 canonical;否则原样返回。
|
||||
fn replace_legacy_prefix(s: &str, legacy: &str, canonical: &str) -> String {
|
||||
let p = format!("{}.", legacy);
|
||||
if let Some(rest) = s.strip_prefix(&p) {
|
||||
format!("{}.{}", canonical, rest)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 经 `/api/admin/import_seed` 逐个上传历史结果入库。
|
||||
///
|
||||
/// **关键**:上传前把 conv.json 里的 `name` 字段重写为配置权威名(canonical_name)。
|
||||
/// 原因:服务端 `import_seed` 用 `summary.name` 落库(task.rs:306),而调度器
|
||||
/// `initialize_grid` 用配置 `model_name()` 插入新行 —— 两者必须逐字符一致,否则
|
||||
/// 复合唯一键 `(workflow_name, name)` 不匹配,导入的 converged 行会被孤立、该点被重算。
|
||||
/// 旧版 conv.json 的 name 可能与配置精度不一致(如旧 `g5` vs 配置 `g5.0`),故必须重写。
|
||||
/// 返回成功入库的点数(供调用方与已迁移的 result 点数比对,发现软不一致)。
|
||||
async fn upload_results_to_server(
|
||||
server_url: &str,
|
||||
workflow: &str,
|
||||
candidates: &[CandidateResult],
|
||||
token: Option<&str>,
|
||||
) -> Result<usize> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()?;
|
||||
// 注意路径必须带 /api 前缀:服务端把 report_router(含 import_seed)合并进 api_router
|
||||
// 后统一 nest 在 /api 下(见 crates/server/src/main.rs)。
|
||||
let import_url = format!(
|
||||
"{}/api/admin/import_seed?workflow={}",
|
||||
server_url.trim_end_matches('/'),
|
||||
urlencoding(workflow)
|
||||
);
|
||||
|
||||
let total = candidates.len();
|
||||
let mut success_count = 0;
|
||||
|
||||
for (idx, c) in candidates.iter().enumerate() {
|
||||
info!(
|
||||
"正在入库 [{}/{}] 网格点: {} (旧名: {}, 路径: {})...",
|
||||
idx + 1,
|
||||
total,
|
||||
c.canonical_name,
|
||||
c.legacy_name,
|
||||
c.seed_path.display()
|
||||
);
|
||||
|
||||
let seed_bytes = match tokio::fs::read(&c.seed_path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("读取 .7 大气文件失败,跳过入库: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// report 段:重写 name 为配置权威名后发送。服务端用此 name 落库 grid_points,
|
||||
// 必须与调度器 initialize_grid 的 model_name() 一致,否则复合唯一键不匹配。
|
||||
let report_json = match rewrite_summary_name(&c.summary_json, &c.canonical_name) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"网格点 {} 重写 conv.json name 失败,跳过入库: {}",
|
||||
c.canonical_name, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let form = Form::new()
|
||||
.part(
|
||||
"report",
|
||||
Part::text(report_json).mime_str("application/json")?,
|
||||
)
|
||||
.part(
|
||||
"seed_file",
|
||||
Part::bytes(seed_bytes)
|
||||
.file_name(format!("{}.7", c.canonical_name))
|
||||
.mime_str("application/octet-stream")?,
|
||||
);
|
||||
|
||||
let mut req = client.post(&import_url);
|
||||
if let Some(t) = token {
|
||||
req = req.header("Authorization", format!("Bearer {}", t));
|
||||
}
|
||||
|
||||
match req.multipart(form).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
success_count += 1;
|
||||
info!("网格点 {} 入库成功!", c.canonical_name);
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
warn!(
|
||||
"网格点 {} 入库失败,服务端响应 HTTP {}: {}",
|
||||
c.canonical_name, status, body
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("网格点 {} 网络上传失败: {}", c.canonical_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"成功将 {}/{} 个结果入库至服务端工作流 {}!",
|
||||
success_count, total, workflow
|
||||
);
|
||||
Ok(success_count)
|
||||
}
|
||||
|
||||
/// 极简的 query 参数百分号编码(仅编码工作流名里非保留字符)。
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for &b in s.as_bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 把 conv.json 文本里的 `name` 字段重写为配置权威名。
|
||||
///
|
||||
/// 服务端 `import_seed` 用 `summary.name` 落库 grid_points;为使其与调度器
|
||||
/// `initialize_grid` 的 `model_name()` 逐字符一致,必须用配置权威名覆盖旧 name。
|
||||
/// 解析为 `serde_json::Value` 后仅替换 name,其余字段(params/stages/收敛状态等)原样保留。
|
||||
fn rewrite_summary_name(summary_json: &str, canonical_name: &str) -> Result<String> {
|
||||
let mut val: serde_json::Value =
|
||||
serde_json::from_str(summary_json).context("解析旧 conv.json 失败")?;
|
||||
let obj = val
|
||||
.as_object_mut()
|
||||
.context("旧 conv.json 顶层不是 JSON 对象,无法重写 name")?;
|
||||
obj.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(canonical_name.to_string()),
|
||||
);
|
||||
Ok(serde_json::to_string(&val)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 测试用宽泛 .9 正则(与 migrate_result_tree 内的编译正则一致)。
|
||||
fn test_re() -> Regex {
|
||||
Regex::new(r"(?P<rest>.+?)_chmax(?P<chmax>[^_]+)_itek[^.]*\.9$").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_filename_basic() {
|
||||
let re = test_re();
|
||||
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
|
||||
// conv.json / fort.8 保留
|
||||
assert_eq!(
|
||||
map_filename("conv.json", old, new, &re, "nl"),
|
||||
Some("conv.json".into())
|
||||
);
|
||||
assert_eq!(
|
||||
map_filename("fort.8", old, new, &re, "nl"),
|
||||
Some("fort.8".into())
|
||||
);
|
||||
|
||||
// 科学产物前缀重命名
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.7", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.7", new))
|
||||
);
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.spec", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.spec", new))
|
||||
);
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.cont", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.cont", new))
|
||||
);
|
||||
|
||||
// 阶段 .7 快照前缀重命名
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.lte.7", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.lte.7", new))
|
||||
);
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.nc.7", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.nc.7", new))
|
||||
);
|
||||
|
||||
// .9 收敛快照正则重命名(默认 chmax=0.001 → 旧版 0p001 → 还原 0.001)
|
||||
let old9 = format!("{}.nc_chmax0p001_itekNone.9", old);
|
||||
assert_eq!(
|
||||
map_filename(&old9, old, new, &re, "nl"),
|
||||
Some(format!("{}.nc_chmax0.001.9", new))
|
||||
);
|
||||
let old9b = format!("{}.nl_chmax0p001_itekNone.9", old);
|
||||
assert_eq!(
|
||||
map_filename(&old9b, old, new, &re, "nl"),
|
||||
Some(format!("{}.nl_chmax0.001.9", new))
|
||||
);
|
||||
|
||||
// 旧版最后阶段 .5/.6 补 nl 标签
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.5", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.nl.5", new))
|
||||
);
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.6", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.nl.6", new))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_filename_chmax_variants() {
|
||||
// 覆盖非默认 chmax 形态(旧版 stagename.replace(".","p") 后的各种形态)。
|
||||
let re = test_re();
|
||||
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
|
||||
// chmax=0.01 → 旧版 0p01 → 还原 0.01
|
||||
let f = format!("{}.nc_chmax0p01_itekNone.9", old);
|
||||
assert_eq!(
|
||||
map_filename(&f, old, new, &re, "nl"),
|
||||
Some(format!("{}.nc_chmax0.01.9", new))
|
||||
);
|
||||
|
||||
// chmax=0.1 → 旧版 0p1 → 还原 0.1
|
||||
let f = format!("{}.nc_chmax0p1_itekNone.9", old);
|
||||
assert_eq!(
|
||||
map_filename(&f, old, new, &re, "nl"),
|
||||
Some(format!("{}.nc_chmax0.1.9", new))
|
||||
);
|
||||
|
||||
// itek 是数字(itek=15)→ itek 段被去掉,不影响 chmax 还原
|
||||
let f = format!("{}.nc_chmax0p001_itek15.9", old);
|
||||
assert_eq!(
|
||||
map_filename(&f, old, new, &re, "nl"),
|
||||
Some(format!("{}.nc_chmax0.001.9", new))
|
||||
);
|
||||
|
||||
// 科学计数法 chmax(1e-05,无小数点,旧版 replace 不改变)→ 原样保留
|
||||
let f = format!("{}.nc_chmax1e-05_itekNone.9", old);
|
||||
assert_eq!(
|
||||
map_filename(&f, old, new, &re, "nl"),
|
||||
Some(format!("{}.nc_chmax1e-05.9", new))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_filename_last_stage_label() {
|
||||
// last_stage 从配置派生(非 nl,如 seed_step chain 末段 seed_nc)
|
||||
let re = test_re();
|
||||
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.5", old), old, new, &re, "seed_nc"),
|
||||
Some(format!("{}.seed_nc.5", new))
|
||||
);
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.6", old), old, new, &re, "seed_nc"),
|
||||
Some(format!("{}.seed_nc.6", new))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_filename_rename_with_diff_precision() {
|
||||
let re = test_re();
|
||||
let old = "t20000_g5_he-2_c-4_n-4_o-4";
|
||||
let new = "t20000_g5.0_he-2_c-4_n-4_o-4"; // 配置权威名补回小数
|
||||
|
||||
assert_eq!(
|
||||
map_filename(&format!("{}.7", old), old, new, &re, "nl"),
|
||||
Some(format!("{}.7", new))
|
||||
);
|
||||
let old9 = format!("{}.nc_chmax0p001_itekNone.9", old);
|
||||
assert_eq!(
|
||||
map_filename(&old9, old, new, &re, "nl"),
|
||||
Some(format!("{}.nc_chmax0.001.9", new))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_filename_skip_unmapped() {
|
||||
let re = test_re();
|
||||
let old = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let new = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
assert_eq!(map_filename("random_file.txt", old, new, &re, "nl"), None);
|
||||
assert_eq!(map_filename("fort.19", old, new, &re, "nl"), None);
|
||||
}
|
||||
|
||||
/// 验证 map_filename 改名 + is_result_worthy 白名单复核的组合(migrate_result_tree 的
|
||||
/// 实际过滤路径):科学产物/阶段快照/_chmax.9 保留;Tlusty 中间单元/无 _chmax 的 .9 丢弃。
|
||||
#[test]
|
||||
fn test_map_then_whitelist_filtering() {
|
||||
use common::result_filter::is_result_worthy;
|
||||
let re = test_re();
|
||||
let old = "t20000_g5_he-2_c-4_n-4_o-4"; // 旧 Python 命名(无 .0)
|
||||
let new = "t20000_g5.0_he-2_c-4_n-4_o-4"; // 新格式权威名
|
||||
let last_stage = "nl";
|
||||
|
||||
// 保留:map_filename 改名后落在白名单内
|
||||
let keep_inputs = [
|
||||
"conv.json",
|
||||
"fort.8",
|
||||
&format!("{old}.7"),
|
||||
&format!("{old}.spec"),
|
||||
&format!("{old}.nl.7"),
|
||||
&format!("{old}.nl.nst"),
|
||||
&format!("{old}.nc_chmax0p001_itekNone.9"), // 旧格式 .9 收敛诊断
|
||||
];
|
||||
for fname in keep_inputs {
|
||||
let mapped = map_filename(fname, old, new, &re, last_stage);
|
||||
assert!(mapped.is_some(), "应能映射: {}", fname);
|
||||
let mapped = mapped.unwrap();
|
||||
assert!(
|
||||
is_result_worthy(&mapped, new),
|
||||
"映射后应通过白名单: {} → {}",
|
||||
fname,
|
||||
mapped
|
||||
);
|
||||
}
|
||||
|
||||
// 丢弃:Tlusty 中间单元(map_filename 返回 None,根本不进白名单)
|
||||
for fname in ["fort.18", "fort.13", "fort.84", "fort.1", "fort.95"] {
|
||||
assert_eq!(
|
||||
map_filename(fname, old, new, &re, last_stage),
|
||||
None,
|
||||
"Tlusty 中间单元应无法映射: {}",
|
||||
fname
|
||||
);
|
||||
}
|
||||
|
||||
// 丢弃:无 _chmax 的 .9 快照(旧版若有),map_filename 通用前缀规则会映射成
|
||||
// <new>.<label>.9,但白名单拒绝(与 _chmax.9 重复)
|
||||
let bare_dot9 = format!("{old}.nl.9");
|
||||
let mapped = map_filename(&bare_dot9, old, new, &re, last_stage);
|
||||
assert_eq!(mapped, Some(format!("{new}.nl.9")));
|
||||
assert!(
|
||||
!is_result_worthy(mapped.as_ref().unwrap(), new),
|
||||
"无 _chmax 的 .9 应被白名单拒绝: {}",
|
||||
mapped.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_summary_name_changes_only_name() {
|
||||
let original = r#"{"name":"t20000_g5_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},"converged":true,"final_max_relc":0.001,"atmosphere_has_nan":false}"#;
|
||||
let rewritten = rewrite_summary_name(original, "t20000_g5.0_he-2_c-4_n-4_o-4").unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&rewritten).unwrap();
|
||||
assert_eq!(v["name"].as_str().unwrap(), "t20000_g5.0_he-2_c-4_n-4_o-4");
|
||||
assert_eq!(v["params"]["logg"].as_f64().unwrap(), 5.0);
|
||||
assert!(v["converged"].as_bool().unwrap());
|
||||
assert_eq!(v["final_max_relc"].as_f64().unwrap(), 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_summary_name_rejects_non_object() {
|
||||
// 非对象 JSON(数组/标量)应报错,而非静默放过
|
||||
assert!(rewrite_summary_name("[1,2,3]", "x").is_err());
|
||||
assert!(rewrite_summary_name("42", "x").is_err());
|
||||
assert!(rewrite_summary_name("\"hello\"", "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_param_fingerprint_normalizes_neg_zero() {
|
||||
// -0.0 与 0.0 应产出相同指纹(否则静默跳点)
|
||||
assert_eq!(
|
||||
param_fingerprint(&[0.0, -0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
param_fingerprint(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
);
|
||||
// 含 0 值轴的网格点能匹配(如 loghe: [0])
|
||||
assert_eq!(
|
||||
param_fingerprint(&[20000.0, 5.0, 0.0, -4.0, -4.0, -4.0]),
|
||||
param_fingerprint(&[20000.0, 5.0, -0.0, -4.0, -4.0, -4.0]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_canonical_name_map_preserves_decimal() {
|
||||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
|
||||
let map = build_canonical_name_map(&cfg);
|
||||
let key = param_fingerprint(&[20000.0, 5.0, -2.0, -4.0, -4.0, -4.0]);
|
||||
assert_eq!(
|
||||
map.get(&key).unwrap(),
|
||||
"t20000_g5.0_he-2_c-4_n-4_o-4",
|
||||
"logg=5.0 应保留小数 → g5.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_last_stage_label_from_chain() {
|
||||
// 配置显式指定 chain → 取末段标签
|
||||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\nchain:\n - {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}\n - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}\n - {label: nl_final, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}\n";
|
||||
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
|
||||
assert_eq!(derive_last_stage_label(&cfg), "nl_final");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_last_stage_label_default_when_no_chain() {
|
||||
// chain 为空 → 回退到默认收敛链末段(nl)
|
||||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
|
||||
assert_eq!(derive_last_stage_label(&cfg), "nl");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user