Files
DCTS/crates/common/src/fort55_writer.rs
T
fmq 43b82b1ae2 feat(all): 物理正确性五重硬门槛、输入文件结构化与 fort.55 错位修复、conv 诊断 DB 化与阶段归因修复、ORELAX 收敛修复与导入工具下线
物理正确性校验体系(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 报错
2026-08-09 12:09:48 +08:00

340 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SYNSPEC fort.55 控制卡生成器。
//!
//! `generate_fort55_content` 把 [`SynspecInput`](按行分组的子结构体)序列化为 fort.55 文本。
//! 每行字段顺序严格对应 `synspec54.f` 的 `READ(55,*)` 语句,字段名一一映射——详见各
//! `Fort55Line*` 结构体的文档注释(附 `synspec54.f` 行号)。
use crate::config::SynspecInput;
/// 把 [`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 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!(
// 行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 时仅写出 nmlistlist-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 {
line6: Fort55Line6 {
alam0: 3000.0,
alast: 7000.0,
..Fort55Line6::default()
},
..SynspecInput::default()
};
let content = generate_fort55_content(&cfg);
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"
);
}
}