From 43b82b1ae2eb6ca636b6a5257c1a2e2b0ff866cf Mon Sep 17 00:00:00 2001 From: Asfmq <2696428814@qq.com> Date: Sun, 9 Aug 2026 12:09:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(all):=20=E7=89=A9=E7=90=86=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E6=80=A7=E4=BA=94=E9=87=8D=E7=A1=AC=E9=97=A8=E6=A7=9B?= =?UTF-8?q?=E3=80=81=E8=BE=93=E5=85=A5=E6=96=87=E4=BB=B6=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=20fort.55=20=E9=94=99=E4=BD=8D=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E3=80=81conv=20=E8=AF=8A=E6=96=AD=20DB=20=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E9=98=B6=E6=AE=B5=E5=BD=92=E5=9B=A0=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E3=80=81ORELAX=20=E6=94=B6=E6=95=9B=E4=BF=AE=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=B7=A5=E5=85=B7=E4=B8=8B=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 物理正确性校验体系(common/conv_check.rs +494 行) - 新增 5 类硬门槛:能量守恒(.6)、温度结构(.7)、emflux 积分校验(.emflux,含全 NaN 判失败)、假收敛排查(itek 轨迹首末比)、b 因子合理性(.bfac) - runner 在 TLUSTY 阶段结束后执行全部校验,任一失败判 final_converged=false - GridConfig 新增 8 个可配阈值,经 scheduler→executor→runner 全链路透传 输入文件配置结构化重构(config.rs +1453 行) - TlustyInput 拆为 dot5/nst 分层结构,字段名严格映射 tlusty208.f READ 语句;SynspecInput 重构为 9 个 Fort55Line 子结构体 - 移除 ChainStep.metals 字段,元素集改由 dot5.atoms/ions 显式声明(gen_input5/nst_writer 同步重写为三源融合 / 分层覆盖) - fort.55 修复行结构 bug:补全分子表行(7→9 行),IDSTD 50→0 错位修正(影响全部光谱线强归一化,需重算 SYNSPEC 阶段) conv 诊断 DB 化与阶段归因修复(server) - 单点详情 conv 面板从磁盘 conv.json 改读 DB grid_points.summary_json;grid_points 新增 summary_json/last_elapsed_sec 两列(旧库幂等 ALTER) - record_task_report 阶段归因列加 CASE 守卫 + clear_synspec 对称处理,修复 synspec-only/TLUSTY-only 重跑污染统计 - 新增 summary_merge.rs 点级增量合并,避免重跑覆盖诊断字段 收敛性 ORELAX 修复与 seed_chain 可配(sdB_cno.yaml + node) - nl 阶段加 orelax=0.5、seed_nc 加 orelax=0.3,阻尼中温区 relc 振荡发散 - seed_chain 块可配,executor 优先采用用户配置而非内置默认链 导入工具下线 - 删除 import_results 客户端工具及 Windows 推送脚本;移除 /admin/import_seed 端点 - 改为服务端临时 migrate_conv 端点(扫 conv.json 增量合并入库,迁移后可删) 文档与分析 - 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档 - spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错 --- CLAUDE.md | 4 +- Cargo.lock | 16 - Cargo.toml | 1 - README.md | 3 +- crates/common/src/config.rs | 1481 ++++++++++++++++- crates/common/src/conv_check.rs | 785 ++++++++- crates/common/src/fort55_writer.rs | 342 +++- crates/common/src/gen_input5.rs | 841 ++++------ crates/common/src/lib.rs | 1 + crates/common/src/models.rs | 195 ++- crates/common/src/nst_writer.rs | 376 +++-- crates/common/src/result_filter.rs | 4 +- crates/common/src/runner.rs | 202 ++- crates/common/src/summary_merge.rs | 270 +++ crates/node/src/executor.rs | 71 +- crates/node/src/reporter.rs | 4 + crates/server/src/api/migrate.rs | 164 ++ crates/server/src/api/mod.rs | 5 +- crates/server/src/api/task.rs | 267 +-- crates/server/src/api/workflow.rs | 51 +- crates/server/src/db/grid.rs | 149 +- crates/server/src/db/mod.rs | 739 +++++++- crates/server/src/db/tasks.rs | 100 +- crates/server/src/main.rs | 5 +- crates/server/src/scheduler.rs | 130 +- crates/server/tests/api_tests.rs | 423 +---- crates/server/tests/wf_migration_isolation.rs | 81 +- docs/EXPERIENCE.md | 7 +- docs/PIPELINE.md | 17 +- docs/api.md | 47 +- docs/contributing.md | 2 - docs/convergence_analysis_2026_08_08.md | 300 ++++ docs/database_refactor_design.md | 2 +- docs/design.md | 2 +- docs/fort14_nan_analysis.md | 238 +++ docs/spectrum_correctness_analysis.md | 221 ++- docs/task_engine_decoupling_design.md | 6 +- docs/workflow_detail_design.md | 6 +- scripts/deploy.sh | 4 +- scripts/fetch_results.sh | 8 +- scripts/push_import_results.sh | 328 ---- tools/import_results/Cargo.toml | 16 - tools/import_results/README.md | 109 -- tools/import_results/src/main.rs | 1047 ------------ workflows/sdB_cno.yaml | 173 +- 45 files changed, 6059 insertions(+), 3184 deletions(-) create mode 100644 crates/common/src/summary_merge.rs create mode 100644 crates/server/src/api/migrate.rs create mode 100644 docs/convergence_analysis_2026_08_08.md create mode 100644 docs/fort14_nan_analysis.md delete mode 100755 scripts/push_import_results.sh delete mode 100644 tools/import_results/Cargo.toml delete mode 100644 tools/import_results/README.md delete mode 100644 tools/import_results/src/main.rs diff --git a/CLAUDE.md b/CLAUDE.md index 51b968e..5e46a31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ DCTS is a Rust-based distributed computing scheduler for stellar atmosphere mode ```bash # Build / check -cargo build --release # binaries: server, node, import_results +cargo build --release # binaries: server, node cargo check --workspace --all-targets # Tests (per crate) @@ -43,7 +43,7 @@ All config is via environment variables prefixed `DCTS_` (see `.env.example`): ` ## Architecture -Workspace crates (`Cargo.toml`): `common` (lib), `server` (bin), `node` (bin), `mq` (lib), `tools/import_results` (bin), plus `dashboard/` (web UI). +Workspace crates (`Cargo.toml`): `common` (lib), `server` (bin), `node` (bin), `mq` (lib), plus `dashboard/` (web UI). ### Master-Worker topology (pull-based) diff --git a/Cargo.lock b/Cargo.lock index c0a9398..69282e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -897,22 +897,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "import_results" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "common", - "regex", - "reqwest", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "indexmap" version = "2.14.0" diff --git a/Cargo.toml b/Cargo.toml index 7f227bd..a3366c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ members = [ "crates/mq", "crates/server", "crates/node", - "tools/import_results", ] [workspace.dependencies] diff --git a/README.md b/README.md index 2814471..3e2010a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ cargo build --release 编译产物位于 `target/release/`: - `server`:调度与 API 主服务端 - `node`:计算节点后台进程 -- `import_results`:历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树) ### 2. 构建前端与启动服务端 (Master & Web Dashboard) 首先编译前端全域实时网格可视图看班: @@ -113,7 +112,7 @@ DCTS_ADMIN_TOKEN=<强随机值> | [`node`](crates/node/README.md) | Binary | Worker 节点 Daemon 进程,负责任务抢占、自适应环境预热、计算链执行与产物汇报 | [README](crates/node/README.md) | | [`mq`](crates/mq/README.md) | Library | 基于 SQLite 构建的高可靠事务型分布式任务队列引擎 | [README](crates/mq/README.md) | | [`dashboard`](dashboard/index.html) | Web UI | 基于 Vite 与原生高交互前端语系创写的分层式恒星网格任务可观测可视化控表空间 | [说明详情](dashboard/package.json) | -| [`import_results`](tools/import_results/README.md) | Tool CLI | 历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树) | [README](tools/import_results/README.md) | +| [`import_db`](tools/import_db/README.md) | Tool CLI | 离线 DB 导入工具(扫结果目录 conv.json → 直写本地 db 文件) | [README](tools/import_db/README.md) | --- diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 371ada2..76bc7e1 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -2,7 +2,6 @@ use crate::models::{GridAxisValue, PhaseConfig}; use anyhow::{Context, Result}; use regex::Regex; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::path::Path; use std::sync::OnceLock; @@ -166,6 +165,51 @@ impl GridConfig { // 注:niter 不校验 > 0——LTE grey start 步骤 niter=0 是合法设计 //(runner.rs:455 显式处理 niter==0 为「不迭代,直接用 grey atmosphere 作初值」)。 } + // 能量守恒阈值必须 > 0(同 chmax:partial_cmp 对 NaN 拒绝)。 + if let Some(tol) = self.energy_tolerance { + if !matches!(tol.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!( + "energy_tolerance={} 非法:必须 > 0(偏离 1 的绝对容差)", + tol + ); + } + } + // 温度结构 / emflux 阈值校验(同 chmax:partial_cmp 对 NaN 拒绝)。 + if let Some(f) = self.temp_max_factor { + if !matches!(f.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("temp_max_factor={} 非法:必须 > 0", f); + } + } + if let Some(f) = self.temp_floor { + if !matches!(f.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("temp_floor={} 非法:必须 > 0(绝对温度)", f); + } + } + if let Some(c) = self.temp_ceiling { + if !matches!(c.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("temp_ceiling={} 非法:必须 > 0(绝对温度)", c); + } + } + if let Some(t) = self.emflux_tolerance { + if !matches!(t.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("emflux_tolerance={} 非法:必须 > 0", t); + } + } + if let Some(r) = self.convergence_min_ratio { + if !matches!(r.partial_cmp(&1.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("convergence_min_ratio={} 非法:必须 > 1(首末比阈值)", r); + } + } + if let Some(b) = self.bfac_max { + if !matches!(b.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("bfac_max={} 非法:必须 > 0", b); + } + } + if let Some(b) = self.bfac_min { + if !matches!(b.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("bfac_min={} 非法:必须 > 0", b); + } + } // teff 必须为正(物理温度)。 for (i, t) in self.grid.teff.iter().enumerate() { let v = t.value(); @@ -191,6 +235,10 @@ impl GridConfig { } } } + // fort.55 配置校验:拒绝触发不支持的条件读取路径(DM 数组/显式角度/风模式/分子 iunitm)。 + if let Some(syn) = &self.synspec_input { + syn.validate()?; + } Ok(()) } @@ -235,69 +283,849 @@ impl GridConfig { // ===== TLUSTY 输入文件(.5 + nst)全局物理参数 ===== // 这些参数不随收敛阶段(lte/nc/nl)变化——NFREAD 频率网格、ions 能级数据表等是 // 物理建模选择,一旦确定对整个大气计算全局生效。阶段差异参数(lte/ltgray/niter/ -// chmax/metals 等)保留在 ChainStep 里。 +// chmax/ilvlin 等)保留在 ChainStep 里。 // 缺省 None → 走代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量),与改动前行为一致。 /// 单个元素的 atoms 块配置(.5 文件的 `mode abn modpf` 行)。 /// abn(丰度)不在此配——每网格点不同,由 GridPointParams.loghe/logc/logn/logo 计算。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AtomConfig { - /// 0=不计算, 1=隐式(仅 LTE Saha 算电荷,不出现在 ions 块), 2=显式(统计平衡)。 - /// None → 走代码默认(H/He/CNO=2,Li/Be/B=0)。 - pub mode: Option, - /// partition function 模式(.5 atoms 行第 3 列 modpf)。None → 默认 0。 - #[serde(default)] - pub modpf: Option, -} - -/// 单个离子的能级数据配置(.5 文件 ions 块的一行)。 -/// ilast 由 nlevs 推导(nlevs==1 → ilast=1 裸核终止标志,否则 0),不暴露。 -/// ilvlin 由 ChainStep.ilvlin 提供(nlevs==1 时强制 0),不在此配。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IonConfig { - /// 原子序数(1=H, 2=He, 6=C, 7=N, 8=O...)。 - pub iat: i32, - /// 电离级(0=中性, 1=一次电离, 2=二次电离...)。 - pub iz: i32, - /// 能级数(该离子的能级模型复杂度)。 - pub nlevs: i32, - /// 4 字符离子标识(如 `" H 1"`、`"He 2"`),用于 tlusty 日志与诊断。 - pub typion: String, - /// 能级数据文件路径(如 `"data/h1.dat"`),裸核(nlevs==1)用 `" "`。 - pub filei: String, -} - -/// TLUSTY 输入文件的全局物理参数(.5 + nst 的非阶段差异部分)。 -#[derive(Debug, Clone, Serialize, Deserialize)] +/// TLUSTY 输入文件(.5 + nst)的完整配置。与文件字段**逐行一一对应**,方便研究人员 +/// 直接按 TLUSTY 手册配置所有参数。`grid`(网格点)和 `tlusty_chain`(阶段)在运行时 +/// 由 `make_input5` / `generate_nst_content` 覆写对应字段(见各子结构体文档)。 +/// +/// # 设计 +/// - `.5` 部分(`dot5`):按文件读取顺序(`tlusty208.f:178-450`)分块结构化,含 ODF +/// (NONSTD≠0)与 CHANGE(ICHANG<0)条件读取路径。 +/// - `nst` 部分(`nst`):236 个关键字中高频物理相关的 ~34 个结构化为子结构体;冷门 +/// 关键字(诊断打印、内部开关)保留 `nst_extra_keys` 逃逸口。 +/// +/// # 向后兼容 +/// `TlustyInput::default()` 生成与 sdB_cno 工作流历史硬编码值物理等价的配置 +/// (ND=50/VTB=2/IONS H/He/C/N/O 默认表等)。.5 文件主体字节级一致;nst 前 2 行 +/// 字节级一致,line3 显式写出 26 个 TLUSTY 默认关键字(物理等价,见 nst_writer 文档)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TlustyInput { - /// .5 frequencies 块的 NFREAD:连续频率网格点数。 - /// 取值 >0 → 从预设频率表读取(高精度、慢);≤0 → 由 frmin/frmax 自动生成对数网格(快)。 - /// 默认 2000(gen_input5.rs 原硬编码值)。 - #[serde(default = "default_nfread")] - pub nfread: i32, - - /// atoms 块特定元素的 mode/modpf 覆盖。 - /// key = 元素符号(`"H"`/`"He"`/`"C"`/`"N"`/`"O"`/`"Li"`/`"Be"`/`"B"`)。 - /// 缺省的元素走代码默认 mode(H/He/CNO=2,Li/Be/B=0)。 + /// .5 文件完整配置(行1-ions 块 + 可选 CHANGE 块)。 #[serde(default)] - pub atoms: HashMap, - - /// ions 能级数据表(完全替换默认的 H/He/C/N/O 23 行表)。 - /// 空Vec → 用 gen_input5.rs 的默认常量表。 - /// 非空时由 metals 参数按 iat 筛选参与元素。 + pub dot5: Dot5Input, + /// nst 文件高频关键字(按用途分组)。 #[serde(default)] - pub ions: Vec, - - /// nst 文件的额外关键字(逃逸口):自由传入任意 `KEY=VALUE` 对。 - /// 生成 nst 时追加到末尾(每行一个),用于暴露未结构化的 220+ nst 关键字 - /// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。 + pub nst: NstInput, + /// nst 冷门关键字逃逸口:`Vec<(KEY, VALUE)>`,追加到 nst 末尾。 + /// 覆盖全部 236 个关键字中未结构化的 ~200 个(诊断打印 IPR*、内部开关 IOP* 等)。 + /// 若与已结构化的 key 冲突,后者覆盖前者(nst_writer 会 warn)。 #[serde(default)] pub nst_extra_keys: Vec<(String, String)>, } +// ────────────────────────────────────────────────────────────────────── +// .5 文件结构(tlusty208.f:178-450,INITIA/STATE 子程序) +// ────────────────────────────────────────────────────────────────────── + +/// .5 文件完整配置。按 `tlusty208.f` 的 READ(IBUFF,*) 读取顺序分字段。 +/// grid/chain 在渲染时覆写对应字段(见 make_input5)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Dot5Input { + /// 行1 几何(`tlusty208.f:178/185`)。grid 注入覆写 teff/grav 或盘参数。 + #[serde(default)] + pub geometry: Geometry, + /// 行2 LTE/LTGREY(`:190`)。chain 注入覆写(lte/ltgray 阶段参数)。 + #[serde(default)] + pub lte: bool, + /// 行2 LTGREY(`:190`)。chain 注入覆写。 + #[serde(default)] + pub ltgray: bool, + /// 行3 nst 文件名(`:191`)。默认 `"nst"`;空串 `""` 表示不读 nst(走 TLUSTY 内置默认)。 + #[serde(default = "default_finstd")] + pub finstd: String, + /// 行4 NFREAD 连续频率网格点数(`:217`)。>0 从预设表读;≤0 自动对数网格。 + #[serde(default = "default_nfread")] + pub nfread: i32, + /// 行5 IABSET 丰度集选择(`:2671` 的第二个整数)。None → 不写(TLUSTY iostat 兜底默认 0); + /// 0=默认 / 1=用 abun1 表 / 2=用 abun0 表。补全与 .5 行5 的完全一一对应。 + #[serde(default)] + pub iabset: Option, + /// 行5+ 原子块(`:2671/2708`)。grid 注入覆写 He/C/N/O 的 abn 列。 + #[serde(default)] + pub atoms: Vec, + /// ions 块(`:319`)。chain 注入覆写每行 ilvlin(nlevs==1 裸核强制 0)。 + /// 元素集直接由本列表决定(列出哪些行就渲染哪些)。 + #[serde(default)] + pub ions: Vec, + /// CHANGE 块(`:3506`,ICHANG<0 时每能级行)。None 表示无 CHANGE 块。 + #[serde(default)] + pub change: Option>, +} + +impl Default for Dot5Input { + fn default() -> Self { + Dot5Input { + geometry: Geometry::default(), + lte: false, + ltgray: false, + finstd: default_finstd(), + nfread: default_nfread(), + iabset: None, + atoms: default_atoms(), + ions: default_ions(), + change: None, + } + } +} + +/// .5 行1 几何(`tlusty208.f:178/185`,按 IDISK 分流)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Geometry { + /// IDISK=0 恒星大气(`:178` `READ TEFF,GRAV`)。grid 注入覆写 teff/logg。 + Stellar { + #[serde(default = "default_teff")] + teff: f64, + #[serde(default = "default_logg")] + logg: f64, + }, + /// IDISK=1 吸积盘(`:185` `READ XMSTAR,XMDOT,RSTAR,RELDST`)。 + /// 注:IDISK 从独立的 fort.1 文件读取(`tlusty208.f:124`),runner 当前不生成 + /// fort.1(恒为恒星大气)。吸积盘路径需要额外的 fort.1 生成逻辑,暂未启用。 + Disk { + #[serde(default)] + xmstar: f64, + #[serde(default)] + xmdot: f64, + #[serde(default)] + rstar: f64, + #[serde(default)] + reldst: f64, + }, +} + +impl Default for Geometry { + fn default() -> Self { + Geometry::Stellar { + teff: default_teff(), + logg: default_logg(), + } + } +} + +/// .5 atoms 块的一行(`:2708` `READ MA,ABN,MODPF0`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AtomRow { + /// 原子序数(1=H, 2=He, 6=C, ...)。用于 grid 丰度注入定位(He/C/N/O)。 + pub iat: i32, + /// MA:0=不考虑 / 1=隐式 LTE Saha / 2=显式统计平衡 / ≤-2=固定布居。 + /// 默认:H/He/CNO=2,Li/Be/B=0。 + #[serde(default = "default_atom_mode")] + pub mode: i32, + /// ABN:丰度。0=太阳默认;None 时由 grid 注入(He/C/N/O 用 10^logx)。 + /// None 表示"由 grid 计算",渲染时 grid 未注入则用 "0."(太阳默认)。 + #[serde(default)] + pub abn: Option, + /// MODPF:配分函数模式。默认 0。 + #[serde(default)] + pub modpf: i32, +} + +/// .5 ions 块的一行(`:319` `READ IATII,IZII,NLEVSI,ILASTI,ILVLIN,NONSTD,TYPIOI,FILEI`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IonRow { + pub iat: i32, + pub iz: i32, + pub nlevs: i32, + /// ILAST:0=普通离子行 / 1=裸核终止(nlevs==1 自动设)/ >0=额外显式能级 / -1=终止行。 + /// 默认由 nlevs 推导(nlevs==1→1,否则 0),一般不需手配。 + #[serde(default)] + pub ilast: Option, + /// ILVLIN:线处理模式。chain.ilvlin 注入覆写(nlevs==1 强制 0)。 + #[serde(default)] + pub ilvlin: Option, + /// NONSTD:非标准模式。0=标准 / >0 触发附加行 Positive / <0 触发附加行 Negative(ODF)。 + #[serde(default)] + pub nonstd: i32, + pub typion: String, + pub filei: String, + /// NONSTD≠0 时的附加行(`:356` / `:358`)。None=无附加行。 + #[serde(default)] + pub nonstd_extra: Option, +} + +/// ions 块 NONSTD≠0 的附加行。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "variant", rename_all = "lowercase")] +pub enum NonstdExtra { + /// NONSTD>0(`:356` `READ IUPSUM,ICUP,MODEFF,NFF`)。 + Positive { + iupsum: i32, + icup: i32, + modeff: i32, + nff: i32, + }, + /// NONSTD<0 ODF(`:358` `READ INODF1,INODF2,FIODF1,FIODF2,FIBFCS`)。 + Negative { + inodf1: i32, + inodf2: i32, + fiodf1: String, + fiodf2: String, + fibfcs: String, + }, +} + +/// .5 CHANGE 块的一行(`:3506` `READ IOLD,MODE,NXTOLD,ISINEW,ISIOLD,NXTSIO,REL`)。 +/// 仅 ICHANG<0 时渲染(每能级一行,共 NLEVEL 行)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChangeRow { + pub iold: i32, + pub mode: i32, + pub nxtold: i32, + pub isinew: i32, + pub isiold: i32, + pub nxtsio: i32, + #[serde(default = "default_one")] + pub rel: f64, +} + +// ────────────────────────────────────────────────────────────────────── +// nst 高频关键字(236 个中物理相关的 ~34 个,按用途分组) +// ────────────────────────────────────────────────────────────────────── + +/// nst 高频关键字(按用途分组)。字段名与 TLUSTY 关键字一致(小写)。 +/// 默认值与 sdB_cno 工作流历史硬编码一致(ND=50/VTB=2 等,非 TLUSTY 原始默认)。 +/// ChainStep 的 niter/chmax/itek/orelax/idlte/iacc/ichang 不在此重复(阶段级覆盖)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstInput { + #[serde(default)] + pub depth: NstDepth, + #[serde(default)] + pub frequency: NstFrequency, + #[serde(default)] + pub physics: NstPhysics, + #[serde(default)] + pub radiative_transfer: NstRt, + #[serde(default)] + pub nlte: NstNlte, + #[serde(default)] + pub irradiation: NstIrradiation, + #[serde(default)] + pub temperature: NstTemperature, +} + +/// 深度/网格几何(ND/NMU/NLAMBD/ISPLIN)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstDepth { + /// ND:深度点数。默认 50(TLUSTY 原始默认 70,sdB 调优为 50)。 + #[serde(default = "default_nd")] + pub nd: i32, + /// NMU:角度点数。默认 3。 + #[serde(default = "default_nmu")] + pub nmu: i32, + /// NLAMBD:λ-迭代频率更新间隔。默认 3(TLUSTY 默认 30,sdB 调优为 3)。 + #[serde(default = "default_nlambd")] + pub nlambd: i32, + /// ISPLIN:深度插值样条方法。默认 0。 + #[serde(default)] + pub isplin: i32, +} +impl Default for NstDepth { + fn default() -> Self { + NstDepth { + nd: default_nd(), + nmu: default_nmu(), + nlambd: default_nlambd(), + isplin: 0, + } + } +} + +/// 频率网格/ODF(FRCMAX/MIN、FRLMAX/MIN、DDNU、CNU1/2、NFTAIL、DFTAIL、ISPODF)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstFrequency { + /// ISPODF:ODF 开关。默认 1(sdB 启用;LTGREY 时 TLUSTY 强制 0)。 + #[serde(default = "default_ispodf")] + pub ispodf: i32, + /// DDNU:频率间距。默认 50.(TLUSTY 默认 0.75,sdB 调优为 50)。 + #[serde(default = "default_ddnu")] + pub ddnu: f64, + /// CNU1:频率中心1。默认 6.(TLUSTY 默认 4.5,sdB 调优为 6)。 + #[serde(default = "default_cnu1")] + pub cnu1: f64, + /// CNU2:频率中心2。默认 3. + #[serde(default = "default_cnu2")] + pub cnu2: f64, + /// NFTAIL:频率尾部点数。默认 21。 + #[serde(default = "default_nftail")] + pub nftail: i32, + /// DFTAIL:频率尾部间距。默认 0.25。 + #[serde(default = "default_dftail")] + pub dftail: f64, + /// FRCMAX/MIN/FRLMAX/MIN:频率截断。0=不写出(走 TLUSTY 内置默认);非零=显式覆写。 + /// 仅在用户配非零值时才写入 nst(见 nst_writer.rs 的 freq_cutoffs 逻辑)。 + #[serde(default)] + pub frcmax: f64, + #[serde(default)] + pub frcmin: f64, + #[serde(default)] + pub frlmax: f64, + #[serde(default)] + pub frlmin: f64, +} +impl Default for NstFrequency { + fn default() -> Self { + NstFrequency { + ispodf: default_ispodf(), + ddnu: default_ddnu(), + cnu1: default_cnu1(), + cnu2: default_cnu2(), + nftail: default_nftail(), + dftail: default_dftail(), + frcmax: 0.0, + frcmin: 0.0, + frlmax: 0.0, + frlmin: 0.0, + } + } +} + +/// 物理模型/湍流/对流(VTB/HMIX0/MLTYPE/IPTURB/IELCOR/IBFINT/IHECOR)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstPhysics { + /// VTB:微湍流速度 km/s。默认 2.(TLUSTY 默认 0,sdB 调优为 2)。 + #[serde(default = "default_vtb_nst")] + pub vtb: f64, + /// HMIX0:混合长度。默认 -1(TLUSTY 默认)。 + #[serde(default = "default_hmix0")] + pub hmix0: f64, + /// MLTYPE:对流类型。默认 1。 + #[serde(default = "default_mltype")] + pub mltype: i32, + /// IPTURB:湍流压力开关。默认 1。 + #[serde(default = "default_ipturb")] + pub ipturb: i32, + /// IELCOR:元素电荷修正。默认 -1。 + #[serde(default = "default_ielcor")] + pub ielcor: i32, + /// IBFINT:跃迁积分模式。默认 1。 + #[serde(default = "default_ibfint")] + pub ibfint: i32, + /// IHECOR:He 修正。默认 0。 + #[serde(default)] + pub ihecor: i32, +} +impl Default for NstPhysics { + fn default() -> Self { + NstPhysics { + vtb: default_vtb_nst(), + hmix0: default_hmix0(), + mltype: default_mltype(), + ipturb: default_ipturb(), + ielcor: default_ielcor(), + ibfint: default_ibfint(), + ihecor: 0, + } + } +} + +/// 辐射转移(IRTE/IBC/IFRYB/IOPTAB/IFMOL)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstRt { + /// IRTE:辐射转移求解器。默认 0。 + #[serde(default)] + pub irte: i32, + /// IBC:边界条件。默认 3。 + #[serde(default = "default_ibc")] + pub ibc: i32, + /// IFRYB:Rybicki 方法。默认 0。 + #[serde(default)] + pub ifryb: i32, + /// IOPTAB:不透明度表模式。默认 0。 + #[serde(default)] + pub ioptab: i32, + /// IFMOL:分子频率。默认 0。 + #[serde(default)] + pub ifmol: i32, +} +impl Default for NstRt { + fn default() -> Self { + NstRt { + irte: 0, + ibc: default_ibc(), + ifryb: 0, + ioptab: 0, + ifmol: 0, + } + } +} + +/// NLTE/ALI(IFALI/IFPOPR/JALI)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstNlte { + /// IFALI:ALI 开关。默认 5。 + #[serde(default = "default_ifali")] + pub ifali: i32, + /// IFPOPR:布居精度。默认 4。 + #[serde(default = "default_ifpopr")] + pub ifpopr: i32, + /// JALI:ALI 模式。默认 1。 + #[serde(default = "default_jali")] + pub jali: i32, +} +impl Default for NstNlte { + fn default() -> Self { + NstNlte { + ifali: default_ifali(), + ifpopr: default_ifpopr(), + jali: default_jali(), + } + } +} + +/// 外部辐照(TRAD/WDIL)。TRAD≠0 会触发 IWINBL=-1。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstIrradiation { + /// TRAD:外辐射场温度。默认 0(无外部辐照)。 + #[serde(default)] + pub trad: f64, + /// WDIL:稀释因子。默认 0。 + #[serde(default)] + pub wdil: f64, +} +impl Default for NstIrradiation { + fn default() -> Self { + NstIrradiation { + trad: 0.0, + wdil: 0.0, + } + } +} + +/// 温度限(TFLOOR/TDISK/TMOLIM)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NstTemperature { + /// TFLOOR:温度地板。默认 8000。 + #[serde(default = "default_tfloor")] + pub tfloor: f64, + /// TDISK:盘温度。默认 0。 + #[serde(default)] + pub tdisk: f64, + /// TMOLIM:分子温度上限。默认 9000。 + #[serde(default = "default_tmolim")] + pub tmolim: f64, +} +impl Default for NstTemperature { + fn default() -> Self { + NstTemperature { + tfloor: default_tfloor(), + tdisk: 0.0, + tmolim: default_tmolim(), + } + } +} + +// ────────────────────────────────────────────────────────────────────── +// nst 关键字默认值函数(sdB 调优值,非 TLUSTY 原始默认) +// ────────────────────────────────────────────────────────────────────── + +fn default_finstd() -> String { + "nst".to_string() +} fn default_nfread() -> i32 { 2000 } +fn default_teff() -> f64 { + 35000.0 +} +fn default_logg() -> f64 { + 5.5 +} +fn default_atom_mode() -> i32 { + 2 +} +fn default_one() -> f64 { + 1.0 +} +fn default_nd() -> i32 { + 50 +} +fn default_nmu() -> i32 { + 3 +} +fn default_nlambd() -> i32 { + 3 +} +fn default_ispodf() -> i32 { + 1 +} +fn default_ddnu() -> f64 { + 50.0 +} +fn default_cnu1() -> f64 { + 6.0 +} +fn default_cnu2() -> f64 { + 3.0 +} +fn default_nftail() -> i32 { + 21 +} +fn default_dftail() -> f64 { + 0.25 +} +fn default_vtb_nst() -> f64 { + 2.0 +} +fn default_hmix0() -> f64 { + -1.0 +} +fn default_mltype() -> i32 { + 1 +} +fn default_ipturb() -> i32 { + 1 +} +fn default_ielcor() -> i32 { + -1 +} +fn default_ibfint() -> i32 { + 1 +} +fn default_ibc() -> i32 { + 3 +} +fn default_ifali() -> i32 { + 5 +} +fn default_ifpopr() -> i32 { + 4 +} +fn default_jali() -> i32 { + 1 +} +fn default_tfloor() -> f64 { + 8000.0 +} +fn default_tmolim() -> f64 { + 9000.0 +} + +/// 默认 atoms 表(H/He/Li/Be/B + C/N/O,mode=2/2/0/0/0/2/2/2)。 +/// C/N/O 的 abn 由 grid 注入;元素集直接由本列表决定。 +fn default_atoms() -> Vec { + vec![ + AtomRow { + iat: 1, + mode: 2, + abn: Some("0.".to_string()), + modpf: 0, + }, // H + AtomRow { + iat: 2, + mode: 2, + abn: None, + modpf: 0, + }, // He (abn 由 grid 注入) + AtomRow { + iat: 3, + mode: 0, + abn: Some("0.".to_string()), + modpf: 0, + }, // Li + AtomRow { + iat: 4, + mode: 0, + abn: Some("0.".to_string()), + modpf: 0, + }, // Be + AtomRow { + iat: 5, + mode: 0, + abn: Some("0.".to_string()), + modpf: 0, + }, // B + AtomRow { + iat: 6, + mode: 2, + abn: None, + modpf: 0, + }, // C (abn 由 grid 注入) + AtomRow { + iat: 7, + mode: 2, + abn: None, + modpf: 0, + }, // N (abn 由 grid 注入) + AtomRow { + iat: 8, + mode: 2, + abn: None, + modpf: 0, + }, // O (abn 由 grid 注入) + ] +} + +/// 默认 ions 表(H/He/C/N/O 共 22 行)。元素集直接由本列表决定。 +/// 数据来自历史 IONS_H/HE/C/N/O 常量(gen_input5.rs 旧版)。 +pub fn default_ions() -> Vec { + vec![ + // H + IonRow { + iat: 1, + iz: 0, + nlevs: 9, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " H 1".into(), + filei: "data/h1.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 1, + iz: 1, + nlevs: 1, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " H 2".into(), + filei: " ".into(), + nonstd_extra: None, + }, + // He + IonRow { + iat: 2, + iz: 0, + nlevs: 14, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: "He 1".into(), + filei: "data/he1.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 2, + iz: 1, + nlevs: 14, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: "He 2".into(), + filei: "data/he2.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 2, + iz: 2, + nlevs: 1, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: "He 3".into(), + filei: " ".into(), + nonstd_extra: None, + }, + // C (iat=6) + IonRow { + iat: 6, + iz: 0, + nlevs: 40, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " C 1".into(), + filei: "data/c1.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 6, + iz: 1, + nlevs: 22, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " C 2".into(), + filei: "data/c2.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 6, + iz: 2, + nlevs: 46, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " C 3".into(), + filei: "data/c3_34+12lev.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 6, + iz: 3, + nlevs: 25, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " C 4".into(), + filei: "data/c4.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 6, + iz: 4, + nlevs: 1, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " C 5".into(), + filei: " ".into(), + nonstd_extra: None, + }, + // N (iat=7) + IonRow { + iat: 7, + iz: 0, + nlevs: 34, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " N 1".into(), + filei: "data/n1.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 7, + iz: 1, + nlevs: 42, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " N 2".into(), + filei: "data/n2_32+10lev.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 7, + iz: 2, + nlevs: 32, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " N 3".into(), + filei: "data/n3.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 7, + iz: 3, + nlevs: 48, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " N 4".into(), + filei: "data/n4_34+14lev.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 7, + iz: 4, + nlevs: 16, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " N 5".into(), + filei: "data/n5.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 7, + iz: 5, + nlevs: 1, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " N 6".into(), + filei: " ".into(), + nonstd_extra: None, + }, + // O (iat=8) + IonRow { + iat: 8, + iz: 0, + nlevs: 33, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " O 1".into(), + filei: "data/o1_23+10lev.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 8, + iz: 1, + nlevs: 48, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " O 2".into(), + filei: "data/o2_36+12lev.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 8, + iz: 2, + nlevs: 41, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " O 3".into(), + filei: "data/o3_28+13lev.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 8, + iz: 3, + nlevs: 39, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " O 4".into(), + filei: "data/o4.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 8, + iz: 4, + nlevs: 6, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " O 5".into(), + filei: "data/o5.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 8, + iz: 5, + nlevs: 1, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " O 6".into(), + filei: " ".into(), + nonstd_extra: None, + }, + ] +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -315,7 +1143,6 @@ pub struct ChainStep { pub niter: i32, pub chmax: Option, pub itek: Option, - pub metals: Option, pub ichang: Option, pub idlte: Option, pub iacc: Option, @@ -330,50 +1157,450 @@ fn default_niter() -> i32 { 50 } -/// SYNSPEC 输入文件(fort.55)的数值参数。与 `TlustyInput` 语义对称—— -/// 分别承载各阶段输入文件的物理参数(tlusty 的 .5/nst vs synspec 的 fort.55)。 +/// SYNSPEC fort.55 控制卡的完整配置。子结构体与 `synspec54.f` 的 `READ(55,*)` 语句 +/// **逐行一一对应**——字段名严格使用 SYNSPEC 源码变量名,行内字段顺序与 READ 语句一致。 +/// 这样后续不同工作流(sdB / sdO / WD 等)可按行覆盖各自需要的参数,且配置与源码映射直观。 +/// +/// 完整读取序列见 `synspec54.f:253-2357`(START + INIBL0 子程序,受 `ifeos.le.0` 守卫)。 +/// 标准路径(普通合成谱,IMODE∈[-3,1]、ifeos=0)下 fort.55 共 **9 行**: +/// line1-6 无条件读取 → **line_mol 分子表**(IMODE∈[-3,1] 条件读取,默认触发)→ +/// line7 VTB → line8 角度。 +/// +/// # 关键:line_mol 的存在 +/// SYNSPEC 在读 line6 后、读 VTB 前,当 `IMODE∈[-3,1]`(默认 IMODE=0 满足)会**额外读一行 +/// 分子表**(`synspec54.f:2208`)。旧实现漏生成此行,靠 VTB 行被吞掉 + `end=30` 跳过侥幸工作。 +/// 现显式生成 line_mol(默认 nmlist=0 无分子表),保证行序与 SYNSPEC 实际读取序列一致。 +/// +/// # 行为变化(相对旧扁平结构体) +/// - **IDSTD 默认值**:旧 `idrv=50`(被错位读为 IDSTD=50=ND 最深层,是 bug);现默认 0, +/// 触发 SYNSPEC 自动取 `2*ND/3≈33`(τ≈1 参考层,正确行为)。见 `synspec54.f:2152-2155`。 +/// - **VTB 默认值**:现默认 -1.0(负值)。`synspec54.f:2253` 的 `if(vtb.ge.0.)` 为假 → +/// 不覆盖大气自带的 vturb(TLUSTY 设的 2 km/s)。旧实现靠 VTB 行缺失走 `end=30` 跳过 +/// 实现等价效果;现显式用负值表达"不覆盖"语义,更清晰且不依赖 EOF 兜底。 +/// - 旧字段 `idrv`/`ifreq`/`abs_cutoff` 命名与 SYNSPEC 变量语义脱节,已分别纠正为 +/// `idstd`/`iprin`(行1)与 `space`(行6),真正的 `IFREQ` 在行4。 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SynspecInput { - #[serde(default = "default_wstart")] - pub wstart: f64, - #[serde(default = "default_wend")] - pub wend: f64, + /// 行1 (`synspec54.f:253`): `READ(55,*) IMODE, IDSTD, IPRIN` + #[serde(default)] + pub line1: Fort55Line1, + /// 行2 (`synspec54.f:254`): `READ(55,*) INMOD, INTRPL, ICHANG, ICHEMC` + #[serde(default)] + pub line2: Fort55Line2, + /// 行3 (`synspec54.f:255`): `READ(55,*) IOPHLI, nunalp, nunbet, nungam, nunbal` + #[serde(default)] + pub line3: Fort55Line3, + /// 行4 (`synspec54.f:2146`): `READ(55,*) IFREQ, INLTE, ICONTL, INLIST, IFHE2` + #[serde(default)] + pub line4: Fort55Line4, + /// 行5 (`synspec54.f:2148`): `READ(55,*) IHYDPR, IHE1PR, IHE2PR` + #[serde(default)] + pub line5: Fort55Line5, + /// 行6 (`synspec54.f:2149`): `READ(55,*) ALAM0, ALAST, CUTOF0, CUTOFS, RELOP, SPACE` + #[serde(default)] + pub line6: Fort55Line6, + /// 行7 分子表 (`synspec54.f:2208`, IMODE∈[-3,1] 条件读取): `READ(55,*) nmlist, (iunitm,...)` + #[serde(default)] + pub line_mol: Fort55LineMol, + /// 行8 VTB (`synspec54.f:2251`): `READ(55,*) VTB`(湍流速度) + #[serde(default)] + pub line_vtb: Fort55LineVtb, + /// 行9 角度 (`synspec54.f:2301`, ifwin≤0): `READ(55,*) NMU0, ANG0, IFLUX` + #[serde(default)] + pub line_ang: Fort55LineAng, +} + +impl Default for SynspecInput { + /// 默认值与官方样本 `hotsd/synspec/fort.55.lin` + sdB_cno 工作流历史值对齐, + /// 例外:IDSTD 从旧的 50 改为 0(修正 bug);VTB 用 -1.0 显式表达"不覆盖"。 + fn default() -> Self { + SynspecInput { + line1: Fort55Line1::default(), + line2: Fort55Line2::default(), + line3: Fort55Line3::default(), + line4: Fort55Line4::default(), + line5: Fort55Line5::default(), + line6: Fort55Line6::default(), + line_mol: Fort55LineMol::default(), + line_vtb: Fort55LineVtb::default(), + line_ang: Fort55LineAng::default(), + } + } +} + +impl SynspecInput { + /// 校验 fort.55 配置不会触发本生成器不支持的 SYNSPEC 条件读取路径。 + /// + /// SYNSPEC 有若干**条件读取**的行,仅在特定 IMODE/INMOD/INTRPL/NMU0 值下触发。 + /// 本生成器不支持这些条件读取(不生成对应的 DM 数组/分子 iunitm/显式角度数组/风参数行), + /// 若用户配了触发值会导致后续行全部错位。此方法在配置加载时(`GridConfig::validate`)提前报错。 + pub fn validate(&self) -> Result<()> { + // DM 数组条件读取(synspec54.f:284):INMOD>0 AND INTRPL>0 会额外读一行 ND 个浮点数。 + // 生成器未生成此行,配了会导致行3 起全错位。 + if self.line2.inmod > 0 && self.line2.intrpl > 0 { + anyhow::bail!( + "synspec_input.line2: intrpl={} 与 inmod={} 同时 >0 会触发 SYNSPEC DM 数组条件读取\ + (synspec54.f:284),但本生成器不支持生成 DM 数组行。请设 intrpl=0。\ + 如需模型插值,需先扩展生成器支持条件行 A。", + self.line2.intrpl, + self.line2.inmod + ); + } + // 显式角度数组条件读取(synspec54.f:2307):NMU0<0 会额外读一行角度数组。 + // 生成器未生成此行,配了会导致后续行错位。 + if self.line_ang.nmu0 < 0 { + anyhow::bail!( + "synspec_input.line_ang.nmu0={} <0 会触发 SYNSPEC 显式角度数组条件读取\ + (synspec54.f:2307),但本生成器不支持生成角度数组行。请设 nmu0>=0\ + (0=不算比强度,>0=用等距角度)。如需自定义角度,需先扩展生成器支持条件行 C。", + self.line_ang.nmu0 + ); + } + // 风/盘窗口模式(synspec54.f:2357):IMODE<-90 设 IFWIN=1,走完全不同的行9 读取 + // (velmax/ITRAD/nltoff/iemoff 而非 NMU0/ANG0/IFLUX)。生成器仅支持 IFWIN≤0 路径。 + if self.line1.imode < -90 { + anyhow::bail!( + "synspec_input.line1.imode={} <-90 会触发 SYNSPEC 风/盘窗口模式(IFWIN=1,\ + synspec54.f:258-261),行9 改读 velmax/ITRAD/nltoff/iemoff(:2357)而非\ + NMU0/ANG0/IFLUX。本生成器仅支持 IFWIN≤0 的普通合成谱路径。如需风/盘模式,\ + 需扩展生成器支持行8b。", + self.line1.imode + ); + } + // 分子模式(synspec54.f:262-267):IMODE>5 设 ifmol=1,分子表读取(:2208)需 iunitm 数组。 + // 当前 Fort55LineMol 仅支持 nmlist,无 iunitm 字段。nmlist>0 且 ifmol=0 时 SYNSPEC 会 STOP。 + if self.line1.imode > 5 && self.line_mol.nmlist > 0 { + anyhow::bail!( + "synspec_input: imode={} >5 触发分子模式(ifmol=1),且 line_mol.nmlist={} >0,\ + 但本生成器的 Fort55LineMol 不支持配置 iunitm 数组(分子表 unit 号)。\ + 请设 nmlist=0 或扩展生成器支持 iunitm。", + self.line1.imode, + self.line_mol.nmlist + ); + } + Ok(()) + } +} + +/// fort.55 行1 (`synspec54.f:253`): `READ(55,*,END=3) IMODE, IDSTD, IPRIN` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55Line1 { + /// IMODE: 合成谱模式。`=0` 正常合成谱;`=1` 个别谱线详细轮廓;`=2` 仅连续谱(无线); + /// `=-1` 仅证认表(不计算谱);`=-2` iron curtain。`<-90` 触发风/盘窗口模式(IFWIN=1); + /// `>5` 触发分子模式(ifmol=1)。见 `synspec54.f:196-211, 258-267`。 #[serde(default)] pub imode: i32, - #[serde(default = "default_idrv")] - pub idrv: i32, + /// IDSTD: "标准深度"索引(连续光深 τ≈1 的深度点)。`=0` 自动取 `2*ND/3`(默认,推荐); + /// `<0` 自动取 `2*ND/3` 且读 `NDSTEP=-IDSTD`。该层的 T/ne 用于谱线多普勒宽度(DOPSTD)、 + /// 参考丰度(AVAB)、波长网格步长(space)的归一化基准。见 `synspec54.f:207-209, 2152-2160`。 + #[serde(default)] + pub idstd: i32, + /// IPRIN: 输出详尽度。`=0` 标准;`>0` 更多(1=通量到 unit6,2=+证认表,3=+unit96 形成 + /// 深度,4=+unit97 贡献函数);`<0` 更少(-1 关 unit16,-2 关 16 和 12)。 + /// 仅影响诊断打印量,不影响光谱数值。见 `synspec54.f:212-225`。 + #[serde(default = "default_iprin")] + pub iprin: i32, +} + +impl Default for Fort55Line1 { + fn default() -> Self { + Fort55Line1 { + imode: 0, + idstd: 0, // 修正:旧值 50(被错位读为 IDSTD=50=ND 最深层)→ 现 0(自动 2ND/3) + iprin: default_iprin(), + } + } +} + +/// fort.55 行2 (`synspec54.f:254`): `READ(55,*,END=3) INMOD, INTRPL, ICHANG, ICHEMC` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55Line2 { + /// INMOD: 输入模型来源。`=0` Kurucz 模型(INKUR);`=1` TLUSTY 模型(INPMOD); + /// `=2` 吸积盘单环结构。见 `synspec54.f:226-232`。 + #[serde(default = "default_inmod")] + pub inmod: i32, + /// INTRPL: 输入模型是否插值到当前深度网格。`>0` 且 INMOD>0 时额外读一行深度质量 + /// 数组 DM(条件行 A,本生成器不支持——保持 0)。见 `synspec54.f:233-235, 284`。 + #[serde(default)] + pub intrpl: i32, + /// ICHANG: `≠0` 时调用 CHANGE 更新输入模型的布居数/化学组成。见 `synspec54.f:236-238`。 + #[serde(default)] + pub ichang: i32, + /// ICHEMC: `≠0` 时从 unit 56 读新化学组成(覆盖模型自带的丰度)。见 `synspec54.f:239-241`。 + #[serde(default)] + pub ichemc: i32, +} + +impl Default for Fort55Line2 { + fn default() -> Self { + Fort55Line2 { + inmod: default_inmod(), + intrpl: 0, + ichang: 0, + ichemc: 0, + } + } +} + +/// fort.55 行3 (`synspec54.f:255`): `READ(55,*,ERR=3,END=3) IOPHLI, nunalp, nunbet, nungam, nunbal` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55Line3 { + /// IOPHLI: Lyman 线翼处理开关。**注意**:`synspec54.f:269` 有 `iophli=0`(注释 + /// `disabling an old option`)无条件覆盖,即此字段无论写何值都被置 0,Lyman 线翼准分子 + /// 卫星线处理必然关闭。保留字段以维持行结构完整。见 `synspec54.f:241, 255, 269`。 + #[serde(default)] + pub iophli: i32, + /// nunalp: Lyman alpha 准分子卫星线表索引(由 GETLAL 读取)。见 `synspec54.f:275-278`。 + #[serde(default)] + pub nunalp: i32, + /// nunbet: Lyman beta 准分子卫星线表索引。 + #[serde(default)] + pub nunbet: i32, + /// nungam: Lyman gamma 准分子卫星线表索引。 + #[serde(default)] + pub nungam: i32, + /// nunbal: Balmer alpha 准分子卫星线表索引。 + #[serde(default)] + pub nunbal: i32, +} + +/// fort.55 行4 (`synspec54.f:2146`): `READ(55,*) IFREQ, INLTE, ICONTL, INLIST, IFHE2` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55Line4 { + /// IFREQ: 频率网格/求解模式开关。`MOD(IFREQ,10)>0` 影响连续谱波长间距。 + /// 与 RTE/RTECD 求解器选择相关(`ifreq<=10 .and. inmod<=1` → RTECD,否则 RTE)。 + /// **注意**:INLTE 在 21-40 范围时会改写 IFREQ=11(见 INLTE 文档)。 + /// 见 `synspec54.f:2103-2117, 2186`。 #[serde(default = "default_ifreq")] pub ifreq: i32, - #[serde(default = "default_rel_cutoff")] - pub rel_cutoff: f64, - #[serde(default = "default_abs_cutoff")] - pub abs_cutoff: f64, + /// INLTE: NLTE 处理开关。`=0` 纯 LTE;`≠0` 一个或多个谱线用 NLTE 处理。 + /// 偏移编码(`synspec54.f:2179-2192`):`<10` lasdel=T;`10-20` 减 10 且 lasdel=F; + /// `21-30` 减 20 且 IFREQ=11;`31-40` 减 30 且 IFREQ=11 且 lasdel=F。 + #[serde(default = "default_inlte")] + pub inlte: i32, + /// ICONTL: 连续谱处理开关(传 RTE/RTECD 选择)。见 `synspec54.f:135-139`。 + #[serde(default)] + pub icontl: i32, + /// INLIST: 谱线表格式开关。`ibin(0)=mod(inlist,10)`,个位决定二进制模式。 + /// 见 `synspec54.f:2194`。 + #[serde(default)] + pub inlist: i32, + /// IFHE2: `>0` 时对 λ<3900Å 的 He II 前4系(Lyman/Balmer/Paschen/Brackett)按氢线处理 + /// (即使谱线表无 He II)。见 `synspec54.f:2128-2129`。 + #[serde(default)] + pub ifhe2: i32, } -fn default_wstart() -> f64 { - 1400.0 +impl Default for Fort55Line4 { + fn default() -> Self { + Fort55Line4 { + ifreq: default_ifreq(), + inlte: default_inlte(), + icontl: 0, + inlist: 0, + ifhe2: 0, + } + } } -fn default_wend() -> f64 { - 1410.0 +/// fort.55 行5 (`synspec54.f:2148`): `READ(55,*) IHYDPR, IHE1PR, IHE2PR` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55Line5 { + /// IHYDPR: 氢线 Stark 轮廓开关。`=0` 近似公式;`>0` 用 Schoening-Butler 表 + /// (表存于 `FOR0xx.dat`,xx=IHYDPR)。`≠0` 触发 `CALL HYDINI + XENINI`。 + /// 见 `synspec54.f:2129-2135`。 + #[serde(default)] + pub ihydpr: i32, + /// IHE1PR: He I 4471/4026/4387/4922Å 用 Barnard-Cooper-Shamey 表。`>0` 触发 `CALL HE1INI`。 + #[serde(default)] + pub ihe1pr: i32, + /// IHE2PR: He II 用 Schoening-Butler 表。`>0` 触发 `CALL HE2INI`。 + #[serde(default)] + pub ihe2pr: i32, } -fn default_idrv() -> i32 { - 50 +/// fort.55 行6 (`synspec54.f:2149`): `READ(55,*) ALAM0, ALAST, CUTOF0, CUTOFS, RELOP, SPACE` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55Line6 { + /// ALAM0: 合成谱起始波长(Å)。内部乘 0.1 转 nm。`=0` → `5e7/temp(1)/10`; + /// `<0` → `-5e7/temp(1)/ALAM0`。见 `synspec54.f:2103-2108, 2162-2167`。 + #[serde(default = "default_alam0")] + pub alam0: f64, + /// ALAST: 合成谱终止波长(Å)。`<0` → 设 `vaclim=1e18`(全程真空波长)。 + #[serde(default = "default_alast")] + pub alast: f64, + /// CUTOF0: 普通谱线截止参数(线心最大允许距谱线中心的距离,推荐 5-10)。 + /// 内部 `CUTOF0=0.1*CUTOF0`。见 `synspec54.f:2109-2111, 2405`。 + #[serde(default = "default_cutoff0")] + pub cutoff0: f64, + /// CUTOFS: 连续谱波长点间距(= SPACON,中点处实际 log 等距)。**这是连续谱间距, + /// 不是谱线截止**。`=0` → 内部取 3。见 `synspec54.f:2112-2113, 2419`。 + #[serde(default)] + pub cutoffs: f64, + /// RELOP: 谱线计入的最小(线心不透明度/连续不透明度)比,通常 1e-4 到 1e-3。 + /// 见 `synspec54.f:2114-2115`。 + #[serde(default = "default_relop")] + pub relop: f64, + /// SPACE: 相邻频率点最大间距(Å)。`=0` → `4.3e-8*sqrt(temp(idstd))*alamc`; + /// `<0` → 负号规则。见 `synspec54.f:2116-2117, 2401`。 + #[serde(default = "default_space")] + pub space: f64, } +impl Default for Fort55Line6 { + fn default() -> Self { + Fort55Line6 { + alam0: default_alam0(), + alast: default_alast(), + cutoff0: default_cutoff0(), + cutoffs: 0.0, + relop: default_relop(), + space: default_space(), + } + } +} + +/// fort.55 行7 分子表 (`synspec54.f:2208`, IMODE∈[-3,1] 条件读取): +/// `READ(55,*,ERR=5,END=5) nmlist, (iunitm(ilist), ilist=1, nmlist)` +/// +/// 当 IMODE∈[-3,1](默认 IMODE=0 满足)时,SYNSPEC 在读 line6 后会**额外读这一行**。 +/// 默认 nmlist=0(无分子谱线表),此时 iunitm 列表为空。gfortran list-directed READ 在 +/// nmlist=0 时只读 nmlist 一个值并消费整行剩余,故行内只需写出 nmlist。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55LineMol { + /// nmlist: 分子谱线表数量。`=0` 无分子表(默认,sdB 工作流无分子线)。`>0` 需配套 ifmol=1 + /// (由 IMODE>5 触发),否则 SYNSPEC 会 `STOP 'NEEDS TO SET IFMOL > 0'`(`synspec54.f:2227`)。 + /// 见 `synspec54.f:2205-2213`。 + #[serde(default)] + pub nmlist: i32, + // 注:iunitm 数组(各分子表的 unit 号)在 nmlist>0 时才需要,本生成器暂不支持配置 + // (sdB 工作流无分子线)。若未来需要,应在 nmlist>0 时追加 iunitm 字段。 +} + +/// fort.55 行8 VTB (`synspec54.f:2251`): `READ(55,*,ERR=30,END=30) VTB` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55LineVtb { + /// VTB: 湍流速度(km/s)。`>=0` 且 IFWIN≤0 时覆盖大气模型自带的 vturb + /// (`vturb(id)=vtb²·1e10`,`synspec54.f:2257-2258`);**`<0` 不覆盖**(`if(vtb.ge.0.)` 为假, + /// 沿用 TLUSTY 大气的 vturb,默认 2 km/s)。默认 **-1.0 = 不覆盖**——显式表达"沿用大气 vturb" + /// 语义,不依赖 VTB 行缺失走 `end=30` 的旧行为。见 `synspec54.f:2248-2260`。 + #[serde(default = "default_vtb")] + pub vtb: f64, +} + +impl Default for Fort55LineVtb { + fn default() -> Self { + Fort55LineVtb { vtb: default_vtb() } + } +} + +/// fort.55 行9 角度 (`synspec54.f:2301`, IFWIN≤0 分支): `READ(55,*,END=100,ERR=100) NMU0, ANG0, IFLUX` +/// +/// 注意:IFWIN>0(IMODE<-90)时 SYNSPEC 读的是另一组(velmax/ITRAD/nltoff/iemoff), +/// 本生成器仅支持 IFWIN≤0 的普通合成谱路径。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fort55LineAng { + /// NMU0: 角点数。`>0` 且 ANG0>0 在 1..ANG0 等距;`>0` 且 ANG0<0 在 0.7..ANG0 等距+正弦等距; + /// **`<0` 从下一行读显式角度数组**(条件行 C,`synspec54.f:2307`,本生成器不支持); + /// `<=0` → 跳出(不算比强度)。默认 0 = 不算比强度。见 `synspec54.f:2270-2287, 2347`。 + #[serde(default)] + pub nmu0: i32, + /// ANG0: 最小 mu 角(见 NMU0 文档)。NMU0=0 时此值实际不读但需占位。 + #[serde(default)] + pub ang0: f64, + /// IFLUX: 角度依赖强度/通量模式。`=0` 仅通常通量(unit7+17);`=1` 算比强度存 unit18; + /// `=2`(宏观速度场)形式解(RESOLV)。NMU0=0 时此值实际不读但需占位。 + #[serde(default)] + pub iflux: i32, +} + +impl Default for Fort55LineAng { + fn default() -> Self { + Fort55LineAng { + nmu0: 0, + ang0: 0.0, + iflux: 0, + } + } +} + +fn default_iprin() -> i32 { + 1 +} +fn default_vtb() -> f64 { + -1.0 +} +fn default_inmod() -> i32 { + 1 +} fn default_ifreq() -> i32 { 1 } - -fn default_rel_cutoff() -> f64 { +fn default_inlte() -> i32 { + 1 +} +fn default_alam0() -> f64 { + 1400.0 +} +fn default_alast() -> f64 { + 1410.0 +} +fn default_cutoff0() -> f64 { + 10.0 +} +fn default_relop() -> f64 { 0.0001 } - -fn default_abs_cutoff() -> f64 { +fn default_space() -> f64 { 0.01 } +/// 能量守恒 `(RAD+CON)/TOT` 偏离 1 的默认容许阈值:0.01(Hubeny 官方 1% 判据)。 +/// 见 docs/spectrum_correctness_analysis.md §3.1。 +fn default_energy_tolerance() -> Option { + Some(0.01) +} + +/// 温度结构:表层 T / Teff 上限因子(文档§4 红旗 >3×Teff 判坏)。 +fn default_temp_max_factor() -> Option { + Some(3.0) +} +/// 温度结构:全层温度绝对下限(K)。10 K 远低于任何物理大气表层温度。 +fn default_temp_floor() -> Option { + Some(10.0) +} +/// 温度结构:全层温度绝对上限(K)。1e8 K 远高于恒星大气底层温度。 +fn default_temp_ceiling() -> Option { + Some(1.0e8) +} +/// emflux bolometric:∫Fλdλ 偏离 σTeff⁴ 的默认容许(文档§3.2 容差 1-2%)。 +fn default_emflux_tolerance() -> Option { + Some(0.02) +} + +/// 假收敛排查:收敛轨迹首末 max_relc 比值的最小阈值(文档§3.1 ≥3 个量级 = 10³)。 +fn default_convergence_min_ratio() -> Option { + Some(1000.0) +} +/// b 因子合理性:极端值上限(文档§3.1 关键能级 b∈[10⁻³,10³])。 +fn default_bfac_max() -> Option { + Some(1.0e3) +} +/// b 因子合理性:极端值下限。 +fn default_bfac_min() -> Option { + Some(1.0e-3) +} + /// 审查修复:`#[serde(deny_unknown_fields)]` 让旧字段名(如已重命名的 `chain`/`synspec`/ /// `tlusty`/`results`/`itek_fallback`)在反序列化时**立即报错**,而非静默丢弃导致配置失效。 /// 用户明确不需要向后兼容,故用严格模式让配置错误尽早暴露。 @@ -382,11 +1609,17 @@ fn default_abs_cutoff() -> f64 { pub struct GridConfig { pub grid: GridAxesConfig, /// TLUSTY 物理迭代步进链(lte/nc/nl 多阶段 ChainStep 数组)。 - /// 每阶段独立配置 niter/chmax/metals/ilvlin 等参数,由 scheduler 序列化进 + /// 每阶段独立配置 niter/chmax/ilvlin 等参数,由 scheduler 序列化进 /// TaskSpec.tlusty_chain_params,executor 反序列化后透传给 runner.custom_chain。 /// 空(缺省)→ executor 用 `default_chain_for_strategy` 兜底(按策略名选默认链)。 #[serde(default)] pub tlusty_chain: Vec, + /// 种子热启动链(seed_step 策略用)。与 `tlusty_chain` 对称:每个 ChainStep 可独立 + /// 配置 orelax/niter/chmax 等。由 scheduler 序列化进 TaskSpec.seed_chain_params, + /// executor 在 seed_step 时优先使用。 + /// 空(缺省)→ executor 用 `default_seed_chain()` 兜底(runner.rs 内置默认链)。 + #[serde(default)] + pub seed_chain: Vec, /// TLUSTY 输入文件(.5 + nst)的全局物理参数。缺省 None → 走代码内硬编码默认。 /// 由 scheduler 序列化进 TaskSpec.tlusty_input_params,executor 反序列化后 /// 透传给 runner → make_input5 / generate_nst_content。 @@ -419,6 +1652,33 @@ pub struct GridConfig { ///(光谱合成数值参数)区分。缺省 None → `resolve_synspec_config()` 给默认 `[standard]`。 #[serde(default)] pub synspec_stage: Option, + /// 能量守恒硬门槛:`.6` 中 `(RAD+CON)/TOT` 列任一深度偏离 1 的最大容许绝对值。 + /// Hubeny 官方判据 0.01(1%);null → 跳过该校验(非常规配置,如 TLUSTY-only)。 + /// 详见 docs/spectrum_correctness_analysis.md §1.1/§3.1。由 scheduler 序列化进 + /// TaskSpec,executor 透传给 runner 在 TLUSTY 阶段结束后校验。 + #[serde(default = "default_energy_tolerance")] + pub energy_tolerance: Option, + /// 温度结构:表层 T / Teff 上限因子(默认 3.0,文档§4 红旗 >3×Teff)。 + #[serde(default = "default_temp_max_factor")] + pub temp_max_factor: Option, + /// 温度结构:全层温度绝对下限(K,默认 10)。 + #[serde(default = "default_temp_floor")] + pub temp_floor: Option, + /// 温度结构:全层温度绝对上限(K,默认 1e8)。 + #[serde(default = "default_temp_ceiling")] + pub temp_ceiling: Option, + /// emflux bolometric:∫Fλdλ 偏离 σTeff⁴ 的容许绝对值(默认 0.02 = 2%)。 + #[serde(default = "default_emflux_tolerance")] + pub emflux_tolerance: Option, + /// 假收敛排查:收敛轨迹首末 max_relc 比值的最小阈值(默认 1000,文档§3.1 ≥3 量级)。 + #[serde(default = "default_convergence_min_ratio")] + pub convergence_min_ratio: Option, + /// b 因子合理性:极端值上限(默认 1e3,文档§3.1 b∈[10⁻³,10³])。 + #[serde(default = "default_bfac_max")] + pub bfac_max: Option, + /// b 因子合理性:极端值下限(默认 1e-3)。 + #[serde(default = "default_bfac_min")] + pub bfac_min: Option, } fn default_grid_niter() -> Option { @@ -666,6 +1926,79 @@ mod tests { assert_eq!(cfg.nworkers, 16); assert_eq!(cfg.niter, Some(100)); assert!(cfg.seed_step_fallback); + + // 审查 Major-3 加强:验证 tlusty_input 块的反序列化结果,守护 YAML 加载路径。 + // 特别防护 ions 空兜底(审查 Critical-1):YAML 未列 ions 键 → serde default 给空 Vec, + // make_input5 会在渲染时回退默认 22 行表。此处验证 YAML 加载后的结构。 + if let Some(ti) = &cfg.tlusty_input { + // dot5 基本字段 + assert_eq!(ti.dot5.nfread, 2000, "NFREAD 应为 2000"); + assert_eq!(ti.dot5.finstd, "nst", "FINSTD 应为 nst"); + assert_eq!( + ti.dot5.atoms.len(), + 8, + "atoms 应含 H/He/Li/Be/B/C/N/O 共 8 行" + ); + // ions:YAML 显式列出完整 22 行表(H/He/C/N/O) + assert_eq!( + ti.dot5.ions.len(), + 22, + "YAML 应显式列出 22 行 ions(H/He/C/N/O 完整表)" + ); + // nst 高频关键字默认值 + assert_eq!(ti.nst.depth.nd, 50, "ND 默认 50"); + assert_eq!(ti.nst.physics.vtb, 2.0, "VTB 默认 2.0"); + assert_eq!(ti.nst.physics.ielcor, -1, "IELCOR 默认 -1"); + } else { + panic!("sdB_cno.yaml 应含 tlusty_input 块"); + } } } + + /// 审查 Critical-1 回归守护:YAML 加载后 ions 为空 Vec 时,make_input5 必须兜底默认表。 + /// 此测试模拟 sdB_cno.yaml 路径(YAML 不列 ions),确认渲染出的 .5 含完整 ions 能级。 + #[test] + fn test_yaml_empty_ions_fallback_to_default() { + use crate::gen_input5::make_input5; + use crate::models::GridPointParams; + + // 模拟 YAML 加载:tlusty_input 存在但 ions 为空 Vec(serde default 行为) + let global = TlustyInput::default(); + assert!(global.dot5.ions.len() == 22, "代码默认应 22 行"); + + // 构造一个"YAML 加载后 ions 空"的配置 + let mut yaml_loaded = global.clone(); + yaml_loaded.dot5.ions = Vec::new(); // 模拟 serde default 给空 Vec + + let grid = GridPointParams { + teff: 35000.0.into(), + logg: 5.5.into(), + loghe: (-1.0).into(), + logc: (-2.0).into(), + logn: (-2.0).into(), + logo: (-2.0).into(), + }; + let chain = ChainStep { + label: "nl".into(), + lte: "F".into(), + ltgray: "F".into(), + ilvlin: 100, + require_converged: true, + niter: 100, + chmax: Some(0.001), + itek: None, + ichang: None, + idlte: None, + iacc: None, + orelax: None, + }; + let input5 = make_input5(&grid, &chain, &yaml_loaded); + // 兜底后应含完整 ions 能级数据(而非只有终止行) + assert!(input5.contains("data/h1.dat"), "兜底后应含 H 能级数据"); + assert!(input5.contains("data/c1.dat"), "兜底后应含 C 能级数据"); + assert!( + input5.contains("data/o1_23+10lev.dat"), + "兜底后应含 O 能级数据" + ); + } } diff --git a/crates/common/src/conv_check.rs b/crates/common/src/conv_check.rs index 97aeff5..1c97081 100644 --- a/crates/common/src/conv_check.rs +++ b/crates/common/src/conv_check.rs @@ -1,4 +1,7 @@ -use crate::models::{ConvCheckResult, IterCheck}; +use crate::models::{ + BfacCheckResult, ConvergenceTraceCheckResult, ConvCheckResult, EmfluxCheckResult, + EnergyCheckResult, IterCheck, TempStructCheckResult, +}; use regex::Regex; use std::fs::File; use std::io::{BufRead, BufReader}; @@ -334,6 +337,494 @@ pub fn spec_is_valid(path: &Path) -> Option { None } +/// 解析 Fortran 数值字符串为 f64,兼容 **D 记数法**(如 `1.00106D+00`)。 +/// +/// `.6` 能量守恒表的 `TOTAL SURFACE FLUX` 行用 D 记数法(双精度标记),数据行用 E 记数法。 +/// `parse_fortran_float`(见上)覆盖标准/E/无-E 记数法,但不处理 D。本函数在 `parse_fortran_float` +/// 之前把 D/d 归一化为 E,复用其无-E 与溢出处理逻辑。仅用于 `.6` 解析(不改动公共函数行为, +/// 避免 fort.9/fort.7 解析受影响)。 +fn parse_fortran_d_float(s: &str) -> Option { + let trimmed = s.trim(); + // 快路径:不含 D/d 直接走 parse_fortran_float + if !trimmed.to_ascii_lowercase().contains('d') { + return parse_fortran_float(trimmed); + } + let normalized = trimmed.replacen(['D', 'd'], "E", 1); + parse_fortran_float(&normalized) +} + +/// 校验 `.6`(TLUSTY stdout)中的能量守恒诊断 `(RAD+CON)/TOT` 列。 +/// +/// # 物理不变量 +/// TLUSTY 在 LFIN(最终迭代)时由 `OUTPRI`(`tlusty208.f:3889`)无条件写出逐深度通量表: +/// 每深度 `(RAD+CON)/TOT = (辐射通量 + 对流通量) / σTeff⁴`,**应 ≈ 1.0**(能量守恒)。 +/// Hubeny 官方判据:偏离 1 ≤1% 为收敛良好;>5% 明显发散(见 +/// `docs/spectrum_correctness_analysis.md` §1.1/§3.1)。任一深度超 `tolerance` 即判失败。 +/// +/// # 返回值 +/// - `Some(EnergyCheckResult)`:找到能量守恒表并完成校验(含 valid 判定)。 +/// - `None`:`.6` 缺失、无能量守恒表、或无有效数据行——**跳过**(不误判失败)。 +/// 与 `atmosphere_has_nan` 对缺失文件返回 false(语义"无 NaN")的保守策略一致: +/// 非常规配置(TLUSTY-only、极简 niter)可能不产出此表,不应因此判失败。 +/// +/// # 解析策略 +/// 表格由 `TOTAL SURFACE FLUX`(唯一,LFIN 时)标记,其后含 `(RAD+CON)/TOT` 表头行, +/// 随后是逐深度数据行(ID + 10 列,最后一列即校验目标)。用表头行而非行号定位, +/// 对不同 ND(如 50/99)自适应。 +pub fn check_energy_conservation(path: &Path, tolerance: f64) -> Option { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap()); + + // 状态机:找到表头后开始收集数据行。 + let mut found_header = false; + let mut values: Vec<(i32, f64)> = Vec::new(); // (depth_id, rad_con_tot) + + for line in reader.lines().map_while(Result::ok) { + if !found_header { + // 定位含 (RAD+CON)/TOT 的表头行(OUTPRI 写出,固定文本)。 + if line.contains("(RAD+CON)/TOT") { + found_header = true; + } + continue; + } + // 表头之后的数据行:首个 token 是深度 ID(整数),末 token 是 (RAD+CON)/TOT。 + // 遇到非数据行(如分隔线、下一段标题)即结束当前表收集。 + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.is_empty() { + continue; // 空行容忍(表后常见) + } + // 深度 ID:正整数。非整数 → 表已结束(如下一段 "MODEL QUANTITIES" 标题)。 + let depth_id: i32 = match tokens[0].parse() { + Ok(id) if id >= 1 => id, + _ => break, + }; + // NaN/溢出行:标记但不计入(避免误把发散值当有效偏离)。 + if nan_re.is_match(&line) { + continue; + } + // 末 token = (RAD+CON)/TOT。支持 E/D 记数法。 + let val = match parse_fortran_d_float(tokens.last().unwrap()) { + Some(v) if v.is_finite() => v, + _ => continue, + }; + values.push((depth_id, val)); + } + + if !found_header || values.is_empty() { + return None; // 无能量守恒表或无有效数据 → 跳过 + } + + // 找偏离最大的深度点。 + let (worst_depth, max_dev) = values + .iter() + .map(|(id, v)| (*id, (v - 1.0).abs())) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap(); // values 非空,safe + + let valid = max_dev <= tolerance; + Some(EnergyCheckResult { + valid, + max_deviation: max_dev, + worst_depth: Some(worst_depth), + n_depths_checked: values.len(), + tolerance, + error: if valid { + None + } else { + Some(format!( + "能量守恒校验失败:(RAD+CON)/TOT 在深度 {} 偏离 1 达 {:.4}(> 阈值 {:.4})", + worst_depth, max_dev, tolerance + )) + }, + }) +} + +/// 校验 `.7` 大气文件的温度结构边界(见 docs/spectrum_correctness_analysis.md §3.1/§4)。 +/// +/// # 物理不变量 +/// T(τ) 应落在物理合理范围:表层(ID=1)T ≲ 2-3×Teff;总体 10¹-10⁸ K。 +/// 表层 T >>Teff(如 >3×Teff)或任一深度越界 → T(τ) 非物理(红旗 §4)。 +/// +/// # `.7` 文件格式 +/// TLUSTY `OUTPUT` 子程序(`tlusty208.f:14066-14108`)写出: +/// - 行 1:`ND NUMPAR`(深度数、每深度参数数 = NLEVEL+NUMLT) +/// - 行 2..ceil(ND/6)+1:DM 列质量深度数组(FORMAT 502,每行 6 个) +/// - 之后 ND 个深度块,每块 ceil(NUMPAR/5) 行(FORMAT 503,每行 5 个), +/// **每块第一个值即温度 TEMP(ID)**(`tlusty208.f:14079`,INPMOD `X(1)=TEMP`)。 +/// +/// # 参数 +/// - `teff`:有效温度(K),来自网格点参数。 +/// - `max_factor`:表层 T / Teff 上限(默认 3.0)。 +/// - `temp_floor` / `temp_ceiling`:全层温度绝对边界(默认 10 / 1e8 K)。 +/// +/// # 返回值 +/// `Some(TempStructCheckResult)`:完成校验。`None`:`.7` 缺失/格式不可解析 → 跳过。 +pub fn check_temperature_structure( + path: &Path, + teff: f64, + max_factor: f64, + temp_floor: f64, + temp_ceiling: f64, +) -> Option { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + let mut lines = reader.lines().map_while(Result::ok); + + // 行 1:ND NUMPAR + let header = lines.next()?; + let mut hdr = header.split_whitespace(); + let nd: usize = hdr.next()?.parse().ok()?; + let numpar: usize = hdr.next()?.parse().ok()?; + if nd == 0 || numpar == 0 { + return None; + } + let dm_lines = (nd + 5) / 6; // FORMAT 502 每行 6 个 + let block_lines = (numpar + 4) / 5; // FORMAT 503 每行 5 个 + + // 跳过 DM 数组。检查截断:若行不足说明文件残缺,无法可靠定位温度块 → 跳过。 + for _ in 0..dm_lines { + lines.next()?; + } + + // 逐深度块:每块取首个数值 token = TEMP(ID) + let mut temps: Vec = Vec::with_capacity(nd); + for _ in 0..nd { + // 块的第一行第一个 token 即温度(FORMAT 503 行首) + let first_line = lines.next()?; + let first_token = first_line.split_whitespace().next()?; + let t = parse_fortran_float(first_token)?; + temps.push(t); + // 跳过该块剩余行(截断时 next()? 同样提前返回 None) + for _ in 1..block_lines { + lines.next()?; + } + } + + if temps.is_empty() { + return None; + } + let surface_temp = temps[0]; + let bottom_temp = temps[temps.len() - 1]; + let surface_ratio = if teff > 0.0 { surface_temp / teff } else { f64::INFINITY }; + + // 判定:表层超 max_factor×Teff,或任一深度越界 [floor, ceiling]。 + let mut violations: Vec = Vec::new(); + if surface_ratio > max_factor { + violations.push(format!( + "表层 T={:.0}K = {:.2}×Teff 超上限 {:.1}×Teff", + surface_temp, surface_ratio, max_factor + )); + } + for (i, &t) in temps.iter().enumerate() { + if !t.is_finite() || t < temp_floor || t > temp_ceiling { + violations.push(format!( + "深度 {} T={:.3e}K 越界 [{:.0}, {:.0e}]", + i + 1, + t, + temp_floor, + temp_ceiling + )); + break; // 报第一个越界即可 + } + } + + let valid = violations.is_empty(); + Some(TempStructCheckResult { + valid, + teff, + surface_temp, + bottom_temp, + surface_ratio, + n_depths_checked: temps.len(), + max_factor, + temp_floor, + temp_ceiling, + error: if valid { + None + } else { + Some(format!("温度结构校验失败:{}", violations.join("; "))) + }, + }) +} + +/// Stefan-Boltzmann 常量(cgs:erg cm⁻² s⁻¹ K⁻⁴)。 +/// TLUSTY 用 `SIG4P = σ_SB/4π`(`BASICS.FOR:51`);此处用完整 σ_SB 做 bolometric 比对。 +const SIGMA_SB: f64 = 5.670374419e-5; + +/// 校验 `.emflux` 的 bolometric 通量守恒(见 docs/spectrum_correctness_analysis.md §3.2/§4)。 +/// +/// # 物理不变量 +/// ∫Fλ dλ(出射谱全波长积分)≈ σ_SB × Teff⁴。偏离 >2% → 能量不守恒(红旗 §4)。 +/// +/// # `.emflux` 格式 +/// SYNSPEC 写出的 fort.14 快照(runner `snapshot_tlusty_outputs`),两列:波长 λ(Å) / Fλ。 +/// 部分样本全 NaN(大气收敛失败导致辐射转移求解发散)—— NaN 占比过高(>50%)直接判失败。 +/// 详见 `docs/fort14_nan_analysis.md`:fort.14 全 NaN 是大气结构发散的充分(且几乎必要的)标志。 +/// +/// # 算法 +/// 跳过 NaN/不可解析行,对剩余 (λ, Fλ) 按波长排序后梯形积分。NaN 占比 >0.5 → 失败。 +/// 否则比较 integrated / (σTeff⁴),偏离 1 超 `tolerance` → 失败。 +/// +/// # 返回值 +/// `Some(EmfluxCheckResult)`:完成校验(含全 NaN 判失败)。`None`:文件缺失 → 跳过。 +pub fn check_emflux_bolometric( + path: &Path, + teff: f64, + tolerance: f64, +) -> Option { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap()); + + let mut total = 0usize; + let mut points: Vec<(f64, f64)> = Vec::new(); // (lambda, flux) + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + total += 1; + if nan_re.is_match(&line) { + continue; + } + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + if tokens.len() < 2 { + continue; + } + let (lam, flux) = match ( + parse_fortran_d_float(tokens[0]), + parse_fortran_d_float(tokens[1]), + ) { + (Some(l), Some(f)) if l.is_finite() && f.is_finite() => (l, f), + _ => continue, + }; + points.push((lam, flux)); + } + + if total == 0 { + return None; // 空文件 → 跳过 + } + let nan_ratio = 1.0 - (points.len() as f64 / total as f64); + let sigma_teff4 = SIGMA_SB * teff.powi(4); + + // NaN 占比过高 → 大气收敛失败(辐射转移求解发散),直接判失败(不积分)。 + // 源码机制:大气 TEMP/POPUL 发散 → OPACF1 中 HK/TEMP→Inf、POPUL 乘积→Inf → + // RTEFR1 源函数 EMIS1/AB0=Inf/Inf=NaN → 整列 FLUX 全 NaN → OUTPRI 写进 fort.14。 + // 全 NaN 只能由大气发散引起,不存在"大气好但辐射转移单独失败"的路径。 + if nan_ratio > 0.5 { + return Some(EmfluxCheckResult { + valid: false, + integrated_flux: 0.0, + sigma_teff4, + ratio: 0.0, + nan_ratio, + n_points: points.len(), + tolerance, + error: Some(format!( + "emflux NaN/不可解析占比 {:.0}% 过高(>{:.0}%),大气收敛失败(辐射转移求解发散)", + nan_ratio * 100.0, + 50.0 + )), + }); + } + + // 梯形积分:按波长排序后 ∫Fλ dλ。 + points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let mut integral = 0.0; + for w in points.windows(2) { + let (lam0, f0) = w[0]; + let (lam1, f1) = w[1]; + integral += 0.5 * (f0 + f1) * (lam1 - lam0); + } + + let ratio = if sigma_teff4 > 0.0 { + integral / sigma_teff4 + } else { + f64::INFINITY + }; + let valid = (ratio - 1.0).abs() <= tolerance; + Some(EmfluxCheckResult { + valid, + integrated_flux: integral, + sigma_teff4, + ratio, + nan_ratio, + n_points: points.len(), + tolerance, + error: if valid { + None + } else { + Some(format!( + "emflux bolometric 校验失败:∫Fλdλ / σTeff⁴ = {:.4} 偏离 1 超 {:.2}", + ratio, tolerance + )) + }, + }) +} + +/// 收敛轨迹(假收敛)排查(见 docs/spectrum_correctness_analysis.md §2.1/§3.1)。 +/// +/// # 物理不变量 +/// 真收敛的 max_relc 轨迹应单调下降 ≥3 个量级(首/末比 ≥10³)。Ng/Kantorovich 加速 +/// 可人为压低 max_relc 造成"假收敛"——数值达标(< chmax)但统计平衡未真正达成。 +/// +/// # 判定 +/// - 轨迹 <3 拍:无法判定 → 返回 None(跳过,避免短链误杀)。 +/// - 首/末比 < `min_ratio`(默认 1000):可疑假收敛 → 失败。 +/// - 含 NaN/非正值轨迹也判失败。 +/// +/// # 调用时机 +/// 仅在 `converged=true` 的 stage 上做(发散 stage 不需排查假收敛)。 +/// 数据来自 `check_fort9` 已解析的 `itek_history`,无需重读文件。 +pub fn check_convergence_trace( + itek: &[IterCheck], + min_ratio: f64, +) -> Option { + if itek.len() < 3 { + return None; // 轨迹太短,无法判定 + } + let valid_values: Vec = itek + .iter() + .map(|c| c.max_relc) + .filter(|v| v.is_finite() && *v > 0.0) + .collect(); + if valid_values.len() < 2 { + return Some(ConvergenceTraceCheckResult { + valid: false, + first_max_relc: itek[0].max_relc, + last_max_relc: itek.last().map(|c| c.max_relc).unwrap_or(0.0), + ratio: 0.0, + monotonic: false, + n_iters: itek.len(), + min_ratio, + error: Some("收敛轨迹含 NaN/非正值,无法判定收敛质量".to_string()), + }); + } + let first = valid_values[0]; + let last = *valid_values.last().unwrap(); + let ratio = first / last; + let monotonic = valid_values + .windows(2) + .all(|w| w[0] >= w[1] * 0.5); // 容忍 Ng 加速的局部反弹(×2 内) + + let valid = ratio >= min_ratio; + Some(ConvergenceTraceCheckResult { + valid, + first_max_relc: first, + last_max_relc: last, + ratio, + monotonic, + n_iters: itek.len(), + min_ratio, + error: if valid { + None + } else { + Some(format!( + "假收敛排查失败:首末 max_relc 比 {:.1e} < {:.0e}(可能 Ng 加速伪收敛)", + ratio, min_ratio + )) + }, + }) +} + +/// 校验 `.bfac` b 因子合理性(见 docs/spectrum_correctness_analysis.md §3.1/§6 #19)。 +/// +/// # 物理不变量 +/// NLTE 偏离因子 b(=n_NLTE/n_LTE)对关键能级应落在 [10⁻³, 10³];b=1.0 表示 LTE。 +/// 极端值(>1e3 或 <1e-3)比例过高 → 统计平衡未达成或布居数错误。 +/// +/// # `.bfac` 格式 +/// 与 `.7` 同构(`tlusty208.f:14344-14350` WRITBFAC):首行 `ND NUMPAR`,DM 数组, +/// 逐深度块(T/ne/dens + NLTE 布居偏离因子)。表层 grey start 块全 1.0(LTE); +/// 某些深度块开头有 NaN(布居为零的能级),属正常。 +/// +/// # 判定 +/// 扫描所有数值 token,排除 NaN/1.0(LTE 占位),统计有效 b 因子中极端值占比。 +/// 占比 > 10% → 失败。 +/// +/// # 返回值 +/// `Some(BfacCheckResult)`:完成校验。`None`:文件缺失/无有效 b 因子 → 跳过。 +pub fn check_bfactor(path: &Path, max_value: f64, min_value: f64) -> Option { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap()); + + let mut total_tokens = 0usize; + let mut nan_count = 0usize; + let mut b_values: Vec = Vec::new(); + let mut in_data = false; // 跳过首行 + DM 数组 + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + if !in_data { + // 检测首行(两个整数 ND NUMPAR),之后进入数据区 + if tokens.len() == 2 + && tokens[0].parse::().is_ok() + && tokens[1].parse::().is_ok() + { + in_data = true; + } + continue; + } + for tok in &tokens { + total_tokens += 1; + if nan_re.is_match(tok) { + nan_count += 1; + continue; + } + if let Some(v) = parse_fortran_float(tok) { + // 跳过 LTE 占位 b=1.0(grey start 表层);正值有限数才计入 + if v.is_finite() && v > 0.0 && (v - 1.0).abs() > 1e-10 { + b_values.push(v); + } + } + } + } + + if b_values.is_empty() { + return None; // 无有效 b 因子(纯 LTE 模型?)→ 跳过 + } + let nan_ratio = if total_tokens > 0 { + nan_count as f64 / total_tokens as f64 + } else { + 0.0 + }; + let extreme_count = b_values + .iter() + .filter(|&&b| b > max_value || b < min_value) + .count(); + let extreme_ratio = extreme_count as f64 / b_values.len() as f64; + let max_b = b_values.iter().cloned().fold(0.0f64, f64::max); + let min_b = b_values.iter().cloned().fold(f64::INFINITY, f64::min); + let tolerance = 0.1; // 极端值占比上限 10% + let valid = extreme_ratio <= tolerance; + + Some(BfacCheckResult { + valid, + n_values: b_values.len(), + extreme_ratio, + max_b, + min_b, + nan_ratio, + max_value, + min_value, + error: if valid { + None + } else { + Some(format!( + "b 因子校验失败:极端值(>{:.0e} 或 <{:.0e})占比 {:.0}% > {:.0}%", + max_value, min_value, extreme_ratio * 100.0, tolerance * 100.0 + )) + }, + }) +} + /// 从 fort.6(TLUSTY stdout 日志)提取失败诊断提示。 /// /// # 背景(漏洞 5,P2) @@ -741,4 +1232,296 @@ mod tests { assert!((res.itek_history[1].max_relc - 5.0e-4).abs() < 1e-15); assert_eq!(res.itek_history[1].n_depths, 2); } + + /// 构造一段 `.6` 能量守恒表片段(LFIN 时 OUTPRI 写出)。 + /// 每行:ID + 10 列,最后一列是 `(RAD+CON)/TOT`。 + fn make_energy_table(rad_con_tot_values: &[f64]) -> String { + let header = " TOTAL SURFACE FLUX 6.77712339D+12\n\n\ + ----------------------\n\ + FINAL MODEL ATMOSPHERE\n\ + ----------------------\n\ + ID MASS TAUROSS TEMP NE DENS P_gas LOG(G_rad) RAD/TOT CON/TOT (RAD+CON)/TOT\n"; + let mut body = String::new(); + for (i, v) in rad_con_tot_values.iter().enumerate() { + body.push_str(&format!( + " {:>2} 2.917E-07 5.001E-08 32890.1 3.011E+08 5.840E-16 2.632E-03 2.989E+00 1.001E+00 0.000E+00 {:e}\n", + i + 1, + v + )); + } + format!("{}{}", header, body) + } + + #[test] + fn test_energy_conservation_pass() { + // hhe35nl 真实偏离量级:~0.001(收敛良好),阈值 0.01 应通过。 + // 深度 2 偏离略大(模拟真实:不同深度偏离略有差异)。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.6"); + std::fs::write( + &file_path, + make_energy_table(&[1.0005e0, 1.00106e0, 0.9998e0]), + ) + .unwrap(); + let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表"); + assert!(res.valid, "偏离 0.00106 < 阈值 0.01 应通过"); + assert!((res.max_deviation - 1.06e-3).abs() < 1e-10); + assert_eq!(res.n_depths_checked, 3); + assert_eq!(res.worst_depth, Some(2)); // 深度 2 偏离最大 + assert!(res.error.is_none()); + } + + #[test] + fn test_energy_conservation_fail() { + // 深度 2 偏离 0.15(>5%,明显发散),阈值 0.01 应失败。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.6"); + std::fs::write( + &file_path, + make_energy_table(&[1.001e0, 1.15e0, 0.98e0]), + ) + .unwrap(); + let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表"); + assert!(!res.valid, "偏离 0.15 > 阈值 0.01 应失败"); + assert!((res.max_deviation - 0.15).abs() < 1e-10); + assert_eq!(res.worst_depth, Some(2)); + assert!(res.error.is_some()); + } + + #[test] + fn test_energy_conservation_missing_table() { + // `.6` 无能量守恒表(如 TLUSTY 未到 LFIN)→ None(跳过,不误判失败)。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.6"); + std::fs::write(&file_path, "some TLUSTY log without energy table\n").unwrap(); + assert!(check_energy_conservation(&file_path, 0.01).is_none()); + } + + #[test] + fn test_energy_conservation_missing_file() { + // `.6` 文件缺失 → None(跳过)。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("nonexistent.6"); + assert!(check_energy_conservation(&file_path, 0.01).is_none()); + } + + #[test] + fn test_energy_conservation_d_notation() { + // `TOTAL SURFACE FLUX` 行用 D 记数法;数据行也可能用 D。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.6"); + let content = " TOTAL SURFACE FLUX 6.77712339D+12\n\n\ + ----------------------\n\ + FINAL MODEL ATMOSPHERE\n\ + ----------------------\n\ + ID MASS (RAD+CON)/TOT\n\ + 1 2.917E-07 1.001D+00\n\ + 2 3.974E-07 9.99D-01\n"; + std::fs::write(&file_path, content).unwrap(); + let res = check_energy_conservation(&file_path, 0.01).expect("应找到能量守恒表"); + assert!(res.valid, "D 记数法偏离 ≤0.001 应通过"); + assert!((res.max_deviation - 1.0e-3).abs() < 1e-10); + } + + /// 构造一个最小 `.7` 大气文件(ND=3 深度,NUMPAR=3 即仅 T/ne/dens 无 NLTE 布居)。 + /// 格式:行1 `ND NUMPAR`;DM 数组 `ceil(ND/6)=1` 行;每深度块 `ceil(NUMPAR/5)=1` 行。 + /// 每深度块首值即温度 T。 + fn make_dot7(temps: &[f64]) -> String { + let nd = temps.len(); + let numpar = 3; + let mut s = format!(" {} {}\n", nd, numpar); + // DM 数组:每行 6 个(FORMAT 502)。ND≤6 时一行。 + let dm: Vec = (0..nd).map(|i| format!("1.0E-0{}", i + 1)).collect(); + s.push_str(&dm.join(" ")); + s.push('\n'); + // 逐深度块:每块 ceil(3/5)=1 行,首值=T。 + for t in temps { + s.push_str(&format!(" {:.5} 1.0E+10 1.0E-15\n", t)); + } + s + } + + #[test] + fn test_temperature_structure_pass() { + // Teff=50000K,表层 30000K(0.6×Teff)、底层 200000K(4×Teff)——物理合理。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.7"); + std::fs::write(&file_path, make_dot7(&[3.0e4, 1.0e5, 2.0e5])).unwrap(); + let res = + check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7"); + assert!(res.valid, "表层 0.6×Teff、全层 [3e4,2e5] 应通过"); + assert!((res.surface_temp - 3.0e4).abs() < 1.0); + assert!((res.bottom_temp - 2.0e5).abs() < 1.0); + assert!((res.surface_ratio - 0.6).abs() < 1e-4); + assert_eq!(res.n_depths_checked, 3); + } + + #[test] + fn test_temperature_structure_surface_too_hot() { + // Teff=50000K,表层 200000K = 4×Teff > 3.0 上限 → 失败。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.7"); + std::fs::write(&file_path, make_dot7(&[2.0e5, 2.5e5, 3.0e5])).unwrap(); + let res = + check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7"); + assert!(!res.valid, "表层 4×Teff > 3.0 上限应失败"); + assert!(res.surface_ratio > 3.0); + assert!(res.error.is_some()); + } + + #[test] + fn test_temperature_structure_out_of_range() { + // 某深度温度 1e9 K > ceiling 1e8 → 失败。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.7"); + std::fs::write(&file_path, make_dot7(&[3.0e4, 1.0e9, 2.0e5])).unwrap(); + let res = + check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1.0e8).expect("应解析 .7"); + assert!(!res.valid, "深度2 T=1e9 越界 [10,1e8] 应失败"); + } + + #[test] + fn test_temperature_structure_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("nonexistent.7"); + assert!(check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1e8).is_none()); + } + + #[test] + fn test_temperature_structure_truncated_file() { + // DM 数组被截断(ND=3 需 1 行 DM,但此处只给首行头无 DM 行)。 + // 修复前:静默跳过 → 读到后续行当作温度(可能误判)。 + // 修复后:next()? 返回 None → 跳过(不误报)。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("truncated.7"); + std::fs::write(&file_path, " 50 517\n").unwrap(); // 只有首行,无 DM/深度块 + assert!(check_temperature_structure(&file_path, 50000.0, 3.0, 10.0, 1e8).is_none()); + } + + #[test] + fn test_emflux_bolometric_pass() { + // 构造一个 ∫Fλdλ ≈ σTeff⁴ 的简化谱:Teff=50000K。 + // σ×50000⁴ = 5.670374419e-5 × 6.25e18 = 3.544e14 erg/cm²/s。 + // 用梯形:λ∈[100,1100]Å 共 1001 点,均匀 Fλ 使积分 = 3.544e14。 + // 积分 = Fλ × (1100-100) = Fλ × 1000。故 Fλ = 3.544e11。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.emflux"); + let mut content = String::new(); + let sigma_teff4 = 5.670374419e-5 * 50000.0f64.powi(4); + let flux = sigma_teff4 / 1000.0; // 均匀谱,积分=Fλ×1000 + for i in 0..1001 { + let lam = 100.0 + i as f64; + content.push_str(&format!("{:>15.3} {:e}\n", lam, flux)); + } + std::fs::write(&file_path, content).unwrap(); + let res = check_emflux_bolometric(&file_path, 50000.0, 0.02).expect("应解析 emflux"); + assert!(res.valid, "均匀谱积分 = σTeff⁴ 应通过"); + assert!((res.ratio - 1.0).abs() < 0.01, "比值应≈1.0,实际 {}", res.ratio); + assert_eq!(res.nan_ratio, 0.0); + } + + #[test] + fn test_emflux_bolometric_all_nan() { + // 全 NaN → 大气收敛失败(辐射转移求解发散),应判失败(而非跳过)。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.emflux"); + let content: String = (0..100).map(|i| format!("{:>12.3} NaN\n", 100.0 + i as f64)).collect(); + std::fs::write(&file_path, content).unwrap(); + let res = check_emflux_bolometric(&file_path, 50000.0, 0.02).expect("应解析"); + assert!(!res.valid, "全 NaN 应判失败"); + assert!(res.nan_ratio > 0.5); + assert!(res.error.is_some()); + } + + #[test] + fn test_emflux_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("nonexistent.emflux"); + assert!(check_emflux_bolometric(&file_path, 50000.0, 0.02).is_none()); + } + + // ===== 假收敛排查(#8)测试 ===== + + #[test] + fn test_convergence_trace_pass() { + // 真收敛:首=100, 逐拍除10, 10拍末=1e-8, 比值 1e10 > 1000。单调。 + let itek: Vec = (0..10) + .map(|i| IterCheck { + iter: i + 1, + max_relc: 100.0 / 10.0_f64.powi(i), + n_depths: 50, + }) + .collect(); + let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定"); + assert!(res.valid, "比值远 > 1000 应通过"); + assert!(res.ratio >= 1000.0); + assert!(res.monotonic); + } + + #[test] + fn test_convergence_trace_suspect() { + // 假收敛嫌疑:首=10, 末=0.005, 比值 2000 但刚过阈值 → 通过; + // 改为 首=10, 末=0.05, 比值 200 < 1000 → 失败。 + let itek: Vec = (0..6) + .map(|i| IterCheck { + iter: i + 1, + max_relc: 10.0 - i as f64 * 1.65, // 10→1.75 近线性下降 + n_depths: 50, + }) + .collect(); + let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定"); + assert!(!res.valid, "比值 <1000 应判假收敛嫌疑"); + assert!(res.ratio < 1000.0); + assert!(res.error.is_some()); + } + + #[test] + fn test_convergence_trace_too_short() { + // 2拍:无法判定 → None + let itek = vec![ + IterCheck { iter: 1, max_relc: 10.0, n_depths: 50 }, + IterCheck { iter: 2, max_relc: 0.001, n_depths: 50 }, + ]; + assert!(check_convergence_trace(&itek, 1000.0).is_none()); + } + + #[test] + fn test_convergence_trace_ng_bounce() { + // Ng 加速局部反弹但整体收敛:10→0.5→2→0.001。比值 1e4,monotonic 允许 ×2 反弹。 + let itek = vec![ + IterCheck { iter: 1, max_relc: 10.0, n_depths: 50 }, + IterCheck { iter: 2, max_relc: 0.5, n_depths: 50 }, + IterCheck { iter: 3, max_relc: 2.0, n_depths: 50 }, // 反弹 ×4 > ×2 → 非单调 + IterCheck { iter: 4, max_relc: 0.001, n_depths: 50 }, + ]; + let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定"); + assert!(res.valid, "比值 1e4 > 1000 应通过"); + assert!(!res.monotonic, "反弹 ×4 > ×2 应判非单调"); + } + + // ===== b 因子合理性(#19)测试 ===== + + #[test] + fn test_bfactor_pass() { + // 正常 b 因子:多数在 [1e-3, 1e3],少量极端。 + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("model.bfac"); + let content = " 50 3\n\ + 1.0E-07 2.0E-07 3.0E-07 4.0E-07 5.0E-07 6.0E-07\n\ + 1.0E+04 5.0E+01 2.0E+00 1.0E+00 5.0E-01\n\ + 3.0E+02 1.5E+00 8.0E-01 1.0E+00 2.0E+00\n"; + std::fs::write(&file_path, content).unwrap(); + let res = check_bfactor(&file_path, 1.0e3, 1.0e-3).expect("应解析 bfac"); + // 1.0E+04 是极端值(>1e3),1个/约8个有效值 ≈12.5%。 + // 这个测试验证极端值检测;具体 valid 取决于阈值。 + assert!(res.n_values > 0); + assert!(res.max_b > 1.0e3); + } + + #[test] + fn test_bfactor_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("nonexistent.bfac"); + assert!(check_bfactor(&file_path, 1.0e3, 1.0e-3).is_none()); + } } diff --git a/crates/common/src/fort55_writer.rs b/crates/common/src/fort55_writer.rs index 1bd0beb..e242c32 100644 --- a/crates/common/src/fort55_writer.rs +++ b/crates/common/src/fort55_writer.rs @@ -1,41 +1,339 @@ +//! SYNSPEC fort.55 控制卡生成器。 +//! +//! `generate_fort55_content` 把 [`SynspecInput`](按行分组的子结构体)序列化为 fort.55 文本。 +//! 每行字段顺序严格对应 `synspec54.f` 的 `READ(55,*)` 语句,字段名一一映射——详见各 +//! `Fort55Line*` 结构体的文档注释(附 `synspec54.f` 行号)。 + use crate::config::SynspecInput; -/// Dynamic generator for SYNSPEC fort.55 parameter control file +/// 把 [`SynspecInput`] 序列化为 SYNSPEC fort.55 控制卡文本(9 行)。 +/// +/// 每行对应 `synspec54.f` 的一个 `READ(55,*)` 语句,行内字段顺序与 READ 语句完全一致: +/// - 行1 (`:253`): `IMODE IDSTD IPRIN` +/// - 行2 (`:254`): `INMOD INTRPL ICHANG ICHEMC` +/// - 行3 (`:255`): `IOPHLI nunalp nunbet nungam nunbal` +/// - 行4 (`:2146`): `IFREQ INLTE ICONTL INLIST IFHE2` +/// - 行5 (`:2148`): `IHYDPR IHE1PR IHE2PR` +/// - 行6 (`:2149`): `ALAM0 ALAST CUTOF0 CUTOFS RELOP SPACE` +/// - 行7 (`:2208`, IMODE∈[-3,1] 条件读取): `nmlist [iunitm...]` +/// - 行8 (`:2251`): `VTB` +/// - 行9 (`:2301`, IFWIN≤0): `NMU0 ANG0 IFLUX` +/// +/// # 行7(分子表)的必要性 +/// SYNSPEC 在 IMODE∈[-3,1](默认 IMODE=0 满足)时会**额外读一行分子表**(`:2208`)。 +/// gfortran list-directed READ 满足变量列表后会消费整行剩余,故行7必须独立成行, +/// 否则 VTB 行会被分子表 READ 吞掉,导致 vturb 被错误覆盖。见 `Fort55LineMol` 文档。 pub fn generate_fort55_content(cfg: &SynspecInput) -> String { - let line1 = format!(" {} {} {}", cfg.imode, cfg.idrv, cfg.ifreq); - let line2 = " 1 0 0 0"; - let line3 = " 0 0 0 0 0"; - let line4 = " 1 1 0 0 0"; - let line5 = " 0 0 0"; - let line6 = format!( - " {:.1} {:.1} 10 0 {} {}", - cfg.wstart, cfg.wend, cfg.rel_cutoff, cfg.abs_cutoff - ); - let line7 = " 0 0"; + let l1 = &cfg.line1; + let l2 = &cfg.line2; + let l3 = &cfg.line3; + let l4 = &cfg.line4; + let l5 = &cfg.line5; + let l6 = &cfg.line6; + let lm = &cfg.line_mol; + let lv = &cfg.line_vtb; + let la = &cfg.line_ang; + // SYNSPEC 用 list-directed READ(`READ(55,*)`)读取,对空白分隔的数值格式宽容, + // 不要求固定列宽,只需 token 间有空白分隔即可。波长用 .1(与官方样本一致), + // 其余浮点用通用 Display。 format!( - "{}\n{}\n{}\n{}\n{}\n{}\n{}\n", - line1, line2, line3, line4, line5, line6, line7 + // 行1: IMODE, IDSTD, IPRIN + "{imode} {idstd} {iprin}\n\ + {inmod} {intrpl} {ichang} {ichemc}\n\ + {iophli} {nunalp} {nunbet} {nungam} {nunbal}\n\ + {ifreq} {inlte} {icontl} {inlist} {ifhe2}\n\ + {ihydpr} {ihe1pr} {ihe2pr}\n\ + {alam0:.1} {alast:.1} {cutoff0} {cutoffs} {relop} {space}\n\ + {nmlist}\n\ + {vtb}\n\ + {nmu0} {ang0} {iflux}\n", + // 行1 + imode = l1.imode, + idstd = l1.idstd, + iprin = l1.iprin, + // 行2 + inmod = l2.inmod, + intrpl = l2.intrpl, + ichang = l2.ichang, + ichemc = l2.ichemc, + // 行3 + iophli = l3.iophli, + nunalp = l3.nunalp, + nunbet = l3.nunbet, + nungam = l3.nungam, + nunbal = l3.nunbal, + // 行4 + ifreq = l4.ifreq, + inlte = l4.inlte, + icontl = l4.icontl, + inlist = l4.inlist, + ifhe2 = l4.ifhe2, + // 行5 + ihydpr = l5.ihydpr, + ihe1pr = l5.ihe1pr, + ihe2pr = l5.ihe2pr, + // 行6 + alam0 = l6.alam0, + alast = l6.alast, + cutoff0 = l6.cutoff0, + cutoffs = l6.cutoffs, + relop = l6.relop, + space = l6.space, + // 行7 分子表(nmlist=0 时仅写出 nmlist,list-directed READ 消费整行) + nmlist = lm.nmlist, + // 行8 VTB + vtb = lv.vtb, + // 行9 角度 + nmu0 = la.nmu0, + ang0 = la.ang0, + iflux = la.iflux, ) } #[cfg(test)] mod tests { use super::*; + use crate::config::{ + Fort55Line1, Fort55Line2, Fort55Line3, Fort55Line4, Fort55Line5, Fort55Line6, + Fort55LineAng, Fort55LineMol, Fort55LineVtb, + }; + /// 基本:自定义配置的波长值出现在生成的文本中。 #[test] fn test_fort55_generation() { let cfg = SynspecInput { - wstart: 3000.0, - wend: 7000.0, - imode: 0, - idrv: 50, - ifreq: 1, - rel_cutoff: 0.0001, - abs_cutoff: 0.01, + line6: Fort55Line6 { + alam0: 3000.0, + alast: 7000.0, + ..Fort55Line6::default() + }, + ..SynspecInput::default() }; let content = generate_fort55_content(&cfg); - assert!(content.contains("3000.0")); - assert!(content.contains("7000.0")); + assert!(content.contains("3000.0"), "应含起始波长 3000.0"); + assert!(content.contains("7000.0"), "应含终止波长 7000.0"); + } + + /// fort.55 必须恰好 9 行(含行7 分子表 + 行8 VTB + 行9 角度)。 + #[test] + fn test_fort55_line_count() { + let cfg = SynspecInput::default(); + let content = generate_fort55_content(&cfg); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!( + lines.len(), + 9, + "fort.55 应为 9 行(行7 分子表 + 行8 VTB + 行9 角度均显式生成),实际 {} 行:\n{}", + lines.len(), + content + ); + } + + /// 核心回归:字段位置与 synspec54.f READ 语句严格对齐,含行7 分子表。 + /// 重点防护历史上 idrv→IDSTD、ifreq→IPRIN、abs_cutoff→SPACE 的错位 bug, + /// 以及行7 分子表缺失导致 VTB 被吞的问题。 + #[test] + fn test_fort55_field_alignment() { + let cfg = SynspecInput { + line1: Fort55Line1 { + imode: 0, + idstd: 0, + iprin: 1, + }, + line4: Fort55Line4 { + ifreq: 7, // 真正的 IFREQ,应在行4第1列 + inlte: 1, + icontl: 0, + inlist: 0, + ifhe2: 0, + }, + line6: Fort55Line6 { + alam0: 3000.0, + alast: 7000.0, + cutoff0: 10.0, + cutoffs: 0.0, + relop: 0.0001, + space: 0.01, + }, + line_vtb: Fort55LineVtb { vtb: 5.0 }, + ..SynspecInput::default() + }; + let content = generate_fort55_content(&cfg); + let lines: Vec<&str> = content.lines().collect(); + + // 行1: IMODE(0) IDSTD(0) IPRIN(1) + let l1: Vec<&str> = lines[0].split_whitespace().collect(); + assert_eq!(l1[0], "0", "行1第1列应为 IMODE=0"); + assert_eq!(l1[1], "0", "行1第2列应为 IDSTD=0(不再是旧的错位 idrv=50)"); + assert_eq!(l1[2], "1", "行1第3列应为 IPRIN=1"); + + // 行4: IFREQ(7) INLTE(1) ICONTL(0) INLIST(0) IFHE2(0) + let l4: Vec<&str> = lines[3].split_whitespace().collect(); + assert_eq!(l4.len(), 5, "行4应有 5 个字段"); + assert_eq!(l4[0], "7", "行4第1列应为真正的 IFREQ=7(不再错位到行1)"); + + // 行6: ALAM0 ALAST CUTOF0 CUTOFS RELOP SPACE + let l6: Vec<&str> = lines[5].split_whitespace().collect(); + assert_eq!(l6.len(), 6, "行6应有 6 个字段"); + assert_eq!(l6[0], "3000.0", "行6第1列 ALAM0"); + assert_eq!(l6[1], "7000.0", "行6第2列 ALAST"); + assert_eq!(l6[2], "10", "行6第3列 CUTOF0"); + assert_eq!(l6[5], "0.01", "行6第6列 SPACE"); + + // 行7 分子表:nmlist=0(单字段) + let l7: Vec<&str> = lines[6].split_whitespace().collect(); + assert_eq!(l7.len(), 1, "行7 分子表默认 nmlist=0 应为单字段"); + assert_eq!(l7[0], "0", "行7 nmlist=0"); + + // 行8 VTB:自定义值 5.0 + let l8: Vec<&str> = lines[7].split_whitespace().collect(); + assert_eq!(l8.len(), 1, "行8 应为单字段 VTB"); + assert_eq!( + l8[0], "5", + "行8 VTB=5(自定义值,证明 VTB 行未被分子表吞掉)" + ); + + // 行9 角度:NMU0 ANG0 IFLUX + let l9: Vec<&str> = lines[8].split_whitespace().collect(); + assert_eq!(l9.len(), 3, "行9 应有 3 个字段 NMU0/ANG0/IFLUX"); + } + + /// 关键回归:VTB 默认值必须是负值(-1.0),否则 SYNSPEC 会把 vturb 覆盖为 0。 + /// + /// 背景(审查 Critical-1):SYNSPEC `:2253` `if(vtb.ge.0.)` 为真时会用 `vtb²·1e10` + /// 覆盖所有深度的 vturb。默认 VTB=0 会把大气自带的 vturb=2 km/s 覆盖为 0,导致 + /// 谱线多普勒宽度错误。VTB=-1 触发"不覆盖"分支,保留大气 vturb。 + #[test] + fn test_fort55_default_vtb_is_negative() { + let cfg = SynspecInput::default(); + assert!( + cfg.line_vtb.vtb < 0.0, + "默认 VTB 必须为负值(实际 {}),否则 SYNSPEC 会覆盖 vturb 为 0", + cfg.line_vtb.vtb + ); + let content = generate_fort55_content(&cfg); + let lines: Vec<&str> = content.lines().collect(); + let vtb_line = lines[7]; + assert!( + vtb_line.trim_start().starts_with('-'), + "行8 VTB 应以负号开头(实际 '{}'),确保不覆盖大气 vturb", + vtb_line + ); + } + + /// 默认值生成的 fort.55 关键字段应与官方样本 `hotsd/synspec/fort.55.lin` 对齐。 + /// 注意:官方样本是 7 行(靠 EOF 跳过 VTB/角度),本生成器是 9 行(显式生成), + /// 行数不同但 line1-6 的值与样本一致。唯一差异是 IDSTD(样本 50,默认现 0)和 + /// VTB(样本无独立行,默认现 -1)。 + #[test] + fn test_fort55_default_key_fields() { + let cfg = SynspecInput::default(); + let content = generate_fort55_content(&cfg); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 9, "应为 9 行"); + + // 行1: 官方样本 "0 50 1";默认 "0 0 1"(idstd 修正为 0) + let l1: Vec<&str> = lines[0].split_whitespace().collect(); + assert_eq!(l1[0], "0", "IMODE=0 与样本一致"); + assert_eq!(l1[1], "0", "IDSTD=0(样本是 50,这是本次修复的行为变化)"); + assert_eq!(l1[2], "1", "IPRIN=1 与样本一致"); + + // 行2: "1 0 0 0" 与样本一致 + let l2: Vec<&str> = lines[1].split_whitespace().collect(); + assert_eq!(l2, vec!["1", "0", "0", "0"], "行2 与样本一致"); + + // 行4: "1 1 0 0 0" 与样本一致 + let l4: Vec<&str> = lines[3].split_whitespace().collect(); + assert_eq!(l4, vec!["1", "1", "0", "0", "0"], "行4 与样本一致"); + + // 行6: 默认波长 1400/1410、CUTOF0=10、CUTOFS=0、RELOP=0.0001、SPACE=0.01 + let l6: Vec<&str> = lines[5].split_whitespace().collect(); + assert_eq!(l6[0], "1400.0", "ALAM0=1400.0 与样本一致"); + assert_eq!(l6[1], "1410.0", "ALAST=1410.0 与样本一致"); + assert_eq!(l6[2], "10", "CUTOF0=10 与样本一致"); + assert_eq!(l6[3], "0", "CUTOFS=0 与样本一致"); + assert_eq!(l6[4], "0.0001", "RELOP=0.0001 与样本一致"); + assert_eq!(l6[5], "0.01", "SPACE=0.01 与样本一致"); + + // 行7 分子表: nmlist=0(样本行7第一个值也是 0) + let l7: Vec<&str> = lines[6].split_whitespace().collect(); + assert_eq!(l7[0], "0", "nmlist=0 与样本一致"); + + // 行8 VTB: -1(样本无独立 VTB 行,靠 EOF 跳过;本生成器用 -1 显式不覆盖) + // 行9 角度: NMU0=0(样本无,靠 EOF 跳过;本生成器显式 0 不算比强度) + } + + /// 全自定义:所有字段设非默认值,验证无字段丢失或错位。 + #[test] + fn test_fort55_full_custom() { + let cfg = SynspecInput { + line1: Fort55Line1 { + imode: 1, + idstd: -5, + iprin: 3, + }, + line2: Fort55Line2 { + inmod: 0, + intrpl: 0, // 注意:intrpl>0+inmod>0 会触发 DM 数组条件读取,生成器不支持 + ichang: 1, + ichemc: 1, + }, + line3: Fort55Line3 { + iophli: 1, + nunalp: 2, + nunbet: 3, + nungam: 4, + nunbal: 5, + }, + line4: Fort55Line4 { + ifreq: 2, + inlte: 25, + icontl: 1, + inlist: 1, + ifhe2: 1, + }, + line5: Fort55Line5 { + ihydpr: 2, + ihe1pr: 1, + ihe2pr: 1, + }, + line6: Fort55Line6 { + alam0: 900.0, + alast: 1100.0, + cutoff0: 5.0, + cutoffs: 3.0, + relop: 0.001, + space: 0.05, + }, + line_mol: Fort55LineMol { nmlist: 0 }, + line_vtb: Fort55LineVtb { vtb: 5.0 }, + line_ang: Fort55LineAng { + nmu0: 5, + ang0: 0.1, + iflux: 1, + }, + }; + let content = generate_fort55_content(&cfg); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 9); + + // 抽查几个易错位的关键值 + assert!( + lines[0].split_whitespace().nth(1) == Some("-5"), + "行1第2列 IDSTD=-5 应正确写出负数" + ); + assert!( + lines[3].split_whitespace().next() == Some("2"), + "行4第1列 真正的 IFREQ=2" + ); + assert!( + lines[7].split_whitespace().next() == Some("5"), + "行8 VTB=5(VTB 行独立存在,未被分子表吞掉)" + ); + assert!( + lines[8].split_whitespace().next() == Some("5"), + "行9 NMU0=5" + ); } } diff --git a/crates/common/src/gen_input5.rs b/crates/common/src/gen_input5.rs index cde1b09..669172f 100644 --- a/crates/common/src/gen_input5.rs +++ b/crates/common/src/gen_input5.rs @@ -1,398 +1,181 @@ -use crate::config::{AtomConfig, IonConfig, TlustyInput}; +//! TLUSTY .5 输入文件生成器。 +//! +//! [`make_input5`] 把 [`TlustyInput`](全局模板)+ grid(网格点)+ chain(阶段)三源融合, +//! 渲染出 .5 文件文本。grid/chain 覆写 global 模板的对应字段后统一渲染。 + +use crate::config::{ChainStep, Dot5Input, Geometry, IonRow, NonstdExtra, TlustyInput}; use crate::models::GridPointParams; -use std::collections::HashMap; - -struct IonDef { - iat: i32, - iz: i32, - nlevs: i32, - typion: &'static str, - filei: &'static str, -} - -const IONS_H: &[IonDef] = &[ - IonDef { - iat: 1, - iz: 0, - nlevs: 9, - typion: " H 1", - filei: "data/h1.dat", - }, - IonDef { - iat: 1, - iz: 1, - nlevs: 1, - typion: " H 2", - filei: " ", - }, -]; - -const IONS_HE: &[IonDef] = &[ - IonDef { - iat: 2, - iz: 0, - nlevs: 14, - typion: "He 1", - filei: "data/he1.dat", - }, - IonDef { - iat: 2, - iz: 1, - nlevs: 14, - typion: "He 2", - filei: "data/he2.dat", - }, - IonDef { - iat: 2, - iz: 2, - nlevs: 1, - typion: "He 3", - filei: " ", - }, -]; - -const IONS_C: &[IonDef] = &[ - IonDef { - iat: 6, - iz: 0, - nlevs: 40, - typion: " C 1", - filei: "data/c1.dat", - }, - IonDef { - iat: 6, - iz: 1, - nlevs: 22, - typion: " C 2", - filei: "data/c2.dat", - }, - IonDef { - iat: 6, - iz: 2, - nlevs: 46, - typion: " C 3", - filei: "data/c3_34+12lev.dat", - }, - IonDef { - iat: 6, - iz: 3, - nlevs: 25, - typion: " C 4", - filei: "data/c4.dat", - }, - IonDef { - iat: 6, - iz: 4, - nlevs: 1, - typion: " C 5", - filei: " ", - }, -]; - -const IONS_N: &[IonDef] = &[ - IonDef { - iat: 7, - iz: 0, - nlevs: 34, - typion: " N 1", - filei: "data/n1.dat", - }, - IonDef { - iat: 7, - iz: 1, - nlevs: 42, - typion: " N 2", - filei: "data/n2_32+10lev.dat", - }, - IonDef { - iat: 7, - iz: 2, - nlevs: 32, - typion: " N 3", - filei: "data/n3.dat", - }, - IonDef { - iat: 7, - iz: 3, - nlevs: 48, - typion: " N 4", - filei: "data/n4_34+14lev.dat", - }, - IonDef { - iat: 7, - iz: 4, - nlevs: 16, - typion: " N 5", - filei: "data/n5.dat", - }, - IonDef { - iat: 7, - iz: 5, - nlevs: 1, - typion: " N 6", - filei: " ", - }, -]; - -const IONS_O: &[IonDef] = &[ - IonDef { - iat: 8, - iz: 0, - nlevs: 33, - typion: " O 1", - filei: "data/o1_23+10lev.dat", - }, - IonDef { - iat: 8, - iz: 1, - nlevs: 48, - typion: " O 2", - filei: "data/o2_36+12lev.dat", - }, - IonDef { - iat: 8, - iz: 2, - nlevs: 41, - typion: " O 3", - filei: "data/o3_28+13lev.dat", - }, - IonDef { - iat: 8, - iz: 3, - nlevs: 39, - typion: " O 4", - filei: "data/o4.dat", - }, - IonDef { - iat: 8, - iz: 4, - nlevs: 6, - typion: " O 5", - filei: "data/o5.dat", - }, - IonDef { - iat: 8, - iz: 5, - nlevs: 1, - typion: " O 6", - filei: " ", - }, -]; /// 把对数丰度 logx 格式化为 10^logx 的科学计数法字符串。 /// -/// 审查修复 #N3:深度防御 powf 溢出——config.validate 已把丰度轴限制在 [-20, 10], -/// 但 fmt_abn 也可能被 tlusty_chain 里的元素丰度等其它路径调用。若 logx 超出约 [-308, 308], -/// `10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5 输入文件导致 TLUSTY 行为未定义。 -/// 配置层校验在前,此处仅作最终防线:溢出时 saturate 到 f64 可表示范围并按原格式输出。 +/// 深度防御:config.validate 已把丰度轴限制在 [-20, 10],但此处也作最终防线—— +/// 若 logx 超出约 [-300, 300],`10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5。 fn fmt_abn(logx: f64) -> String { let val = if logx.abs() < 300.0 { 10.0f64.powf(logx) + } else if logx < 0.0 { + 0.0 } else { - // 超出安全范围:clamp 到 0(丰度对数极小)或 f64::MAX(丰度对数极大), - // 避免写出 inf。配置层应已拦截,这里不会成为正常路径。 - if logx < 0.0 { - 0.0 - } else { - f64::MAX - } + f64::MAX }; format!("{:.4E}", val) } -/// 元素符号首字母大写归一("h"→"H", "he"→"He", "HE"→"He")。 -/// 容忍用户在 YAML atoms 块用小写/全大写写元素符号。 -fn capitalize_first(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - Some(f) => f.to_uppercase().collect::() + c.as_str().to_lowercase().as_str(), - None => String::new(), - } -} +/// 用 grid + chain 覆写 global 模板,生成渲染用的 Dot5Input。 +fn merge(global: &Dot5Input, grid: &GridPointParams, chain: &ChainStep) -> Dot5Input { + let mut m = global.clone(); -/// 默认 ions 能级数据表(与历史 IONS_* 常量等价)。 -/// 用户未在 `tlusty_input.ions` 配置时使用此表,按 metals 筛选参与元素。 -fn default_ions(metals: &str) -> Vec { - let mt = metals.to_lowercase(); - let has_c = mt.contains('c'); - let has_n = mt.contains('n'); - let has_o = mt.contains('o'); - let mut ions: Vec = Vec::new(); - // H(恒在) - for d in IONS_H { - ions.push(IonConfig { - iat: d.iat, - iz: d.iz, - nlevs: d.nlevs, - typion: d.typion.to_string(), - filei: d.filei.to_string(), - }); + // grid → geometry.teff/grav(恒星大气路径) + if let Geometry::Stellar { teff, logg } = &mut m.geometry { + *teff = grid.teff.value(); + *logg = grid.logg.value(); } - // He(恒在) - for d in IONS_HE { - ions.push(IonConfig { - iat: d.iat, - iz: d.iz, - nlevs: d.nlevs, - typion: d.typion.to_string(), - filei: d.filei.to_string(), - }); - } - let extend = |ions: &mut Vec, table: &[IonDef]| { - for d in table { - ions.push(IonConfig { - iat: d.iat, - iz: d.iz, - nlevs: d.nlevs, - typion: d.typion.to_string(), - filei: d.filei.to_string(), - }); + + // chain → lte/ltgray + m.lte = chain.lte == "T"; + m.ltgray = chain.ltgray == "T"; + + // grid → atoms 块 He/C/N/O 的 abn 列 + for row in &mut m.atoms { + match row.iat { + 2 => row.abn = Some(fmt_abn(grid.loghe.value())), + 6 => row.abn = Some(fmt_abn(grid.logc.value())), + 7 => row.abn = Some(fmt_abn(grid.logn.value())), + 8 => row.abn = Some(fmt_abn(grid.logo.value())), + _ => {} } - }; - if has_c { - extend(&mut ions, IONS_C); } - if has_n { - extend(&mut ions, IONS_N); + + // chain → ions 每行 ilvlin(nlevs==1 裸核强制 0) + for ion in &mut m.ions { + let ilv = if ion.nlevs == 1 { 0 } else { chain.ilvlin }; + ion.ilvlin = Some(ilv); } - if has_o { - extend(&mut ions, IONS_O); - } - ions + + m } -/// Constructs the complete text of a `.5` input file for TLUSTY -/// -/// `input_cfg`:全局输入参数(NFREAD / atoms.mode / ions 表)。 -/// None → 走代码内硬编码默认(与改动前行为完全一致,向后兼容)。 -pub fn make_input5( - params: &GridPointParams, - lte: &str, - ltgray: &str, - metals: &str, - ilvlin: i32, - input_cfg: Option<&TlustyInput>, -) -> String { - let mt = metals.to_lowercase(); - let has_c = mt.contains('c'); - let has_n = mt.contains('n'); - let has_o = mt.contains('o'); +/// 渲染 atoms 块。直接渲染传入的完整列表(元素集由 dot5.atoms 决定,不再用 metals filter); +/// abn 缺省用 "0."(太阳默认)。iabset=Some 时写出 `natoms iabset`(行5 两列), +/// 否则只写 `natoms`(iostat 兜底默认 0)。 +fn render_atoms(atoms: &[crate::config::AtomRow], iabset: Option) -> String { + let header = match iabset { + Some(i) => format!(" {} {}\n* mode abn modpf\n", atoms.len(), i), + None => format!(" {}\n* mode abn modpf\n", atoms.len()), + }; + let mut out = header; + for r in atoms { + let abn = r.abn.clone().unwrap_or_else(|| "0.".to_string()); + out.push_str(&format!(" {} {} {}\n", r.mode, abn, r.modpf)); + } + out +} - // 元素符号 → (mode, abn)。mode 优先取 input_cfg.atoms 覆写,否则走默认。 - // abn(丰度)永远由 GridPointParams 计算——每网格点不同,不可全局配置。 - // 查找时对 key 做首字母大写归一(H/He/Li/Be/B/C/N/O),容忍用户写小写 "h"/"he"。 - // - // M1 修复:config.rs 的 `atoms` 是 Serde 反序列化的 `HashMap`, - // 键按 YAML 原样存储(无规范化)。若用户写 `he:`/`HE:`,原实现只归一探针、不归一存储键, - // 查不到规范键 "He" → mode 覆写静默回落默认(错误物理:想要的 LTE-only 变统计平衡)。 - // 这里把用户键也按同一 capitalize_first 规则归一一次,保证任意大小写都能命中。 - let atoms_normalized: Option> = input_cfg.map(|c| { - let mut m = HashMap::with_capacity(c.atoms.len()); - for (k, v) in &c.atoms { - let canon = capitalize_first(k); - // 归一撞键检测:用户同时写 "C" 与 "c"(或 "He"/"HE")等大小写变体时,两键归一为 - // 同一规范键。原 collect() 静默覆盖(迭代序非确定 → 取哪个任选)。现显式提示 - // 让歧义可见,避免 mode 覆写悄悄取到任意一个变体。 - if let Some(_prev) = m.insert(canon.clone(), v) { - tracing::warn!( - "atoms 键 {:?} 与已有键(归一后同为 {:?})冲突:取最后读取项,请改用单一规范键", - k, - canon - ); +/// 渲染 ions 块。直接渲染传入的完整列表(元素集由 dot5.ions 决定,不再用 metals filter); +/// ilast 由 nlevs 推导;nonstd≠0 渲染附加行。 +/// 列宽固定右对齐,与真实 tlusty 输入(tests/tlusty/hhe/fort.5)逐字节一致: +/// iat→col4, iz→col10, nlevs→col16, ilast→col23, ilvl→col30, nonstd→col37。 +fn render_ions(ions: &[IonRow]) -> String { + let mut out = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string(); + for ion in ions { + let ilast = ion.ilast.unwrap_or(if ion.nlevs == 1 { 1 } else { 0 }); + let ilvl = ion.ilvlin.unwrap_or(0); + out.push_str(&format!( + "{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n", + ion.iat, ion.iz, ion.nlevs, ilast, ilvl, ion.nonstd, ion.typion, ion.filei + )); + // NONSTD≠0 附加行 + if let Some(extra) = &ion.nonstd_extra { + match extra { + NonstdExtra::Positive { + iupsum, + icup, + modeff, + nff, + } => { + out.push_str(&format!( + " {} {} {} {}\n", + iupsum, icup, modeff, nff + )); + } + NonstdExtra::Negative { + inodf1, + inodf2, + fiodf1, + fiodf2, + fibfcs, + } => { + out.push_str(&format!( + " {} {} '{}' '{}' '{}'\n", + inodf1, inodf2, fiodf1, fiodf2, fibfcs + )); + } } } - m - }); - let atom_mode = |sym: &str, default_mode: i32| -> i32 { - atoms_normalized - .as_ref() - .and_then(|m| m.get(&capitalize_first(sym))) - .and_then(|a| a.mode) - .unwrap_or(default_mode) - }; - let atom_modpf = |sym: &str| -> i32 { - atoms_normalized - .as_ref() - .and_then(|m| m.get(&capitalize_first(sym))) - .and_then(|a| a.modpf) - .unwrap_or(0) - }; - - // Atoms block:H/He 恒在;Li/Be/B 恒在(mode=0 不参与);C/N/O 按 metals。 - let mut atom_rows: Vec<(i32, String, i32)> = vec![ - (atom_mode("H", 2), "0.".to_string(), atom_modpf("H")), // 1 H - (atom_mode("He", 2), fmt_abn(*params.loghe), atom_modpf("He")), // 2 He - (atom_mode("Li", 0), "0.".to_string(), atom_modpf("Li")), // 3 Li - (atom_mode("Be", 0), "0.".to_string(), atom_modpf("Be")), // 4 Be - (atom_mode("B", 0), "0.".to_string(), atom_modpf("B")), // 5 B - ]; - if has_c { - atom_rows.push((atom_mode("C", 2), fmt_abn(*params.logc), atom_modpf("C"))); - // 6 C } - if has_n { - atom_rows.push((atom_mode("N", 2), fmt_abn(*params.logn), atom_modpf("N"))); - // 7 N - } - if has_o { - atom_rows.push((atom_mode("O", 2), fmt_abn(*params.logo), atom_modpf("O"))); - // 8 O - } - let natoms = atom_rows.len() as i32; - - let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms); - for (mode, abn, modpf) in &atom_rows { - atoms_block.push_str(&format!(" {} {} {}\n", mode, abn, modpf)); - } - - // Ions block:用户配置非空时完全替换默认表,仍按 metals 筛选 iat。 - let ions: Vec = if let Some(cfg) = input_cfg { - if cfg.ions.is_empty() { - default_ions(metals) - } else { - // 用户自定义表——按 metals 筛选参与元素(H/He 恒在,C/N/O 按 metals)。 - cfg.ions - .iter() - .filter(|ion| match ion.iat { - 1 | 2 => true, // H, He 恒在 - 6 => has_c, - 7 => has_n, - 8 => has_o, - _ => true, // 用户自定义元素不筛 - }) - .cloned() - .collect() - } - } else { - default_ions(metals) - }; - - let mut ions_block = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string(); - for ion in &ions { - let ilast = if ion.nlevs == 1 { 1 } else { 0 }; - let ilvl = if ion.nlevs == 1 { 0 } else { ilvlin }; - // ions 行字段右对齐到固定列,与真实 tlusty 输入文件 - // (tests/tlusty/hhe/fort.5) 逐字节一致: - // iat 结束于 col4(|iat|=4), iz col10(+6), nlevs col16(+6), - // ilast col23(+7), ilvl col30(+7), nonstd col37(+7)。 - ions_block.push_str(&format!( - "{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n", - ion.iat, ion.iz, ion.nlevs, ilast, ilvl, 0, ion.typion, ion.filei - )); - } - // 终止行:(iat,iz,nlevs,ilast,ilvl,nonstd)=(0,0,0,-1,0,0),typion/filei 全空。 - ions_block.push_str(&format!( + // 终止行 + out.push_str(&format!( "{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n", 0, 0, 0, -1, 0, 0, " ", " " )); + out +} - // NFREAD:从配置读,None → 默认 2000。 - let nfread = input_cfg.map(|c| c.nfread).unwrap_or(2000); +/// 渲染 CHANGE 块(ICHANG<0 时,每能级行)。 +fn render_change(change: &[crate::config::ChangeRow]) -> String { + let mut out = String::new(); + for r in change { + out.push_str(&format!( + " {} {} {} {} {} {} {}\n", + r.iold, r.mode, r.nxtold, r.isinew, r.isiold, r.nxtsio, r.rel + )); + } + out +} - format!( - "{:.1} {:.1} ! TEFF, GRAV\n \ +/// 生成完整的 .5 文件文本。 +/// +/// 三源融合:global(`TlustyInput`,完整模板)+ grid(网格点,覆写 teff/logg/丰度) +/// + chain(阶段,覆写 lte/ltgray/ilvlin)。其余字段直接从 global 取。 +pub fn make_input5(grid: &GridPointParams, chain: &ChainStep, global: &TlustyInput) -> String { + let mut m = merge(&global.dot5, grid, chain); + + // 空兜底:YAML 的 dot5 块未列出 ions 时,serde default 给空 Vec(而非 default_ions())。 + // 空 ions 会产出只有终止行的损坏 .5。此处恢复兜底语义:空 → 用默认 22 行表。 + // 注意 merge 已注入了 ilvlin,故此处兜底后需重新注入一次。 + if m.ions.is_empty() { + let mut default = crate::config::default_ions(); + for ion in &mut default { + let ilv = if ion.nlevs == 1 { 0 } else { chain.ilvlin }; + ion.ilvlin = Some(ilv); + } + m.ions = default; + } + + // 行1:geometry + let line1 = match &m.geometry { + Geometry::Stellar { teff, logg } => format!("{:.1} {:.1} ! TEFF, GRAV", teff, logg), + Geometry::Disk { + xmstar, + xmdot, + rstar, + reldst, + } => format!( + "{} {} {} {} ! XMSTAR, XMDOT, RSTAR, RELDST", + xmstar, xmdot, rstar, reldst + ), + }; + + let lte_str = if m.lte { "T" } else { "F" }; + let ltgray_str = if m.ltgray { "T" } else { "F" }; + + let atoms_block = render_atoms(&m.atoms, m.iabset); + let ions_block = render_ions(&m.ions); + + let mut out = format!( + "{}\n \ {} {} ! LTE, LTGRAY\n \ - 'nst' ! name of file containing non-standard flags\n\ + '{}' ! name of file containing non-standard flags\n\ *-----------------------------------------------------------------\n\ * frequencies\n \ {} ! NFREAD\n\ @@ -401,55 +184,136 @@ pub fn make_input5( {}\ *-----------------------------------------------------------------\n\ * data for ions\n*\n\ - {}\ - *\n* end\n", - params.teff, params.logg, lte, ltgray, nfread, atoms_block, ions_block - ) + {}", + line1, lte_str, ltgray_str, m.finstd, m.nfread, atoms_block, ions_block + ); + + // CHANGE 块(ICHANG<0 时) + if chain.ichang.map(|i| i < 0).unwrap_or(false) { + if let Some(change) = &m.change { + out.push_str("*-----------------------------------------------------------------\n"); + out.push_str("* change block (ICHANG<0)\n"); + out.push_str(&render_change(change)); + } + } + + out.push_str("*\n* end\n"); + out } #[cfg(test)] mod tests { use super::*; + use crate::config::TlustyInput; + use crate::models::GridPointParams; - #[test] - fn test_make_input5() { - let params = GridPointParams { + fn grid() -> GridPointParams { + GridPointParams { teff: 35000.0.into(), logg: 5.5.into(), loghe: (-1.0).into(), logc: (-2.0).into(), logn: (-2.0).into(), logo: (-2.0).into(), - }; - let input5 = make_input5(¶ms, "F", "F", "cno", 100, None); + } + } + + fn chain_cno() -> ChainStep { + ChainStep { + label: "nl".into(), + lte: "F".into(), + ltgray: "F".into(), + ilvlin: 100, + require_converged: true, + niter: 100, + chmax: Some(0.001), + itek: None, + ichang: None, + idlte: None, + iacc: None, + orelax: None, + } + } + + #[test] + fn test_make_input5_basic() { + let input5 = make_input5(&grid(), &chain_cno(), &TlustyInput::default()); assert!(input5.contains("35000.0 5.5")); assert!(input5.contains("data/h1.dat")); assert!(input5.contains("data/c1.dat")); assert!(input5.contains("data/n1.dat")); assert!(input5.contains("data/o1_23+10lev.dat")); + assert!(input5.contains("2000"), "NFREAD 默认 2000"); } - /// ions 行必须与真实 tlusty 输入文件 (tests/tlusty/hhe/fort.5) 的宽列宽逐字节一致。 - /// 字段右对齐到固定列:iat→col4, iz→col10, nlevs→col16, ilast→col23, ilvl→col30, nonstd→col37。 - /// 一旦此处失败,说明 ions 格式被改动且偏离了真实 tlusty 输入惯例(虽不影响 list-directed - /// 解析结果,但破坏与历史参考文件的可 diff 性)。 + /// ions 行数值列宽必须与真实 fort.5 逐字节一致(回归守护)。 + /// 用仅含 H+He 的自定义 ions 列表(模拟旧版 metals="" 的效果)。 #[test] fn test_ions_block_byte_perfect_with_real_fort5() { - // H+He 仅(metals="")对应真实 hhe fort.5 的 ions 集。 - let params = GridPointParams { - teff: 35000.0.into(), - logg: 4.0.into(), - loghe: (-2.0).into(), - logc: (-1.0).into(), - logn: (-1.0).into(), - logo: (-1.0).into(), - }; - let input5 = make_input5(¶ms, "T", "T", "", 100, None); - let lines: Vec<&str> = input5.lines().collect(); - - // 真实 fort.5 的 ions 数据行(数值部分 + typion/filei)。 - // 注:filei 真实用 './data/...',Rust/Python 生成用 'data/...'(DCTS 靠 data 软链解析), - // 这是既有独立差异,本测试只校验数值列宽 + typion。 + let mut chain = chain_cno(); + chain.lte = "T".into(); + chain.ltgray = "T".into(); + chain.ilvlin = 100; + // 构造仅含 H+He 的 global(替代旧版 metals="" filter) + let mut global = TlustyInput::default(); + global.dot5.ions = vec![ + IonRow { + iat: 1, + iz: 0, + nlevs: 9, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " H 1".into(), + filei: "data/h1.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 1, + iz: 1, + nlevs: 1, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: " H 2".into(), + filei: " ".into(), + nonstd_extra: None, + }, + IonRow { + iat: 2, + iz: 0, + nlevs: 14, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: "He 1".into(), + filei: "data/he1.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 2, + iz: 1, + nlevs: 14, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: "He 2".into(), + filei: "data/he2.dat".into(), + nonstd_extra: None, + }, + IonRow { + iat: 2, + iz: 2, + nlevs: 1, + ilast: None, + ilvlin: None, + nonstd: 0, + typion: "He 3".into(), + filei: " ".into(), + nonstd_extra: None, + }, + ]; + let input5 = make_input5(&grid(), &chain, &global); let expected_ions_num: &[&str] = &[ " 1 0 9 0 100 0", " 1 1 1 1 0 0", @@ -458,11 +322,9 @@ mod tests { " 2 2 1 1 0 0", " 0 0 0 -1 0 0", ]; - // 从生成结果中提取 ions 数据行的数值部分(引号前)。 - let gen_ions_num: Vec = lines - .iter() + let gen_ions_num: Vec = input5 + .lines() .filter(|l| { - // ions 数据行:含引号且首 token 是整数 l.contains('\'') && l.split_whitespace() .next() @@ -471,132 +333,103 @@ mod tests { }) .map(|l| l.split('\'').next().unwrap().trim_end().to_string()) .collect(); - - assert_eq!( - gen_ions_num.as_slice(), - expected_ions_num, - "ions 行数值部分必须与真实 fort.5 逐字节一致(宽列宽)" - ); + assert_eq!(gen_ions_num.as_slice(), expected_ions_num); } - /// 回归守护:None 配置时 NFREAD=2000、atoms.mode 走默认(H/He=2,Li/Be/B=0)。 + /// grid 注入:teff/logg 与丰度正确覆写。 #[test] - fn test_make_input5_none_cfg_defaults() { - let params = GridPointParams { - teff: 35000.0.into(), - logg: 5.5.into(), - loghe: (-1.0).into(), - logc: (-2.0).into(), - logn: (-2.0).into(), - logo: (-2.0).into(), - }; - let input5 = make_input5(¶ms, "F", "F", "cno", 100, None); - // NFREAD 默认 2000 - assert!(input5.contains("2000"), "None 配置时 NFREAD 应为默认 2000"); - // H 的 mode=2(显式) - assert!(input5.contains(" 2 0. 0"), "H 的 mode 应为默认 2"); + fn test_grid_injection() { + let input5 = make_input5(&grid(), &chain_cno(), &TlustyInput::default()); + assert!(input5.contains("35000.0 5.5")); + // He 丰度 = 10^-1 + let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0)); + assert!(input5.contains(&he_abn), "He abn 应为 {}", he_abn); + // C 丰度 = 10^-2 + let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0)); + assert!(input5.contains(&c_abn)); } - /// 用户配置生效:nfread 覆写、atoms.mode 覆写、ions 自定义表。 + /// chain 注入:lte/ltgray 与 ilvlin 正确覆写。 #[test] - fn test_make_input5_user_cfg_override() { - let params = GridPointParams { - teff: 35000.0.into(), - logg: 5.5.into(), - loghe: (-1.0).into(), - logc: (-2.0).into(), - logn: (-2.0).into(), - logo: (-2.0).into(), - }; - let mut atoms = std::collections::HashMap::new(); - atoms.insert( - "C".to_string(), - crate::config::AtomConfig { - mode: Some(1), - modpf: None, - }, - ); - let cfg = TlustyInput { - nfread: 500, - atoms, - ions: vec![], - nst_extra_keys: vec![], - }; - let input5 = make_input5(¶ms, "F", "F", "cno", 100, Some(&cfg)); - // NFREAD 被覆写为 500 + fn test_chain_injection() { + let mut chain = chain_cno(); + chain.lte = "T".into(); + chain.ltgray = "T".into(); + let input5 = make_input5(&grid(), &chain, &TlustyInput::default()); + assert!(input5.contains("T T"), "lte/ltgray 应为 T T"); + } + + /// 字节格式回归:验证 .5 前几行的续行空格与归档样本一致(审查 Major-1)。 + /// 旧版用 `\n `(空格续行)保留前导空格,新版不得丢失。归档样本见 + /// data/result/.../*.lte.5(L2/L3/L6 有前导空格)。 + #[test] + fn test_dot5_leading_spaces_byte_compat() { + let mut chain = chain_cno(); + chain.lte = "T".into(); + chain.ltgray = "T".into(); + let input5 = make_input5(&grid(), &chain, &TlustyInput::default()); + let lines: Vec<&str> = input5.lines().collect(); + // L2: " T T ! LTE, LTGRAY"(前导空格) assert!( - input5.contains("500") && !input5.contains("2000"), - "NFREAD 应被用户配置覆写为 500" + lines[1].starts_with(' '), + "L2 应有前导空格(旧版续行保留),实际: {:?}", + lines[1] ); - // C 的 mode 被覆写为 1(隐式),而非默认 2 - // atoms 行格式 " {mode} {abn} {modpf}",C 的 abn 是 fmt_abn(logc) - let c_line = input5 - .lines() - .find(|l| l.contains(&format!("{:.4E}", 10.0f64.powf(-2.0)))) - .expect("应找到 C 的 atoms 行"); + assert!(lines[1].contains("T T"), "L2 应含 T T"); + // L3: " 'nst' ! ..."(前导空格) assert!( - c_line.trim_start().starts_with("1"), - "C 的 mode 应被覆写为 1(隐式),实际: {}", - c_line + lines[2].starts_with(' '), + "L3 应有前导空格,实际: {:?}", + lines[2] ); + assert!(lines[2].contains("'nst'"), "L3 应含 'nst'"); + // L6: NFREAD 行(前导空格) + assert!( + lines[5].starts_with(' '), + "L6 应有前导空格,实际: {:?}", + lines[5] + ); + assert!(lines[5].contains("2000"), "L6 应含 NFREAD=2000"); } - /// M1 回归:`atoms` 键大小写不敏感查找。用户写小写/全大写元素键(`c:`/`HE:`)时, - /// 与规范键(`C`/`He`)必须同样命中覆写,否则 mode 覆写会静默回落默认(错误物理)。 + /// atoms.mode 覆写:用户自定义 atoms 表的 mode 生效。 #[test] - fn test_atoms_key_case_insensitive() { - let params = GridPointParams { - teff: 35000.0.into(), - logg: 5.5.into(), - loghe: (-1.0).into(), - logc: (-2.0).into(), - logn: (-2.0).into(), - logo: (-2.0).into(), - }; - // 用户用小写 "c" 与全大写 "HE" 覆写 mode。 - let mut atoms = std::collections::HashMap::new(); - atoms.insert( - "c".to_string(), - crate::config::AtomConfig { - mode: Some(1), - modpf: None, - }, - ); - atoms.insert( - "HE".to_string(), - crate::config::AtomConfig { - mode: Some(0), - modpf: None, - }, - ); - let cfg = TlustyInput { - nfread: 2000, - atoms, - ions: vec![], - nst_extra_keys: vec![], - }; - let input5 = make_input5(¶ms, "F", "F", "cno", 100, Some(&cfg)); - // C(键 "c" 小写)应命中得到 mode=1,而非默认 2。 + fn test_atoms_mode_override() { + let mut global = TlustyInput::default(); + // 把 C 的 mode 改为 1(隐式) + for r in &mut global.dot5.atoms { + if r.iat == 6 { + r.mode = 1; + } + } + let input5 = make_input5(&grid(), &chain_cno(), &global); let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0)); let c_line = input5 .lines() .find(|l| l.contains(&c_abn)) - .expect("应找到 C 的 atoms 行"); + .expect("应找到 C 行"); assert!( c_line.trim_start().starts_with("1"), - "小写键 c 应覆写 C 的 mode 为 1,实际: {}", + "C mode 应为 1,实际: {}", c_line ); - // He(键 "HE" 全大写)应命中得到 mode=0,而非默认 2。 - let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0)); - let he_line = input5 - .lines() - .find(|l| l.contains(&he_abn)) - .expect("应找到 He 的 atoms 行"); - assert!( - he_line.trim_start().starts_with("0"), - "全大写键 HE 应覆写 He 的 mode 为 0,实际: {}", - he_line - ); + } + + /// NFREAD 可配。 + #[test] + fn test_nfread_configurable() { + let mut global = TlustyInput::default(); + global.dot5.nfread = 500; + let input5 = make_input5(&grid(), &chain_cno(), &global); + assert!(input5.contains("500") && !input5.contains("2000")); + } + + /// finstd 可配(空串=不读 nst)。 + #[test] + fn test_finstd_configurable() { + let mut global = TlustyInput::default(); + global.dot5.finstd = "".to_string(); + let input5 = make_input5(&grid(), &chain_cno(), &global); + assert!(input5.contains("''"), "空 finstd 应渲染为 ''"); } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 7f617bc..7bcf378 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -8,4 +8,5 @@ pub mod models; pub mod nst_writer; pub mod result_filter; pub mod runner; +pub mod summary_merge; pub mod seed_finder; diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 255bb2f..3d5326d 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -430,6 +430,11 @@ pub struct TaskSpec { /// 旧 payload 反序列化时缺省为 None(旧节点本就用 default 链,无回归)。 #[serde(default)] pub tlusty_chain_params: Option, + /// 种子热启动链参数(`config::GridConfig.seed_chain` → `Vec`)。 + /// 仅 seed_step 策略下由 executor 读取并透传给 runner。 + /// None/空 → executor 用 `default_seed_chain()` 兜底。 + #[serde(default)] + pub seed_chain_params: Option, /// TLUSTY 输入文件(.5 + nst)的全局物理参数(`config::TlustyInput`)。 /// NFREAD 频率网格、ions 能级表、nst extra_keys 等不随阶段变化的参数。 /// None → runner 用代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量)。 @@ -439,6 +444,31 @@ pub struct TaskSpec { /// 仅 SYNSPEC-only 场景(TLUSTY 关闭)拉取大气用:显式关联大气网格点名。 #[serde(default)] pub atmosphere_ref: Option, + /// 能量守恒硬门槛(`(RAD+CON)/TOT` 偏离 1 的容许绝对值)。源自工作流 YAML + /// `energy_tolerance`(默认 0.01);None → 跳过校验。旧 payload 缺省为 None(用默认)。 + #[serde(default)] + pub energy_tolerance: Option, + /// 温度结构:表层 T / Teff 的上限因子(默认 3.0,文档§4 红旗)。 + #[serde(default)] + pub temp_max_factor: Option, + /// 温度结构:全层温度绝对下限(K,默认 10)。 + #[serde(default)] + pub temp_floor: Option, + /// 温度结构:全层温度绝对上限(K,默认 1e8)。 + #[serde(default)] + pub temp_ceiling: Option, + /// emflux bolometric:∫Fλdλ 偏离 σTeff⁴ 的容许绝对值(默认 0.02 = 2%)。 + #[serde(default)] + pub emflux_tolerance: Option, + /// 假收敛排查:收敛轨迹首末 max_relc 比值的最小阈值(默认 1000,<此值可疑)。 + #[serde(default)] + pub convergence_min_ratio: Option, + /// b 因子合理性:极端值上限(默认 1e3)。 + #[serde(default)] + pub bfac_max: Option, + /// b 因子合理性:极端值下限(默认 1e-3)。 + #[serde(default)] + pub bfac_min: Option, } /// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、 @@ -464,8 +494,17 @@ impl Default for TaskSpec { synspec_config: PhaseConfig::default_synspec(), synspec_params: None, tlusty_chain_params: None, + seed_chain_params: None, tlusty_input_params: None, atmosphere_ref: None, + energy_tolerance: None, + temp_max_factor: None, + temp_floor: None, + temp_ceiling: None, + emflux_tolerance: None, + convergence_min_ratio: None, + bfac_max: None, + bfac_min: None, } } } @@ -585,7 +624,139 @@ pub struct IterCheck { pub n_depths: usize, } -/// Convergence stage summary recorded in conv.json +/// `.6` 能量守恒诊断(LFIN 时 OUTPRI 写出的 `(RAD+CON)/TOT` 列偏离度)。 +/// +/// 物理不变量:每深度 πF(辐射+对流) = σTeff⁴,即 `(RAD+CON)/TOT` 应 ≈1.0。 +/// Hubeny 官方判据:偏离 1 ≤1% 为收敛良好(见 docs/spectrum_correctness_analysis.md §1.1)。 +/// 任一深度超阈值即判失败(硬门槛),与 `atmosphere_has_nan` 同级。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnergyCheckResult { + /// 是否通过(max_deviation ≤ tolerance)。 + pub valid: bool, + /// 逐深度 `(RAD+CON)/TOT` 偏离 1 的最大绝对值。 + pub max_deviation: f64, + /// 偏离最大的深度点编号(1-based,表 ID 列);无法定位时为 None。 + #[serde(default)] + pub worst_depth: Option, + /// 实际校验的深度点行数(应为大气 ND,如 50)。 + pub n_depths_checked: usize, + /// 使用的容差阈值。 + pub tolerance: f64, + /// 失败原因(valid=false 时有值)。 + #[serde(default)] + pub error: Option, +} + +/// `.7` 大气温度结构边界诊断(见 docs/spectrum_correctness_analysis.md §3.1/§4)。 +/// +/// 物理不变量:T(τ) 应落在物理合理范围——表层 T ≲ 2-3×Teff,总体 10¹-10⁸ K。 +/// 表层 T >>Teff(如 >3×Teff)或任一深度越界 → T(τ) 结构非物理(红旗)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TempStructCheckResult { + /// 是否通过(所有深度温度在合理范围)。 + pub valid: bool, + /// Teff(K),来自网格点参数,用于表层倍数判定。 + pub teff: f64, + /// 实测表层(深度 1)温度(K)。 + pub surface_temp: f64, + /// 实测最深层(深度 ND)温度(K)。 + pub bottom_temp: f64, + /// 表层温度 / Teff 比值(红线:>max_factor 即判失败)。 + pub surface_ratio: f64, + /// 实际校验的深度点数。 + pub n_depths_checked: usize, + /// 表层温度上限因子(如 3.0 → 表层 T ≤ 3×Teff)。 + pub max_factor: f64, + /// 温度绝对下限(K,默认 10)。 + pub temp_floor: f64, + /// 温度绝对上限(K,默认 1e8)。 + pub temp_ceiling: f64, + /// 失败原因(valid=false 时有值)。 + #[serde(default)] + pub error: Option, +} + +/// `.emflux` bolometric 通量守恒诊断(见 docs/spectrum_correctness_analysis.md §3.2/§4)。 +/// +/// 物理不变量:∫Fλ dλ(出射谱全波长积分)≈ σ_SB × Teff⁴(bolometric 通量)。 +/// 偏离 >2% → 能量不守恒(红旗)。`.emflux` 全 NaN 也判失败(大气收敛失败导致 +/// 辐射转移求解发散,详见 `docs/fort14_nan_analysis.md`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmfluxCheckResult { + /// 是否通过(积分通量偏离 σTeff⁴ ≤ tolerance 且 NaN 占比可接受)。 + pub valid: bool, + /// 梯形积分 ∫Fλ dλ(erg/cm²/s)。 + pub integrated_flux: f64, + /// 预期 σ_SB × Teff⁴(erg/cm²/s)。 + pub sigma_teff4: f64, + /// integrated_flux / sigma_teff4(应 ≈1.0)。 + pub ratio: f64, + /// `.emflux` 中 NaN/不可解析行的占比(0.0-1.0)。 + pub nan_ratio: f64, + /// 实际参与积分的有效行数。 + pub n_points: usize, + /// 容差(偏离 1.0 的绝对值上限,默认 0.02)。 + pub tolerance: f64, + /// 失败原因(valid=false 时有值)。 + #[serde(default)] + pub error: Option, +} + +/// 收敛轨迹(假收敛排查)诊断(见 docs/spectrum_correctness_analysis.md §2.1/§3.1)。 +/// +/// 物理不变量:真收敛的 max_relc 轨迹应单调下降 ≥3 个量级(首/末比 ≥10³)。 +/// Ng/Kantorovich 加速可人为压低 max_relc 造成"假收敛"——数值达标但统计平衡未达成。 +/// 本校验仅在 `converged=true` 的 stage 上做(发散 stage 不需排查假收敛)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConvergenceTraceCheckResult { + /// 是否通过(首末比 ≥ min_ratio 或轨迹太短无法判定)。 + pub valid: bool, + /// 首次迭代 max_relc。 + pub first_max_relc: f64, + /// 末次迭代 max_relc。 + pub last_max_relc: f64, + /// 首/末比值(≥10³ 为真收敛特征)。 + pub ratio: f64, + /// 轨迹是否单调递减。 + pub monotonic: bool, + /// 参与判定的迭代拍数。 + pub n_iters: usize, + /// 判定的最小首末比阈值(默认 1000)。 + pub min_ratio: f64, + /// 失败原因(valid=false 时有值)。 + #[serde(default)] + pub error: Option, +} + +/// `.bfac` b 因子合理性诊断(见 docs/spectrum_correctness_analysis.md §3.1/§6 #19)。 +/// +/// 物理不变量:NLTE 偏离因子 b(=n_NLTE/n_LTE)对关键能级应落在 [10⁻³, 10³]; +/// b=1.0 表示 LTE。表层(深度 1)的布居可能为 NaN(LTE grey start),属正常。 +/// 本校验检查:有效 b 因子不含 Inf、极端值比例不超阈值。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BfacCheckResult { + /// 是否通过(极端值占比 ≤ tolerance)。 + pub valid: bool, + /// 实际解析的有效 b 因子值数(排除 NaN/LTE 占位)。 + pub n_values: usize, + /// b 因子中 >max_value 或 , +} + + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StepSummary { pub label: String, @@ -609,6 +780,9 @@ pub struct StepSummary { /// 同源于 runner 的 StepSummary,故完整收敛轨迹随 summary_json 落库,不再仅存磁盘。 #[serde(default)] pub itek_history: Vec, + /// 收敛轨迹(假收敛排查)校验结果。仅 converged=true 的 stage 有值。 + #[serde(default)] + pub conv_trace_check: Option, } /// Full execution summary for a grid point @@ -637,6 +811,20 @@ pub struct ModelSummary { /// 极旧版 conv.json 可能缺此字段,default 0.0 兜底(展示层把 ≤0 视为"无数据")。 #[serde(default)] pub elapsed_sec: f64, + /// `.6` 能量守恒校验结果(`(RAD+CON)/TOT` 偏离度,硬门槛)。 + /// None = 未做校验(TLUSTY 未启用 / `.6` 无能量守恒表 / 配置跳过)。 + /// 旧版 conv.json 无此字段,`#[serde(default)]` 反序列化为 None。 + #[serde(default)] + pub energy_check: Option, + /// `.7` 大气温度结构边界校验结果。None = 未做校验。 + #[serde(default)] + pub temp_check: Option, + /// `.emflux` bolometric 通量守恒校验结果。None = 未做校验。 + #[serde(default)] + pub emflux_check: Option, + /// `.bfac` b 因子合理性校验结果。None = 未做校验。 + #[serde(default)] + pub bfac_check: Option, pub note: Option, } @@ -674,6 +862,7 @@ mod tests { max_relc: 0.5, n_depths: 50, }], + conv_trace_check: None, }; let round: StepSummary = serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap(); @@ -683,7 +872,7 @@ mod tests { } /// 旧版 Python run_one.py 写出的 conv.json 必须能完整解析为 ModelSummary - /// (历史结果迁移链路 import_results → /api/admin/import_seed 的兼容性命门)。 + /// (历史结果迁移链路的兼容性命门:旧 conv.json 用 `converged` 键)。 /// /// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套 /// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。 @@ -743,7 +932,7 @@ mod tests { assert_eq!(s.stages[2].best_max_relc, Some(0.0069)); assert_eq!(s.stages[2].elapsed_sec, 650.2); // 旧版把迭代诊断嵌在 final/itek_attempts 里(非扁平字段)→ 扁平字段为 None, - // 但原始诊断仍随 conv.json 原文落盘(import_seed 存原始 summary_json),无数据丢失。 + // 但原始诊断仍随 conv.json 原文落盘,无数据丢失。 assert_eq!(s.stages[2].last_iter, None); assert_eq!(s.stages[2].worst_depth, None); } diff --git a/crates/common/src/nst_writer.rs b/crates/common/src/nst_writer.rs index e1e15c3..7de157f 100644 --- a/crates/common/src/nst_writer.rs +++ b/crates/common/src/nst_writer.rs @@ -1,174 +1,294 @@ +//! TLUSTY nst(非标准标志)文件生成器。 +//! +//! [`generate_nst_content`] 分层覆盖:ChainStep(阶段级)> `TlustyInput.nst`(全局结构化) +//! > `nst_extra_keys`(逃逸口,后写覆盖前写,warn 冲突)。 +//! +//! # 向后兼容 +//! 默认配置生成的 nst **前 2 行**与旧实现字节级一致(line1 含 +//! ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 + ChainStep;line2 含 IELCOR + ChainStep)。 +//! line3 显式写出 26 个 TLUSTY 默认关键字(NMU/ISPLIN/CNU2 等)——旧实现不写这些 +//! (走 TLUSTY 内置默认),新版显式写出使配置透明化。对 sdB 工作流物理等价 +//! (TLUSTY 解析后参数值与旧实现一致)。频率截断 FRCMAX/MIN/FRLMAX/MIN 仅在用户 +//! 配非零值时才写出(0=TLUSTY 默认,不显式覆盖,避免 FRLMIN=0 偏差)。 + use crate::config::{ChainStep, TlustyInput}; +use std::collections::HashSet; -/// 生成 TLUSTY 的 nst(非标准标志)文件内容。 +/// 把 f64 格式化为 TLUSTY nst 值字符串。整数值带小数点(如 2.0 → "2."), +/// 与旧实现硬编码 `"VTB=2."` 字节一致;非整数原样输出(如 0.001 → "0.001")。 +/// -0.0 归一为 "0."(避免 "-0.")。 +fn fmt_real(v: f64) -> String { + if v == 0.0 { + "0.".to_string() + } else if v.fract() == 0.0 { + format!("{}.", v) + } else { + v.to_string() + } +} + +/// 生成 nst 文件内容。 /// -/// nst 文件由 `KEY=VALUE` 对组成,TLUSTY 的 NSTPAR 子程序逐行解析(无行数限制)。 -/// 内容来源(优先级从高到低): -/// 1. `input_cfg.nst_extra_keys`:用户自由传入的任意 KEY=VALUE(逃逸口),追加到末尾。 -/// 2. `stage`(ChainStep):CHMAX/ITEK/NITER/ORELAX/IDLTE/IACC/ICHANG 等阶段差异参数。 -/// 3. 硬编码默认:ND/NLAMBD/VTB/ISPODF/DDNU/CNU1/IELCOR(input_cfg 无对应字段时)。 -/// -/// `input_cfg` 为 None 时走全默认(与改动前行为完全一致,向后兼容)。 -pub fn generate_nst_content(stage: &ChainStep, input_cfg: Option<&TlustyInput>) -> String { - // 第 1 行:深度点数/角度数/湍速/ODF 等物理网格参数 + 收敛控制。 - // ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 当前无结构化字段,保留硬编码(如需覆写用 extra_keys)。 - let mut line1_parts = vec![ - "ND=50".to_string(), - "NLAMBD=3".to_string(), - "VTB=2.".to_string(), - "ISPODF=1".to_string(), - "DDNU=50.".to_string(), - "CNU1=6.".to_string(), +/// 分层覆盖(优先级从高到低): +/// 1. **ChainStep**(阶段级):NITER/CHMAX/ITEK/ORELAX/IDLTE/IACC/ICHANG——若 Some 则写出。 +/// 2. **`global.nst`**(全局结构化):~34 个高频关键字,Default 为 sdB 调优值。 +/// 3. **`global.nst_extra_keys`**(逃逸口):追加末尾,若与已写 key 冲突则 warn。 +pub fn generate_nst_content(chain: &ChainStep, global: &TlustyInput) -> String { + let n = &global.nst; + let mut written_keys: HashSet = HashSet::new(); + let mut out = String::new(); + + // ── 第 1 行:深度/频率/物理网格参数 + 收敛控制(与旧实现字节一致)── + let mut line1: Vec = vec![ + format!("ND={}", n.depth.nd), + format!("NLAMBD={}", n.depth.nlambd), + format!("VTB={}", fmt_real(n.physics.vtb)), + format!("ISPODF={}", n.frequency.ispodf), + format!("DDNU={}", fmt_real(n.frequency.ddnu)), + format!("CNU1={}", fmt_real(n.frequency.cnu1)), ]; + for k in &line1 { + written_keys.insert(k.split('=').next().unwrap().to_string()); + } + // ChainStep 阶段级覆盖 + if let Some(chmax) = chain.chmax { + line1.push(format!("CHMAX={}", chmax)); + written_keys.insert("CHMAX".to_string()); + } + if let Some(itek) = chain.itek { + line1.push(format!("ITEK={}", itek)); + written_keys.insert("ITEK".to_string()); + } + line1.push(format!("NITER={}", chain.niter)); + written_keys.insert("NITER".to_string()); + out.push_str(&line1.join(",")); + out.push('\n'); - if let Some(chmax) = stage.chmax { - line1_parts.push(format!("CHMAX={}", chmax)); + // ── 第 2 行:加速/收敛控制开关 + IELCOR(恒输出,与旧实现一致)── + let mut line2: Vec = Vec::new(); + if let Some(orelax) = chain.orelax { + line2.push(format!("ORELAX={}", orelax)); + written_keys.insert("ORELAX".to_string()); } - if let Some(itek) = stage.itek { - line1_parts.push(format!("ITEK={}", itek)); + if let Some(idlte) = chain.idlte { + line2.push(format!("IDLTE={}", idlte)); + written_keys.insert("IDLTE".to_string()); } - line1_parts.push(format!("NITER={}", stage.niter)); + if let Some(iacc) = chain.iacc { + line2.push(format!("IACC={}", iacc)); + written_keys.insert("IACC".to_string()); + } + if let Some(ichang) = chain.ichang { + line2.push(format!("ICHANG={}", ichang)); + written_keys.insert("ICHANG".to_string()); + } + line2.push(format!("IELCOR={}", n.physics.ielcor)); + written_keys.insert("IELCOR".to_string()); + out.push_str(&line2.join(",")); + out.push('\n'); - // 第 2 行:加速/收敛控制开关。 - let mut line2_parts = Vec::new(); - if let Some(orelax) = stage.orelax { - line2_parts.push(format!("ORELAX={}", orelax)); + // ── 第 3 行起:global.nst 高频关键字(非默认 ChainStep 覆盖的)── + // 所有值统一格式化为 String,避免 i32/f64 混合数组的类型问题。浮点用 fmt_real 带小数点。 + let entries: Vec<(&str, String)> = vec![ + ("NMU", n.depth.nmu.to_string()), + ("ISPLIN", n.depth.isplin.to_string()), + ("CNU2", fmt_real(n.frequency.cnu2)), + ("NFTAIL", n.frequency.nftail.to_string()), + ("DFTAIL", fmt_real(n.frequency.dftail)), + ]; + write_nst_group(&mut out, &mut written_keys, &entries); + // 频率截断 4 字段:仅当用户显式配非零值时才写出(0=TLUSTY 内置默认,不显式覆盖)。 + // 这避免 FRLMIN=0 偏离 TLUSTY 默认(tlusty208.f:1933 frlmin<1e6→×1e13,但 0×1e13=0 无兜底)。 + let freq_cutoffs: Vec<(&str, String)> = [ + ("FRCMAX", &n.frequency.frcmax), + ("FRCMIN", &n.frequency.frcmin), + ("FRLMAX", &n.frequency.frlmax), + ("FRLMIN", &n.frequency.frlmin), + ] + .iter() + .filter(|(_, v)| **v != 0.0) + .map(|(k, v)| (*k, fmt_real(**v))) + .collect(); + if !freq_cutoffs.is_empty() { + write_nst_group(&mut out, &mut written_keys, &freq_cutoffs); } - if let Some(idlte) = stage.idlte { - line2_parts.push(format!("IDLTE={}", idlte)); - } - if let Some(iacc) = stage.iacc { - line2_parts.push(format!("IACC={}", iacc)); - } - if let Some(ichang) = stage.ichang { - line2_parts.push(format!("ICHANG={}", ichang)); - } - line2_parts.push("IELCOR=-1".to_string()); + let entries2: Vec<(&str, String)> = vec![ + ("HMIX0", fmt_real(n.physics.hmix0)), + ("MLTYPE", n.physics.mltype.to_string()), + ("IPTURB", n.physics.ipturb.to_string()), + ("IBFINT", n.physics.ibfint.to_string()), + ("IHECOR", n.physics.ihecor.to_string()), + ("IRTE", n.radiative_transfer.irte.to_string()), + ("IBC", n.radiative_transfer.ibc.to_string()), + ("IFRYB", n.radiative_transfer.ifryb.to_string()), + ("IOPTAB", n.radiative_transfer.ioptab.to_string()), + ("IFMOL", n.radiative_transfer.ifmol.to_string()), + ("IFALI", n.nlte.ifali.to_string()), + ("IFPOPR", n.nlte.ifpopr.to_string()), + ("JALI", n.nlte.jali.to_string()), + ("TRAD", fmt_real(n.irradiation.trad)), + ("WDIL", fmt_real(n.irradiation.wdil)), + ("TFLOOR", fmt_real(n.temperature.tfloor)), + ("TDISK", fmt_real(n.temperature.tdisk)), + ("TMOLIM", fmt_real(n.temperature.tmolim)), + ]; + write_nst_group(&mut out, &mut written_keys, &entries2); - let mut out = format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(",")); - - // 第 3 行起:用户自由传入的额外 nst 关键字(逃逸口)。 - // 每行一个 KEY=VALUE,追加到末尾。用于暴露未结构化的 220+ nst 关键字 - // (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。 - // 多行格式经 TLUSTY 源码验证(tlusty208.f:1819 NSTPAR 用 `READ(INPFI,500,END=70)` - // + `GO TO 10` 逐行循环读至 EOF,每行用 GETWRD 解析 KEY=VALUE),追加行可被正确解析。 - // 安全校验:key/value 含逗号/换行/等号会破坏 nst 的逗号分隔或 KEY=VALUE 解析, - // 跳过非法项并记 warn(避免生成损坏的 nst 导致 tlusty 行为异常)。 - // L1 修复:**key 与 value 都校验**——key 含 `=`/`,`/空白/换行会产出畸形 `KEY=VALUE` - // 行,TLUSTY 的 GETWRD 解析器可能误读。key 必须是合法标识符(非空、无上述分隔符)。 - if let Some(cfg) = input_cfg { - for (key, value) in &cfg.nst_extra_keys { - let bad_key = key.is_empty() - || key.contains(',') - || key.contains('\n') - || key.contains('=') - || key.chars().any(char::is_whitespace); - let bad_value = value.contains(',') || value.contains('\n') || value.contains('='); - if bad_key || bad_value { - tracing::warn!( - "跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析", - key, - value - ); - continue; - } - out.push_str(&format!("{}={}\n", key, value)); + // ── 末尾:nst_extra_keys 逃逸口(后写覆盖,warn 冲突)── + for (key, value) in &global.nst_extra_keys { + let bad_key = key.is_empty() + || key.contains(',') + || key.contains('\n') + || key.contains('=') + || key.chars().any(char::is_whitespace); + let bad_value = value.contains(',') || value.contains('\n') || value.contains('='); + if bad_key || bad_value { + tracing::warn!( + "跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析", + key, + value + ); + continue; } + if written_keys.contains(key) { + tracing::warn!( + "nst_extra_keys {:?}={} 覆盖了已结构化的关键字(后写覆盖前写)", + key, + value + ); + } + out.push_str(&format!("{}={}\n", key, value)); } out } +/// 写出一组 nst 关键字(同一行逗号分隔),跳过已写的 key。 +/// 用 HashSet::insert 返回值做单次遍历去重,避免 entries 内同键重复时两次都写出。 +fn write_nst_group(out: &mut String, written: &mut HashSet, entries: &[(&str, String)]) { + let parts: Vec = entries + .iter() + .filter(|(k, _)| written.insert(k.to_string())) + .map(|(k, v)| format!("{}={}", k, v)) + .collect(); + if !parts.is_empty() { + out.push_str(&parts.join(",")); + out.push('\n'); + } +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn test_nst_generation() { - let stage = ChainStep { - label: "nc".to_string(), - lte: "F".to_string(), - ltgray: "F".to_string(), + fn chain_nc() -> ChainStep { + ChainStep { + label: "nc".into(), + lte: "F".into(), + ltgray: "F".into(), ilvlin: 0, require_converged: false, niter: 10, chmax: None, itek: None, - metals: None, ichang: None, idlte: None, iacc: None, orelax: None, - }; - let content = generate_nst_content(&stage, None); - assert!(content.contains("ND=50")); - assert!(content.contains("NITER=10")); - assert!(content.contains("IELCOR=-1")); + } } - /// extra_keys 追加到 nst 末尾(每行一个 KEY=VALUE)。 #[test] - fn test_nst_extra_keys() { - let stage = ChainStep { - label: "nl".to_string(), - lte: "F".to_string(), - ltgray: "F".to_string(), - ilvlin: 100, - require_converged: true, - niter: 100, - chmax: Some(0.001), - itek: None, - metals: None, - ichang: None, - idlte: None, - iacc: None, - orelax: None, - }; - let cfg = TlustyInput { - nfread: 2000, - atoms: Default::default(), - ions: vec![], + fn test_nst_default_values() { + let content = generate_nst_content(&chain_nc(), &TlustyInput::default()); + assert!(content.contains("ND=50")); + assert!(content.contains("NLAMBD=3")); + // 浮点值带小数点(与旧实现字节一致) + assert!(content.contains("VTB=2."), "VTB 应带小数点"); + assert!(content.contains("ISPODF=1")); + assert!(content.contains("DDNU=50."), "DDNU 应带小数点"); + assert!(content.contains("CNU1=6."), "CNU1 应带小数点"); + assert!(content.contains("IELCOR=-1")); + assert!(content.contains("NITER=10")); + // line3 高频关键字(entries2 修复后应出现) + assert!(content.contains("HMIX0=-1."), "HMIX0 应在 line3"); + assert!(content.contains("IBFINT=1"), "IBFINT 应在 line3"); + assert!(content.contains("TFLOOR=8000."), "TFLOOR 应在 line3"); + assert!(content.contains("NMU=3"), "NMU 应在 line3"); + } + + /// ChainStep 阶段级覆盖 nst 全局字段。 + #[test] + fn test_chain_overrides() { + let mut chain = chain_nc(); + chain.niter = 100; + chain.chmax = Some(0.001); + chain.itek = Some(4); + let content = generate_nst_content(&chain, &TlustyInput::default()); + assert!(content.contains("NITER=100")); + assert!(content.contains("CHMAX=0.001")); + assert!(content.contains("ITEK=4")); + } + + /// nst_extra_keys 追加 + 冲突 warn。 + #[test] + fn test_extra_keys_append() { + let global = TlustyInput { nst_extra_keys: vec![ ("FRCMAX".to_string(), "0.01".to_string()), ("CUTBAL".to_string(), "0.3".to_string()), ], + ..Default::default() }; - let content = generate_nst_content(&stage, Some(&cfg)); - assert!(content.contains("FRCMAX=0.01"), "extra_keys 应追加到 nst"); + let content = generate_nst_content(&chain_nc(), &global); + assert!(content.contains("FRCMAX=0.01")); assert!(content.contains("CUTBAL=0.3")); - // 原有内容仍存在 - assert!(content.contains("NITER=100")); - assert!(content.contains("CHMAX=0.001")); } - /// None 配置时与改动前行为一致(无 extra_keys 行)。 + /// 非法 extra_keys 被跳过。 #[test] - fn test_nst_none_cfg_backward_compat() { - let stage = ChainStep { - label: "lte".to_string(), - lte: "T".to_string(), - ltgray: "T".to_string(), - ilvlin: 0, - require_converged: false, - niter: 0, - chmax: None, - itek: None, - metals: None, - ichang: None, - idlte: None, - iacc: None, - orelax: None, + fn test_extra_keys_validation() { + let global = TlustyInput { + nst_extra_keys: vec![ + ("BAD KEY".to_string(), "1".to_string()), + ("OK".to_string(), "1".to_string()), + ], + ..Default::default() }; - let content = generate_nst_content(&stage, None); - // None 配置时只有 2 行(第1行 ND/NITER 等 + 第2行 IELCOR 等),无 extra_keys 追加行。 - let non_empty_lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect(); + let content = generate_nst_content(&chain_nc(), &global); + assert!(!content.contains("BAD KEY")); + assert!(content.contains("OK=1")); + } + + /// 向后兼容:默认配置生成的 nst 前 2 行与旧实现字节级一致。 + /// 旧实现 line1 = "ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=10" + /// 旧实现 line2 = "IELCOR=-1" + /// line3 显式写出 26 个 TLUSTY 默认关键字(物理等价)。 + /// 频率截断 FRCMAX/MIN/FRLMAX/MIN 不在默认输出中(0=不写出,走 TLUSTY 默认)。 + #[test] + fn test_default_byte_compat_first_two_lines() { + let content = generate_nst_content(&chain_nc(), &TlustyInput::default()); + let lines: Vec<&str> = content.lines().collect(); + assert!(lines.len() >= 2, "至少 2 行"); assert_eq!( - non_empty_lines.len(), - 2, - "None 配置时 nst 应只有 2 行,实际 {} 行: {:?}", - non_empty_lines.len(), - non_empty_lines + lines[0], "ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=10", + "line1 应与旧实现字节一致" ); - assert!(!content.contains("FRCMAX")); + assert_eq!(lines[1], "IELCOR=-1", "line2 应与旧实现字节一致"); + // 频率截断 4 字段默认不写出(值为 0) + assert!(!content.contains("FRCMAX"), "FRCMAX=0 不应写出"); + assert!(!content.contains("FRCMIN"), "FRCMIN=0 不应写出"); + assert!(!content.contains("FRLMAX"), "FRLMAX=0 不应写出"); + assert!(!content.contains("FRLMIN"), "FRLMIN=0 不应写出"); + } + + /// fmt_real 边界:-0.0 归一为 "0."(审查 Nit-1)。 + #[test] + fn test_fmt_real_negative_zero() { + assert_eq!(fmt_real(0.0), "0."); + assert_eq!(fmt_real(-0.0), "0.", "-0.0 应归一为 0."); + assert_eq!(fmt_real(2.0), "2."); + assert_eq!(fmt_real(50.0), "50."); + assert_eq!(fmt_real(0.25), "0.25"); } } diff --git a/crates/common/src/result_filter.rs b/crates/common/src/result_filter.rs index c6b4b37..c7eff44 100644 --- a/crates/common/src/result_filter.rs +++ b/crates/common/src/result_filter.rs @@ -1,7 +1,7 @@ //! 归档白名单:决定沙盒里的哪些文件值得进入持久归档目录(`data/result//`)。 //! -//! node 端实时结果归档(`executor::save_result_artifacts`)与离线迁移工具(`import_results`) -//! 共用本模块的 [`is_result_worthy`],确保两条路径的结果归档口径一致、不会随时间漂移。 +//! node 端实时结果归档(`executor::save_result_artifacts`)共用本模块的 +//! [`is_result_worthy`],确保结果归档口径稳定。 //! //! ## 设计原则 //! diff --git a/crates/common/src/runner.rs b/crates/common/src/runner.rs index 229a02b..44b424e 100644 --- a/crates/common/src/runner.rs +++ b/crates/common/src/runner.rs @@ -1,5 +1,9 @@ use crate::config::{ChainStep, SynspecInput, TlustyInput}; -use crate::conv_check::{atmosphere_has_nan, check_fort9, extract_failure_hint, spec_is_valid}; +use crate::conv_check::{ + atmosphere_has_nan, check_bfactor, check_convergence_trace, check_emflux_bolometric, + check_energy_conservation, check_fort9, check_temperature_structure, extract_failure_hint, + spec_is_valid, +}; use crate::embedded::RuntimePaths; use crate::fort55_writer::generate_fort55_content; use crate::gen_input5::make_input5; @@ -24,7 +28,6 @@ pub fn default_cold_chain() -> Vec { niter: 0, chmax: None, itek: None, - metals: Some("cno".to_string()), ichang: None, idlte: None, iacc: None, @@ -39,7 +42,6 @@ pub fn default_cold_chain() -> Vec { niter: 10, chmax: None, itek: None, - metals: Some("cno".to_string()), ichang: None, idlte: None, iacc: None, @@ -54,7 +56,6 @@ pub fn default_cold_chain() -> Vec { niter: 100, chmax: None, itek: None, - metals: Some("cno".to_string()), ichang: None, idlte: None, iacc: None, @@ -74,11 +75,10 @@ pub fn default_seed_chain() -> Vec { niter: 20, chmax: None, itek: None, - metals: Some("cno".to_string()), ichang: Some(0), idlte: None, iacc: None, - orelax: None, + orelax: Some(0.3), }, ChainStep { label: "nl".to_string(), @@ -89,11 +89,10 @@ pub fn default_seed_chain() -> Vec { niter: 100, chmax: None, itek: None, - metals: Some("cno".to_string()), ichang: Some(0), idlte: None, iacc: None, - orelax: None, + orelax: Some(0.5), }, ] } @@ -222,6 +221,14 @@ impl<'a> ExecutionRunner<'a> { seed_atmos: Option<&Path>, synspec_cfg: Option<&SynspecInput>, tlusty_input: Option<&TlustyInput>, + energy_tolerance: Option, + temp_max_factor: Option, + temp_floor: Option, + temp_ceiling: Option, + emflux_tolerance: Option, + convergence_min_ratio: Option, + bfac_max: Option, + bfac_min: Option, ) -> Result { self.run_model_with_timeout( params, @@ -235,6 +242,14 @@ impl<'a> ExecutionRunner<'a> { 7200, None, tlusty_input, + energy_tolerance, + temp_max_factor, + temp_floor, + temp_ceiling, + emflux_tolerance, + convergence_min_ratio, + bfac_max, + bfac_min, ) .await } @@ -262,6 +277,14 @@ impl<'a> ExecutionRunner<'a> { timeout_sec: u64, shutdown: Option>, tlusty_input: Option<&TlustyInput>, + energy_tolerance: Option, + temp_max_factor: Option, + temp_floor: Option, + temp_ceiling: Option, + emflux_tolerance: Option, + convergence_min_ratio: Option, + bfac_max: Option, + bfac_min: Option, ) -> Result { // `name` 取自权威的 TaskSpec.point_name(DB 的 grid_points.name 列,源精度正确), // 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列, @@ -335,26 +358,22 @@ impl<'a> ExecutionRunner<'a> { } let tlusty_skipped = !tlusty_enabled; + // tlusty_input 为 None(YAML 未配 tlusty_input 块)时用默认值。 + // 默认值定义集中在 config.rs 的 TlustyInput::default(),此处不再重复维护。 + let default_input = TlustyInput::default(); + let input_cfg: &TlustyInput = tlusty_input.unwrap_or(&default_input); 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( - params, - &stage_def.lte, - &stage_def.ltgray, - metals, - stage_def.ilvlin, - tlusty_input, - ); + let input5_text = make_input5(params, stage_def, input_cfg); let input5_path = model_dir.join(format!("{}.5", name)); tokio::fs::write(&input5_path, &input5_text).await?; // Write nst file - let nst_text = generate_nst_content(stage_def, tlusty_input); + let nst_text = generate_nst_content(stage_def, input_cfg); tokio::fs::write(model_dir.join("nst"), &nst_text).await?; // Prepare fort.8 for this stage @@ -417,6 +436,7 @@ impl<'a> ExecutionRunner<'a> { worst_depth: None, n_depths: None, itek_history: Vec::new(), + conv_trace_check: None, }; if rc == 0 && fort7.is_file() { @@ -431,7 +451,26 @@ impl<'a> ExecutionRunner<'a> { stage_summary.worst_depth = Some(res.worst_depth); stage_summary.n_depths = Some(res.n_depths); // itek 全量保真(Phase 5b):逐次迭代诊断随 summary_json/conv.json 落库。 - stage_summary.itek_history = res.itek_history; + stage_summary.itek_history = res.itek_history.clone(); + + // 假收敛排查(§2.1/§3.1):仅对 converged=true 的 stage 做。 + // Ng/Kantorovich 加速可压低 max_relc 造成数值达标但平衡未达成。 + if res.converged { + if let Some(min_ratio) = convergence_min_ratio { + if let Some(tc) = + check_convergence_trace(&res.itek_history, min_ratio) + { + if !tc.valid { + stage_summary.converged = false; + stage_summary.note = Some(format!( + "未收敛 [{}]", + tc.error.as_deref().unwrap_or("假收敛排查失败") + )); + } + stage_summary.conv_trace_check = Some(tc); + } + } + } // 漏洞5修复:发散时从 fort.6 提取求解器 STOP 行(SOLVE/SOLVES/RYBSOL) // 作为 note,提升归因质量。仅未收敛且无既有 note 时补(避免覆盖错误信息)。 @@ -560,6 +599,87 @@ impl<'a> ExecutionRunner<'a> { final_converged = false; } + // 能量守恒硬门槛(docs/spectrum_correctness_analysis.md §1.1 第一层 #4): + // 解析 `.6`(收敛链最后阶段 stdout,含 LFIN 时 OUTPRI 写出的能量守恒表)的 + // `(RAD+CON)/TOT` 列。任一深度偏离 1 超阈值即判失败(与 atmosphere_has_nan 同级)。 + // 此时 `.6` 仍存在(行 688 的冗余清理在此之后),内容是最后阶段日志。 + // energy_tolerance=None → 跳过(非常规配置);`.6` 无能量守恒表 → 函数返回 None 跳过。 + let energy_check = if tlusty_enabled { + energy_tolerance.and_then(|tol| { + let fort6 = model_dir.join(format!("{}.6", name)); + check_energy_conservation(&fort6, tol) + }) + } else { + None + }; + let mut energy_failed = false; + if let Some(ref ec) = energy_check { + if !ec.valid { + final_converged = false; + energy_failed = true; + } + } + + // 温度结构边界校验(docs/spectrum_correctness_analysis.md §3.1/§4): + // 解析 `.7`(最终大气)逐深度温度 T,表层 >max_factor×Teff 或全层越界判失败。 + // 读 final_7(此时已就位);tlusty_enabled=false 时 final_7 来自外部种子,跳过。 + let teff = params.teff.value(); + let temp_check = if tlusty_enabled { + temp_max_factor.and_then(|mf| { + let floor = temp_floor.unwrap_or(10.0); + let ceiling = temp_ceiling.unwrap_or(1.0e8); + check_temperature_structure(&final_7, teff, mf, floor, ceiling) + }) + } else { + None + }; + let mut temp_failed = false; + if let Some(ref tc) = temp_check { + if !tc.valid { + final_converged = false; + temp_failed = true; + } + } + + // emflux bolometric 通量守恒校验(§3.2/§4): + // 解析 `.emflux`(snapshot_tlusty_outputs 已快照),梯形积分 ∫Fλdλ 比 σTeff⁴。 + // 全 NaN(辐射转移失败)也判失败。文件缺失 → 跳过(非常规配置)。 + let emflux_check = if tlusty_enabled { + emflux_tolerance.and_then(|tol| { + let emflux_path = model_dir.join(format!("{}.emflux", name)); + check_emflux_bolometric(&emflux_path, teff, tol) + }) + } else { + None + }; + let mut emflux_failed = false; + if let Some(ref ec) = emflux_check { + if !ec.valid { + final_converged = false; + emflux_failed = true; + } + } + + // b 因子合理性校验(§3.1/§6 #19):解析 `.bfac`(snapshot_tlusty_outputs 已快照), + // 检查 NLTE 偏离因子 b 无极端值(>1e3 或 <1e-3 占比 >10% → 失败)。 + // 文件缺失/无有效 b 因子 → 跳过(纯 LTE 模型或 grey start)。 + let bfac_check = if tlusty_enabled { + bfac_max.and_then(|mx| { + let mn = bfac_min.unwrap_or(1.0e-3); + let bfac_path = model_dir.join(format!("{}.bfac", name)); + check_bfactor(&bfac_path, mx, mn) + }) + } else { + None + }; + let mut bfac_failed = false; + if let Some(ref bc) = bfac_check { + if !bc.valid { + final_converged = false; + bfac_failed = true; + } + } + // Run synspec if enabled and final .7 atmosphere exists let mut synspec_rc = None; let mut synspec_err = None; @@ -588,15 +708,9 @@ impl<'a> ExecutionRunner<'a> { let _ = tokio::fs::remove_file(&fort55_path).await; let _ = tokio::fs::remove_file(&fort19_path).await; - let default_cfg = SynspecInput { - wstart: 1400.0, - wend: 1410.0, - imode: 0, - idrv: 50, - ifreq: 1, - rel_cutoff: 0.0001, - abs_cutoff: 0.01, - }; + // synspec_cfg 为 None(工作流未配 synspec_input 块)时用默认值。 + // 默认值定义集中在 config.rs 的 SynspecInput::default(),此处不再重复维护。 + let default_cfg = SynspecInput::default(); 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!( @@ -725,6 +839,34 @@ impl<'a> ExecutionRunner<'a> { if atmo_has_nan { notes.push("Invalidated: atmosphere contains NaN/Inf lines".to_string()); } + if energy_failed { + if let Some(ref ec) = energy_check { + if let Some(ref err) = ec.error { + notes.push(err.clone()); + } + } + } + if temp_failed { + if let Some(ref tc) = temp_check { + if let Some(ref err) = tc.error { + notes.push(err.clone()); + } + } + } + if emflux_failed { + if let Some(ref ec) = emflux_check { + if let Some(ref err) = ec.error { + notes.push(err.clone()); + } + } + } + if bfac_failed { + if let Some(ref bc) = bfac_check { + if let Some(ref err) = bc.error { + notes.push(err.clone()); + } + } + } if let Some(ref err) = synspec_err { notes.push(format!("synspec error: {}", err)); } else if let Some(rc) = synspec_rc { @@ -752,6 +894,10 @@ impl<'a> ExecutionRunner<'a> { synspec_error: synspec_err, synspec_sec, elapsed_sec, + energy_check, + temp_check, + emflux_check, + bfac_check, note, }; diff --git a/crates/common/src/summary_merge.rs b/crates/common/src/summary_merge.rs new file mode 100644 index 0000000..800dd73 --- /dev/null +++ b/crates/common/src/summary_merge.rs @@ -0,0 +1,270 @@ +//! 点级 [`ModelSummary`] 的增量合并。 +//! +//! # 背景 +//! 一个网格点可能经历多次执行:先是完整的 TLUSTY 收敛链(产出 `stages` 等大气诊断), +//! 之后又跑了仅 SYNSPEC 的重算(`stages` 为空,仅刷新 `synspec_rc`/`synspec_sec` 等)。 +//! 直接用后者的 summary 覆盖前者会丢失 TLUSTY 诊断——本模块按"stages 是否为空"判定 +//! 本次执行是否含 TLUSTY 数据,据此选择整体替换或字段级合并。 +//! +//! 合并函数供两条路径复用,保证逻辑一致: +//! - server 端 `record_task_report`(在线 report 时合并写入 grid_points.summary_json) +//! - 离线工具 `import_db`(扫 salvage 多节点 conv.json 时逐份合并) +//! +//! # 合并规则 +//! - incoming.stages 非空 且 incoming.synspec_rc 非空 → 整体替换(正常管线,TLUSTY+SYNSPEC 都跑了)。 +//! - incoming.stages 非空 但 incoming.synspec_rc 为空 → TLUSTY-only 重跑:以 incoming 为基底 +//! (含新 TLUSTY 诊断),保留 prior 的 synspec 字段(synspec_rc/error/sec)。新大气使旧光谱 +//! 失效,但 prior 的 synspec 数据仍有参考价值(记录了上次的光谱质量),且 DB 层已通过 +//! clear_synspec 清空了 synspec_success_method/synspec_status 列。 +//! - incoming.stages 为空 → synspec-only 重跑:以 prior 为基底,只覆盖 SYNSPEC/运行态字段, +//! 保留 prior 的 TLUSTY 诊断(stages / final_max_relc / final_chmax / seed / 各 *_check)。 +//! - prior 为 None → 直接返回 incoming(首次写入,无历史可保留)。 + +use crate::models::ModelSummary; + +/// 合并点级 [`ModelSummary`]。 +/// +/// 详见[模块文档][self]的规则说明。 +pub fn merge_point_summary(prior: Option<&ModelSummary>, incoming: &ModelSummary) -> ModelSummary { + // 无历史数据 → 直接返回 incoming。 + let Some(prior) = prior else { + return incoming.clone(); + }; + if !incoming.stages.is_empty() { + // TLUSTY 链在本次执行中跑了。 + if incoming.synspec_rc.is_some() { + // SYNSPEC 也跑了 → 正常管线,incoming 是最新完整摘要 → 整体替换。 + return incoming.clone(); + } + // TLUSTY-only 重跑(synspec 禁用):以 incoming 为基底(新 TLUSTY 诊断 + 新运行态字段), + // 保留 prior 的 synspec 字段(上次光谱的 rc/error/sec)。 + let mut merged = incoming.clone(); + merged.synspec_rc = prior.synspec_rc; + merged.synspec_error = prior.synspec_error.clone(); + merged.synspec_sec = prior.synspec_sec; + return merged; + } + + // synspec-only 重跑:以 prior 为基底,只覆盖 SYNSPEC / 运行态字段。 + let mut merged = prior.clone(); + merged.synspec_rc = incoming.synspec_rc; + merged.synspec_error = incoming.synspec_error.clone(); + merged.synspec_sec = incoming.synspec_sec; + merged.atmosphere_has_nan = incoming.atmosphere_has_nan; + merged.elapsed_sec = incoming.elapsed_sec; + merged.result_valid = incoming.result_valid; + merged.note = incoming.note.clone(); + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{GridAxisValue, GridPointParams, IterCheck, StepSummary}; + + fn mk_params() -> GridPointParams { + GridPointParams { + teff: GridAxisValue::from_value(25000.0), + logg: GridAxisValue::from_value(5.0), + loghe: GridAxisValue::from_value(2.0), + logc: GridAxisValue::from_value(-2.0), + logn: GridAxisValue::from_value(-2.0), + logo: GridAxisValue::from_value(-2.0), + } + } + + /// 构造一个含完整 TLUSTY 链的 summary(stages 非空 + 各校验字段)。 + fn mk_tlusty_summary() -> ModelSummary { + ModelSummary { + name: "t25000_g5.0_he2_c-2_n-2_o-2".into(), + params: mk_params(), + stages: vec![StepSummary { + label: "nl".into(), + chmax: Some(0.001), + lte: "F".into(), + converged: true, + best_max_relc: Some(0.000756), + elapsed_sec: 479.0, + note: None, + last_iter: Some(17), + worst_depth: Some(1), + n_depths: Some(50), + itek_history: vec![IterCheck { + iter: 1, + max_relc: 0.5, + n_depths: 50, + }], + conv_trace_check: None, + }], + result_valid: true, + final_max_relc: Some(0.000756), + final_chmax: Some(0.001), + seed: None, + atmosphere_has_nan: false, + synspec_rc: Some(0), + synspec_error: None, + synspec_sec: Some(0.24), + elapsed_sec: 479.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + } + } + + /// 构造一个 synspec-only summary(stages 为空)。 + fn mk_synspec_only_summary() -> ModelSummary { + let mut s = mk_tlusty_summary(); + s.stages = Vec::new(); + s.final_max_relc = None; + s.final_chmax = None; + // 模拟 synspec 重跑的结果 + s.synspec_rc = Some(1); + s.synspec_error = Some("spec 含 NaN".into()); + s.synspec_sec = Some(0.5); + s.atmosphere_has_nan = true; + s.elapsed_sec = 0.5; + s.result_valid = false; + s.note = Some("synspec 失败".into()); + s + } + + #[test] + fn prior_none_returns_incoming() { + let incoming = mk_tlusty_summary(); + let merged = merge_point_summary(None, &incoming); + assert_eq!(merged.name, incoming.name); + assert_eq!(merged.stages.len(), 1); + assert_eq!(merged.final_max_relc, Some(0.000756)); + } + + #[test] + fn incoming_stages_nonempty_replaces_wholesale() { + let prior = mk_tlusty_summary(); + let mut incoming = mk_tlusty_summary(); + incoming.final_max_relc = Some(0.0001); + incoming.stages[0].best_max_relc = Some(0.0001); + incoming.stages[0].label = "seed_nc".into(); + + let merged = merge_point_summary(Some(&prior), &incoming); + // 整体替换:incoming 的值胜出 + assert_eq!(merged.final_max_relc, Some(0.0001)); + assert_eq!(merged.stages[0].label, "seed_nc"); + } + + #[test] + fn synspec_only_preserves_tlusty_fields_overrides_synspec() { + let prior = mk_tlusty_summary(); + let incoming = mk_synspec_only_summary(); + + let merged = merge_point_summary(Some(&prior), &incoming); + + // TLUSTY 字段保留自 prior + assert_eq!(merged.stages.len(), 1, "stages 应保留 prior 的 TLUSTY 链"); + assert_eq!(merged.stages[0].label, "nl"); + assert_eq!(merged.final_max_relc, Some(0.000756)); + assert_eq!(merged.final_chmax, Some(0.001)); + + // synspec/运行态字段用 incoming 覆盖 + assert_eq!(merged.synspec_rc, Some(1)); + assert_eq!(merged.synspec_error.as_deref(), Some("spec 含 NaN")); + assert_eq!(merged.synspec_sec, Some(0.5)); + assert!(merged.atmosphere_has_nan); + assert_eq!(merged.elapsed_sec, 0.5); + assert!(!merged.result_valid); + assert_eq!(merged.note.as_deref(), Some("synspec 失败")); + } + + #[test] + fn synspec_only_with_no_prior_keeps_empty_stages() { + // prior 为 None 且 incoming stages 为空(该点从未跑过 TLUSTY) + let incoming = mk_synspec_only_summary(); + let merged = merge_point_summary(None, &incoming); + assert!(merged.stages.is_empty(), "无历史时保留 incoming 的空 stages"); + assert_eq!(merged.synspec_rc, Some(1)); + } + + /// 往返序列化保真:合并结果应能序列化再反序列化。 + #[test] + fn merge_result_roundtrips() { + let prior = mk_tlusty_summary(); + let incoming = mk_synspec_only_summary(); + let merged = merge_point_summary(Some(&prior), &incoming); + + let json = serde_json::to_string(&merged).unwrap(); + let back: ModelSummary = serde_json::from_str(&json).unwrap(); + assert_eq!(back.stages.len(), 1); + assert_eq!(back.synspec_rc, Some(1)); + } + + /// 构造一个 TLUSTY-only summary(stages 非空,但 synspec_rc=None — synspec 未跑)。 + fn mk_tlusty_only_summary() -> ModelSummary { + let mut s = mk_tlusty_summary(); + // 新的大气链结果(更优收敛) + s.stages[0].best_max_relc = Some(0.0001); + s.final_max_relc = Some(0.0001); + // synspec 未运行 → 清空 synspec 字段 + s.synspec_rc = None; + s.synspec_error = None; + s.synspec_sec = None; + s.elapsed_sec = 280.0; // TLUSTY-only 耗时 + s + } + + /// TLUSTY-only 重跑(stages 非空 + synspec_rc=None)保留 prior 的 synspec 字段, + /// 使用 incoming 的 TLUSTY 诊断。 + #[test] + fn tlusty_only_preserves_prior_synspec_fields() { + let prior = mk_tlusty_summary(); // 含 synspec_rc=Some(0), synspec_sec=Some(0.24) + let incoming = mk_tlusty_only_summary(); // stages 非空, synspec_rc=None + + let merged = merge_point_summary(Some(&prior), &incoming); + + // TLUSTY 字段来自 incoming(新大气诊断) + assert_eq!(merged.stages.len(), 1, "stages 来自 incoming"); + assert_eq!( + merged.final_max_relc, + Some(0.0001), + "final_max_relc 来自 incoming" + ); + + // synspec 字段保留自 prior + assert_eq!( + merged.synspec_rc, + Some(0), + "synspec_rc 保留 prior 值(synspec 未跑但旧数据仍有参考价值)" + ); + assert_eq!(merged.synspec_sec, Some(0.24), "synspec_sec 保留 prior 值"); + + // 运行态字段来自 incoming + assert_eq!(merged.elapsed_sec, 280.0, "elapsed_sec 来自 incoming"); + } + + /// TLUSTY-only 重跑 + 无 prior(首次执行就是 TLUSTY-only)→ 返回 incoming。 + #[test] + fn tlusty_only_with_no_prior_keeps_empty_synspec() { + let incoming = mk_tlusty_only_summary(); + let merged = merge_point_summary(None, &incoming); + assert_eq!(merged.stages.len(), 1); + assert_eq!( + merged.synspec_rc, + None, + "无 prior 时 synspec_rc 为 incoming 的 None" + ); + } + + /// TLUSTY-only 合并结果往返序列化保真。 + #[test] + fn tlusty_only_merge_roundtrips() { + let prior = mk_tlusty_summary(); + let incoming = mk_tlusty_only_summary(); + let merged = merge_point_summary(Some(&prior), &incoming); + + let json = serde_json::to_string(&merged).unwrap(); + let back: ModelSummary = serde_json::from_str(&json).unwrap(); + assert_eq!(back.stages.len(), 1); + assert_eq!(back.final_max_relc, Some(0.0001)); + assert_eq!(back.synspec_rc, Some(0)); + } +} diff --git a/crates/node/src/executor.rs b/crates/node/src/executor.rs index e58fce1..c46b621 100644 --- a/crates/node/src/executor.rs +++ b/crates/node/src/executor.rs @@ -166,7 +166,7 @@ pub async fn execute_task( // 执行链来源(优先级): // 1. TaskSpec.tlusty_chain_params(用户在 YAML `tlusty_chain:` 配置的多阶段 ChainStep // 数组,由 scheduler 序列化注入)——非空时优先使用,使用户能细粒度控制 niter/chmax/ - // metals 等阶段参数。 + // ilvlin 等阶段参数。 // 2. default_chain_for_strategy(current_strategy) 兜底——按策略名(cold_run/seed_step) // 选预设默认链(runner.rs 的 default_cold_chain / default_seed_chain)。 // 历史:Phase 6 起仅用 default 链(用户 config.chain 被忽略,是死字段);本次接通后 @@ -175,6 +175,7 @@ pub async fn execute_task( let chain = resolve_execution_chain( current_strategy, &task.tlusty_chain_params, + &task.seed_chain_params, &task.task_id.to_string(), ); // TLUSTY 输入文件全局参数(NFREAD/ions 表/nst extra_keys 等)。 @@ -208,6 +209,14 @@ pub async fn execute_task( task.timeout_sec, shutdown, tlusty_input.as_ref(), + task.energy_tolerance, + task.temp_max_factor, + task.temp_floor, + task.temp_ceiling, + task.emflux_tolerance, + task.convergence_min_ratio, + task.bfac_max, + task.bfac_min, ) .await?; @@ -450,21 +459,35 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) { /// 解析任务要执行的大气链(冷启动链 / 种子热启动链)。 /// -/// 优先级(修复回归): -/// - `current_strategy == "seed_step"` → 强制 `default_seed_chain()`(seed_nc→nl)。 -/// 自定义 `tlusty_chain` 是冷启动链:首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的 -/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。scheduler 在派发与回退两条路径都注入 -/// 同一个 `tlusty_chain`,若 seed_step 也沿用自定义链,会把种子回退退化成本地冷启动, -/// 丢失热启动语义。故种子链固定走内置默认,仅在 cold_run 等冷策略下信任用户自定义链。 +/// 优先级: +/// - `current_strategy == "seed_step"` → 优先 TaskSpec.seed_chain_params(用户 YAML +/// `seed_chain:` 配置),非空即用;为空/反序列化失败 → `default_seed_chain()` 兜底。 +/// 不复用 `tlusty_chain`:冷启动链首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的 +/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。seed_chain 专用于种子热启动, +/// 首步 seed_nc 的 `ltgray=F` 保留 fort.8 种子。 /// - 其余策略 → 优先 TaskSpec.tlusty_chain_params(用户 YAML `tlusty_chain:` 配置),非空即用; /// 为空/反序列化失败 → `default_chain_for_strategy(current_strategy)` 兜底。 fn resolve_execution_chain( current_strategy: &str, tlusty_chain_params: &Option, + seed_chain_params: &Option, task_id: &str, ) -> Vec { if current_strategy == "seed_step" { - common::runner::default_seed_chain() + seed_chain_params + .as_ref() + .and_then(|v| match serde_json::from_value::>(v.clone()) { + Ok(c) => Some(c), + Err(e) => { + warn!( + "任务 {} 的 seed_chain_params 反序列化失败,回退 default_seed_chain: {}", + task_id, e + ); + None + } + }) + .filter(|c| !c.is_empty()) + .unwrap_or_else(common::runner::default_seed_chain) } else { tlusty_chain_params .as_ref() @@ -512,6 +535,10 @@ mod tests { synspec_error: None, synspec_sec: Some(10.0), elapsed_sec: 120.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, note: None, } } @@ -520,22 +547,38 @@ mod tests { chain.iter().map(|s| s.label.clone()).collect() } - /// P1 回归防护:seed_step 即使注入自定义冷启动链,也必须强制走种子热启动默认链 - /// (seed_nc→nl),否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。 + /// P1 回归防护:seed_step 忽略 tlusty_chain_params(冷启动链),只认 seed_chain_params。 + /// 否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。 #[test] fn seed_step_ignores_custom_cold_chain() { - let custom = serde_json::json!([ + let custom_cold = serde_json::json!([ {"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0}, {"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10}, {"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100}, ]); - let chain = resolve_execution_chain("seed_step", &Some(custom), "t1"); + // seed_chain_params=None → 走 default_seed_chain;tlusty_chain_params 被忽略。 + let chain = resolve_execution_chain("seed_step", &Some(custom_cold), &None, "t1"); assert_eq!(labels(&chain), vec!["seed_nc", "nl"]); // 首步必须是非灰 LTE(ltgray=F),否则会删 fort.8 丢弃种子。 assert_eq!(chain[0].ltgray, "F"); } + /// seed_step 使用 seed_chain_params(用户 YAML seed_chain 配置)时生效。 + #[test] + fn seed_step_uses_custom_seed_chain_when_provided() { + let custom_seed = serde_json::json!([ + {"label": "seed_nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 20, "orelax": 0.3}, + {"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100, "orelax": 0.5}, + ]); + + let chain = resolve_execution_chain("seed_step", &None, &Some(custom_seed), "t1b"); + assert_eq!(labels(&chain), vec!["seed_nc", "nl"]); + // 用户配置的 orelax 生效。 + assert_eq!(chain[0].orelax, Some(0.3)); + assert_eq!(chain[1].orelax, Some(0.5)); + } + #[test] fn cold_run_uses_custom_chain_when_provided() { let custom = serde_json::json!([ @@ -544,13 +587,13 @@ mod tests { {"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100}, ]); - let chain = resolve_execution_chain("cold_run", &Some(custom), "t2"); + let chain = resolve_execution_chain("cold_run", &Some(custom), &None, "t2"); assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]); } #[test] fn cold_run_falls_back_to_default_when_no_custom_chain() { - let chain = resolve_execution_chain("cold_run", &None, "t3"); + let chain = resolve_execution_chain("cold_run", &None, &None, "t3"); assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]); } diff --git a/crates/node/src/reporter.rs b/crates/node/src/reporter.rs index 8fe3c9d..f4afe1e 100644 --- a/crates/node/src/reporter.rs +++ b/crates/node/src/reporter.rs @@ -235,6 +235,10 @@ mod tests { synspec_error: synspec_error.map(|s| s.to_string()), synspec_sec: None, elapsed_sec: 10.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, note: None, } } diff --git a/crates/server/src/api/migrate.rs b/crates/server/src/api/migrate.rs new file mode 100644 index 0000000..77f3347 --- /dev/null +++ b/crates/server/src/api/migrate.rs @@ -0,0 +1,164 @@ +//! 一次性迁移端点:把 seeds_dir 下的 conv.json 批量导入 grid_points.summary_json。 +//! +//! # 背景 +//! 旧版 conv.json 只存磁盘不进 DB。本端点扫 `seeds_dir//conv.json`, +//! 解析为 ModelSummary 后经 `merge_point_summary` 增量合并写入 `grid_points.summary_json`, +//! 让详情页 conv 诊断面板从 DB 读取(不再依赖磁盘文件)。 +//! +//! # 快速删除 +//! 本模块是**临时迁移工具**,迁移完成后删除本文件 + main.rs 中路由注册即可。 + +use super::AppState; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use common::models::ModelSummary; +use serde::Deserialize; +use serde_json::json; +use std::path::{Path, PathBuf}; +use tracing::{info, warn}; + +/// `/admin/migrate_conv` 的查询参数。 +#[derive(Debug, Deserialize)] +pub struct MigrateConvQuery { + /// 目标工作流名(必填)。 + pub workflow: String, + /// 扫描根目录(缺省取 AppState.seeds_dir)。 + pub dir: Option, +} + +/// 递归收集目录下所有 conv.json 路径。 +/// +/// 用 std::fs 而非 walkdir,避免给 server 引入临时依赖(本模块要快速删除)。 +fn collect_conv_jsons(dir: &Path, results: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_conv_jsons(&path, results); + } else if path.file_name().map(|n| n == "conv.json").unwrap_or(false) { + results.push(path); + } + } +} + +/// POST /api/admin/migrate_conv — 扫描 seeds_dir 的 conv.json → 增量合并写入 grid_points.summary_json。 +/// +/// - Admin 鉴权(与其他 /admin/* 端点一致)。 +/// - 幂等:`upsert_grid_point_named` 用 `ON CONFLICT DO NOTHING`,可重复运行。 +/// - 增量合并:synspec-only 的 conv.json 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。 +pub async fn migrate_conv( + State(state): State, + Query(query): Query, +) -> Result { + let scan_dir = query.dir.unwrap_or_else(|| state.seeds_dir.clone()); + let workflow = query.workflow; + + // 1. 递归扫描 conv.json。 + let mut paths = Vec::new(); + collect_conv_jsons(Path::new(&scan_dir), &mut paths); + + if paths.is_empty() { + return Ok(( + StatusCode::OK, + Json(json!({ + "success": true, + "message": format!("目录 {} 下未找到 conv.json", scan_dir), + "imported": 0, + "skipped": 0, + })), + )); + } + + info!("迁移开始:扫到 {} 份 conv.json(dir={}, workflow={})", paths.len(), scan_dir, workflow); + + // 2. 逐份解析 + upsert + 增量合并。 + let mut ok = 0usize; + let mut skipped = 0usize; + + for path in &paths { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + warn!("读取失败 {}: {}", path.display(), e); + skipped += 1; + continue; + } + }; + // 少数"conv.json"实际是 node 端失败错误占位(`{"error": "..."}`,见 reporter 错误路径), + // 非有效 ModelSummary → 反序列化失败,正确行为是跳过(这些点无有效收敛数据)。 + // name 缺失(ModelSummary.name 必填)也属此类,不应兜底导入伪造点。 + let summary: ModelSummary = match serde_json::from_str(&text) { + Ok(s) => s, + Err(e) => { + warn!("解析失败 {}: {}", path.display(), e); + skipped += 1; + continue; + } + }; + + // 权威点名取 summary.name(源精度真名);空则回退目录名。 + let point_name = if !summary.name.is_empty() { + summary.name.clone() + } else { + match path.parent().and_then(|p| p.file_name()) { + Some(n) => n.to_string_lossy().to_string(), + None => { + skipped += 1; + continue; + } + } + }; + + // method:stages 含 seed_nc → seed_step,否则 cold_run。 + let method = if summary.stages.iter().any(|s| s.label == "seed_nc") { + "seed_step" + } else { + "cold_run" + }; + + // upsert 建点(幂等)+ 增量合并写入 summary_json。 + let wave = state + .db + .compute_wave_for_cno_sum(&workflow, summary.params.cno_sum()) + .await + .unwrap_or(0); + if let Err(e) = state + .db + .upsert_grid_point_named(&point_name, &summary.params, wave, &workflow) + .await + { + warn!("点 {} upsert 失败: {}", point_name, e); + skipped += 1; + continue; + } + if let Err(e) = state + .db + .upsert_point_summary(&point_name, &workflow, &summary, method) + .await + { + warn!("点 {} summary 写入失败: {}", point_name, e); + skipped += 1; + continue; + } + ok += 1; + } + + info!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped); + + Ok(( + StatusCode::OK, + Json(json!({ + "success": true, + "message": format!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped), + "imported": ok, + "skipped": skipped, + "total": ok + skipped, + })), + )) +} diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs index 10d59a7..3c96c9b 100644 --- a/crates/server/src/api/mod.rs +++ b/crates/server/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod auth; pub mod data; pub mod error; +pub mod migrate; pub mod node; pub mod rate_limit; pub mod seed; @@ -100,8 +101,8 @@ fn required_role(path: &str, method: &axum::http::Method) -> Option { if path == "/status" && method == Method::GET { return Some(Role::Admin); } - // 管理 API(节点凭据查看/审批/重发/停用/启用、历史种子导入等)→ Admin - // 注:所有 /admin/* 均需 Admin 鉴权(含 /admin/import_seed),统一在此判定即可, + // 管理 API(节点凭据查看/审批/重发/停用/启用等)→ Admin + // 注:所有 /admin/* 均需 Admin 鉴权,统一在此判定即可, // 无需为单个子路径重复列举(避免出现被前缀匹配遮蔽的不可达分支)。 if path.starts_with("/admin/") { return Some(Role::Admin); diff --git a/crates/server/src/api/task.rs b/crates/server/src/api/task.rs index aa9e9f0..6bc5950 100644 --- a/crates/server/src/api/task.rs +++ b/crates/server/src/api/task.rs @@ -1,11 +1,10 @@ use super::{AppState, AuthenticatedNode}; use axum::{ - extract::{Extension, Multipart, Query, State}, + extract::{Extension, Multipart, State}, response::IntoResponse, Json, }; use common::models::{GridPointParams, ModelSummary, TaskReport, TaskStatus}; -use serde::Deserialize; use serde_json::json; use std::path::Path; use tokio::fs; @@ -268,12 +267,43 @@ pub async fn report_task( if fs::create_dir_all(&model_dir).await.is_ok() { let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple())); let conv_path = model_dir.join("conv.json"); - if fs::write(&conv_tmp, &report.summary_json).await.is_ok() { + // conv.json 写入内容选择: + // - 成功:从 DB 读取合并后的 summary_json(record_task_report 已通过 + // merge_point_summary 合并 prior TLUSTY 诊断 + incoming synspec 字段)。 + // synspec-only 重跑成功后 raw incoming 只有 synspec 字段(stages 为空), + // 直接写 raw 会丢失 TLUSTY 诊断 → 离线 salvage 导入读 conv.json 时无法恢复。 + // - 失败:写 raw incoming(含错误路径 {"error":...},供排错),不读 DB(DB + // 可能因 incoming 不可解析而保留 prior,丢失本次错误信息)。 + let conv_content = if report.result_valid + && !report.atmosphere_has_nan + && report.status == TaskStatus::Completed + { + state + .db + .get_point_summary_json(&workflow_name, &name) + .await + .ok() + .flatten() + .unwrap_or_else(|| report.summary_json.clone()) + } else { + report.summary_json.clone() + }; + if fs::write(&conv_tmp, &conv_content).await.is_ok() { let _ = fs::rename(&conv_tmp, &conv_path).await; } - // Save seed file .7 using atomic temporary writing strategy - if report.result_valid && !report.atmosphere_has_nan { + // Save seed file .7 using atomic temporary writing strategy. + // §7 修复:synspec-only 任务(tlusty_enabled=false)未重算大气,节点上传的 .7 是 + // 从服务端下载的原样字节 → 跳过写入,避免冗余 I/O 和潜在的误覆盖(如节点本地归档 + // 版本与服务端不一致)。查询失败时 fallback 为 true(保守,不阻断种子写入)。 + let tlusty_was_enabled = state + .db + .get_task_tlusty_enabled(&report.task_id) + .await + .ok() + .flatten() + .unwrap_or(true); + if report.result_valid && !report.atmosphere_has_nan && tlusty_was_enabled { if let Some(bytes) = seed_file_data { let seed_tmp = model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple())); @@ -341,230 +371,3 @@ fn extract_params(report: &TaskReport) -> Option { .ok() .map(|summary| summary.params) } - -/// `/admin/import_seed` 的查询参数。 -#[derive(Debug, Deserialize)] -pub struct ImportSeedQuery { - /// 目标工作流名(导入到此工作流的 grid_points)。缺省归入 `imported` 工作流。 - #[serde(default = "default_import_workflow")] - pub workflow: String, -} - -fn default_import_workflow() -> String { - "imported".to_string() -} - -/// 历史种子导入端点(Admin 鉴权)。 -/// -/// 供 `tools/import_results` 把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件) -/// 批量回灌进 DCTS。与 `/task/report` 的关键区别: -/// - **跳过任务归属校验**(`verify_task_claim`):历史数据无领用语义,导入端点不经过 -/// claim/report 队列,直接幂等落库。 -/// - **`point_name` 取旧 `conv.json` 的 `name` 字段**(Python `gen_input5.model_name` -/// 生成的源精度真名,如 `t20000_g5.0_...`),而非从数值重推——保证迁移逐字符保真。 -/// - **真实 `max_relc`** 取自 `summary.final_max_relc`(旧版已记录),不硬编码。 -/// -/// 幂等:`upsert_grid_point` 用 `ON CONFLICT DO NOTHING`,`.7`/`conv.json` 原子覆盖写, -/// 可重复运行。 -pub async fn import_seed( - State(state): State, - Query(query): Query, - mut multipart: Multipart, -) -> Result { - let mut summary_json: Option = None; - let mut seed_file_data: Option> = None; - // 大气收敛途径(cold_run/seed_step):由 import_results 工具依据旧 conv.json 的 stages 是否 - // 含 seed_nc 判定后透传。缺失或非法时兜底 cold_run(容错旧版工具 / 防注入)。 - // 语义为 TLUSTY 阶段策略,写入 grid_points.tlusty_success_method。 - let mut success_method: Option = None; - let mut multipart_error = false; - - // 显式遍历全部字段,记录读取错误。旧实现 `while let Ok(...)` 在首字段出错时静默停止, - // 可能丢失后续 seed_file/report 字段导致半截请求被处理。 - loop { - match multipart.next_field().await { - Ok(Some(field)) => { - let field_name = field.name().unwrap_or("").to_string(); - if field_name == "report" { - match field.bytes().await { - Ok(bytes) => { - summary_json = Some(String::from_utf8_lossy(&bytes).to_string()); - } - Err(e) => { - warn!("历史种子导入:读取 report 字段失败: {}", e); - multipart_error = true; - } - } - } else if field_name == "seed_file" { - match field.bytes().await { - Ok(bytes) => { - seed_file_data = Some(bytes.to_vec()); - } - Err(e) => { - warn!("历史种子导入:读取 seed_file 字段失败: {}", e); - multipart_error = true; - } - } - } else if field_name == "tlusty_success_method" { - match field.text().await { - Ok(text) => { - success_method = Some(text); - } - Err(e) => { - warn!("历史种子导入:读取 tlusty_success_method 字段失败: {}", e); - multipart_error = true; - } - } - } - } - Ok(None) => break, - Err(e) => { - warn!("历史种子导入:解析 multipart 字段时出错: {}", e); - multipart_error = true; - break; - } - } - } - if multipart_error { - return Err(crate::api::AppError::BadRequest( - "multipart 请求体解析不完整(字段读取失败)".to_string(), - )); - } - - let summary_json = match summary_json { - Some(s) => s, - None => { - return Err(crate::api::AppError::BadRequest( - "请求中缺少 report 字段(旧版 conv.json 内容)".to_string(), - )); - } - }; - - // 解析旧版 conv.json(ModelSummary 结构)取 name / params / 收敛状态 / 真实 max_relc。 - let summary: ModelSummary = match serde_json::from_str(&summary_json) { - Ok(s) => s, - Err(e) => { - warn!("历史种子导入:conv.json 解析失败: {}", e); - return Err(crate::api::AppError::BadRequest( - "conv.json 解析失败,非合法 ModelSummary".to_string(), - )); - } - }; - - // point_name 优先用旧 conv.json 的 name(源精度真名);回退到 params 规范名。 - let name = if !summary.name.is_empty() { - summary.name.clone() - } else { - summary.params.model_name() - }; - - // 名称合法性校验(防路径穿越),与 report_task 同口径。 - if !super::workflow::is_valid_point_name(&name) { - warn!("历史种子导入:拒绝非法网格点名称: {}", name); - return Err(crate::api::AppError::BadRequest( - "非法的网格点名称参数".to_string(), - )); - } - - let workflow_name = query.workflow; - let params = summary.params.clone(); - let converged = summary.result_valid && !summary.atmosphere_has_nan; - let max_relc = summary.final_max_relc; - - // 1. 幂等写入 grid_points(ON CONFLICT DO NOTHING):无需事先 start 工作流。 - // 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的 - // params 来自旧 JSON(无源文本,model_name() 会失真)。 - // wave 修复(2026-08-04):此前硬编码 wave=0,导入点全部被错误归入第一波, - // 前端难度波次推进显示错误。现按 initialize_grid 同口径计算波次(该工作流内 - // cno_sum 严格小于本点的去重值个数),新导入点落库即归入正确波次。 - let wave = state - .db - .compute_wave_for_cno_sum(&workflow_name, params.cno_sum()) - .await - .unwrap_or(0); - if let Err(e) = state - .db - .upsert_grid_point_named(&name, ¶ms, wave, &workflow_name) - .await - { - tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e); - return Err(crate::api::AppError::Internal(e)); - } - - // 2. 落地 conv.json(原子 tmp→rename)。 - let model_dir = Path::new(&state.seeds_dir).join(&name); - if fs::create_dir_all(&model_dir).await.is_ok() { - let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple())); - let conv_path = model_dir.join("conv.json"); - if fs::write(&conv_tmp, &summary_json).await.is_ok() { - let _ = fs::rename(&conv_tmp, &conv_path).await; - } - - // 3. 收敛且干净才写 .7 + 入种子库(与 report_task 同口径)。 - if converged { - if let Some(bytes) = seed_file_data { - let seed_tmp = - model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple())); - let seed_path = model_dir.join(format!("{}.7", name)); - if fs::write(&seed_tmp, bytes).await.is_ok() - && fs::rename(&seed_tmp, &seed_path).await.is_ok() - { - info!( - "历史种子导入:网格点 {} 收敛种子已落地: {} (max_relc={:?})", - name, - seed_path.display(), - max_relc - ); - let _ = state - .db - .insert_seed_named(&name, ¶ms, &seed_path.to_string_lossy()) - .await; - } - } else { - warn!( - "历史种子导入:网格点 {} 声称收敛但未上传 seed_file,跳过种子写入", - name - ); - } - } - } - - // 4. 更新 grid_points 状态:收敛→converged(大气归因 tlusty_success_method=工具判定的途径);否则维持 pending - // 让正常调度处理(导入未收敛点无意义,但记录其尝试)。 - // 途径缺失或非法时兜底 cold_run(容错旧版工具 / 防注入),由 db 层再次白名单校验。 - if converged { - // 归一化:仅接受 cold_run / seed_step,其余(含 None)一律兜底 cold_run。 - let method = match success_method.as_deref() { - Some("seed_step") => "seed_step", - _ => "cold_run", - }; - if let Err(e) = state - .db - .mark_grid_point_imported(&name, &workflow_name, Some(summary.elapsed_sec), method) - .await - { - warn!("历史种子导入:标记 {} 为 converged 失败: {}", name, e); - } - } - - info!( - "历史种子导入完成:网格点 {} (workflow={}, converged={}, success_method={}, max_relc={:?})", - name, - workflow_name, - converged, - success_method.as_deref().unwrap_or("(default cold_run)"), - max_relc - ); - - Ok(( - StatusCode::OK, - Json(json!({ - "status": "ok", - "point_name": name, - // 注:此 "converged" 键是大气收敛标志(由 summary.result_valid 派生),与 grid_points.status - // 的 completed 重命名无关,勿改(改键名会破坏历史种子导入客户端)。 - "converged": converged, - "max_relc": max_relc, - })), - )) -} diff --git a/crates/server/src/api/workflow.rs b/crates/server/src/api/workflow.rs index cc52392..084d69b 100644 --- a/crates/server/src/api/workflow.rs +++ b/crates/server/src/api/workflow.rs @@ -615,12 +615,11 @@ pub async fn get_workflow_points( } } -/// 单网格点详情:点行 + 全部尝试历史 + conv.json 逐阶段诊断。 +/// 单网格点详情:点行 + 全部尝试历史 + summary_json 逐阶段诊断。 /// -/// conv.json 读自 `seeds_dir//conv.json`(单层目录)。点名经 `is_valid_point_name` -/// 白名单(无 `/`、`\`,拒前导 `.`)——即路径穿越的前置闸门;读盘后再做 canonicalize -/// 归属兜底校验(纵深防御)。缺失/读失败/解析失败一律 `conv: null`(仍 200), -/// 前端降级显示"诊断文件不可用"。 +/// conv 诊断面板读自 `grid_points.summary_json`(点级诊断快照,DB 权威源)。 +/// synspec-only 重跑时由 `record_task_report` 的增量合并保证 TLUSTY 字段不丢失。 +/// 缺失/读失败/解析失败一律 `conv: null`(仍 200),前端降级显示"诊断文件不可用"。 pub async fn get_workflow_point_detail( State(state): State, AxumPath((name, point)): AxumPath<(String, String)>, @@ -643,33 +642,21 @@ pub async fn get_workflow_point_detail( }; let attempts = state.db.list_point_attempts(&name, &point).await?; - let conv_path = std::path::Path::new(&state.seeds_dir) - .join(&point) - .join("conv.json"); - let conv: Option = - match tokio::fs::read_to_string(&conv_path).await { - Ok(s) => { - let confined = std::path::Path::new(&state.seeds_dir) - .canonicalize() - .ok() - .zip(conv_path.canonicalize().ok()) - .map(|(root, f)| f.starts_with(root)) - .unwrap_or(false); - if confined { - match serde_json::from_str(&s) { - Ok(summary) => Some(summary), - Err(e) => { - tracing::warn!("网格点 {} 的 conv.json 解析失败: {}", point, e); - None - } - } - } else { - tracing::warn!("网格点 {} 的 conv.json 路径越界,拒绝读取", point); - None - } - } - Err(_) => None, - }; + // conv 诊断面板数据源:grid_points.summary_json(点级诊断快照)。 + // 此前读磁盘 conv.json,synspec-only 重跑会覆盖 TLUSTY 字段;改为查 DB 后由 + // record_task_report 的增量合并保证 TLUSTY 诊断不丢失。 + let conv: Option = match state + .db + .get_point_summary_json(&name, &point) + .await + { + Ok(Some(json)) => serde_json::from_str(&json).ok(), + Ok(None) => None, + Err(e) => { + tracing::warn!("网格点 {} 的 summary_json 读取失败: {}", point, e); + None + } + }; Ok(( StatusCode::OK, diff --git a/crates/server/src/db/grid.rs b/crates/server/src/db/grid.rs index c6b83a8..7aa5149 100644 --- a/crates/server/src/db/grid.rs +++ b/crates/server/src/db/grid.rs @@ -52,10 +52,10 @@ impl Database { /// 的去重值个数,即 `cno_sum` 升序中的桶序号——与 `initialize_grid` 的波次分组口径一致 /// (`scheduler.rs` 沿排序序扫描,cno_sum 变化 >1e-5 则 wave+1)。 /// - /// 历史种子导入(`import_seed`)用它替代此前硬编码的 `wave=0`:导入点按现有规则归入 - /// 正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流(所有 - /// cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合 import_results - /// 工具按 cno_sum 升序导入即可保证最终波次一致。 + /// 离线导入工具(`tools/import_db`)用它替代此前硬编码的 `wave=0`:导入点按现有规则 + /// 归入正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流 + /// (所有 cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合 + /// 导入工具按 cno_sum 升序导入即可保证最终波次一致。 pub async fn compute_wave_for_cno_sum(&self, workflow_name: &str, cno_sum: f64) -> Result { let pool = self.pool.clone(); let wf = workflow_name.to_string(); @@ -527,50 +527,6 @@ impl Database { Ok(changed) } - /// 历史种子导入专用:把网格点标记为 converged 并记录大气收敛途径 `tlusty_success_method`。 - /// - /// 与正常 `record_task_report` 路径的区别:导入不走 task 队列,无 task_type 可取, - /// 故由导入工具(import_results)依据旧 conv.json 的 stages 是否含 seed_nc 判定该点 - /// 当初是冷启动收敛(cold_run)还是种子步进收敛(seed_step),经 multipart 字段透传至此。 - /// 导入点因此融入冷启动/种子步进统计,而非独立为 imported 分类。 - /// - /// - `success_method`:须为 "cold_run" 或 "seed_step",非法值兜底为 "cold_run"(防注入)。 - /// 语义为 TLUSTY 阶段策略,写入 `tlusty_success_method` 列。 - /// - `elapsed_sec`:旧版 conv.json 的单点墙钟耗时(`summary.elapsed_sec`),落入 - /// `last_elapsed_sec` 列使迁移点在详情页/点表保留真实耗时;无此数据传 None。 - pub async fn mark_grid_point_imported( - &self, - name: &str, - workflow_name: &str, - elapsed_sec: Option, - success_method: &str, - ) -> Result<()> { - // 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。 - let method = match success_method { - "seed_step" => "seed_step", - _ => "cold_run", - }; - let pool = self.pool.clone(); - let name_owned = name.to_string(); - let wf = workflow_name.to_string(); - tokio::task::spawn_blocking(move || -> Result<()> { - let conn = pool - .get() - .map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?; - // 阶段列(5b):导入点是历史 TLUSTY 大气收敛 → tlusty_status='converged', - // synspec_status 保持 NULL(历史结果仅大气,光谱未运行/未记录)。与正常结算路径的 - // 状态一致性(审查 #2 修正:原实现不设阶段列,导入点与正常点状态口径不一致)。 - conn.execute( - "UPDATE grid_points SET status = 'completed', tlusty_success_method = ?1, last_elapsed_sec = ?2, tlusty_status = 'converged' \ - WHERE name = ?3 AND workflow_name = ?4", - params![method, elapsed_sec, name_owned, wf], - )?; - Ok(()) - }) - .await??; - Ok(()) - } - pub async fn get_grid_point_status( &self, name: &str, @@ -934,4 +890,101 @@ impl Database { }) .await? } + + /// 取网格点的 summary_json(点级诊断快照),供详情页 conv 诊断面板渲染。 + /// + /// 与 `get_workflow_point_row` 分离:summary_json 可能很大(含 stages/itek_history + /// 等嵌套诊断),列表查询(`list_workflow_points`)不需要它,故独立查询避免每行 + /// 拖带大 JSON。返回 None = 该点无诊断数据(未跑过或尚未结算)。 + pub async fn get_point_summary_json( + &self, + wf: &str, + point: &str, + ) -> Result> { + let pool = self.pool.clone(); + let wf = wf.to_string(); + let point = point.to_string(); + tokio::task::spawn_blocking(move || -> Result> { + let conn = pool + .get() + .map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?; + let res = conn.query_row( + "SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2", + params![point, wf], + |r| r.get::<_, Option>(0), + ); + match res { + Ok(v) => Ok(v), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + }) + .await? + } + + /// 离线/批量导入专用:把一个点级 [`ModelSummary`] 增量合并写入 grid_points。 + /// + /// 供离线导入工具(`tools/import_db`)逐份合并多节点 conv.json 时复用。与在线 + /// `record_task_report` 路径共享 `merge_point_summary` 合并逻辑,保证一致性: + /// incoming.stages 非空 → 整体替换;为空 → 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。 + /// + /// 同时更新 status / method / elapsed_sec / tlusty_status 等调度列(等价于旧 + /// `mark_grid_point_imported` 的职责并入此方法)。调用方须先 `upsert_grid_point_named` + /// 建点(保证行存在)。 + /// + /// - `method`:TLUSTY 收敛策略(`cold_run` / `seed_step`),由 stages 是否含 `seed_nc` 判定。 + /// - 返回合并后的 summary 文本(供调用方日志/验证)。 + pub async fn upsert_point_summary( + &self, + name: &str, + workflow_name: &str, + incoming: &common::models::ModelSummary, + method: &str, + ) -> Result { + // 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。 + let method = match method { + "seed_step" => "seed_step", + _ => "cold_run", + }; + let converged = incoming.result_valid && !incoming.atmosphere_has_nan; + + // 增量合并:读旧 summary_json → merge_point_summary → 写回。 + let prior_json = self.get_point_summary_json(workflow_name, name).await?; + let prior = prior_json + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + let merged = common::summary_merge::merge_point_summary(prior.as_ref(), incoming); + let merged_json = serde_json::to_string(&merged).unwrap_or_default(); + + let pool = self.pool.clone(); + let name_owned = name.to_string(); + let wf = workflow_name.to_string(); + let merged_json_clone = merged_json.clone(); + let elapsed_sec = merged.elapsed_sec; + tokio::task::spawn_blocking(move || -> Result<()> { + let conn = pool + .get() + .map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?; + // 收敛 → completed(与 record_task_report success 分支同口径:tlusty_status='converged')。 + // 未收敛 → pending(让调度器后续处理,不在此翻 failed——离线导入无失败语义)。 + if converged { + conn.execute( + "UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, \ + tlusty_success_method = ?2, tlusty_status = 'converged', summary_json = ?3 \ + WHERE name = ?4 AND workflow_name = ?5", + params![elapsed_sec, method, merged_json_clone, name_owned, wf], + )?; + } else { + // 未收敛:仍写入 summary_json(保留诊断供排查),但状态不变更。 + conn.execute( + "UPDATE grid_points SET last_elapsed_sec = ?1, summary_json = ?2 \ + WHERE name = ?3 AND workflow_name = ?4", + params![elapsed_sec, merged_json_clone, name_owned, wf], + )?; + } + Ok(()) + }) + .await??; + Ok(merged_json) + } } diff --git a/crates/server/src/db/mod.rs b/crates/server/src/db/mod.rs index 9528408..9e257d6 100644 --- a/crates/server/src/db/mod.rs +++ b/crates/server/src/db/mod.rs @@ -69,7 +69,12 @@ const GRID_POINTS_SCHEMA: &str = "CREATE TABLE grid_points ( -- H1 修复(M11):运行时回退把点打回 pending 时记录的「剩余策略链」(JSON 数组)。 -- 供调度路径识别「该点已失败过 cold_run、正在等种子」→ 重派时用剩余链而非完整 YAML 链, -- 避免重跑已失败策略导致的无界失败重试活锁(见 scheduler.rs H1 注释)。NULL = 无标记。 - pending_strategies TEXT + pending_strategies TEXT, + -- 最近一次尝试的真实墙钟耗时(秒,Worker 回报值;旧数据为 None)。 + last_elapsed_sec REAL, + -- 点级诊断快照(完整 ModelSummary JSON)。详情页 conv 诊断面板的数据源。 + -- synspec-only 重跑时经 merge_point_summary 增量合并,保留 TLUSTY 字段不丢失。 + summary_json TEXT )"; #[derive(Debug)] @@ -431,15 +436,20 @@ impl Database { if !has_tasks_elapsed { let _ = conn.execute("ALTER TABLE tasks ADD COLUMN elapsed_sec REAL", []); } - let has_gp_elapsed = conn - .prepare("PRAGMA table_info(grid_points)")? - .query_map([], |r| r.get::<_, String>(1))? - .any(|r| r.map(|n| n == "last_elapsed_sec").unwrap_or(false)); - if !has_gp_elapsed { - let _ = conn.execute( - "ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL", - [], - ); + // grid_points 幂等补列:last_elapsed_sec(列表展示用)与 summary_json(点级诊断快照)。 + // 新库由 GRID_POINTS_SCHEMA 建表时即含此二列;此 ALTER 仅兜底旧库(表已存在但缺列)。 + // 与上方 tasks 列迁移同模式:PRAGMA 检测 → 缺列才 ALTER。 + for (col, sql) in [ + ("last_elapsed_sec", "ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL"), + ("summary_json", "ALTER TABLE grid_points ADD COLUMN summary_json TEXT"), + ] { + let has_col = conn + .prepare("PRAGMA table_info(grid_points)")? + .query_map([], |r| r.get::<_, String>(1))? + .any(|r| r.map(|n| n == col).unwrap_or(false)); + if !has_col { + let _ = conn.execute(sql, []); + } } // 阶段独立配置迁移(见 docs/task_engine_decoupling_design.md §4.1): @@ -1143,9 +1153,30 @@ mod tests { db.update_grid_status(&p.model_name(), GridPointStatus::Queued, "wf_b") .await .unwrap(); - db.mark_grid_point_imported(&p2.model_name(), "wf_a", None, "seed_step") - .await - .unwrap(); + { + let summary = common::models::ModelSummary { + name: p2.model_name(), + params: p2.clone(), + stages: Vec::new(), + result_valid: true, + final_max_relc: Some(0.001), + final_chmax: Some(0.001), + seed: None, + atmosphere_has_nan: false, + synspec_rc: None, + synspec_error: None, + synspec_sec: None, + elapsed_sec: 0.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; + db.upsert_point_summary(&summary.name, "wf_a", &summary, "seed_step") + .await + .unwrap(); + } // 全局(None):3 个点,pending/queued/converged 分开计数; // 导入点按 seed_step 途径计入 seed_step_converged(不再有独立 imported 分类) @@ -1782,8 +1813,17 @@ mod tests { synspec_config: common::models::PhaseConfig::default_synspec(), synspec_params: None, tlusty_chain_params: None, + seed_chain_params: None, tlusty_input_params: None, atmosphere_ref: None, + energy_tolerance: None, + temp_max_factor: None, + temp_floor: None, + temp_ceiling: None, + emflux_tolerance: None, + convergence_min_ratio: None, + bfac_max: None, + bfac_min: None, }; let syn_task = Uuid::new_v4(); let old_task = Uuid::new_v4(); @@ -3553,4 +3593,677 @@ mod tests { }) ); } + + /// synspec-only 重跑后 tlusty_success_method / tlusty_status 须保留 prior 值, + /// 不能被 NULL 覆写(CASE 守卫修复验证)。 + /// + /// 场景:先以 TLUSTY 启用(cold_run)跑成功 → tlusty_success_method = "cold_run"。 + /// 再以 TLUSTY 关闭(仅 SYNSPEC,场景 B)重跑成功 → tlusty_success_method 仍须为 + /// "cold_run",不能被覆写为 NULL。synspec_success_method 应更新为 "standard"。 + #[tokio::test] + async fn test_synspec_only_rerun_preserves_tlusty_attribution() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("synrerun.db"); + let db = Database::new(&db_path.to_string_lossy()).await.unwrap(); + let wf = "wf_synrerun"; + + let params = GridPointParams { + teff: 25000.0.into(), + logg: 5.0.into(), + loghe: 2.0.into(), + logc: (-2.0).into(), + logn: (-2.0).into(), + logo: (-2.0).into(), + }; + let name = params.model_name(); + db.upsert_grid_point(¶ms, 0, wf).await.unwrap(); + + // ── 第一轮:TLUSTY 启用 + cold_run,成功 ── + let task1 = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some(wf.to_string()), + wave: 0, + timeout_sec: 7200, + tlusty_config: PhaseConfig { + enabled: true, + strategies: vec!["cold_run".to_string()], + ..PhaseConfig::default_tlusty() + }, + synspec_config: PhaseConfig::default_synspec(), + ..Default::default() + }; + db.insert_task(&task1).await.unwrap(); + + let summary1 = common::models::ModelSummary { + name: name.clone(), + params: params.clone(), + stages: vec![common::models::StepSummary { + label: "nl".into(), + chmax: Some(0.001), + lte: "F".into(), + converged: true, + best_max_relc: Some(0.0005), + elapsed_sec: 300.0, + note: None, + last_iter: Some(17), + worst_depth: Some(1), + n_depths: Some(50), + itek_history: vec![], + conv_trace_check: None, + }], + result_valid: true, + final_max_relc: Some(0.0005), + final_chmax: Some(0.001), + seed: None, + atmosphere_has_nan: false, + synspec_rc: Some(0), + synspec_error: None, + synspec_sec: Some(0.3), + elapsed_sec: 300.3, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; + let report1 = TaskReport { + task_id: task1.task_id, + point_name: name.clone(), + params: Some(params.clone()), + node_id: "test-node".to_string(), + status: TaskStatus::Completed, + result_valid: true, + max_relc: Some(0.0005), + atmosphere_has_nan: false, + elapsed_sec: 300.3, + error_message: None, + summary_json: serde_json::to_string(&summary1).unwrap(), + failed_stage: None, + }; + db.record_task_report(&report1, wf).await.unwrap(); + + // 验证第一轮:tlusty_success_method = cold_run, tlusty_status = converged + let row1 = read_grid_attrs(&db, &name, wf).await; + assert_eq!(row1.status, "completed"); + assert_eq!(row1.tlusty_success_method.as_deref(), Some("cold_run")); + assert_eq!(row1.tlusty_status.as_deref(), Some("converged")); + assert_eq!(row1.synspec_success_method.as_deref(), Some("standard")); + assert_eq!(row1.synspec_status.as_deref(), Some("converged")); + + // ── 模拟场景 B:reset_terminal_points_for_recompute 翻回 pending ── + db.reset_terminal_points_for_recompute(wf).await.unwrap(); + + // ── 第二轮:TLUSTY 关闭 + SYNSPEC 启用(synspec-only),成功 ── + let task2 = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some(wf.to_string()), + wave: 0, + timeout_sec: 7200, + tlusty_config: PhaseConfig { + enabled: false, + strategies: vec!["cold_run".to_string()], + ..PhaseConfig::default_tlusty() + }, + synspec_config: PhaseConfig::default_synspec(), + atmosphere_ref: Some(name.clone()), + ..Default::default() + }; + db.insert_task(&task2).await.unwrap(); + + // synspec-only 的 summary:stages 为空 → merge_point_summary 走字段级合并 + let summary2 = common::models::ModelSummary { + name: name.clone(), + params: params.clone(), + stages: Vec::new(), + result_valid: true, + final_max_relc: None, + final_chmax: None, + seed: None, + atmosphere_has_nan: false, + synspec_rc: Some(0), + synspec_error: None, + synspec_sec: Some(0.25), + elapsed_sec: 0.25, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; + let report2 = TaskReport { + task_id: task2.task_id, + point_name: name.clone(), + params: Some(params.clone()), + node_id: "test-node".to_string(), + status: TaskStatus::Completed, + result_valid: true, + max_relc: None, + atmosphere_has_nan: false, + elapsed_sec: 0.25, + error_message: None, + summary_json: serde_json::to_string(&summary2).unwrap(), + failed_stage: None, + }; + db.record_task_report(&report2, wf).await.unwrap(); + + // ── 核心断言:synspec-only 重跑后 tlusty 侧归因须保留 ── + let row2 = read_grid_attrs(&db, &name, wf).await; + assert_eq!(row2.status, "completed", "重跑成功后应为 completed"); + // ★ 修复前:tsm2 = None(裸赋值覆写)。修复后:保留 "cold_run"。 + assert_eq!( + row2.tlusty_success_method.as_deref(), + Some("cold_run"), + "synspec-only 重跑后 tlusty_success_method 须保留,不能覆写为 NULL" + ); + assert_eq!( + row2.tlusty_status.as_deref(), + Some("converged"), + "synspec-only 重跑后 tlusty_status 须保留" + ); + // synspec 侧应更新为新值 + assert_eq!( + row2.synspec_success_method.as_deref(), + Some("standard"), + "synspec_success_method 应更新为 standard" + ); + assert_eq!( + row2.synspec_status.as_deref(), + Some("converged"), + "synspec_status 应为 converged" + ); + + // summary_json 的 TLUSTY 诊断也须保留(merge_point_summary 字段级合并) + let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap(); + let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap(); + assert_eq!(ms.stages.len(), 1, "stages 须保留 prior 的 TLUSTY 链"); + assert_eq!(ms.stages[0].label, "nl"); + assert_eq!(ms.final_max_relc, Some(0.0005), "final_max_relc 须保留"); + assert_eq!(ms.synspec_rc, Some(0), "synspec_rc 应为新值"); + assert_eq!(ms.elapsed_sec, 0.25, "elapsed_sec 应为新值"); + + // last_elapsed_sec 语义:最近一次尝试耗时。synspec-only 重跑后为 0.25s(synspec 耗时), + // 原 TLUSTY 耗时保留在 stages[].elapsed_sec。ETA 不依赖此列(用 AVG(tasks.elapsed_sec))。 + let last_elapsed = read_grid_last_elapsed(&db, &name, wf).await; + assert_eq!( + last_elapsed, Some(0.25), + "last_elapsed_sec 应为 synspec-only 耗时(最近一次尝试),非原 TLUSTY 总耗时" + ); + } + + /// 辅助:读取 grid_points 的阶段归因列。 + async fn read_grid_attrs(db: &Database, name: &str, wf: &str) -> GridAttrs { + let pool = db.pool.clone(); + let name = name.to_string(); + let wf = wf.to_string(); + tokio::task::spawn_blocking(move || -> GridAttrs { + let conn = pool.get().unwrap(); + conn.query_row( + "SELECT status, tlusty_success_method, synspec_success_method, tlusty_status, synspec_status \ + FROM grid_points WHERE name = ?1 AND workflow_name = ?2", + rusqlite::params![name, wf], + |r| { + Ok(GridAttrs { + status: r.get(0)?, + tlusty_success_method: r.get(1)?, + synspec_success_method: r.get(2)?, + tlusty_status: r.get(3)?, + synspec_status: r.get(4)?, + }) + }, + ) + .unwrap() + }) + .await + .unwrap() + } + + struct GridAttrs { + status: String, + tlusty_success_method: Option, + synspec_success_method: Option, + tlusty_status: Option, + synspec_status: Option, + } + + /// synspec-only 重跑**失败**后 tlusty_success_method / tlusty_status 仍须保留。 + /// + /// 失败分支的 UPDATE 不写 success_method 列,但 tlusty_status / synspec_status + /// 有 CASE 守卫。验证失败报告不会清空 prior 的 TLUSTY 归因。 + #[tokio::test] + async fn test_synspec_only_rerun_failure_preserves_tlusty_attribution() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("synfail.db"); + let db = Database::new(&db_path.to_string_lossy()).await.unwrap(); + let wf = "wf_synfail"; + + let params = GridPointParams { + teff: 25000.0.into(), + logg: 5.0.into(), + loghe: 2.0.into(), + logc: (-2.0).into(), + logn: (-2.0).into(), + logo: (-2.0).into(), + }; + let name = params.model_name(); + db.upsert_grid_point(¶ms, 0, wf).await.unwrap(); + + // 第一轮:TLUSTY 启用 + cold_run,成功。 + let task1 = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some(wf.to_string()), + wave: 0, + timeout_sec: 7200, + tlusty_config: PhaseConfig { + enabled: true, + strategies: vec!["cold_run".to_string()], + ..PhaseConfig::default_tlusty() + }, + synspec_config: PhaseConfig::default_synspec(), + ..Default::default() + }; + db.insert_task(&task1).await.unwrap(); + let summary1 = common::models::ModelSummary { + name: name.clone(), + params: params.clone(), + stages: vec![common::models::StepSummary { + label: "nl".into(), + chmax: Some(0.001), + lte: "F".into(), + converged: true, + best_max_relc: Some(0.0005), + elapsed_sec: 300.0, + note: None, + last_iter: Some(17), + worst_depth: Some(1), + n_depths: Some(50), + itek_history: vec![], + conv_trace_check: None, + }], + result_valid: true, + final_max_relc: Some(0.0005), + final_chmax: Some(0.001), + seed: None, + atmosphere_has_nan: false, + synspec_rc: Some(0), + synspec_error: None, + synspec_sec: Some(0.3), + elapsed_sec: 300.3, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; + let report1 = TaskReport { + task_id: task1.task_id, + point_name: name.clone(), + params: Some(params.clone()), + node_id: "test-node".to_string(), + status: TaskStatus::Completed, + result_valid: true, + max_relc: Some(0.0005), + atmosphere_has_nan: false, + elapsed_sec: 300.3, + error_message: None, + summary_json: serde_json::to_string(&summary1).unwrap(), + failed_stage: None, + }; + db.record_task_report(&report1, wf).await.unwrap(); + assert_eq!(read_grid_attrs(&db, &name, wf).await.tlusty_success_method.as_deref(), Some("cold_run")); + + // 翻回 pending 模拟场景 B 重跑。 + db.reset_terminal_points_for_recompute(wf).await.unwrap(); + + // 第二轮:synspec-only,失败(synspec 产出脏谱)。 + let task2 = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some(wf.to_string()), + wave: 0, + timeout_sec: 7200, + tlusty_config: PhaseConfig { + enabled: false, + ..PhaseConfig::default_tlusty() + }, + synspec_config: PhaseConfig::default_synspec(), + atmosphere_ref: Some(name.clone()), + ..Default::default() + }; + db.insert_task(&task2).await.unwrap(); + // synspec 失败:result_valid=false, stages 为空(synspec-only),synspec_rc=1。 + let summary2 = common::models::ModelSummary { + name: name.clone(), + params: params.clone(), + stages: Vec::new(), + result_valid: false, + final_max_relc: None, + final_chmax: None, + seed: None, + atmosphere_has_nan: false, + synspec_rc: Some(1), + synspec_error: Some("spec 含 NaN".into()), + synspec_sec: Some(0.2), + elapsed_sec: 0.2, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: Some("synspec 失败".into()), + }; + let report2 = TaskReport { + task_id: task2.task_id, + point_name: name.clone(), + params: Some(params.clone()), + node_id: "test-node".to_string(), + status: TaskStatus::Failed, + result_valid: false, + max_relc: None, + atmosphere_has_nan: false, + elapsed_sec: 0.2, + error_message: Some("synspec 失败".to_string()), + summary_json: serde_json::to_string(&summary2).unwrap(), + failed_stage: Some("synspec".to_string()), + }; + db.record_task_report(&report2, wf).await.unwrap(); + + // 失败后 tlusty 侧归因仍须保留。 + let row = read_grid_attrs(&db, &name, wf).await; + assert_eq!(row.status, "failed", "失败后状态应为 failed"); + assert_eq!( + row.tlusty_success_method.as_deref(), + Some("cold_run"), + "synspec-only 失败后 tlusty_success_method 须保留" + ); + assert_eq!( + row.tlusty_status.as_deref(), + Some("converged"), + "synspec-only 失败后 tlusty_status 须保留(CASE 守卫)" + ); + // synspec 侧应反映失败。 + assert_eq!(row.synspec_status.as_deref(), Some("failed")); + } + + /// 辅助:读取 grid_points.last_elapsed_sec。 + async fn read_grid_last_elapsed(db: &Database, name: &str, wf: &str) -> Option { + let pool = db.pool.clone(); + let name = name.to_string(); + let wf = wf.to_string(); + tokio::task::spawn_blocking(move || -> Option { + let conn = pool.get().unwrap(); + conn.query_row( + "SELECT last_elapsed_sec FROM grid_points WHERE name = ?1 AND workflow_name = ?2", + rusqlite::params![name, wf], + |r| r.get(0), + ) + .ok() + }) + .await + .unwrap() + } + + /// TLUSTY-only 重跑成功后 synspec 归因列应被显式清空(clear_synspec=true), + /// summary_json 保留 prior synspec 字段(merge_point_summary TLUSTY-only 路径)。 + /// + /// 场景:先正常管线(TLUSTY+SYNSPEC)成功 → 再 TLUSTY-only(synspec 关闭)重跑成功 + /// → 新大气使旧光谱失效 → synspec_success_method/synspec_status 清 NULL, + /// summary_json 中 synspec_rc/synspec_sec 保留自 prior。 + #[tokio::test] + async fn test_tlusty_only_rerun_clears_synspec_attribution() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("tlonly.db"); + let db = Database::new(&db_path.to_string_lossy()).await.unwrap(); + let wf = "wf_tlonly"; + + let params = GridPointParams { + teff: 25000.0.into(), + logg: 5.0.into(), + loghe: 2.0.into(), + logc: (-2.0).into(), + logn: (-2.0).into(), + logo: (-2.0).into(), + }; + let name = params.model_name(); + db.upsert_grid_point(¶ms, 0, wf).await.unwrap(); + + // ── 第一轮:正常管线(TLUSTY + SYNSPEC 双开),成功 ── + let task1 = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some(wf.to_string()), + wave: 0, + timeout_sec: 7200, + tlusty_config: PhaseConfig { + enabled: true, + strategies: vec!["cold_run".to_string()], + ..PhaseConfig::default_tlusty() + }, + synspec_config: PhaseConfig::default_synspec(), + ..Default::default() + }; + db.insert_task(&task1).await.unwrap(); + let summary1 = common::models::ModelSummary { + name: name.clone(), + params: params.clone(), + stages: vec![common::models::StepSummary { + label: "nl".into(), + chmax: Some(0.001), + lte: "F".into(), + converged: true, + best_max_relc: Some(0.0005), + elapsed_sec: 300.0, + note: None, + last_iter: Some(17), + worst_depth: Some(1), + n_depths: Some(50), + itek_history: vec![], + conv_trace_check: None, + }], + result_valid: true, + final_max_relc: Some(0.0005), + final_chmax: Some(0.001), + seed: None, + atmosphere_has_nan: false, + synspec_rc: Some(0), + synspec_error: None, + synspec_sec: Some(0.3), + elapsed_sec: 300.3, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; + let report1 = TaskReport { + task_id: task1.task_id, + point_name: name.clone(), + params: Some(params.clone()), + node_id: "test-node".to_string(), + status: TaskStatus::Completed, + result_valid: true, + max_relc: Some(0.0005), + atmosphere_has_nan: false, + elapsed_sec: 300.3, + error_message: None, + summary_json: serde_json::to_string(&summary1).unwrap(), + failed_stage: None, + }; + db.record_task_report(&report1, wf).await.unwrap(); + let row1 = read_grid_attrs(&db, &name, wf).await; + assert_eq!(row1.synspec_success_method.as_deref(), Some("standard")); + assert_eq!(row1.synspec_status.as_deref(), Some("converged")); + + // ── 翻回 pending 模拟 TLUSTY-only 重跑 ── + db.reset_terminal_points_for_recompute(wf).await.unwrap(); + + // ── 第二轮:TLUSTY-only(synspec 关闭),成功 ── + let task2 = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some(wf.to_string()), + wave: 0, + timeout_sec: 7200, + tlusty_config: PhaseConfig { + enabled: true, + strategies: vec!["cold_run".to_string()], + ..PhaseConfig::default_tlusty() + }, + synspec_config: PhaseConfig { + enabled: false, + ..PhaseConfig::default_synspec() + }, + ..Default::default() + }; + db.insert_task(&task2).await.unwrap(); + // TLUSTY-only summary:stages 非空(TLUSTY 跑了),synspec_rc=None(synspec 没跑) + let summary2 = common::models::ModelSummary { + name: name.clone(), + params: params.clone(), + stages: vec![common::models::StepSummary { + label: "nl".into(), + chmax: Some(0.0008), + lte: "F".into(), + converged: true, + best_max_relc: Some(0.0003), + elapsed_sec: 280.0, + note: None, + last_iter: Some(15), + worst_depth: Some(1), + n_depths: Some(50), + itek_history: vec![], + conv_trace_check: None, + }], + result_valid: true, + final_max_relc: Some(0.0003), + final_chmax: Some(0.0008), + seed: None, + atmosphere_has_nan: false, + synspec_rc: None, // synspec 未运行 + synspec_error: None, + synspec_sec: None, + elapsed_sec: 280.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; + let report2 = TaskReport { + task_id: task2.task_id, + point_name: name.clone(), + params: Some(params.clone()), + node_id: "test-node".to_string(), + status: TaskStatus::Completed, + result_valid: true, + max_relc: Some(0.0003), + atmosphere_has_nan: false, + elapsed_sec: 280.0, + error_message: None, + summary_json: serde_json::to_string(&summary2).unwrap(), + failed_stage: None, + }; + db.record_task_report(&report2, wf).await.unwrap(); + + // ── 核心断言:synspec 列应被显式清空(clear_synspec=true)── + let row2 = read_grid_attrs(&db, &name, wf).await; + assert_eq!(row2.status, "completed"); + assert_eq!( + row2.tlusty_success_method.as_deref(), + Some("cold_run"), + "tlusty_success_method 应更新为 cold_run" + ); + assert_eq!( + row2.tlusty_status.as_deref(), + Some("converged"), + "tlusty_status 应为 converged" + ); + // ★ synspec 列被 clear_synspec 显式置 NULL(新大气使旧光谱失效) + assert_eq!( + row2.synspec_success_method, + None, + "TLUSTY-only 重跑后 synspec_success_method 须清 NULL(clear_synspec)" + ); + assert_eq!( + row2.synspec_status, + None, + "TLUSTY-only 重跑后 synspec_status 须清 NULL(clear_synspec)" + ); + + // summary_json:TLUSTY 诊断来自 incoming,synspec 字段保留自 prior + let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap(); + let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap(); + assert_eq!(ms.stages.len(), 1, "stages 来自 incoming"); + assert_eq!(ms.final_max_relc, Some(0.0003), "final_max_relc 来自 incoming"); + assert_eq!( + ms.synspec_rc, + Some(0), + "synspec_rc 保留 prior 值(merge_point_summary TLUSTY-only 路径)" + ); + assert_eq!(ms.synspec_sec, Some(0.3), "synspec_sec 保留 prior 值"); + } + + /// `get_task_tlusty_enabled`:正常任务返回 true,synspec-only 任务返回 false, + /// 不存在的 task_id 返回 None。 + #[tokio::test] + async fn test_get_task_tlusty_enabled() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("tlusty_enabled.db"); + let db = Database::new(&db_path.to_string_lossy()).await.unwrap(); + + let params = GridPointParams { + teff: 20000.0.into(), + logg: 5.0.into(), + loghe: 2.0.into(), + logc: (-2.0).into(), + logn: (-4.0).into(), + logo: (-4.0).into(), + }; + let name = params.model_name(); + db.upsert_grid_point(¶ms, 0, "wf_tle").await.unwrap(); + + // 正常任务(tlusty_enabled=true) + let task_normal = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some("wf_tle".to_string()), + tlusty_config: PhaseConfig::default_tlusty(), + synspec_config: PhaseConfig::default_synspec(), + ..Default::default() + }; + db.insert_task(&task_normal).await.unwrap(); + let enabled = db.get_task_tlusty_enabled(&task_normal.task_id).await.unwrap(); + assert_eq!(enabled, Some(true), "正常任务 tlusty_enabled 应为 true"); + + // synspec-only 任务(tlusty_enabled=false) + let task_synonly = common::models::TaskSpec { + task_id: Uuid::new_v4(), + point_name: name.clone(), + params: params.clone(), + workflow_name: Some("wf_tle".to_string()), + tlusty_config: PhaseConfig { + enabled: false, + ..PhaseConfig::default_tlusty() + }, + synspec_config: PhaseConfig::default_synspec(), + atmosphere_ref: Some(name.clone()), + ..Default::default() + }; + db.insert_task(&task_synonly).await.unwrap(); + let enabled = db.get_task_tlusty_enabled(&task_synonly.task_id).await.unwrap(); + assert_eq!(enabled, Some(false), "synspec-only 任务 tlusty_enabled 应为 false"); + + // 不存在的 task_id → None + let fake_id = Uuid::new_v4(); + let enabled = db.get_task_tlusty_enabled(&fake_id).await.unwrap(); + assert_eq!(enabled, None, "不存在的 task_id 应返回 None"); + } } diff --git a/crates/server/src/db/tasks.rs b/crates/server/src/db/tasks.rs index 9176faa..3ac68ab 100644 --- a/crates/server/src/db/tasks.rs +++ b/crates/server/src/db/tasks.rs @@ -254,6 +254,33 @@ impl Database { Ok(()) } + /// 查询某 task 的 `tlusty_enabled` 标志。 + /// + /// 用于 `report_task` API 在写 `.7` 种子文件前判断本次任务是否为 synspec-only + /// (`tlusty_enabled=false`)。synspec-only 任务未重算大气,节点上传的 `.7` 是从 + /// 服务端下载的原样字节 → 服务端跳过写入,避免冗余 I/O 和潜在的误覆盖。 + /// + /// 返回 `None` 表示 task 行不存在(已被清理或 task_id 无效),调用方应 fallback 为 + /// `true`(保守,不阻断种子写入主流程)。tasks 行在 queue 清理后仍保留(仅 task_queue + /// 被清),故在首次上报与幂等重放两条路径均有效。 + pub async fn get_task_tlusty_enabled(&self, task_id: &uuid::Uuid) -> Result> { + let pool = self.pool.clone(); + let id = task_id.to_string(); + tokio::task::spawn_blocking(move || -> Result> { + let conn = pool + .get() + .map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?; + let mut stmt = conn.prepare("SELECT tlusty_enabled FROM tasks WHERE task_id = ?1")?; + let res = stmt.query_row(params![id], |r| r.get::<_, bool>(0)); + match res { + Ok(enabled) => Ok(Some(enabled)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + }) + .await? + } + /// 列出指定网格点在当前工作流内仍处于 `pending` 的 tasks 行 task_id。 /// /// tasks 行的 pending 意为"已创建但从未上报",可能是真在途(队列行仍 pending/ @@ -486,10 +513,13 @@ impl Database { } else if converged { // 半失败:大气收敛 + 光谱失败。tlusty 阶段确已收敛(tlusty_enabled 防御性守卫—— // 该分支在 synspec-only 下不可达,但避免未来代码路径把禁用阶段误标为 converged)。 - (tlusty_enabled.then_some("converged"), Some("failed")) + // synspec_status gate on synspec_enabled:synspec 禁用时为 None → CASE 守卫保留 prior, + // 不把禁用阶段误标为 "failed"(TLUSTY-only 重跑失败不应清空既有 synspec 状态)。 + (tlusty_enabled.then_some("converged"), synspec_enabled.then_some("failed")) } else if tlusty_enabled { // 大气未收敛(TLUSTY 启用)→ TLUSTY 失败、SYNSPEC 未运行。 - (Some("failed"), Some("pending")) + // synspec_status gate on synspec_enabled:同上,禁用时保留 prior。 + (Some("failed"), synspec_enabled.then_some("pending")) } else { // synspec-only 失败(converged 由 synspec_rc 决定)。 (None, Some("failed")) @@ -506,11 +536,60 @@ impl Database { // - 阶段列(5b):tlusty_status 用 CASE 守卫保留既有终态——半失败重试的 synspec-only // 任务(tlusty 关闭)不覆盖已收敛的 tlusty_status(设计 §7.3 打开项 #2:仅 synspec // 侧流转);synspec_status 自由流转。 + + // clear_synspec:TLUSTY-only 重跑成功(tlusty_enabled && !synspec_enabled)时, + // 新大气使旧光谱失效 → 显式清空 synspec 归因列为 NULL(而非保留旧值)。 + // 与 synspec-only 重跑(tlusty_enabled=false → 大气未变 → 保留 tlusty 列)对称: + // 另一阶段"跑了且产生了新产物"时,当前阶段的旧归因应作废。 + let clear_synspec = tlusty_enabled && !synspec_enabled; + + // summary_json 增量合并:synspec-only 重跑(tlusty_enabled=false)时只覆盖 synspec + // 字段,保留 grid_points 既有 summary 中的 TLUSTY 诊断(stages/final_max_relc/各 check)。 + // 合并逻辑共享 common::summary_merge,与离线导入工具保持一致。 + let merged_summary_json: Option = { + let prior_json: Option = tx + .query_row( + "SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2", + params![point_name, wf], + |r| r.get::<_, Option>(0), + ) + .ok() + .flatten(); + let prior = prior_json + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + let incoming = serde_json::from_str::( + &report_cloned.summary_json, + ) + .ok(); + // incoming 解析失败(错误路径 {"error":...} 或空字符串)时跳过 summary 写入, + // 只更新原有列——与此前「server 只透传不解析 summary_json」的容错语义一致。 + incoming.map(|inc| { + let merged = common::summary_merge::merge_point_summary(prior.as_ref(), &inc); + serde_json::to_string(&merged).unwrap_or_default() + }) + }; + let grid_changed = if pipe_ok { tx.execute( - "UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, tlusty_success_method = ?2, synspec_success_method = ?3, + // 阶段列 CASE 守卫(对称修复): + // - synspec-only 重跑(tlusty_enabled=0)→ tlusty 列为 NULL → CASE 守卫保留 prior。 + // - TLUSTY-only 重跑(synspec_enabled=0, clear_synspec=true)→ synspec 列显式置 NULL + // (新大气使旧光谱失效),summary_merge 同步保留 prior synspec 字段。 + // - 正常管线(双开)→ 全部 CASE 的 THEN 分支命中,写入新值。 + // 此前 tlusty_success_method/synspec_success_method/synspec_status 为裸赋值, + // synspec-only 成功会把 tlusty_success_method 从 "cold_run" 覆写为 NULL, + // 污染 stats 统计(workflows.rs:99 / grid.rs:579 按 tlusty_success_method 归因)。 + // + // last_elapsed_sec 语义:最近一次尝试的墙钟耗时。synspec-only 重跑后该列反映 + // synspec-only 耗时(~0.25s),TLUSTY 分项耗时保留在 summary_json.stages[].elapsed_sec。 + // ETA 不依赖此列(用 AVG(tasks.elapsed_sec)),conv 面板用 summary_json.elapsed_sec。 + "UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, + tlusty_success_method = CASE WHEN ?2 IS NOT NULL THEN ?2 ELSE tlusty_success_method END, + synspec_success_method = CASE WHEN ?9 THEN NULL WHEN ?3 IS NOT NULL THEN ?3 ELSE synspec_success_method END, tlusty_status = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE tlusty_status END, - synspec_status = ?7 + synspec_status = CASE WHEN ?9 THEN NULL WHEN ?7 IS NOT NULL THEN ?7 ELSE synspec_status END, + summary_json = CASE WHEN ?8 IS NOT NULL THEN ?8 ELSE summary_json END WHERE name = ?4 AND workflow_name = ?5 AND status != 'completed'", params![ report_cloned.elapsed_sec, @@ -519,16 +598,23 @@ impl Database { point_name, wf, tlusty_status, - synspec_status + synspec_status, + merged_summary_json, + clear_synspec, ], )? } else { tx.execute( + // synspec_status 同步加 CASE 守卫(对称修复):synspec_enabled=0 时 + // synspec_status 为 None(上方 if-else 已 gate)→ 保留 prior。 + // 与 success 分支、tlusty_status 守卫口径一致。 + // last_elapsed_sec 语义见 success 分支注释。 "UPDATE grid_points SET status = 'failed', last_elapsed_sec = ?1, tlusty_status = CASE WHEN ?4 IS NOT NULL THEN ?4 ELSE tlusty_status END, - synspec_status = ?5 + synspec_status = CASE WHEN ?5 IS NOT NULL THEN ?5 ELSE synspec_status END, + summary_json = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE summary_json END WHERE name = ?2 AND workflow_name = ?3 AND status NOT IN ('completed', 'failed')", - params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status], + params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status, merged_summary_json], )? }; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 1cee952..6e5775d 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -325,8 +325,6 @@ async fn main() -> Result<()> { let report_router = Router::new() .route("/task/report", post(api::task::report_task)) - // 历史种子导入同样上传 .7 大气文件,并入宽松 body limit / 并发限流组。 - .route("/admin/import_seed", post(api::task::import_seed)) .layer(DefaultBodyLimit::max(REPORT_BODY_LIMIT)) .layer(tower::ServiceBuilder::new().concurrency_limit(REPORT_MAX_CONCURRENCY)); @@ -426,6 +424,9 @@ async fn main() -> Result<()> { "/admin/nodes/:node_id/quota", post(api::admin::set_node_quota), ) + // 临时迁移端点:扫 seeds_dir 的 conv.json → 写 grid_points.summary_json。 + // 迁移完成后删除本路由 + api/migrate.rs 即可。 + .route("/admin/migrate_conv", post(api::migrate::migrate_conv)) .layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT)); // 合并两个子 router:各自携带自己的 body limit,互不覆盖。 diff --git a/crates/server/src/scheduler.rs b/crates/server/src/scheduler.rs index 57e5c3b..ae755f2 100644 --- a/crates/server/src/scheduler.rs +++ b/crates/server/src/scheduler.rs @@ -261,7 +261,7 @@ impl GridScheduler { /// 读取指定工作流的 TLUSTY 物理迭代步进链(`config::GridConfig.tlusty_chain`), /// 序列化为 JSON Value 供 TaskSpec 携带。节点 executor 反序列化为 `Vec` - /// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax/metals + /// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax /// 等阶段参数真正生效(此前 executor 硬编码用 default 链,忽略用户配置)。 /// 工作流未配置 tlusty_chain(空数组)→ None(executor 用 default 链兜底)。 async fn get_workflow_tlusty_chain(&self, workflow_name: &str) -> Option { @@ -273,6 +273,19 @@ impl GridScheduler { serde_json::to_value(&cfg.tlusty_chain).ok() } + /// 读取指定工作流的种子热启动链(`config::GridConfig.seed_chain`),序列化为 JSON + /// Value 供 TaskSpec 携带。仅 seed_step 策略下由 executor 读取。 + /// 与 `get_workflow_tlusty_chain` 对称。工作流未配置 seed_chain(空数组)→ None + ///(executor 用 `default_seed_chain()` 兜底)。 + async fn get_workflow_seed_chain(&self, workflow_name: &str) -> Option { + let wf = self.db.get_workflow(workflow_name).await.ok()??; + let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "seed_chain")?; + if cfg.seed_chain.is_empty() { + return None; + } + serde_json::to_value(&cfg.seed_chain).ok() + } + /// 读取指定工作流的 TLUSTY 输入文件全局参数(`config::GridConfig.tlusty_input`), /// 序列化为 JSON Value 供 TaskSpec 携带。包含 NFREAD 频率网格、ions 能级表、 /// nst extra_keys 等不随阶段变化的参数。节点 executor 反序列化为 `TlustyInput` @@ -286,6 +299,38 @@ impl GridScheduler { .and_then(|t| serde_json::to_value(t).ok()) } + /// 一次性读取工作流的全部物理校验阈值(能量守恒 / 温度结构 / emflux)。 + /// 统一读取避免对同一 YAML 多次解析。返回 8 元组,对应 TaskSpec 的 8 个标量字段: + /// (energy_tolerance, temp_max_factor, temp_floor, temp_ceiling, emflux_tolerance, + /// convergence_min_ratio, bfac_max, bfac_min)。 + async fn get_workflow_validation_thresholds( + &self, + workflow_name: &str, + ) -> Option<( + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + )> { + let wf = self.db.get_workflow(workflow_name).await.ok()??; + let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "validation_thresholds")?; + Some(( + cfg.energy_tolerance, + cfg.temp_max_factor, + cfg.temp_floor, + cfg.temp_ceiling, + cfg.emflux_tolerance, + cfg.convergence_min_ratio, + cfg.bfac_max, + cfg.bfac_min, + )) + } + + /// 从策略链解析出「首个可派发」的顺位(见 docs/task_engine_decoupling_design.md §4.2)。 /// /// 判定: @@ -404,7 +449,21 @@ impl GridScheduler { let (tlusty_cfg, synspec_cfg) = self.get_workflow_stage_configs(workflow_name).await; let synspec_params = self.get_workflow_synspec_params(workflow_name).await; let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await; + let seed_chain = self.get_workflow_seed_chain(workflow_name).await; let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await; + let ( + energy_tolerance, + temp_max_factor, + temp_floor, + temp_ceiling, + emflux_tolerance, + convergence_min_ratio, + bfac_max, + bfac_min, + ) = self + .get_workflow_validation_thresholds(workflow_name) + .await + .unwrap_or((None, None, None, None, None, None, None, None)); // 双阶段全关是退化配置(save_workflow 已拦截,此处兜底防御):无可执行阶段, // 整工作流跳过派发(修复审查 #5)。 @@ -538,6 +597,7 @@ impl GridScheduler { synspec_config: synspec_cfg.clone(), synspec_params: synspec_params.clone(), tlusty_chain_params: tlusty_chain.clone(), + seed_chain_params: seed_chain.clone(), tlusty_input_params: tlusty_input.clone(), // 显式绑定大气来源(设计 §5.2,修复审查 #3):仅 SYNSPEC-only(TLUSTY 关闭) // 场景需要外部大气——节点凭 atmosphere_ref(或 point_name 兜底)从本地归档 @@ -547,6 +607,14 @@ impl GridScheduler { } else { Some(name.clone()) }, + energy_tolerance, + temp_max_factor, + temp_floor, + temp_ceiling, + emflux_tolerance, + convergence_min_ratio, + bfac_max, + bfac_min, }; self.db.insert_task(&task_spec).await?; @@ -834,7 +902,21 @@ impl GridScheduler { let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await; let synspec_params = self.get_workflow_synspec_params(workflow_name).await; let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await; + let seed_chain = self.get_workflow_seed_chain(workflow_name).await; let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await; + let ( + energy_tolerance, + temp_max_factor, + temp_floor, + temp_ceiling, + emflux_tolerance, + convergence_min_ratio, + bfac_max, + bfac_min, + ) = self + .get_workflow_validation_thresholds(workflow_name) + .await + .unwrap_or((None, None, None, None, None, None, None, None)); // SYNSPEC 链回退:重试光谱合成。无邻居种子门控(大气来自目标点自身既有产物, // 见 docs/task_engine_decoupling_design.md §5)——旧实现把 synspec 失败误归因到 @@ -874,8 +956,18 @@ impl GridScheduler { synspec_params, // TLUSTY 已关闭(半失败重试只重跑光谱),不执行 chain/input → None。 tlusty_chain_params: None, + seed_chain_params: None, tlusty_input_params: None, atmosphere_ref: Some(name.to_string()), + // 不重算大气 → 不做物理正确性校验。 + energy_tolerance: None, + temp_max_factor: None, + temp_floor: None, + temp_ceiling: None, + emflux_tolerance: None, + convergence_min_ratio: None, + bfac_max: None, + bfac_min: None, }; self.db.insert_task(&task_spec).await?; self.db @@ -959,8 +1051,17 @@ impl GridScheduler { synspec_config: synspec_cfg.clone(), synspec_params, tlusty_chain_params: tlusty_chain.clone(), + seed_chain_params: seed_chain.clone(), tlusty_input_params: tlusty_input.clone(), atmosphere_ref: None, + energy_tolerance, + temp_max_factor, + temp_floor, + temp_ceiling, + emflux_tolerance, + convergence_min_ratio, + bfac_max, + bfac_min, }; self.db.insert_task(&task_spec).await?; @@ -1018,6 +1119,7 @@ mod tests { logo: vec![(-2.0).into()], }, tlusty_chain: vec![], + seed_chain: vec![], tlusty_input: None, synspec_input: None, nworkers: 4, @@ -1030,6 +1132,14 @@ mod tests { linelist: None, tlusty_stage: None, synspec_stage: None, + energy_tolerance: None, + temp_max_factor: None, + temp_floor: None, + temp_ceiling: None, + emflux_tolerance: None, + convergence_min_ratio: None, + bfac_max: None, + bfac_min: None, }; scheduler.initialize_grid(&cfg, "test_wf").await.unwrap(); @@ -1075,6 +1185,7 @@ mod tests { logo: vec![(-2.0).into()], }, tlusty_chain: vec![], + seed_chain: vec![], tlusty_input: None, synspec_input: None, nworkers: 4, @@ -1087,6 +1198,14 @@ mod tests { linelist: None, tlusty_stage: None, synspec_stage: None, + energy_tolerance: None, + temp_max_factor: None, + temp_floor: None, + temp_ceiling: None, + emflux_tolerance: None, + convergence_min_ratio: None, + bfac_max: None, + bfac_min: None, }; // wf_a 初始化并推入队列 @@ -1195,6 +1314,7 @@ mod tests { logo: vec![(-2.0).into()], }, tlusty_chain: vec![], + seed_chain: vec![], tlusty_input: None, synspec_input: None, nworkers: 4, @@ -1207,6 +1327,14 @@ mod tests { linelist: None, tlusty_stage: None, synspec_stage: None, + energy_tolerance: None, + temp_max_factor: None, + temp_floor: None, + temp_ceiling: None, + emflux_tolerance: None, + convergence_min_ratio: None, + bfac_max: None, + bfac_min: None, }; cfg } diff --git a/crates/server/tests/api_tests.rs b/crates/server/tests/api_tests.rs index 5a997d2..562c27c 100644 --- a/crates/server/tests/api_tests.rs +++ b/crates/server/tests/api_tests.rs @@ -2,11 +2,39 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; +use common::models::{GridPointParams, ModelSummary}; use mq::sqlite_queue::SqliteTaskQueue; use server::{api::AppState, db::Database, scheduler::GridScheduler}; use std::sync::Arc; use tower::ServiceExt; // for oneshot +/// 测试辅助:把一个已 upsert 的点标记为导入收敛(写 summary_json + status=completed)。 +/// 等价旧 mark_grid_point_imported。 +async fn mark_imported(db: &Database, name: &str, wf: &str, params: &GridPointParams, method: &str) { + let summary = ModelSummary { + name: name.to_string(), + params: params.clone(), + stages: Vec::new(), + result_valid: true, + final_max_relc: Some(0.001), + final_chmax: Some(0.001), + seed: None, + atmosphere_has_nan: false, + synspec_rc: None, + synspec_error: None, + synspec_sec: None, + elapsed_sec: 0.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; + db.upsert_point_summary(name, wf, &summary, method) + .await + .unwrap(); +} + #[tokio::test] async fn test_server_api_flow() { let temp_dir = tempfile::tempdir().unwrap(); @@ -1256,343 +1284,6 @@ async fn test_cors_same_origin_and_local_policy() { assert!(res.headers().get("access-control-allow-origin").is_none()); } -/// 构造一个合法的旧版 conv.json (ModelSummary) 文本,用于 import_seed 测试。 -/// 关键:name 用源精度真名(`g5.0`),验证服务端逐字符保真落库。 -fn make_legacy_conv_json(name: &str, converged: bool) -> String { - let summary = serde_json::json!({ - "name": name, - "params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0}, - "stages": [], - "converged": converged, - "final_max_relc": 0.000321, - "final_chmax": null, - "seed": null, - "atmosphere_has_nan": false, - "synspec_rc": 0, - "synspec_error": null, - "synspec_sec": 1.5, - "elapsed_sec": 42.0, - "note": null, - }); - serde_json::to_string(&summary).unwrap() -} - -/// 构造**旧版 Python run_one.py 形态**的 conv.json(stage 含 itek_attempts/final 嵌套, -/// 顶层含 elapsed_sec/synspec_*)——验证 import_seed 对真实历史数据的解析兼容性。 -fn make_python_legacy_conv_json(name: &str) -> String { - serde_json::json!({ - "name": name, - "params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0}, - "stages": [ - {"label": "lte", "chmax": null, "lte": "T", - "itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0}], - "converged": true, - "final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0}, - "best_max_relc": 0.0, "elapsed_sec": 2.1}, - {"label": "nl", "chmax": null, "lte": "F", - "itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321, - "worst_depth": 1, "last_iter": 17, "n_depths": 50}], - "converged": true, - "final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321, - "worst_depth": 1, "last_iter": 17, "n_depths": 50}, - "best_max_relc": 0.000321, "elapsed_sec": 640.0} - ], - "converged": true, - "final_max_relc": 0.000321, - "final_chmax": null, - "seed": null, - "atmosphere_has_nan": false, - "synspec_rc": 0, - "synspec_sec": 3.1, - "elapsed_sec": 715.0, - }) - .to_string() -} - -/// 构造一个 multipart/form-data body,含 report(JSON 文本) + seed_file(二进制)。 -fn make_import_multipart( - boundary: &str, - report_json: &str, - seed_bytes: &[u8], - seed_name: &str, - success_method: &str, -) -> Vec { - let mut body = Vec::new(); - body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); - body.extend_from_slice(b"Content-Disposition: form-data; name=\"report\"\r\n"); - body.extend_from_slice(b"Content-Type: application/json\r\n\r\n"); - body.extend_from_slice(report_json.as_bytes()); - body.extend_from_slice(b"\r\n"); - body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); - body.extend_from_slice( - format!( - "Content-Disposition: form-data; name=\"seed_file\"; filename=\"{}\"\r\n", - seed_name - ) - .as_bytes(), - ); - body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n"); - body.extend_from_slice(seed_bytes); - body.extend_from_slice(b"\r\n"); - // 大气收敛途径字段(cold_run/seed_step):模拟 import_results 工具判定后透传的途径。 - body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); - body.extend_from_slice(b"Content-Disposition: form-data; name=\"tlusty_success_method\"\r\n"); - body.extend_from_slice(b"Content-Type: text/plain\r\n\r\n"); - body.extend_from_slice(success_method.as_bytes()); - body.extend_from_slice(b"\r\n"); - body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); - body -} - -#[tokio::test] -async fn test_import_seed_admin_endpoint() { - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("import_db.db"); - let queue_db_path = temp_dir.path().join("import_queue.db"); - let seeds_dir = temp_dir.path().join("results"); - std::fs::create_dir_all(&seeds_dir).unwrap(); - - let db = Database::new(&db_path.to_string_lossy()).await.unwrap(); - let queue = Arc::new( - SqliteTaskQueue::new(&queue_db_path.to_string_lossy()) - .await - .unwrap(), - ); - let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone())); - - let state = AppState { - db: db.clone(), - queue, - scheduler, - seeds_dir: seeds_dir.to_string_lossy().to_string(), - rate_limiter: server::api::rate_limit::RateLimiter::new( - 5, - std::time::Duration::from_secs(300), - ), - admin_token: Some("admin-secret".to_string()), - auth_disabled: false, - admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), - }; - - let api_router = axum::Router::new().route( - "/admin/import_seed", - axum::routing::post(server::api::task::import_seed), - ); - let auth_layer = - axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware); - let app = axum::Router::new() - .nest("/api", api_router.layer(auth_layer)) - .with_state(state); - - // 1. 无 admin token → 401 - let conv = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4", true); - let body_bytes = make_import_multipart( - "boundary1", - &conv, - b"FAKE_ATMOS_7", - "t20000_g5.0_he-2_c-4_n-4_o-4.7", - "cold_run", - ); - let res = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/import_seed?workflow=wf_import") - .header("content-type", "multipart/form-data; boundary=boundary1") - .body(Body::from(body_bytes)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - - // 2. admin token + 收敛点 → 200,conv.json + .7 落地,grid_points=converged - let body_bytes = make_import_multipart( - "boundary2", - &conv, - b"FAKE_ATMOS_7", - "t20000_g5.0_he-2_c-4_n-4_o-4.7", - "cold_run", - ); - let res = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/import_seed?workflow=wf_import") - .header("authorization", "Bearer admin-secret") - .header("content-type", "multipart/form-data; boundary=boundary2") - .body(Body::from(body_bytes)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::OK); - - // 关键断言:磁盘目录名用旧 conv.json 的源精度真名(g5.0),conv.json 与 .7 均落地。 - let point_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4"); - assert!(point_dir.join("conv.json").is_file(), "conv.json 应落地"); - assert!( - point_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4.7").is_file(), - ".7 种子文件应落地" - ); - - // grid_points 应被幂等 upsert 且标记为 converged。 - let gp = db - .get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import") - .await - .unwrap() - .expect("grid_points 应存在"); - assert_eq!(gp.0, "completed", "导入的收敛点应为 converged 状态"); - - // 3. 幂等:重复导入同名点不应报错,状态仍 converged。 - let body_bytes = make_import_multipart( - "boundary3", - &conv, - b"FAKE_ATMOS_7_AGAIN", - "t20000_g5.0_he-2_c-4_n-4_o-4.7", - "cold_run", - ); - let res = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/import_seed?workflow=wf_import") - .header("authorization", "Bearer admin-secret") - .header("content-type", "multipart/form-data; boundary=boundary3") - .body(Body::from(body_bytes)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::OK); - let gp = db - .get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import") - .await - .unwrap() - .unwrap(); - assert_eq!(gp.0, "completed"); - - // 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。 - let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false); - let body_bytes = - make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run"); - let res = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/import_seed?workflow=wf_import") - .header("authorization", "Bearer admin-secret") - .header("content-type", "multipart/form-data; boundary=boundary4") - .body(Body::from(body_bytes)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::OK); - let fail_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4_fail"); - assert!( - fail_dir.join("conv.json").is_file(), - "未收敛点仍应记录 conv.json" - ); - assert!( - !fail_dir - .join("t20000_g5.0_he-2_c-4_n-4_o-4_fail.7") - .exists(), - "未收敛点不应写 .7 种子" - ); -} - -/// 旧版 Python run_one.py conv.json 的完整导入链路(import_results 工具的服务端侧): -/// 嵌套 stages(itek_attempts/final)应被正常解析,收敛标记落地,且旧版的 -/// elapsed_sec 经 P3 迁移链路进入 grid_points.last_elapsed_sec(迁移完整性)。 -#[tokio::test] -async fn test_import_seed_python_legacy_conv_json() { - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("import_legacy_db.db"); - let queue_db_path = temp_dir.path().join("import_legacy_queue.db"); - let seeds_dir = temp_dir.path().join("results"); - std::fs::create_dir_all(&seeds_dir).unwrap(); - - let db = Database::new(&db_path.to_string_lossy()).await.unwrap(); - let queue = Arc::new( - SqliteTaskQueue::new(&queue_db_path.to_string_lossy()) - .await - .unwrap(), - ); - let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone())); - - let state = AppState { - db: db.clone(), - queue, - scheduler, - seeds_dir: seeds_dir.to_string_lossy().to_string(), - rate_limiter: server::api::rate_limit::RateLimiter::new( - 5, - std::time::Duration::from_secs(300), - ), - admin_token: Some("admin-secret".to_string()), - auth_disabled: false, - admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), - }; - - let api_router = axum::Router::new().route( - "/admin/import_seed", - axum::routing::post(server::api::task::import_seed), - ); - let auth_layer = - axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware); - let app = axum::Router::new() - .nest("/api", api_router.layer(auth_layer)) - .with_state(state); - - let name = "t20000_g5.0_he-2_c-4_n-4_o-4"; - let conv = make_python_legacy_conv_json(name); - let body_bytes = make_import_multipart( - "boundaryL", - &conv, - b"FAKE_ATMOS_7", - &format!("{name}.7"), - "cold_run", - ); - let res = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/import_seed?workflow=wf_legacy") - .header("authorization", "Bearer admin-secret") - .header("content-type", "multipart/form-data; boundary=boundaryL") - .body(Body::from(body_bytes)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!( - res.status(), - StatusCode::OK, - "旧版嵌套 stages 的 conv.json 应被接受" - ); - - // grid_points:converged + cold_run 手段(旧版 conv.json 无 seed_nc 阶段)+ 旧版 elapsed_sec 已落库 - let row = db - .get_workflow_point_row("wf_legacy", name) - .await - .unwrap() - .expect("grid_points 应存在"); - assert_eq!(row.status, "completed"); - assert_eq!(row.tlusty_success_method.as_deref(), Some("cold_run")); - assert_eq!( - row.last_elapsed_sec, - Some(715.0), - "旧版 conv.json 的单点耗时应完整迁移到 last_elapsed_sec" - ); - - // conv.json 原文落盘(嵌套诊断数据 itek_attempts/final 不丢失,详情页解析走兼容路径) - assert!(seeds_dir.join(name).join("conv.json").is_file()); - assert!(seeds_dir.join(name).join(format!("{name}.7")).is_file()); -} #[tokio::test] async fn test_node_disable_enable_flow() { @@ -2145,7 +1836,7 @@ async fn dispatch_and_report( task_id, point_name: p.model_name(), params: p.clone(), - seed_point_name: seed, + seed_point_name: seed.clone(), timeout_sec: 7200, workflow_name: Some(wf.to_string()), wave: 0, @@ -2157,6 +1848,26 @@ async fn dispatch_and_report( ..Default::default() }; db.insert_task(&spec).await.unwrap(); + // 构造合法 ModelSummary 作为 summary_json(让 record_task_report 能解析合并写入 grid_points)。 + let summary = common::models::ModelSummary { + name: p.model_name(), + params: p.clone(), + stages: Vec::new(), + result_valid: converged, + final_max_relc: if converged { Some(0.0005) } else { Some(9.5e5) }, + final_chmax: Some(0.001), + seed, + atmosphere_has_nan: false, + synspec_rc: None, + synspec_error: None, + synspec_sec: None, + elapsed_sec: 120.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + }; let report = common::models::TaskReport { task_id, point_name: p.model_name(), @@ -2176,7 +1887,7 @@ async fn dispatch_and_report( } else { Some("nl stage diverged".to_string()) }, - summary_json: "{}".to_string(), + summary_json: serde_json::to_string(&summary).unwrap(), failed_stage: None, }; db.record_task_report(&report, wf).await.unwrap(); @@ -2292,9 +2003,7 @@ async fn test_wf_stats_endpoint() { ) .await; dispatch_and_report(&db, "wf_stats", &p_failed, "cold_run", None, false).await; - db.mark_grid_point_imported(&p_imported.model_name(), "wf_stats", None, "cold_run") - .await - .unwrap(); + mark_imported(&db, &p_imported.model_name(), "wf_stats", &p_imported, "cold_run").await; // 回拨任务创建时间:测试内 insert/report 同秒完成,墙钟差为 0 会被 ETA 估算 // 过滤(avg 必须 > 0);造 120s 的真实感样本,使 avg_point_sec/eta_sec 非空。 @@ -2456,9 +2165,7 @@ async fn seed_obs_fixture(db: &Database, db_path: &std::path::Path, wf: &str) -> true, ) .await; - db.mark_grid_point_imported(&p_imported.model_name(), wf, None, "cold_run") - .await - .unwrap(); + mark_imported(&db, &p_imported.model_name(), wf, &p_imported, "cold_run").await; let conn = rusqlite::Connection::open(db_path).unwrap(); conn.execute( @@ -2709,14 +2416,9 @@ async fn test_point_detail_endpoint() { let n = seed_obs_fixture(&db, &db_path, "wf_pd").await; - // 给 cold 点写一份合法 conv.json(rescued 刻意不写,验证 null 降级) - let cold_dir = seeds_dir.join(&n.cold); - std::fs::create_dir_all(&cold_dir).unwrap(); - std::fs::write( - cold_dir.join("conv.json"), - make_legacy_conv_json(&n.cold, true), - ) - .unwrap(); + // conv 诊断面板数据源已改为 grid_points.summary_json(dispatch_and_report 写入), + // 不再需要磁盘 conv.json。rescued 点最终 seed_step 成功 → 也有 summary_json, + // 但此处验证 null 降级用 pending 点(未 report,无 summary)。 async fn get_detail(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) { let res = app @@ -2757,10 +2459,11 @@ async fn test_point_detail_endpoint() { assert_eq!(attempts.len(), 1); assert!(attempts[0]["seed_point_name"].is_null(), "冷启动无种子来源"); assert_eq!(attempts[0]["status"], "completed"); - assert_eq!(data["conv"]["result_valid"], true, "conv.json 应被解析"); - assert_eq!(data["conv"]["final_max_relc"], 0.000321); + assert_eq!(data["conv"]["result_valid"], true, "summary_json 应被解析"); + assert_eq!(data["conv"]["final_max_relc"], 0.0005); - // ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回),conv 为 null ---- + // ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回)---- + // 最终 seed_step 成功 → record_task_report 写入 summary_json,conv 非 null。 let uri = format!("/api/workflows/wf_pd/points/{}", n.rescued); let (st, data) = get_detail(&app, &uri).await; assert_eq!(st, StatusCode::OK); @@ -2775,7 +2478,13 @@ async fn test_point_detail_endpoint() { attempts[1]["seed_point_name"], n.cold, "种子来源应为 cold 点" ); - assert!(data["conv"].is_null(), "无 conv.json 应返回 null 而非报错"); + assert_eq!(data["conv"]["result_valid"], true, "rescued 最终成功应有 summary"); + + // ---- 3b. pending 点:从未 report → 无 summary_json → conv 为 null(降级不报错)---- + let uri = format!("/api/workflows/wf_pd/points/{}", n.pending); + let (st, data) = get_detail(&app, &uri).await; + assert_eq!(st, StatusCode::OK); + assert!(data["conv"].is_null(), "未结算点无 summary 应返回 null"); // ---- 4. 路径穿越 / 非法字符 → 400 ---- for bad in ["..%2Fevil", "a%2Fb", ".hidden", ".."] { diff --git a/crates/server/tests/wf_migration_isolation.rs b/crates/server/tests/wf_migration_isolation.rs index 94bd282..feae986 100644 --- a/crates/server/tests/wf_migration_isolation.rs +++ b/crates/server/tests/wf_migration_isolation.rs @@ -1,10 +1,10 @@ //! 验证「历史种子导入的工作流名」与「正式工作流名」的隔离关系。 //! -//! 用户意图:import_results 把旧 Python 计算结果导入,标记为已完成,避免重算。 +//! 用户意图:离线导入工具把旧计算结果导入,标记为已完成,避免重算。 //! 关键问题:导入到工作流 A,之后正式启动工作流 B(同名/异名),B 能否看到 A 标记的 converged? use common::config::GridConfig; -use common::models::GridPointParams; +use common::models::{GridPointParams, ModelSummary}; use mq::sqlite_queue::SqliteTaskQueue; use server::{db::Database, scheduler::GridScheduler}; use std::sync::Arc; @@ -21,6 +21,47 @@ fn make_params() -> GridPointParams { } } +/// 构造一个收敛的 ModelSummary(result_valid=true, atmosphere_has_nan=false), +/// 用 point_name 作权威名。供测试模拟离线导入写入 summary_json。 +fn make_converged_summary(name: &str, params: &GridPointParams) -> ModelSummary { + ModelSummary { + name: name.to_string(), + params: params.clone(), + stages: Vec::new(), + result_valid: true, + final_max_relc: Some(0.001), + final_chmax: Some(0.001), + seed: None, + atmosphere_has_nan: false, + synspec_rc: None, + synspec_error: None, + synspec_sec: None, + elapsed_sec: 0.0, + energy_check: None, + temp_check: None, + emflux_check: None, + bfac_check: None, + note: None, + } +} + +/// 测试辅助:模拟离线导入——upsert 点 + 写收敛 summary(等价旧 mark_grid_point_imported)。 +async fn mark_imported( + db: &Database, + name: &str, + workflow: &str, + params: &GridPointParams, + method: &str, +) { + db.upsert_grid_point_named(name, params, 0, workflow) + .await + .unwrap(); + let summary = make_converged_summary(name, params); + db.upsert_point_summary(name, workflow, &summary, method) + .await + .unwrap(); +} + /// 构造只含一个网格点(t20000_g5.0_he-2_c-4_n-4_o-4)的 config。 fn make_grid_cfg() -> GridConfig { let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n"; @@ -49,13 +90,8 @@ async fn test_same_workflow_name_preserves_converged() { let name = "t20000_g5.0_he-2_c-4_n-4_o-4"; let p = make_params(); - // 模拟 import_seed:upsert + mark_imported,工作流名 = sdB_cno - db.upsert_grid_point_named(name, &p, 0, "sdB_cno") - .await - .unwrap(); - db.mark_grid_point_imported(name, "sdB_cno", None, "cold_run") - .await - .unwrap(); + // 模拟离线导入:upsert + 写收敛 summary,工作流名 = sdB_cno + mark_imported(&db, name, "sdB_cno", &p, "cold_run").await; // 之后正式启动同名工作流:initialize_grid(sdB_cno) sched @@ -80,13 +116,8 @@ async fn test_different_workflow_name_causes_recompute() { let name = "t20000_g5.0_he-2_c-4_n-4_o-4"; let p = make_params(); - // 模拟 import_seed:导入到 "imported" 工作流 - db.upsert_grid_point_named(name, &p, 0, "imported") - .await - .unwrap(); - db.mark_grid_point_imported(name, "imported", None, "cold_run") - .await - .unwrap(); + // 模拟离线导入:导入到 "imported" 工作流 + mark_imported(&db, name, "imported", &p, "cold_run").await; // 之后正式启动 "sdB_cno" 工作流 sched @@ -118,13 +149,8 @@ async fn test_mixed_grid_import_then_init_avoids_recompute() { let (db, sched) = setup().await; let p_old = make_params(); // t20000_g5.0_... - // 模拟 import_seed:旧网格里这个点已收敛,导入到 sdB_cno - db.upsert_grid_point_named("t20000_g5.0_he-2_c-4_n-4_o-4", &p_old, 0, "sdB_cno") - .await - .unwrap(); - db.mark_grid_point_imported("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", None, "cold_run") - .await - .unwrap(); + // 模拟离线导入:旧网格里这个点已收敛,导入到 sdB_cno + mark_imported(&db, "t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", &p_old, "cold_run").await; // 正式启动 sdB_cno,config 比旧网格多了一个新点(t25000) let yaml = "grid:\n teff: [20000, 25000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n"; @@ -172,13 +198,8 @@ async fn test_precision_diff_import_then_init_preserves_converged() { logo: GridAxisValue::from_value(-4.0), }; - // 模拟 import_results 重写 name 后入库:grid_points.name = canonical(g5.0) - db.upsert_grid_point_named(canonical, &p, 0, "sdB_cno") - .await - .unwrap(); - db.mark_grid_point_imported(canonical, "sdB_cno", None, "cold_run") - .await - .unwrap(); + // 模拟离线导入重写 name 后入库:grid_points.name = canonical(g5.0) + mark_imported(&db, canonical, "sdB_cno", &p, "cold_run").await; // 启动同名工作流:initialize_grid 用配置 model_name()(=g5.0) 插入 let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n"; diff --git a/docs/EXPERIENCE.md b/docs/EXPERIENCE.md index f1932c5..1f1acb9 100644 --- a/docs/EXPERIENCE.md +++ b/docs/EXPERIENCE.md @@ -157,6 +157,11 @@ IELCOR=-1 - nl: NITER=100 - **不设 CHMAX**(用默认 0.001)、**不设 ITEK**(用默认 4) +> 注(2026-08-07 起的当前实现):`nst_writer` 在前两行之外还会显式写出 +> line3/line4 的 NLTE 高频关键字(NMU/ISPLIN/CNU2/NFTAIL/DFTAIL 与 +> HMIX0…TMOLIM,共 23 个,值等于 TLUSTY 内置默认)。配方物理等价,仅行数 +> 由旧 2 行扩为 4+ 行。 + ### 3.3 CNO 模型原子(`.5` 里声明的 nlevs) > 下表"nlevs"列是 `.5` 文件里每个离子实际声明的 NLTE 能级数(即 `gen_input5.py` @@ -220,7 +225,7 @@ IELCOR=-1 ### 错误 6:nst 行长截断 bug - **来源**:tlusty 的 nst 解析器有 ~72 字符行宽限制 - **影响**:参数太多时(如加了 IDLTE/IACC),行尾参数被静默截断 → 用默认值 -- **纠正**:write_nst 把参数分两行写(line1 ≤ 64 字符) +- **纠正**:write_nst 把参数分多行写(每行 ≤ 64 字符);当前实现为 4+ 行(见 §3.2 注) - **教训**:Fortran 的固定格式行宽限制是隐蔽 bug 源 ### 错误 7:丰度范围设置过高(最严重的错误) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 8f86133..971c400 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -121,17 +121,20 @@ ions段: iat iz nlevs ilast ilvlin nonstd typion filei ### 3.2 nst 文件(非标准参数,每阶段统一结构,NITER 不同) -`nst_writer::generate_nst_content` 对**所有阶段(含 lte)统一生成两行**: +`nst_writer::generate_nst_content` 对**所有阶段(含 lte)统一生成 4+ 行**:前 2 行与旧实现字节级一致(阶段参数 + `IELCOR=-1`),从第 3 行起显式写出 `global.nst` 的 NLTE 高频关键字(值等于 TLUSTY 内置默认,物理等价): ``` ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,[CHMAX=..][,ITEK=..],NITER=<阶段> [ORELAX=..][,IDLTE=..][,IACC=..][,ICHANG=..],IELCOR=-1 +NMU=3,ISPLIN=0,CNU2=3.,NFTAIL=21,DFTAIL=0.25 +HMIX0=-1.,MLTYPE=1,IPTURB=1,IBFINT=1,IHECOR=0,IRTE=0,IBC=3,IFRYB=0,IOPTAB=0,IFMOL=0,IFALI=5,IFPOPR=4,JALI=1,TRAD=0.,WDIL=0.,TFLOOR=8000.,TDISK=0.,TMOLIM=9000. ``` - lte: `NITER=0`(灰大气不迭代,只做一次形式解) - nc: `NITER=10`, nl: `NITER=100`, seed_nc: `NITER=20` - **不设 CHMAX**(用默认 0.001,强迫 nc 真正收敛) - **不设 ITEK**(用默认 4) - 尾部统一 `IELCOR=-1`(电子密度修正关闭) -- 参数分行写(line1 ≤64c, line2 余下),避免 tlusty nst 解析器 ~72 字符行宽截断 +- 频率截断 `FRCMAX/MIN/FRLMAX/MIN` 仅当显式配非零值才写出(0=TLUSTY 内置默认,不覆盖) +- 参数分行写(每行 ≤64c),避免 tlusty nst 解析器 ~72 字符行宽截断 每个参数的作用与原理: @@ -300,9 +303,11 @@ synspec_stage: # SYNSPEC 阶段独立配置(enabled/ enabled: true policy: skip_converged strategies: ["standard"] -synspec_input: # SYNSPEC 数值参数(fort.55 波长范围等) - wstart: 3000.0 - wend: 7000.0 +synspec_input: # SYNSPEC fort.55 控制卡(9 行子结构体,按行配置) + line1: {imode: 0, idstd: 0, iprin: 1} # 行1: IMODE/IDSTD(=0 自动 2ND/3)/IPRIN + line6: # 行6: 波长范围等(其余行走 SynspecInput::default) + alam0: 3000.0 # 光谱波长起始点 (Å) + alast: 7000.0 # 光谱波长终止点 (Å) ``` ### 第2步:创建并启动工作流(HTTP API / Dashboard) @@ -367,7 +372,7 @@ curl -X POST localhost:8090/api/workflows/sdB_cno/points \ 6. **nst 文件行长限制(已修复)**:tlusty 的 nst 解析器有 ~72 字符的行宽限制。 如果参数太多写在一行(如加了 IDLTE/IACC 后 >72 字符),行尾的参数会被 - 静默截断。`nst_writer` 把参数分两行写(line1≤64c, line2 余下参数)。 + 静默截断。`nst_writer` 把参数分多行写(每行 ≤64c),避免触及该行宽限制。 这是个隐蔽 bug——截断后 tlusty 不报错而是用默认值,导致"看似成功实则参数 没生效"。 diff --git a/docs/api.md b/docs/api.md index 78e8533..7efa22e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -648,44 +648,19 @@ Worker 节点向服务端上报的任务计算结果。 -F 'seed_file=@/path/to/t35000_g5.5_he-1_c-2_n-2_o-2.7' ``` -### 4.3 历史种子批量导入 (`POST /api/admin/import_seed`) +### 4.3 迁移端点 (`POST /api/admin/migrate_conv`) -把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)批量回灌进 DCTS。与 `/task/report` 的关键区别:**跳过任务归属校验**(历史数据无领用语义),直接幂等落库。供 [`tools/import_results`](file:///home/fmq/program/tlusty/tl208-s54/dcts/tools/import_results/src/main.rs) 调用。 +临时迁移端点:扫 `seeds_dir` 下的 `conv.json` → 增量合并写入 `grid_points.summary_json`。 +让详情页 conv 诊断面板从 DB 读取(不再依赖磁盘文件)。**迁移完成后删除本端点即可。** -- **处理函数**: [`import_seed`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/api/task.rs) -- **鉴权**: **Admin 角色**(admin token 或登录 session) -- **请求格式**: `multipart/form-data` - - Part `report`: 旧版 `conv.json` 的**原文 JSON**(映射为 `ModelSummary`,服务端解析出 `name` / `params` / `converged` / `final_max_relc`) - - Part `seed_file`: 二进制数据(`.7` 大气种子文件;收敛点必传) - - Part `tlusty_success_method` *(可选)*: 文本 `cold_run` / `seed_step`。由 `tools/import_results` 依据旧 `conv.json` 的 stages 是否含 `seed_nc` 判定后设置,写入导入点大气归因 `tlusty_success_method`(缺省按 seed_step 统计) -- **Query 参数**: `workflow`(可选,默认 `imported`):目标工作流名,种子导入到该工作流的 `grid_points`。 -- **命名保真**: `point_name` 取旧 `conv.json` 的 `name` 字段(源精度真名,如 `t20000_g5.0_...`),**逐字符**落库(磁盘目录、`grid_points.name`、`seeds.point_name`),与旧版 Python `gen_input5.model_name` 完全一致。 -- **幂等**: `ON CONFLICT DO NOTHING` upsert `grid_points`、`conv.json` 与 `.7` 原子覆盖写,可重复运行。 -- **响应 Schema**: - - `200 OK`: - ```json - { - "status": "ok", - "point_name": "t20000_g5.0_he-2_c-4_n-4_o-4", - "converged": true, - "max_relc": 0.000321 - } - ``` - - `400 Bad Request`: 缺少 `report` / `conv.json` 解析失败 / 非法网格点名称 - - `401 Unauthorized`: 缺少或无效 admin 凭据 -- **curl 示例**: - ```bash - curl -X POST "http://localhost:8090/api/admin/import_seed?workflow=sdB_cno" \ - -H "Authorization: Bearer " \ - -F 'report=@/path/to/old_results/t20000_g5.0_he-2_c-4_n-4_o-4/conv.json;type=application/json' \ - -F 'seed_file=@/path/to/old_results/t20000_g5.0_he-2_c-4_n-4_o-4/t20000_g5.0_he-2_c-4_n-4_o-4.7' - ``` -- **批量导入工具**: [`import_results`](file:///home/fmq/program/tlusty/tl208-s54/dcts/tools/import_results/src/main.rs) 自动扫描结果目录、校验无 NaN 且收敛、逐点调用本端点(同时把完整产物树迁移到 `data/result/`): - ```bash - cargo run -p import_results -- --dir <旧 results 根目录> \ - --config workflows/sdB_cno.yaml \ - --server http://127.0.0.1:8090 --workflow sdB_cno --token - ``` +- **鉴权**: Admin 角色 +- **Query 参数**: `workflow`(必填)、`dir`(可选,缺省取 `seeds_dir`) +- **特性**: 幂等(ON CONFLICT DO NOTHING)、增量合并(synspec-only 保留 TLUSTY 字段)、多版本兼容(旧 `converged` 键) + +```bash +curl -X POST "http://localhost:8090/api/admin/migrate_conv?workflow=sdB_cno" \ + -H "Authorization: Bearer " +``` --- diff --git a/docs/contributing.md b/docs/contributing.md index 00595ae..4ba1c3a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -32,8 +32,6 @@ dcts/ │ └── mq/ # [Library] SQLite 事务型分布式任务队列 ├── dashboard/ # [Web] ESM 模块化运维看板(Vite) ├── workflows/sdB_cno.yaml # 示例网格工作流配置 -├── tools/ -│ └── import_results/ # [Binary] 历史计算结果导入工具(旧版网格结果 → 种子/收敛入库) ├── scripts/ # 结果拉取等运维脚本 └── docs/ # 分主题架构与规格技术文档 ``` diff --git a/docs/convergence_analysis_2026_08_08.md b/docs/convergence_analysis_2026_08_08.md new file mode 100644 index 0000000..ca65176 --- /dev/null +++ b/docs/convergence_analysis_2026_08_08.md @@ -0,0 +1,300 @@ +# TLUSTY 收敛性分析与修复(2026-08-08 ~ 08-09) + +> 本文完整记录了对 `sdB_cno` 工作流 1305 个失败网格点的根因分析、实测验证过程、 +> 以及最终落地的代码改动。所有 tlusty/synspec 实测均使用**生产二进制** +> `dcts/assets/tlusty_static`(md5=`14cd144b...`),而非开发目录的 `tlusty/tlusty.exe` +> (md5=`9b249e00...`)——两者编译版本不同,数值行为有差异。 + +--- + +## 1. 数据库分析(`dcts.db`) + +### 1.1 真实规模(纠正初期统计错误) + +| 维度 | 数量 | +|------|------| +| grid_points 总数 | **9216**(9 Teff × 4 logg × 4 logHe × 4 logC × 4 logN × 4 logO) | +| ✅ completed | **7911(85.8%)** | +| ❌ failed | **1305(14.2%)** | + +初期曾误把 `tasks` 表的 12344 行(含重试)当作网格点总数,得出"60% 失败"的错误结论。 +实际上 tasks 表每个失败点因 cold_run→seed_step 回退产生多行(attempt_count 中位数 3-4), +且 3876 个早已收敛的点根本不在 tasks 表里(`skip_converged` 跳过)。 + +### 1.2 失败点参数空间分布 + +**按 Teff × logHe(每 cell 256 点):** + +| Teff | logHe | 失败/256 | 失败率 | 备注 | +|------|-------|---------|-------|------| +| 60000 | -4 | 148 | **58%** | 主体失败区 | +| 55000 | -4 | 136 | **53%** | 主体失败区 | +| 60000 | -2 | 126 | 49% | | +| 55000 | -2 | 128 | 50% | | +| 60000 | 0 | 101 | 39% | | +| 50000 | -2 | 83 | 32% | | +| 20000 | 2 | 89 | 35% | 次要失败簇(富氦+低logg) | +| 25000~45000 各 He | — | <16% | — | 大多健康 | + +**两大失败簇:** +1. **高温贫氦区(55-60kK / He=-4,-2,0)**:733 个,占失败总数 **56%** +2. **20kK 富氦区(He=2 / logg=6.0-6.5)**:89 个(35%) + +**按 logg**:logg=5.0 最难(661 个,占 51%),logg=6.5 最好(139 个)。 + +**按 CNO 丰度(55000K/He=-4 cell 实测)**:CNO 越富失败率越高—— +``` +cno_sum ≤ -11: 失败率 33% ← 贫金属,相对可收敛 +cno_sum -7~-8: 失败率 92% +cno_sum ≥ -6: 失败率 100% ← 最富金属,物理不可收敛 +``` + +### 1.3 失败点的策略执行情况 + +所有 1305 个失败点均执行过 cold_run + seed_step(`pending_strategies` 已空,策略穷尽)。 +seed_step 的实际结果: +- seed 高 relc 发散(≥100):1461 条 task 记录 +- seed NaN 发散:1091 条 +- seed 下载/IO 失败:34 条 +- seed 低 relc(<100):14 条 + +--- + +## 2. 可能的失败原因(分析假设) + +### 2.1 ORELAX 未设置 → 满牛顿步长振荡 + +所有 salvage 的 `.nc.nst` / `.seed_nc.nst` 只有 2 行,**无 ORELAX**: +``` +ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,CHMAX=0.001,NITER=10 +IELCOR=-1 +``` +tlusty 源码 `tlusty208.f:844`:`IF(ORELAX.EQ.0.) ORELAX=UN`(UN=1.0),即默认 100% 牛顿步长。 +满步长在硬网格点易振荡发散。 + +### 2.2 Ng/Kantorovich 加速器把解推飞 + +tlusty 默认 `IACC=7`(Ng 加速从 iter7 开始)。fort.6 日志显示 `KANTOROVICH acceleration: ITER 5-9` +后 relc 从几十跳到上万——加速器在不稳定解上放大了误差。 + +### 2.3 灰 LTE 起点离高温 NLTE 解太远 + +55kK 点 cold nc iter1 relc 就高达 12.7-21.6(对比正常点 0.06-0.2),LTE 灰大气作为 NLTE 迭代 +起点在高温下偏差太大。 + +### 2.4 富金属方向 seed_step 发散(物理原因) + +seed_finder.rs 记录:increasing metals from a poor seed adds UV line-radiation driving that +breaks radiative equilibrium(贫方向成功率 42-54%,富方向仅 3-11%)。 + +### 2.5 synspec 在 Balmer edge(3645Å)数值发散 + +459 个点 tlusty 收敛了(max_relc<0.001)但 synspec 产出的 spec 在 3645Å(H n=2 bound-free edge) +突然变 NaN。机制:`ST0(I)=ET(IJ,I)/CH(IJ,I)`(synspec54.f:12990),表层 NLTE 粒子数为 0 导致 0/0=NaN。 + +### 2.6 IACC≤4 被源码强制改 7 + +`tlusty208.f:1928`:`IF(IACC.LE.4) IACC=7`。设 IACC=0 无法禁用 Ng 加速器。 + +--- + +## 3. 实测验证(生产二进制 tlusty_static) + +### 3.1 关键教训:二进制选择 + +**初期所有测试误用了 `tlusty/tlusty.exe`(开发版,Apr 2025)而非生产版 +`dcts/assets/tlusty_static`(Jul 2025)。** 两者 md5 不同,数值行为不同 +(如 55kK 点 iter1 relc:tlusty.exe=12.7,salvage 记录=254)。 +以下结论均以**生产二进制**为准,凡 tlusty.exe 结果均已标注作废。 + +### 3.2 部分读取陷阱 + +tlusty 运行中读取 fort.9 会得到**不完整迭代**的 relc(如 iter2=0.003 看似收敛), +但完整跑完后该迭代实际 relc 可能是 18700。**所有结论必须等运行完成后才可靠。** +本会话多次因部分读取得出错误中间结论,最终以完整运行结果为准。 + +### 3.3 测试点 A:t20000_g5.0_he-4_c-4_n-2_o-2(20kK,次要区) + +**注意:此点非主体失败区代表(20000/He=-4 cell 仅 2/256 失败),结论代表性有限。** + +| 配置 | iter1 | 最终 iter | fort.7 NaN | 结论 | +|------|-------|----------|-----------|------| +| default ORELAX=1.0(tlusty.exe) | 0.217 | iter7=NaN | 4349 | ❌ 发散 | +| ORELAX=0.3, NITER=10(tlusty.exe) | 0.217 | iter10=1.33 | 0 | ⚠️ 非NaN但未收敛 | +| ORELAX=0.1, NITER=15(tlusty.exe) | 0.217 | iter15=0.556 | 0 | ⚠️ 振荡但无NaN | +| **default(tlusty_static)** | 0.217 | **iter6=0.00672** | 0 | ✅ **收敛中** | + +**结论**:此点在生产二进制下 default 直接收敛中。salvage 标记失败可能是 seed_nc 阶段 +失败或旧二进制偶然发散。 + +### 3.4 测试点 B:t55000_g5.0_he-4_c-1_n-2_o-2(55kK,主体失败区) + +| 配置 | iter1 | 最终 iter | fort.7 NaN | 结论 | +|------|-------|----------|-----------|------| +| default N30 | 12.7 | iter10=1.95e8 | 0 | ❌ 发散 | +| ORELAX=0.05 N50 | 12.7 | iter10=9.9e11 | 0 | ❌ 发散 | +| ORELAX=0.01 N50 | 12.7 | iter9=1.05e6 | 0 | ❌ 发散 | +| ORELAX=0.005 N50 | 12.7 | iter10=1.95e8 | 0 | ❌ 发散 | +| ITEK=-50 N50(禁Ng) | 12.7 | iter11=NaN | 2047 | ❌ 更糟 | +| IACC=0(禁加速) | 12.7 | iter5=3.89e8 | 0 | ❌ 无效(源码强制 IACC=7) | +| seed_nc default(收敛邻居种子) | 19.5 | iter8=1.28e8 | 0 | ❌ 富方向发散 | +| seed_nc IACC=0 | 19.5 | iter8=1.28e8 | 0 | ❌ IACC=0 无效 | + +**结论**:55kK 主体失败区,**所有 nst 参数组合都发散**。iter1 relc=12.7(灰 LTE 起点太远), +Ng 加速器 iter5-7 把解推飞。富方向 seed_step(ΔlogC=+2)iter3 外层深度爆炸到 18700。 +这是物理极限,非参数可解。 + +### 3.5 测试点 C:t25000_g5.0_he-4_c-4_n-4_o-3(25kK,中温区) + +| 配置 | 最终 iter | fort.7 NaN | 结论 | +|------|----------|-----------|------| +| default N15 | iter10=4.72e-6 | 0 | ✅ **收敛** | +| ORELAX=0.3 N30 | iter10=3.92e-6 | 0 | ✅ **收敛** | + +**结论**:25kK 失败点在生产二进制下 default 和 ORELAX=0.3 **都收敛了**。 +此点 salvage 标记失败是旧二进制偶然发散——用新代码重跑即可救回。 + +### 3.6 测试点 D:t45000_g6.0_he-4_c-1_n-3_o-3(45kK,中温区) + +| 配置 | iter1 | 最终 iter | fort.7 NaN | 结论 | +|------|-------|----------|-----------|------| +| default N15 | 21.6 | iter6=2.93e7 | 0 | ❌ Ng 加速爆炸 | +| ORELAX=0.3 N30 | 21.6 | iter16=2.81e13 | 0 | ❌ 延迟但最终发散 | + +**结论**:45kK 点 ORELAX 无法阻止最终发散,但 default 和 ORELAX=0.3 全程 0 NaN。 +(注:中间曾观察到 iter10=0.000335 的"收敛"假象,证实是部分读取 artifact。) + +### 3.7 测试点 E:t20000_g6.5_he2_c-4_n-3_o-4(20kK 富氦高重力簇) + +conv.json 显示 seed_nc relc=0.0115(接近收敛),但 **nl 爆炸到 5.32e16**。 +nc 阶段 default 和 ORELAX=0.3 轨迹接近(iter4: 1.31 vs 1.27),问题在 nl 阶段。 + +### 3.8 synspec Balmer edge NaN 测试(t50000_g5.0_he-4_c-2_n-3_o-2) + +tlusty 收敛(max_relc=0.000247),但 synspec spec 在 **3645.88Å(Balmer limit)** 变 NaN, +前 87750 行正常,之后全 NaN(313695 行)。 + +| 配置 | 第一个 NaN 波长 | spec NaN 行数 | 结论 | +|------|----------------|-------------|------| +| 旧版 fort.55(IDSTD=50) | 3645.54Å | 30 万+ | ❌ | +| 新版 fort.55(IDSTD=0) | 3645.88Å | 313695 | ❌ IDSTD 无效 | +| ICONTL=1(纯连续谱) | 3645.88Å | 313695 | ❌ 连续谱 H bf 本身发散 | + +**结论**:Balmer edge NaN 不是 fort.55 配置问题,是 synspec 连续谱氢 bound-free +在表层 NLTE 粒子数为 0 时产生 0/0=NaN。大气 depth-1 粒子数块确认含大量 `0.000000E+00`。 + +--- + +## 4. 综合结论 + +### 4.1 失败分类(1305 个 grid failed 点) + +| 类别 | 数量 | 可救性 | +|------|------|--------| +| 高 relc 严重发散(≥100) | 746 | 高温区不可救;中温区可能重跑即救 | +| NaN 物理发散 | 527 | 旧二进制 NaN → 新二进制可能改善 | +| 未知 | 24 | — | +| 接近收敛(relc<1) | 3 | 可救 | +| 中度发散(1-100) | 5 | 可能可救 | + +另:459 个点 grid 状态为 completed(synspec 失败但 tlusty 收敛),不在 1305 之列。 + +### 4.2 什么能救、什么不能 + +**能救(预计 ~150-300 点):** +- 中温区(20-45kK)旧二进制偶然失败的点——用新代码 `tlusty_static` 重跑即可 +- 20kK/He2/g6.5 簇 nl 阶段振荡发散——ORELAX=0.5 阻尼 nl 的 Newton 步长振荡 +- 部分旧二进制 NaN 发散——新二进制数值更稳定(55kK 点全程 0 NaN vs 旧版 NaN) + +**不可救(~600-800 点):** +- 55-60kK/贫氦/富CNO(cno_sum≥-6)——灰 LTE 起点离 NLTE 解太远,所有参数组合发散 +- synspec Balmer edge NaN——表层 NLTE 粒子数为 0 导致连续谱 H bf 数值发散 + +### 4.3 无效方案(实测验证,勿重复) + +| 方案 | 测试结果 | 原因 | +|------|---------|------| +| ORELAX 对 55kK cold nc | ❌ 全发散 | 灰 LTE 起点太远,ORELAX 压不住 | +| IACC=0 禁用 Ng | ❌ 无效 | `tlusty208.f:1928` 强制 IACC=7 | +| ITEK=-50 禁用 Ng | ❌ 更快 NaN | 失去加速后纯 Newton 也发散 | +| 新版 fort.55(IDSTD=0) 修 synspec | ❌ 仍 NaN | IDSTD 与 Balmer edge 无关 | +| TFLOOR 防发散 | ❌ 零效果 | 生产二进制实测轨迹与无 TFLOOR 逐位相同 | +| 调大 chmax | ❌ 有害 | nc 提前停止给 nl 坏种子 | + +--- + +## 5. 落地的代码改动 + +### 5.1 核心思路 + +给 nl/seed_nc 阶段加 ORELAX 欠松弛阻尼,并在 YAML 新增 `seed_chain` 字段让 seed_step 链 +可配置(此前硬编码在 `default_seed_chain()`)。配合当前 `skip_converged` 策略,重启工作流 +自动重跑 1305 个失败点(收敛的 7911 点保留)。 + +### 5.2 改动清单(6 个文件) + +| 文件 | 改动 | +|------|------| +| `crates/common/src/config.rs` | `GridConfig` 新增 `seed_chain: Vec` 字段 | +| `crates/common/src/models.rs` | `TaskSpec` 新增 `seed_chain_params` 字段 | +| `crates/server/src/scheduler.rs` | 新增 `get_workflow_seed_chain()`;dispatch/fallback 注入 | +| `crates/node/src/executor.rs` | `resolve_execution_chain()` seed_step 优先用 `seed_chain_params` | +| `crates/common/src/runner.rs` | `default_seed_chain()` 兜底:seed_nc `orelax: Some(0.3)`,nl `orelax: Some(0.5)` | +| `workflows/sdB_cno.yaml` | `tlusty_chain` nl 加 `orelax: 0.5`;新增 `seed_chain` 块 | + +### 5.3 ORELAX 取值依据 + +- **nl 步 orelax=0.5**:nl 含谱线(ilvlin=100)扰动大,0.5 是保守的欠松弛值, + 阻尼 Newton 步长振荡但不至于过度阻尼导致谱线约束不足。 +- **seed_nc 步 orelax=0.3**:seed_nc 从已收敛邻居热启动,起点扰动小,可用更激进的 0.3。 +- **nc 步不加 orelax**:实测中温区 nc 用 default 即可收敛,不加更安全。 + +### 5.4 向后兼容 + +- `seed_chain` 空时回退 `default_seed_chain()`(已同步加 orelax),与旧行为一致 +- `TaskSpec.seed_chain_params` 缺省 None(旧 MQ payload 兼容) +- YAML 不配 `seed_chain` 时完全不影响现有行为 + +### 5.5 验证 + +- `cargo build` ✅ 编译通过 +- `cargo test` ✅ **194 个测试全绿,0 失败** + (含新增 `seed_step_uses_custom_seed_chain_when_provided` 测试) + +--- + +## 6. 后续建议 + +### 6.1 重启工作流验证 + +当前 `skip_converged` 策略重启时自动重置 1305 个失败点回 pending。 +部署新二进制 + 新代码后重启,观察失败率下降情况。预计中温区(20-45kK)的 +~150 个失败点可被救回。 + +### 6.2 接力热启动(更大收益,需改代码) + +对高温区(55-60kK)的失败点,可把 seed_step 的大步长 ΔCNO=2 拆成 Δ=1 的多步接力 +(如 c-4→c-3→c-2→c-1),降低单步富金属扰动。需改 `scheduler.rs` 的 fallback 逻辑 +支持多跳 seed。这是唯一可能救高温区的物理手段。 + +### 6.3 synspec Balmer edge 修复(需改 synspec 源码) + +表层 NLTE 粒子数为 0 导致连续谱 H bound-free 0/0=NaN。需在 `synspec54.f` 的 +OPAC/连续谱计算加 NaN 守卫,或编译时启用 `-ffpe-trap=invalid,zero` 定位。 + +### 6.4 接受物理极限 + +55-60kK/贫氦/富CNO(cno_sum≥-6)约 400-500 个点是 sdB 纯 He 大气模型的物理极限, +光谱应从相邻收敛点插值。 + +--- + +## 附:关键文件路径 + +- 数据库: `/home/fmq/下载/dcts.db` +- Salvage 归档: `data/salvage/{node}/result/{point}/` +- 生产二进制: `dcts/assets/tlusty_static`(md5=`14cd144b...`) +- 开发二进制(勿用于测试): `tlusty/tlusty.exe`(md5=`9b249e00...`) +- tlusty 源码: `tlusty/tlusty208.f`(IACC 强制改 7 在 :1928;ORELAX 默认在 :844) +- synspec 源码: `synspec/synspec54.f`(NaN 源头 ST0=ET/CH 在 :12990) +- seed_finder.rs: `crates/common/src/seed_finder.rs`(贫富方向逻辑 :14-40) diff --git a/docs/database_refactor_design.md b/docs/database_refactor_design.md index 3274a43..3173bb3 100644 --- a/docs/database_refactor_design.md +++ b/docs/database_refactor_design.md @@ -156,7 +156,7 @@ pub fn apply_migrations(conn: &mut Connection) -> Result<()>; 服务端 `record_task_report`(db.rs:1920)已持有 `&TaskReport`——**无需改签名**,只在结算 UPDATE 补两列。 -> **保真边界(审查修正)**:`ModelSummary`(models.rs:599-616)与 `StageSummary`(577-595)**不含 conv.json 的逐次 itek 迭代数组**——该细节在 Rust serde 往返中结构性丢失,仍仅存磁盘 conv.json。落库的 summary_json 是"阶段级摘要"(含 last_iter / worst_depth / n_depths 等部分迭代诊断),非原始全量。若需完整逐次迭代入库,须给 `StageSummary` 加 `#[serde(flatten)]` 保留字段(暂缓,先文档化此边界)。 +> **保真边界(审查修正,2026-08 更新)**:`ModelSummary`(models.rs:616-641)与 `StepSummary`(590-612)现已携带逐次 itek 迭代诊断数组(`StepSummary.itek_history: Vec`,runner.rs:425 填充),随 summary_json 全量落库(见状态表「itek 全量保真 ✅ 已实施」),不再仅存磁盘 conv.json。落库的 summary_json 与磁盘 conv.json 同源于 runner 的 `StepSummary`,含 last_iter / worst_depth / n_depths 与完整收敛轨迹。 ### 3.3 迁移 M1(tasks 表,均在线 ADD COLUMN) diff --git a/docs/design.md b/docs/design.md index 534b903..05577d2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -40,7 +40,7 @@ flowchart LR | **2. NLTE 连续谱 (nc)** | `tlusty.exe` | 切换到 NLTE,忽略束缚-束缚线跃迁(`ilvlin=0`),收敛基础电离平衡。 | `lte=F, ltgray=F, ilvlin=0, niter=10` | 10 次 | | **3. NLTE 完整谱线 (nl)** | `tlusty.exe` | 引入全部线跃迁(`ilvlin=100`),求解包含非平衡辐射场的大气结构;**要求收敛**(`require_converged=true`)。 | `lte=F, ltgray=F, ilvlin=100, niter=100` | 100 次 | | **3'. 种子热启动 (seed_nc)** | `tlusty.exe` | 以近邻收敛大气 `.7` 为 `fort.8` 初猜做 NLTE 连续谱收敛(跳过低温度灰大气阶段)。 | `lte=F, ltgray=F, ilvlin=0, niter=20, ichang=0` | 20 次 | -| **4. 合成光谱 (synspec)** | `synspec.exe` | 基于收敛大气结构(`.7`),计算高分辨率合成光谱。 | 波长窗/展宽/截断由 `synspec:` 数值参数配置 | — | +| **4. 合成光谱 (synspec)** | `synspec.exe` | 基于收敛大气结构(`.7`),计算高分辨率合成光谱。 | 波长窗/展宽/截断由 `synspec_input:`(fort.55 控制卡)配置 | — | > 阶段标签由 workflow 配置保证唯一(`lte`/`nc`/`nl`/`seed_nc`),各阶段输入卡/日志/大气/收敛诊断 > 以 `.