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:
fmq
2026-07-31 01:34:05 +08:00
parent b91f1e4fa5
commit 1bfa240cb0
73 changed files with 12332 additions and 1608 deletions
+958
View File
@@ -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))
);
// 科学计数法 chmax1e-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");
}
}