feat(all): 任务引擎双阶段解耦、僵尸涡旋修复、动态 CPU 配额与前端详情页重构
将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。
引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
(mark_grid_point_running 仅 pending/queued→running;
record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照
僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复
动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效
科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)
前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画
文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use crate::models::GridAxisValue;
|
||||
use crate::models::{EngineStageConfig, GridAxisValue};
|
||||
use anyhow::{Context, Result};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -138,6 +138,43 @@ impl GridConfig {
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// 解析 TLUSTY 阶段配置。
|
||||
///
|
||||
/// 优先级(见 docs/task_engine_decoupling_design.md §3):
|
||||
/// 1. 新版顶层 `tlusty:` 块(EngineStageConfig)—— 显式覆盖;
|
||||
/// 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 {
|
||||
return cfg.clone();
|
||||
}
|
||||
let mut cfg = EngineStageConfig::default_tlusty();
|
||||
if !self.seed_step_fallback {
|
||||
cfg.strategies = vec!["cold_run".to_string()];
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
/// 解析 SYNSPEC 阶段配置。
|
||||
///
|
||||
/// 优先级:
|
||||
/// 1. 新版顶层 `synspec_stage:` 块(EngineStageConfig)—— 显式覆盖(含 enabled 开关);
|
||||
/// 2. 兜底 `default_synspec()`(enabled=true,保持旧行为:有大气就跑光谱)。
|
||||
///
|
||||
/// 注:旧版 `synspec: SynspecConfig`(数值参数)不影响阶段启用/策略——它只携带
|
||||
/// 波长范围等数值,由调度器透传到 TaskSpec.synspec_params。如需禁用 SYNSPEC,
|
||||
/// 必须用新版 `synspec_stage: { enabled: false }`。
|
||||
pub fn resolve_synspec_config(&self) -> EngineStageConfig {
|
||||
if let Some(cfg) = &self.synspec_stage {
|
||||
return cfg.clone();
|
||||
}
|
||||
EngineStageConfig::default_synspec()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -213,7 +250,6 @@ fn default_abs_cutoff() -> f64 {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GridConfig {
|
||||
pub grid: GridAxesConfig,
|
||||
#[serde(default)]
|
||||
@@ -228,8 +264,7 @@ pub struct GridConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub seed_step_fallback: bool,
|
||||
/// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以
|
||||
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。仅因 `deny_unknown_fields`
|
||||
/// 必须能解析而保留。workflow YAML 里仍可写(如 `results: data/seeds`)但被忽略。
|
||||
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。保留以兼容旧 workflow YAML。
|
||||
#[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")]
|
||||
#[serde(default)]
|
||||
pub results: Option<String>,
|
||||
@@ -240,6 +275,14 @@ pub struct GridConfig {
|
||||
pub template: Option<String>,
|
||||
pub fort55: Option<String>,
|
||||
pub linelist: Option<String>,
|
||||
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
|
||||
/// 缺省 None → `resolve_tlusty_config()` 据旧 `seed_step_fallback` 推断默认链。
|
||||
#[serde(default)]
|
||||
pub tlusty: Option<EngineStageConfig>,
|
||||
/// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecConfig`
|
||||
///(光谱合成数值参数)区分。缺省 None → `resolve_synspec_config()` 给默认 `[standard]`。
|
||||
#[serde(default)]
|
||||
pub synspec_stage: Option<EngineStageConfig>,
|
||||
}
|
||||
|
||||
fn default_grid_niter() -> Option<i32> {
|
||||
@@ -410,7 +453,7 @@ pub struct NodeConfig {
|
||||
/// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物**
|
||||
/// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。
|
||||
/// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。
|
||||
/// 超过 MAX_RESULT_MODELS 个网格点子目录时按 LRU 删除最旧的。
|
||||
/// 归档**永久保留**,不做 LRU 淘汰(2026-08-02 撤销旧 MAX_RESULT_MODELS=200 上限)。
|
||||
pub result_dir: String,
|
||||
pub heartbeat_sec: u64,
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ fn parse_fortran_float(s: &str) -> Option<f64> {
|
||||
return Some(v);
|
||||
}
|
||||
// 2. 归一化无-E 记数法:[前导符号?]<尾数>(含小数点或多位数字)[+/-]<指数>
|
||||
let re = NO_E_EXP_RE.get_or_init(|| {
|
||||
Regex::new(r"^([+-]?[\d.]+)([+-]\d+)$").unwrap()
|
||||
});
|
||||
let re = NO_E_EXP_RE.get_or_init(|| Regex::new(r"^([+-]?[\d.]+)([+-]\d+)$").unwrap());
|
||||
if let Some(caps) = re.captures(s) {
|
||||
let normalized = format!("{}E{}", &caps[1], &caps[2]);
|
||||
if let Ok(v) = normalized.parse::<f64>() {
|
||||
@@ -197,9 +195,8 @@ 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(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap());
|
||||
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
total_lines += 1;
|
||||
@@ -274,7 +271,9 @@ mod tests {
|
||||
|
||||
// Rust 的 f64::from_str 接受 "NaN"/"inf",返回 NaN/Inf(非 None)。
|
||||
// 这些在 check_fort9 中会被 is_finite() 判为无效 → converged=false,行为正确。
|
||||
assert!(parse_fortran_float("NaN").map(|v| v.is_nan()).unwrap_or(false));
|
||||
assert!(parse_fortran_float("NaN")
|
||||
.map(|v| v.is_nan())
|
||||
.unwrap_or(false));
|
||||
assert_eq!(parse_fortran_float("inf"), Some(f64::INFINITY));
|
||||
|
||||
// 无法解析的垃圾 → None(调用方 continue 跳过)
|
||||
|
||||
@@ -173,8 +173,13 @@ fn write_if_changed(target_path: &Path, content: &[u8], executable: bool) -> Res
|
||||
fs::set_permissions(&tmp_path, perms)?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp_path, target_path)
|
||||
.with_context(|| format!("原子重命名 {} -> {} 失败", tmp_path.display(), target_path.display()))?;
|
||||
fs::rename(&tmp_path, target_path).with_context(|| {
|
||||
format!(
|
||||
"原子重命名 {} -> {} 失败",
|
||||
tmp_path.display(),
|
||||
target_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -332,8 +332,7 @@ mod tests {
|
||||
.filter(|l| {
|
||||
// ions 数据行:含引号且首 token 是整数
|
||||
l.contains('\'')
|
||||
&& l
|
||||
.split_whitespace()
|
||||
&& l.split_whitespace()
|
||||
.next()
|
||||
.map(|t| t.parse::<i32>().is_ok())
|
||||
.unwrap_or(false)
|
||||
|
||||
@@ -280,12 +280,121 @@ impl From<&str> for GridPointStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行策略(决定如何处理历史记录)。
|
||||
///
|
||||
/// 见 docs/task_engine_decoupling_design.md §2.1:阶段独立配置三维之一。
|
||||
///
|
||||
/// **语义(2026-08-04 修正)**:策略只决定**启动工作流时**对历史终态点(converged/failed)
|
||||
/// 的处理;失败后的策略链回退**只由启动时的策略链(回退优先级排序)驱动**,不受策略门控。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StagePolicy {
|
||||
/// 跳过已收敛、重试已失败:启动时把已失败点打回 pending 重试,收敛点保留(增量+重试失败)。
|
||||
/// 默认值。
|
||||
#[default]
|
||||
SkipConverged,
|
||||
/// 强制重算(无视历史状态与产物):启动时收敛 + 失败全部打回 pending。
|
||||
ForceRecompute,
|
||||
/// 跳过收敛及失败:启动时收敛和失败点都保留,只算从未计算过的点(最保守增量)。
|
||||
SkipFailed,
|
||||
}
|
||||
|
||||
impl StagePolicy {
|
||||
/// 序列化为 DB 文本列存储用的 snake_case 字符串。
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StagePolicy::SkipConverged => "skip_converged",
|
||||
StagePolicy::ForceRecompute => "force_recompute",
|
||||
StagePolicy::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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 独立阶段配置(TLUSTY / SYNSPEC 各一份)。
|
||||
///
|
||||
/// 见 docs/task_engine_decoupling_design.md §3:嵌套式单阶段配置模型,
|
||||
/// 包含三个正交维度:enabled / policy / strategies。
|
||||
///
|
||||
/// 为避免与 `common::config::StageConfig`(迭代步进参数)同名冲突,命名为
|
||||
/// `EngineStageConfig`。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EngineStageConfig {
|
||||
/// 是否在当前计算流中启用该阶段。
|
||||
#[serde(default = "default_engine_stage_enabled")]
|
||||
pub enabled: bool,
|
||||
/// 决定如何处理历史记录。
|
||||
#[serde(default)]
|
||||
pub policy: StagePolicy,
|
||||
/// 策略链队列(按回退优先级排序),如 `["cold_run", "seed_step"]`。
|
||||
/// 节点总是执行 `strategies[0]`;失败后由服务端弹出首项,下一顺位顶上。
|
||||
#[serde(default)]
|
||||
pub strategies: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_engine_stage_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl EngineStageConfig {
|
||||
/// TLUSTY 阶段默认配置:启用、增量、策略链 `[cold_run, seed_step]`。
|
||||
pub fn default_tlusty() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
policy: StagePolicy::SkipConverged,
|
||||
strategies: vec!["cold_run".to_string(), "seed_step".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// SYNSPEC 阶段默认配置:启用、增量、策略链 `[standard]`。
|
||||
pub fn default_synspec() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
policy: StagePolicy::SkipConverged,
|
||||
strategies: vec!["standard".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前应执行的策略(队列首项)。空链兜底为传入的 fallback。
|
||||
pub fn current_strategy<'a>(&'a self, fallback: &'a str) -> &'a str {
|
||||
self.strategies
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// 是否还含指定策略(任意位置)。用于回退去重等场景。
|
||||
///
|
||||
/// 注(审查 #6):生产回退路径现走 DB 侧策略链弹栈(`pop_stage_strategy_for_fallback`),
|
||||
/// 本方法当前主要用于测试断言与诊断(判断某策略是否仍在链中),保留为公共工具。
|
||||
pub fn has_strategy(&self, name: &str) -> bool {
|
||||
self.strategies.iter().any(|s| s == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Task execution specification sent to Node
|
||||
///
|
||||
/// 注:`EngineStageConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY
|
||||
/// `[cold_run, seed_step]` vs SYNSPEC `[standard]`),无中立的默认语义。构造某阶段的配置请用
|
||||
/// `..EngineStageConfig::default_tlusty()` / `..EngineStageConfig::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,
|
||||
pub seed_point_name: Option<String>,
|
||||
pub timeout_sec: u64,
|
||||
@@ -297,6 +406,72 @@ pub struct TaskSpec {
|
||||
/// 旧 payload 反序列化时缺省为 0。
|
||||
#[serde(default)]
|
||||
pub wave: i32,
|
||||
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
|
||||
/// 旧 payload 反序列化时缺省为 `default_tlusty()`。
|
||||
#[serde(default = "EngineStageConfig::default_tlusty")]
|
||||
pub tlusty_config: EngineStageConfig,
|
||||
/// SYNSPEC 阶段独立配置。旧 payload 反序列化时缺省为 `default_synspec()`。
|
||||
#[serde(default = "EngineStageConfig::default_synspec")]
|
||||
pub synspec_config: EngineStageConfig,
|
||||
/// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecConfig`)。
|
||||
/// 以 `serde_json::Value` 携带避免 models ↔ config 循环依赖;executor 侧
|
||||
/// 反序列化为 `SynspecConfig` 后透传给 runner。None → runner 用硬编码默认。
|
||||
/// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。
|
||||
#[serde(default)]
|
||||
pub synspec_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 {
|
||||
task_id: Uuid::nil(),
|
||||
point_name: String::new(),
|
||||
params: GridPointParams {
|
||||
teff: 0.0.into(),
|
||||
logg: 0.0.into(),
|
||||
loghe: 0.0.into(),
|
||||
logc: 0.0.into(),
|
||||
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(),
|
||||
synspec_params: None,
|
||||
atmosphere_ref: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -331,6 +506,10 @@ pub struct TaskReport {
|
||||
pub elapsed_sec: f64,
|
||||
pub error_message: Option<String>,
|
||||
pub summary_json: String,
|
||||
/// 失败阶段归因(见 docs/task_engine_decoupling_design.md §4.2):`"tlusty"` / `"synspec"`。
|
||||
/// 节点据 ModelSummary 推断;旧节点不携带该字段 → 服务端兜底按 TLUSTY 链回退(兼容)。
|
||||
#[serde(default)]
|
||||
pub failed_stage: Option<String>,
|
||||
}
|
||||
|
||||
/// Node registration request
|
||||
@@ -349,6 +528,22 @@ pub struct NodeHeartbeatRequest {
|
||||
pub memory_usage: f32,
|
||||
}
|
||||
|
||||
/// Node heartbeat response.
|
||||
///
|
||||
/// 设计依据见 docs/dynamic_cpu_slots_design.md:服务端在心跳响应里透传管理员设置的
|
||||
/// `admin_max_slots`(并发槽位配额上限),Worker 据此动态调整本地领用并发数,
|
||||
/// 避免 Pull 模式下被服务端强行拒绝 claim 而陷入空轮询。
|
||||
///
|
||||
/// - `status`:固定 "ok"(401/403 由 HTTP 状态码承载,不会进入反序列化路径)。
|
||||
/// - `admin_max_slots`:管理员强制配额上限(`null` 表示无限制,恢复节点物理槽位上限)。
|
||||
/// `#[serde(default)]` 保证旧服务端(响应体不含此字段)反序列化兜底为 None。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeHeartbeatResponse {
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub admin_max_slots: Option<i32>,
|
||||
}
|
||||
|
||||
/// Node state in database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeInfo {
|
||||
@@ -359,6 +554,10 @@ pub struct NodeInfo {
|
||||
pub cpu_usage: f32,
|
||||
pub memory_usage: f32,
|
||||
pub last_heartbeat: DateTime<Utc>,
|
||||
/// 管理员强制并发槽位上限(动态调整 CPU 核数)。None 表示无限制,使用 max_slots。
|
||||
/// 透传给前端供 Dashboard 渲染配额状态,并在心跳响应里下发给 Worker。
|
||||
#[serde(default)]
|
||||
pub admin_max_slots: Option<i32>,
|
||||
}
|
||||
|
||||
/// Single iteration convergence result parsed from fort.9
|
||||
@@ -665,4 +864,109 @@ mod tests {
|
||||
assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed);
|
||||
assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending);
|
||||
}
|
||||
|
||||
/// `StagePolicy` 的 snake_case serde 往返 + DB 文本兜底。
|
||||
#[test]
|
||||
fn test_stage_policy_serde_roundtrip() {
|
||||
for p in [
|
||||
StagePolicy::SkipConverged,
|
||||
StagePolicy::ForceRecompute,
|
||||
StagePolicy::SkipFailed,
|
||||
] {
|
||||
let s = serde_json::to_string(&p).unwrap();
|
||||
let back: StagePolicy = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(p, back);
|
||||
}
|
||||
// snake_case 形态锁定(前端 payload 与 DB 列口径)
|
||||
assert_eq!(
|
||||
serde_json::to_string(&StagePolicy::SkipConverged).unwrap(),
|
||||
"\"skip_converged\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&StagePolicy::ForceRecompute).unwrap(),
|
||||
"\"force_recompute\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&StagePolicy::SkipFailed).unwrap(),
|
||||
"\"skip_failed\""
|
||||
);
|
||||
// as_str/from_str_lossy 互逆(非法值兜底 SkipConverged)
|
||||
assert_eq!(
|
||||
StagePolicy::from_str_lossy("skip_converged"),
|
||||
StagePolicy::SkipConverged
|
||||
);
|
||||
assert_eq!(
|
||||
StagePolicy::from_str_lossy("garbage"),
|
||||
StagePolicy::SkipConverged
|
||||
);
|
||||
assert_eq!(
|
||||
StagePolicy::from_str_lossy(StagePolicy::ForceRecompute.as_str()),
|
||||
StagePolicy::ForceRecompute
|
||||
);
|
||||
}
|
||||
|
||||
/// `EngineStageConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。
|
||||
#[test]
|
||||
fn test_engine_stage_config_serde_and_defaults() {
|
||||
let cfg = EngineStageConfig {
|
||||
enabled: false,
|
||||
policy: StagePolicy::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();
|
||||
assert_eq!(cfg, back);
|
||||
|
||||
// 空 payload 应产出默认值(enabled=true, policy=skip_converged, strategies=[])
|
||||
let empty: EngineStageConfig = serde_json::from_str("{}").unwrap();
|
||||
assert!(empty.enabled);
|
||||
assert_eq!(empty.policy, StagePolicy::SkipConverged);
|
||||
assert!(empty.strategies.is_empty());
|
||||
|
||||
// current_strategy 空链兜底
|
||||
assert_eq!(empty.current_strategy("cold_run"), "cold_run");
|
||||
assert_eq!(cfg.current_strategy("x"), "cold_run");
|
||||
assert!(cfg.has_strategy("seed_step"));
|
||||
assert!(!cfg.has_strategy("standard"));
|
||||
}
|
||||
|
||||
/// 旧版 MQ 在途消息(仅含 task_type,无 tlusty_config)经 `#[serde(default)]`
|
||||
/// 反序列化后,`normalize_compat()` 应据 task_type 回填 strategies。
|
||||
#[test]
|
||||
fn test_task_spec_normalize_compat_from_legacy_task_type() {
|
||||
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();
|
||||
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 消息保持默认链"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//!
|
||||
//! 1. **裸名保留**(有独立语义,不以 model_name 为前缀):
|
||||
//! `conv.json`、`fort.8`(synspec 输入大气)、`fort.55`(synspec 控制卡)
|
||||
//! 2. **科学核心**:`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log`
|
||||
//! 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)
|
||||
//! 4. **收敛诊断**:`<name>.<label>_chmax*.9`(**唯一保留的 .9**;裸 `<name>.<label>.9`
|
||||
//! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复)
|
||||
@@ -21,7 +22,10 @@
|
||||
//! 符号链接、子目录、`.tmp`、`fort.84` 及所有 Tlusty 中间单元均不在白名单内,自然被跳过。
|
||||
|
||||
/// 科学核心产物的文件名后缀(挂在 `<name>.` 之后,无阶段标签)。
|
||||
const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log"];
|
||||
/// 除 SYNSPEC 产物(spec/cont/iden/log)外,含 runner 快照的 TLUSTY 最终产物:
|
||||
/// `bfac`(b 因子/非 LTE 偏离因子,源 fort.12)与 `emflux`(出射谱 λ–Fλ,源 fort.14),
|
||||
/// 二者若不快照会被 SYNSPEC 覆盖丢失(见 `runner::snapshot_tlusty_outputs`)。
|
||||
const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log", "bfac", "emflux"];
|
||||
|
||||
/// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。
|
||||
const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"];
|
||||
@@ -120,12 +124,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_science_core() {
|
||||
for s in ["7", "spec", "cont", "iden", "log"] {
|
||||
for s in ["7", "spec", "cont", "iden", "log", "bfac", "emflux"] {
|
||||
let f = format!("{}.{}", NAME, s);
|
||||
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
|
||||
}
|
||||
}
|
||||
|
||||
/// TLUSTY 快照产物(b 因子 / 出射谱)应进入白名单,缺失时不误伤其他后缀
|
||||
#[test]
|
||||
fn test_tlusty_snapshots_kept() {
|
||||
assert!(is_result_worthy(&format!("{}.bfac", NAME), NAME));
|
||||
assert!(is_result_worthy(&format!("{}.emflux", NAME), NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stage_snapshots() {
|
||||
// 各阶段标签 × 快照后缀 都应保留
|
||||
|
||||
+170
-25
@@ -113,13 +113,12 @@ async fn run_child_async_with_timeout(
|
||||
timeout_sec: u64,
|
||||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> Result<std::process::ExitStatus> {
|
||||
let timeout_fut = tokio::time::timeout(
|
||||
tokio::time::Duration::from_secs(timeout_sec),
|
||||
child.wait(),
|
||||
);
|
||||
let timeout_fut =
|
||||
tokio::time::timeout(tokio::time::Duration::from_secs(timeout_sec), child.wait());
|
||||
|
||||
// 若提供了 shutdown 标志,则与超时/正常结束三路 select;否则只等超时/正常结束。
|
||||
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown {
|
||||
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown
|
||||
{
|
||||
let shutdown_watcher = async move {
|
||||
// 轮询 shutdown 标志(10ms 粒度足够灵敏,开销可忽略)。
|
||||
loop {
|
||||
@@ -148,20 +147,12 @@ async fn run_child_async_with_timeout(
|
||||
Ok(status) => Ok(status),
|
||||
Err(ShutdownOrTimeout::Shutdown) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
child.wait(),
|
||||
)
|
||||
.await;
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
|
||||
anyhow::bail!("节点收到退出信号,子进程已被终止");
|
||||
}
|
||||
Err(ShutdownOrTimeout::Timeout) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
child.wait(),
|
||||
)
|
||||
.await;
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
|
||||
anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec);
|
||||
}
|
||||
}
|
||||
@@ -173,6 +164,31 @@ enum ShutdownOrTimeout {
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// 快照 TLUSTY 最终模型的 b 因子与出射谱,防止被 SYNSPEC 覆盖丢失。
|
||||
///
|
||||
/// TLUSTY 在最终迭代(`LFIN=.TRUE.`)经 `OUTPRI` 写出(见 tlusty208.f):
|
||||
/// - `fort.12`:b 因子 / 非 LTE 偏离因子表(头 2I5 + 每深度 TEMP/ELEC/DENS/BFAC,格式 701/702/703)。
|
||||
/// 随后 SYNSPEC 会复用 unit 12 写谱线证认表并覆盖它(runner 再将其存为 `<name>.iden`),
|
||||
/// 故 TLUSTY 的 b 因子若不在此快照即静默丢失。
|
||||
/// - `fort.14`:出射谱(波长 Å + Fλ,格式 614),同样会被 SYNSPEC 的谱线数据覆盖。
|
||||
///
|
||||
/// 在收敛链循环结束(链上最后一次 TLUSTY 运行即最终模型)、SYNSPEC 启动前调用,
|
||||
/// 快照为 `<name>.bfac` / `<name>.emflux`,与科学核心产物一并进入归档白名单
|
||||
/// (见 `result_filter::is_result_worthy` 的 `bfac`/`emflux` 后缀)。
|
||||
/// 文件不存在时静默跳过(TLUSTY 未运行/未写出);IO 错误降级为 warn,不阻断主流程。
|
||||
async fn snapshot_tlusty_outputs(model_dir: &Path, name: &str) {
|
||||
for (src, suffix) in [("fort.12", "bfac"), ("fort.14", "emflux")] {
|
||||
let src_path = model_dir.join(src);
|
||||
if !src_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let dst = model_dir.join(format!("{}.{}", name, suffix));
|
||||
if let Err(e) = tokio::fs::copy(&src_path, &dst).await {
|
||||
warn!("快照 TLUSTY {} 到 {} 失败: {}", src, dst.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecutionRunner<'a> {
|
||||
pub runtime: &'a RuntimePaths,
|
||||
pub work_dir: PathBuf,
|
||||
@@ -199,12 +215,19 @@ impl<'a> ExecutionRunner<'a> {
|
||||
custom_chain,
|
||||
seed_atmos,
|
||||
synspec_cfg,
|
||||
true,
|
||||
true,
|
||||
7200,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 阶段独立配置执行入口(见 docs/task_engine_decoupling_design.md §5)。
|
||||
///
|
||||
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
|
||||
/// - TLUSTY 关闭:跳过 chain 循环,直接以 seed_atmos(或单独拉取的大气)作 final_7;
|
||||
/// - SYNSPEC 关闭:跳过光谱合成块(即便 final_7 存在)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_model_with_timeout(
|
||||
&self,
|
||||
@@ -214,6 +237,8 @@ impl<'a> ExecutionRunner<'a> {
|
||||
custom_chain: Option<Vec<StageConfig>>,
|
||||
seed_atmos: Option<&Path>,
|
||||
synspec_cfg: Option<&SynspecConfig>,
|
||||
tlusty_enabled: bool,
|
||||
synspec_enabled: bool,
|
||||
timeout_sec: u64,
|
||||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> Result<ModelSummary> {
|
||||
@@ -282,7 +307,20 @@ impl<'a> ExecutionRunner<'a> {
|
||||
let mut final_chmax: Option<f64> = None;
|
||||
let mut final_max_relc: Option<f64> = None;
|
||||
|
||||
// 阶段独立配置(见 docs/task_engine_decoupling_design.md §5):
|
||||
// TLUSTY 关闭时跳过整个 chain 循环——current_seed 直接作为 final_7 来源,
|
||||
// 适配「仅 SYNSPEC」场景(用既有大气合成光谱,不重算大气结构)。
|
||||
if tlusty_enabled {
|
||||
info!("TLUSTY 阶段启用:执行 {} 步收敛链", chain.len());
|
||||
} else {
|
||||
info!("TLUSTY 阶段关闭:跳过大气结构计算,直接进入 SYNSPEC 阶段");
|
||||
}
|
||||
|
||||
let tlusty_skipped = !tlusty_enabled;
|
||||
for stage_def in &chain {
|
||||
if tlusty_skipped {
|
||||
break;
|
||||
}
|
||||
let stage_t0 = Instant::now();
|
||||
let metals = stage_def.metals.as_deref().unwrap_or("cno");
|
||||
let input5_text = make_input5(
|
||||
@@ -335,7 +373,8 @@ impl<'a> ExecutionRunner<'a> {
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let status_res = run_child_async_with_timeout(child, timeout_sec, shutdown.clone()).await;
|
||||
let status_res =
|
||||
run_child_async_with_timeout(child, timeout_sec, shutdown.clone()).await;
|
||||
let rc = match status_res {
|
||||
Ok(st) => st.code().unwrap_or(-1),
|
||||
Err(e) => {
|
||||
@@ -440,17 +479,27 @@ impl<'a> ExecutionRunner<'a> {
|
||||
let _ = tokio::fs::copy(model_dir.join("fort.7"), &final_7).await;
|
||||
}
|
||||
|
||||
// TLUSTY 最终 b 因子 + 出射谱快照。必须在此处(SYNSPEC 覆盖 fort.12/fort.14 之前)
|
||||
// 完成:synspec 会复用 unit 12/14 写谱线数据,覆盖 TLUSTY 的最终产物(见上方
|
||||
// snapshot_tlusty_outputs 的注释)。仅 SYNSPEC 场景(tlusty_enabled=false)下
|
||||
// fort.12/14 不存在,函数内按文件是否存在静默跳过。
|
||||
snapshot_tlusty_outputs(&model_dir, name).await;
|
||||
|
||||
let atmo_has_nan = atmosphere_has_nan(&final_7);
|
||||
if atmo_has_nan {
|
||||
final_converged = false;
|
||||
}
|
||||
|
||||
// Run synspec if final .7 atmosphere exists
|
||||
// Run synspec if enabled and final .7 atmosphere exists
|
||||
let mut synspec_rc = None;
|
||||
let mut synspec_err = None;
|
||||
let mut synspec_sec = None;
|
||||
|
||||
if final_7.is_file() {
|
||||
if !synspec_enabled {
|
||||
// SYNSPEC 关闭是合法配置(TLUSTY-only 大气计算),不写入 synspec_error
|
||||
// 以免污染 conv.json 的错误归因——下游把非空 synspec_error 当「光谱有缺陷」。
|
||||
info!("SYNSPEC 阶段关闭:跳过光谱合成(TLUSTY-only 模式)");
|
||||
} else if final_7.is_file() {
|
||||
let syn_t0 = Instant::now();
|
||||
// H10:synspec 输入文件(fort.8 大气 / fort.55 控制卡)写入失败不可静默吞掉。
|
||||
// 历史上用 `let _ =` 忽略错误,磁盘满/inode 耗尽时 synspec 会读到旧/缺失的
|
||||
@@ -480,7 +529,10 @@ impl<'a> ExecutionRunner<'a> {
|
||||
};
|
||||
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
|
||||
if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await {
|
||||
warn!("synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}", e);
|
||||
warn!(
|
||||
"synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}",
|
||||
e
|
||||
);
|
||||
synspec_err = Some(format!("fort.55 write failed: {}", e));
|
||||
} else {
|
||||
#[cfg(unix)]
|
||||
@@ -511,7 +563,8 @@ impl<'a> ExecutionRunner<'a> {
|
||||
|
||||
let synspec_timeout_sec = 600_u64.min(timeout_sec);
|
||||
let status_res =
|
||||
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone()).await;
|
||||
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone())
|
||||
.await;
|
||||
let rc = match status_res {
|
||||
Ok(st) => st.code().unwrap_or(-1),
|
||||
Err(e) => {
|
||||
@@ -549,6 +602,19 @@ impl<'a> ExecutionRunner<'a> {
|
||||
synspec_err = Some("No atmosphere .7 produced".to_string());
|
||||
}
|
||||
|
||||
// 收敛判定(见 docs/task_engine_decoupling_design.md §5):
|
||||
// - TLUSTY 启用:final_converged 由 chain 循环内各阶段收敛状态累积得出(既有逻辑)。
|
||||
// - TLUSTY 关闭(仅 SYNSPEC 场景):final_converged 不能恒为 false——否则成功的
|
||||
// 光谱合成任务被误判失败并触发策略回退。此时收敛 = 大气加载干净(无 NaN)且
|
||||
// SYNSPEC 成功(rc=0)或 SYNSPEC 也关闭(TLUSTY-only 等价的纯校验场景,虽罕见)。
|
||||
// 仅 SYNSPEC 场景下大气来自既有产物(非本任务重算),NaN 检查仍必要(产物可能损坏)。
|
||||
if !tlusty_enabled && !atmo_has_nan {
|
||||
final_converged = match synspec_rc {
|
||||
Some(rc) => rc == 0,
|
||||
None => !synspec_enabled, // SYNSPEC 也关闭 → 仅校验大气,干净即收敛
|
||||
};
|
||||
}
|
||||
|
||||
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
|
||||
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
|
||||
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
|
||||
@@ -569,6 +635,29 @@ impl<'a> ExecutionRunner<'a> {
|
||||
}
|
||||
|
||||
let elapsed_sec = t0.elapsed().as_secs_f64();
|
||||
|
||||
// 汇总 note(修复审查 #2 后续):半失败点(大气已收敛 + 光谱失败)须让
|
||||
// synspec 的错误可见——此前 synspec rc≠0 时 note 恒为 None,上报的
|
||||
// error_message 为空,attempts 表与详情面板无从排查失败原因。
|
||||
let note = {
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
if atmo_has_nan {
|
||||
notes.push("Invalidated: atmosphere contains >10% NaN lines".to_string());
|
||||
}
|
||||
if let Some(ref err) = synspec_err {
|
||||
notes.push(format!("synspec error: {}", err));
|
||||
} else if let Some(rc) = synspec_rc {
|
||||
if rc != 0 {
|
||||
notes.push(format!("synspec rc={}", rc));
|
||||
}
|
||||
}
|
||||
if notes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(notes.join("; "))
|
||||
}
|
||||
};
|
||||
|
||||
let summary = ModelSummary {
|
||||
name: name.to_string(),
|
||||
params: params.clone(),
|
||||
@@ -582,11 +671,7 @@ impl<'a> ExecutionRunner<'a> {
|
||||
synspec_error: synspec_err,
|
||||
synspec_sec,
|
||||
elapsed_sec,
|
||||
note: if atmo_has_nan {
|
||||
Some("Invalidated: atmosphere contains >10% NaN lines".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
note,
|
||||
};
|
||||
|
||||
// Write conv.json
|
||||
@@ -599,6 +684,7 @@ impl<'a> ExecutionRunner<'a> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::snapshot_tlusty_outputs;
|
||||
use crate::models::{GridAxisValue, GridPointParams};
|
||||
|
||||
#[test]
|
||||
@@ -647,4 +733,63 @@ mod tests {
|
||||
let authoritative_name = point_name; // 即 executor 传入的 task.point_name
|
||||
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
|
||||
}
|
||||
|
||||
/// 快照 TLUSTY b 因子与出射谱:fort.12→`<name>.bfac`、fort.14→`<name>.emflux`,
|
||||
/// 缺失的源文件静默跳过,未列入快照的 fort.13 不受影响。
|
||||
#[tokio::test]
|
||||
async fn test_snapshot_tlusty_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let model_dir = dir.path().join(name);
|
||||
tokio::fs::create_dir_all(&model_dir).await.unwrap();
|
||||
|
||||
// TLUSTY 最终迭代产物:b 因子(fort.12)与出射谱(fort.14)
|
||||
tokio::fs::write(model_dir.join("fort.12"), "bfac payload")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(model_dir.join("fort.14"), "emflux payload")
|
||||
.await
|
||||
.unwrap();
|
||||
// 不参与快照的文件(出射辐射场 fort.13、大气 fort.7)
|
||||
tokio::fs::write(model_dir.join("fort.13"), "emrad payload")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(model_dir.join("fort.7"), "atmo payload")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
snapshot_tlusty_outputs(&model_dir, name).await;
|
||||
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(model_dir.join(format!("{}.bfac", name)))
|
||||
.await
|
||||
.unwrap(),
|
||||
"bfac payload"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(model_dir.join(format!("{}.emflux", name)))
|
||||
.await
|
||||
.unwrap(),
|
||||
"emflux payload"
|
||||
);
|
||||
// fort.13 未列入快照,不应生成 <name>.emrad
|
||||
assert!(!model_dir.join(format!("{}.emrad", name)).exists());
|
||||
// 原 fort.12/fort.14 保留(后续 synspec 覆盖前仍作为单元文件存在)
|
||||
assert!(model_dir.join("fort.12").is_file());
|
||||
assert!(model_dir.join("fort.14").is_file());
|
||||
}
|
||||
|
||||
/// 缺失源文件(仅 SYNSPEC 场景,tlusty 未运行)时快照应是无害 no-op
|
||||
#[tokio::test]
|
||||
async fn test_snapshot_tlusty_outputs_noop_when_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let model_dir = dir.path().join(name);
|
||||
tokio::fs::create_dir_all(&model_dir).await.unwrap();
|
||||
|
||||
snapshot_tlusty_outputs(&model_dir, name).await;
|
||||
|
||||
assert!(!model_dir.join(format!("{}.bfac", name)).exists());
|
||||
assert!(!model_dir.join(format!("{}.emflux", name)).exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,14 @@ pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
|
||||
fn directed_cno_distance(cand: &GridPointParams, target: &GridPointParams) -> f64 {
|
||||
const RICH_PENALTY: f64 = 4.0; // 目标比种子富 → 该方向微扰不稳定,重罚
|
||||
const POOR_PENALTY: f64 = 1.0; // 目标比种子贫 → 该方向微扰稳定,轻罚
|
||||
// delta = target − cand:正 = 目标更富(坏方向),负 = 目标更贫(好方向)
|
||||
let penalize = |delta: f64| if delta > 0.0 { delta * RICH_PENALTY } else { -delta * POOR_PENALTY };
|
||||
// delta = target − cand:正 = 目标更富(坏方向),负 = 目标更贫(好方向)
|
||||
let penalize = |delta: f64| {
|
||||
if delta > 0.0 {
|
||||
delta * RICH_PENALTY
|
||||
} else {
|
||||
-delta * POOR_PENALTY
|
||||
}
|
||||
};
|
||||
penalize(target.logc.value() - cand.logc.value())
|
||||
+ penalize(target.logn.value() - cand.logn.value())
|
||||
+ penalize(target.logo.value() - cand.logo.value())
|
||||
@@ -64,7 +70,14 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::models::GridAxisValue;
|
||||
|
||||
fn params(teff: f64, logg: f64, loghe: f64, logc: f64, logn: f64, logo: f64) -> GridPointParams {
|
||||
fn params(
|
||||
teff: f64,
|
||||
logg: f64,
|
||||
loghe: f64,
|
||||
logc: f64,
|
||||
logn: f64,
|
||||
logo: f64,
|
||||
) -> GridPointParams {
|
||||
GridPointParams {
|
||||
teff: GridAxisValue::from_value(teff),
|
||||
logg: GridAxisValue::from_value(logg),
|
||||
@@ -79,7 +92,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_directed_cno_distance_favors_poor_metal_direction() {
|
||||
let seed = params(40000.0, 6.0, -2.0, -2.0, -2.0, -2.0); // 种子 CNO=-6
|
||||
// 贫方向:目标 CNO=-7(种子更富,目标往贫走),|Δ|=1
|
||||
// 贫方向:目标 CNO=-7(种子更富,目标往贫走),|Δ|=1
|
||||
let poor_target = params(40000.0, 6.0, -2.0, -3.0, -2.0, -2.0);
|
||||
// 富方向:目标 CNO=-5(目标更富),|Δ|=1,同一分量、同幅度
|
||||
let rich_target = params(40000.0, 6.0, -2.0, -1.0, -2.0, -2.0);
|
||||
@@ -99,9 +112,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_exact_family_prefers_poor_direction_seed() {
|
||||
let target = params(40000.0, 6.0, -2.0, -2.0, -2.0, -2.0); // 目标 CNO=-6
|
||||
// 候选A:富方向种子(目标比种子富),CNO 绝对差=1
|
||||
// 候选A:富方向种子(目标比种子富),CNO 绝对差=1
|
||||
let rich_seed = params(40000.0, 6.0, -2.0, -3.0, -2.0, -2.0); // CNO=-7, 目标更富
|
||||
// 候选B:贫方向种子(目标比种子贫),CNO 绝对差=2(更大)
|
||||
// 候选B:贫方向种子(目标比种子贫),CNO 绝对差=2(更大)
|
||||
let poor_seed = params(40000.0, 6.0, -2.0, -1.0, -1.0, -2.0); // CNO=-4, 目标更贫
|
||||
let (_, d_rich) = calculate_seed_distance(&rich_seed, &target);
|
||||
let (_, d_poor) = calculate_seed_distance(&poor_seed, &target);
|
||||
|
||||
Reference in New Issue
Block a user