feat(all): 重炼 crates/common 核心组件、上线 Web 运维看板与 Docker 容器化部署

This commit is contained in:
fmq
2026-07-28 10:31:57 +08:00
commit 4b4238d702
71 changed files with 163402 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[dependencies]
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
hex.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-appender.workspace = true
anyhow.workspace = true
tempfile.workspace = true
uuid.workspace = true
chrono.workspace = true
regex.workspace = true
reqwest.workspace = true
tokio.workspace = true
[features]
default = ["embed-binaries"]
embed-binaries = []
+35
View File
@@ -0,0 +1,35 @@
# common
> DCTS 核心物理引擎与底层工具库。
---
## 📦 模块概览
`common` 包含了 DCTS 系统的物理逻辑实现,负责将网格点参数转换为 TLUSTY/SYNSPEC 可识别的物理输入文件,启动并监控子进程运行,判定物理收敛性,并计算种子拟合度。
### 核心子模块说明
- **`config.rs`**:物理网格与节点/服务端配置 YAML 解析。
- **`gen_input5.rs`** / **`fort55_writer.rs`** / **`nst_writer.rs`**TLUSTY `fort.5` / `fort.55` / `nst.dat` 输入流构造器。
- **`conv_check.rs`**:解析 TLUSTY 输出日志(`fort.6`),判定物理迭代是否达到收敛标准。
- **`runner.rs`**:异步带超时的子进程(tlusty / synspec)启动器与现场隔离回收管理。
- **`seed_finder.rs`**:基于加权欧氏距离的最近邻收敛种子匹配算法。
- **`embedded.rs`**:计算节点自动 Bootstrap 预热与二进制文件校验。
- **`logging.rs`**:基于 `tracing-appender` 的按天日志轮转器。
---
## 🛠️ Usage & Setup
作为内部库使用:
```toml
[dependencies]
common = { path = "../crates/common" }
```
### 运行测试
```bash
cargo test -p common
```
+270
View File
@@ -0,0 +1,270 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridAxesConfig {
pub teff: Vec<f64>,
pub logg: Vec<f64>,
pub loghe: Vec<f64>,
pub logc: Vec<f64>,
pub logn: Vec<f64>,
pub logo: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageConfig {
pub label: String,
#[serde(default = "default_false_str")]
pub lte: String,
#[serde(default = "default_false_str")]
pub ltgray: String,
#[serde(default)]
pub ilvlin: i32,
#[serde(default)]
pub require_converged: bool,
#[serde(default = "default_niter")]
pub niter: i32,
pub chmax: Option<f64>,
pub itek: Option<i32>,
pub metals: Option<String>,
pub ichang: Option<i32>,
pub idlte: Option<i32>,
pub iacc: Option<i32>,
pub orelax: Option<f64>,
}
fn default_false_str() -> String {
"F".to_string()
}
fn default_niter() -> i32 {
50
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SynspecConfig {
#[serde(default = "default_wstart")]
pub wstart: f64,
#[serde(default = "default_wend")]
pub wend: f64,
#[serde(default)]
pub imode: i32,
#[serde(default = "default_idrv")]
pub idrv: i32,
#[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,
}
fn default_wstart() -> f64 {
1400.0
}
fn default_wend() -> f64 {
1410.0
}
fn default_idrv() -> i32 {
50
}
fn default_ifreq() -> i32 {
1
}
fn default_rel_cutoff() -> f64 {
0.0001
}
fn default_abs_cutoff() -> f64 {
0.01
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GridConfig {
pub grid: GridAxesConfig,
#[serde(default)]
pub chain: Vec<StageConfig>,
pub synspec: Option<SynspecConfig>,
#[serde(default = "default_nworkers")]
pub nworkers: usize,
#[serde(default = "default_timeout")]
pub timeout_sec: u64,
#[serde(default = "default_true")]
pub resume: bool,
#[serde(default = "default_true")]
pub seed_step_fallback: bool,
pub results: Option<String>,
#[serde(default)]
pub itek_fallback: Vec<StageConfig>,
#[serde(default = "default_grid_niter")]
pub niter: Option<i32>,
pub template: Option<String>,
pub fort55: Option<String>,
pub linelist: Option<String>,
}
fn default_grid_niter() -> Option<i32> {
Some(100)
}
fn default_nworkers() -> usize {
16
}
fn default_timeout() -> u64 {
7200
}
fn default_true() -> bool {
true
}
impl GridConfig {
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let cfg: GridConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML config: {}", path.display()))?;
Ok(cfg)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub bind_addr: String,
pub db_path: String,
pub queue_db_path: String,
pub results_dir: String,
pub grid_config: String,
pub stale_sec: u64,
#[serde(default = "default_node_stale_sec")]
pub node_stale_sec: u64,
pub mq_type: String, // "sqlite" or "rabbitmq"
pub rabbitmq_url: Option<String>,
pub auth_token: Option<String>,
}
fn default_node_stale_sec() -> u64 {
60
}
impl Default for ServerConfig {
fn default() -> Self {
let port = std::env::var("DCTS_PORT")
.or_else(|_| std::env::var("CNO_PORT"))
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "8090".to_string());
let db_path = std::env::var("DCTS_DB_PATH")
.unwrap_or_else(|_| "data/dcts.db".to_string());
let queue_db_path = std::env::var("DCTS_QUEUE_DB_PATH")
.unwrap_or_else(|_| "data/dcts_queue.db".to_string());
let results_dir = std::env::var("DCTS_RESULTS_DIR")
.unwrap_or_else(|_| "data/results".to_string());
let grid_config = std::env::var("DCTS_GRID_CONFIG")
.unwrap_or_else(|_| "workflows/sdB_cno.yaml".to_string());
// 默认设置为 7800 秒,比计算任务默认超时(7200 秒)高 600 秒缓冲,避免两边的超时检测同时触发冲突
let stale_sec = std::env::var("DCTS_STALE_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(7800);
let node_stale_sec = std::env::var("DCTS_NODE_STALE_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
let mq_type = std::env::var("DCTS_MQ_TYPE")
.unwrap_or_else(|_| "sqlite".to_string());
let rabbitmq_url = std::env::var("DCTS_RABBITMQ_URL").ok();
let auth_token = std::env::var("DCTS_AUTH_TOKEN").ok();
Self {
bind_addr: format!("0.0.0.0:{}", port),
db_path,
queue_db_path,
results_dir,
grid_config,
stale_sec,
node_stale_sec,
mq_type,
rabbitmq_url,
auth_token,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeConfig {
pub node_id: String,
pub server_url: String,
pub max_slots: usize,
pub runtime_dir: String,
pub work_dir: String,
pub heartbeat_sec: u64,
pub auth_token: Option<String>,
}
impl Default for NodeConfig {
fn default() -> Self {
let server_url = std::env::var("DCTS_SERVER_URL")
.or_else(|_| std::env::var("SERVER_URL"))
.or_else(|_| std::env::var("CNO_SERVER_URL"))
.unwrap_or_else(|_| "http://127.0.0.1:8090".to_string());
let node_id = std::env::var("DCTS_NODE_ID")
.or_else(|_| std::env::var("NODE_ID"))
.and_then(|v| if v.trim().is_empty() { Err(std::env::VarError::NotPresent) } else { Ok(v) })
.unwrap_or_else(|_| format!("node-{}", uuid::Uuid::new_v4().simple()));
let max_slots = std::env::var("DCTS_MAX_SLOTS")
.or_else(|_| std::env::var("MAX_SLOTS"))
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(4);
let runtime_dir = std::env::var("DCTS_RUNTIME_DIR")
.unwrap_or_else(|_| "data/runtime".to_string());
let work_dir = std::env::var("DCTS_WORK_DIR")
.unwrap_or_else(|_| "data/work".to_string());
let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(15);
let auth_token = std::env::var("DCTS_AUTH_TOKEN").ok();
Self {
node_id,
server_url,
max_slots,
runtime_dir,
work_dir,
heartbeat_sec,
auth_token,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_real_grid_configs() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let sdb_path = root.join("workflows/sdB_cno.yaml");
if sdb_path.exists() {
let cfg = GridConfig::load_from_file(&sdb_path).expect("解析 workflows/sdB_cno.yaml 发生失败");
assert_eq!(cfg.nworkers, 16);
assert_eq!(cfg.niter, Some(100));
}
let dense_path = root.join("config_dense.yaml");
if dense_path.exists() {
let cfg = GridConfig::load_from_file(&dense_path).expect("解析 config_dense.yaml 发生失败");
assert_eq!(cfg.template.as_deref(), Some("templates/cno_atmos.5.tpl"));
assert_eq!(cfg.linelist.as_deref(), Some("data/gfVIS99.dat"));
}
}
}
+166
View File
@@ -0,0 +1,166 @@
use crate::models::ConvCheckResult;
use regex::Regex;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::sync::OnceLock;
static FORT9_RE: OnceLock<Regex> = OnceLock::new();
static NAN_RE: OnceLock<Regex> = OnceLock::new();
#[derive(Debug, Clone)]
struct Fort9Row {
depth: i32,
maximum: f64,
}
/// Parses `fort.9` and evaluates convergence against `chmax`
pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
let file = match File::open(path) {
Ok(f) => f,
Err(e) => {
return ConvCheckResult {
converged: false,
max_relc: f64::INFINITY,
worst_depth: -1,
last_iter: None,
n_depths: 0,
chmax,
error: Some(format!("Failed to open fort.9: {}", e)),
}
}
};
let reader = BufReader::new(file);
let re = FORT9_RE.get_or_init(|| {
Regex::new(
r"^\s*(\d+)\s+(\d+)\s+([-+\dE.]+)\s+([-+\dE.]+)\s+([-+\dE.]+)\s+([-+\dE.]+)\s+([-+\dE.]+)\s+(\d+)\s+(\d+)\s*$"
).unwrap()
});
let mut last_iter: Option<i32> = None;
let mut cur_iter: Option<i32> = None;
let mut cur_rows: Vec<Fort9Row> = Vec::new();
for line in reader.lines().map_while(Result::ok) {
if let Some(caps) = re.captures(&line) {
let iter: i32 = match caps[1].parse() {
Ok(v) => v,
Err(_) => continue,
};
let depth: i32 = match caps[2].parse() {
Ok(v) => v,
Err(_) => continue,
};
let maximum: f64 = match caps[7].parse() {
Ok(v) => v,
Err(_) => continue,
};
if cur_iter != Some(iter) {
cur_iter = Some(iter);
cur_rows.clear();
}
cur_rows.push(Fort9Row { depth, maximum });
last_iter = Some(iter);
}
}
if cur_rows.is_empty() || last_iter.is_none() {
return ConvCheckResult {
converged: false,
max_relc: f64::INFINITY,
worst_depth: -1,
last_iter: None,
n_depths: 0,
chmax,
error: Some("No valid iteration data found in fort.9".to_string()),
};
}
// Safely find depth with maximum absolute change without unwrap panic on NaN
let worst = match cur_rows
.iter()
.max_by(|a, b| {
a.maximum
.abs()
.partial_cmp(&b.maximum.abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
{
Some(row) => row,
None => {
return ConvCheckResult {
converged: false,
max_relc: f64::INFINITY,
worst_depth: -1,
last_iter,
n_depths: 0,
chmax,
error: Some("No valid iteration rows found when calculating maximum change".to_string()),
};
}
};
let max_relc = worst.maximum.abs();
let is_valid_num = max_relc.is_finite();
ConvCheckResult {
converged: is_valid_num && max_relc < chmax,
max_relc,
worst_depth: worst.depth,
last_iter,
n_depths: cur_rows.len(),
chmax,
error: if is_valid_num { None } else { Some("Convergence value is NaN or Inf".to_string()) },
}
}
/// Checks if an atmosphere file (.7) contains NaN lines (>10% NaN lines = invalid) using exact word boundary
pub fn atmosphere_has_nan(path: &Path) -> bool {
let file = match File::open(path) {
Ok(f) => f,
Err(_) => return true,
};
let reader = BufReader::new(file);
let mut total_lines = 0;
let mut nan_lines = 0;
let nan_re = NAN_RE.get_or_init(|| Regex::new(r"(?i)\bnan\b").unwrap());
for line in reader.lines().map_while(Result::ok) {
total_lines += 1;
if nan_re.is_match(&line) {
nan_lines += 1;
}
}
if total_lines == 0 {
return true;
}
(nan_lines as f64) > (total_lines as f64 * 0.1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nan_check() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.7");
std::fs::write(&file_path, "1 2 3\n4 5 6\n7 8 9\n").unwrap();
assert!(!atmosphere_has_nan(&file_path));
let nan_file_path = dir.path().join("nan.7");
std::fs::write(&nan_file_path, "NaN 2 3\nNaN 5 6\n7 8 9\n").unwrap();
assert!(atmosphere_has_nan(&nan_file_path));
// Substring false positive test
let banana_file_path = dir.path().join("banana.7");
std::fs::write(&banana_file_path, "banana 2 3\nbanana 5 6\n7 8 9\n").unwrap();
assert!(!atmosphere_has_nan(&banana_file_path));
}
}
+149
View File
@@ -0,0 +1,149 @@
use anyhow::{Context, Result};
use reqwest::Client;
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::info;
#[cfg(feature = "embed-binaries")]
pub static TLUSTY_BIN: &[u8] = include_bytes!("../../../assets/tlusty_static");
#[cfg(not(feature = "embed-binaries"))]
pub static TLUSTY_BIN: &[u8] = &[];
#[cfg(feature = "embed-binaries")]
pub static SYNSPEC_BIN: &[u8] = include_bytes!("../../../assets/synspec_static");
#[cfg(not(feature = "embed-binaries"))]
pub static SYNSPEC_BIN: &[u8] = &[];
#[derive(Debug, Clone)]
pub struct RuntimePaths {
pub tlusty_exe: PathBuf,
pub synspec_exe: PathBuf,
pub data_dir: PathBuf,
pub linelist: PathBuf,
}
fn calc_hash(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
/// Ensures Fortran runtime binaries are unpacked and common partition function data files are fetched
pub async fn ensure_runtime(
runtime_dir: &Path,
server_url: &str,
client: &Client,
) -> Result<RuntimePaths> {
fs::create_dir_all(runtime_dir)
.with_context(|| format!("Failed to create runtime dir: {}", runtime_dir.display()))?;
let tlusty_exe = runtime_dir.join("tlusty_static");
let synspec_exe = runtime_dir.join("synspec_static");
let data_dir = runtime_dir.join("data");
let linelist = runtime_dir.join("gfVIS99.dat");
fs::create_dir_all(&data_dir)?;
// 1. Unpack tlusty_static & synspec_static binaries if embedded
if !TLUSTY_BIN.is_empty() {
write_if_changed(&tlusty_exe, TLUSTY_BIN, true)?;
}
if !SYNSPEC_BIN.is_empty() {
write_if_changed(&synspec_exe, SYNSPEC_BIN, true)?;
}
// 2. Fetch baseline equation of state partition function tables if missing locally
let common_files = &["irwin_bc.dat", "irwin_orig.dat", "tsuji.molec_bc2", "tsuji.molec_orig"];
ensure_specific_data_files(&data_dir, server_url, client, common_files).await?;
// 3. Check gfVIS99.dat
if !linelist.exists() {
let url = format!("{}/api/data/linelist", server_url);
info!("本地缺失主谱线库 gfVIS99.dat,开始从服务端下载: {}...", url);
let resp = client.get(&url).send().await?;
if resp.status().is_success() {
let bytes = resp.bytes().await?;
fs::write(&linelist, &bytes)?;
info!("成功下载并保存主谱线库 gfVIS99.dat");
} else {
anyhow::bail!("从服务端下载主谱线库 gfVIS99.dat 失败,HTTP 状态码: {}", resp.status());
}
}
let abs_runtime_dir = fs::canonicalize(runtime_dir).unwrap_or_else(|_| runtime_dir.to_path_buf());
let tlusty_exe = abs_runtime_dir.join("tlusty_static");
let synspec_exe = abs_runtime_dir.join("synspec_static");
let data_dir = abs_runtime_dir.join("data");
let linelist = abs_runtime_dir.join("gfVIS99.dat");
Ok(RuntimePaths {
tlusty_exe,
synspec_exe,
data_dir,
linelist,
})
}
static DATA_DOWNLOAD_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Checks local `./runtime/data/` for specific required files. If missing, downloads ONLY those specific files from Server!
pub async fn ensure_specific_data_files(
data_dir: &Path,
server_url: &str,
client: &Client,
required_files: &[&str],
) -> Result<()> {
let _guard = DATA_DOWNLOAD_MUTEX.lock().await;
tokio::fs::create_dir_all(data_dir).await?;
for &filename in required_files {
let local_file = data_dir.join(filename);
if !local_file.exists() {
let file_url = format!("{}/api/data/file/{}", server_url, filename);
info!("本地缺失数据文件 {},开始从服务端拉取: {}", filename, file_url);
let resp = client.get(&file_url).send().await?;
if resp.status().is_success() {
let bytes = resp.bytes().await?;
let tmp_file = data_dir.join(format!("{}.{}.tmp", filename, uuid::Uuid::new_v4().simple()));
tokio::fs::write(&tmp_file, &bytes).await?;
tokio::fs::rename(&tmp_file, &local_file).await?;
info!("成功保存数据文件: {}", filename);
} else {
anyhow::bail!("服务端返回 HTTP {} 错误,数据文件: {}", resp.status(), filename);
}
}
}
Ok(())
}
fn write_if_changed(target_path: &Path, content: &[u8], executable: bool) -> Result<()> {
let should_write = if target_path.exists() {
match fs::read(target_path) {
Ok(existing) => calc_hash(&existing) != calc_hash(content),
Err(_) => true,
}
} else {
true
};
if should_write {
let mut file = File::create(target_path)?;
file.write_all(content)?;
file.flush()?;
#[cfg(unix)]
if executable {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(target_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(target_path, perms)?;
}
}
Ok(())
}
+35
View File
@@ -0,0 +1,35 @@
use crate::config::SynspecConfig;
/// Dynamic generator for SYNSPEC fort.55 parameter control file
pub fn generate_fort55_content(cfg: &SynspecConfig) -> 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";
format!("{}\n{}\n{}\n{}\n{}\n{}\n{}\n", line1, line2, line3, line4, line5, line6, line7)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fort55_generation() {
let cfg = SynspecConfig {
wstart: 3000.0,
wend: 7000.0,
imode: 0,
idrv: 50,
ifreq: 1,
rel_cutoff: 0.0001,
abs_cutoff: 0.01,
};
let content = generate_fort55_content(&cfg);
assert!(content.contains("3000.0"));
assert!(content.contains("7000.0"));
}
}
+155
View File
@@ -0,0 +1,155 @@
use crate::models::GridPointParams;
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: " " },
];
fn fmt_abn(logx: f64) -> String {
format!("{:.4E}", 10.0f64.powf(logx))
}
/// Constructs the complete text of a `.5` input file for TLUSTY
pub fn make_input5(
params: &GridPointParams,
lte: &str,
ltgray: &str,
metals: &str,
ilvlin: i32,
) -> 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 block
let mut atom_rows: Vec<(i32, String)> = vec![
(2, "0.".to_string()), // 1 H
(2, fmt_abn(params.loghe)), // 2 He
(0, "0.".to_string()), // 3 Li
(0, "0.".to_string()), // 4 Be
(0, "0.".to_string()), // 5 B
];
if has_c {
atom_rows.push((2, fmt_abn(params.logc))); // 6 C
}
if has_n {
atom_rows.push((2, fmt_abn(params.logn))); // 7 N
}
if has_o {
atom_rows.push((2, fmt_abn(params.logo))); // 8 O
}
let natoms = 5 + (if has_c { 1 } else { 0 }) + (if has_n { 1 } else { 0 }) + (if has_o { 1 } else { 0 });
let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms);
for (mode, abn) in &atom_rows {
atoms_block.push_str(&format!(" {} {} 0\n", mode, abn));
}
// Ions block
let mut ions: Vec<&IonDef> = Vec::new();
ions.extend(IONS_H.iter());
ions.extend(IONS_HE.iter());
if has_c {
ions.extend(IONS_C.iter());
}
if has_n {
ions.extend(IONS_N.iter());
}
if has_o {
ions.extend(IONS_O.iter());
}
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_block.push_str(&format!(
" {:2} {:2} {:5} {:5} {:5} 0 '{}' '{}'\n",
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, ion.typion, ion.filei
));
}
ions_block.push_str(" 0 0 0 -1 0 0 ' ' ' '\n");
format!(
"{:.1} {:.1} ! TEFF, GRAV\n \
{} {} ! LTE, LTGRAY\n \
'nst' ! name of file containing non-standard flags\n\
*-----------------------------------------------------------------\n\
* frequencies\n \
2000 ! NFREAD\n\
*-----------------------------------------------------------------\n\
* data for atoms\n\
{}\
*-----------------------------------------------------------------\n\
* data for ions\n*\n\
{}\
*\n* end\n",
params.teff, params.logg, lte, ltgray, atoms_block, ions_block
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_make_input5() {
let params = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
};
let input5 = make_input5(&params, "F", "F", "cno", 100);
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"));
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod config;
pub mod conv_check;
pub mod embedded;
pub mod fort55_writer;
pub mod gen_input5;
pub mod logging;
pub mod models;
pub mod nst_writer;
pub mod runner;
pub mod seed_finder;
+97
View File
@@ -0,0 +1,97 @@
use anyhow::Result;
use chrono::Local;
use std::env;
use std::fs;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling;
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer};
pub struct LocalTimeFormatter;
impl FormatTime for LocalTimeFormatter {
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
let local_time = Local::now();
write!(w, "{}", local_time.format("%Y-%m-%dT%H:%M:%S%.3f%:z"))
}
}
/// Initializes high-performance, non-blocking structured logging for DCTS applications
pub fn init_logging(app_name: &str, default_filter: &str) -> Result<Vec<WorkerGuard>> {
let mut guards = Vec::new();
let log_level = env::var("DCTS_LOG")
.or_else(|_| env::var("RUST_LOG"))
.unwrap_or_else(|_| default_filter.to_string());
let log_format = env::var("LOG_FORMAT").unwrap_or_else(|_| "pretty".to_string());
let log_outputs = env::var("LOG_OUTPUTS").unwrap_or_else(|_| "stdout,file".to_string());
let log_dir = env::var("LOG_DIR").unwrap_or_else(|_| "data/logs".to_string());
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&log_level));
let is_json = log_format.to_lowercase() == "json";
let mut layers: Vec<Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync>> = Vec::new();
// 1. Non-blocking Console Output Layer (stdout)
if log_outputs.contains("stdout") {
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout());
guards.push(guard);
let fmt_layer = fmt::layer().with_timer(LocalTimeFormatter).with_writer(non_blocking);
if is_json {
layers.push(fmt_layer.json().with_ansi(false).boxed());
} else {
layers.push(fmt_layer.pretty().with_ansi(true).boxed());
}
}
// 2. Non-blocking Daily Rolling File Layer (data/logs/app_name.YYYY-MM-DD.log)
if log_outputs.contains("file") {
fs::create_dir_all(&log_dir).ok();
let file_appender = rolling::RollingFileAppender::builder()
.rotation(rolling::Rotation::DAILY)
.filename_prefix(app_name)
.filename_suffix("log")
.build(&log_dir)?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
guards.push(guard);
let fmt_layer = fmt::layer()
.with_timer(LocalTimeFormatter)
.with_writer(non_blocking)
.with_ansi(false);
if is_json {
layers.push(fmt_layer.json().boxed());
} else {
layers.push(fmt_layer.boxed());
}
}
tracing_subscriber::registry()
.with(layers)
.with(env_filter)
.init();
// Intercept runtime panics and write structured logs
std::panic::set_hook(Box::new(|panic| {
let payload = panic.payload();
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Box<dyn Any>".to_string()
};
let location = panic
.location()
.map(|l| format!(" at {}:{}", l.file(), l.line()))
.unwrap_or_default();
tracing::error!("PANIC{}: {}", location, msg);
}));
Ok(guards)
}
+260
View File
@@ -0,0 +1,260 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use uuid::Uuid;
/// 6D grid point parameter specification
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridPointParams {
pub teff: f64,
pub logg: f64,
pub loghe: f64,
pub logc: f64,
pub logn: f64,
pub logo: f64,
}
fn fmt_num(val: f64) -> String {
let rounded = (val * 1e6).round() / 1e6;
if (rounded - rounded.round()).abs() < 1e-6 {
format!("{:.0}", rounded.round())
} else {
let s = format!("{:.6}", rounded);
let s = s.trim_end_matches('0');
let s = s.trim_end_matches('.');
s.to_string()
}
}
impl GridPointParams {
/// Generates canonical model name string e.g. "t35000_g5.5_he-1_c-2_n-2_o-2"
pub fn model_name(&self) -> String {
format!(
"t{}_g{}_he{}_c{}_n{}_o{}",
fmt_num(self.teff),
fmt_num(self.logg),
fmt_num(self.loghe),
fmt_num(self.logc),
fmt_num(self.logn),
fmt_num(self.logo)
)
}
/// CNO 对数丰度之和 (`logc + logn + logo`)。
///
/// 注:此数值专门用于网格调度中的 Wave 难度分级与保序分组(对数和越小代表重元素丰度越低,
/// 通常在大气模型计算中更容易收敛,作为冷启动基准)。
pub fn cno_sum(&self) -> f64 {
self.logc + self.logn + self.logo
}
}
/// Grid point state in database
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridPoint {
pub id: i64,
pub name: String,
pub params: GridPointParams,
pub cno_sum: f64,
pub wave: i32,
pub status: GridPointStatus,
pub attempt_count: i32,
pub success_method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GridPointStatus {
Pending,
Queued,
Running,
Converged,
Failed,
}
impl std::fmt::Display for GridPointStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
GridPointStatus::Pending => "pending",
GridPointStatus::Queued => "queued",
GridPointStatus::Running => "running",
GridPointStatus::Converged => "converged",
GridPointStatus::Failed => "failed",
};
write!(f, "{}", s)
}
}
impl From<&str> for GridPointStatus {
fn from(s: &str) -> Self {
match s {
"queued" => GridPointStatus::Queued,
"running" => GridPointStatus::Running,
"converged" | "done" => GridPointStatus::Converged,
"failed" => GridPointStatus::Failed,
_ => GridPointStatus::Pending,
}
}
}
/// Task execution specification sent to Node
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSpec {
pub task_id: Uuid,
pub point_name: String,
pub params: GridPointParams,
pub task_type: TaskType,
pub seed_point_name: Option<String>,
pub timeout_sec: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskType {
ColdRun,
SeedStep,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
Running,
Completed,
Failed,
Timeout,
}
/// Result report sent from Node back to Server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskReport {
pub task_id: Uuid,
pub point_name: String,
#[serde(default)]
pub params: Option<GridPointParams>,
pub node_id: String,
pub status: TaskStatus,
pub converged: bool,
pub max_relc: Option<f64>,
pub atmosphere_has_nan: bool,
pub elapsed_sec: f64,
pub error_message: Option<String>,
pub summary_json: String,
}
/// Node registration request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeRegisterRequest {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
}
/// Node heartbeat request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeHeartbeatRequest {
pub node_id: String,
pub active_slots: i32,
pub cpu_usage: f32,
pub memory_usage: f32,
}
/// Node state in database
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInfo {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
pub active_slots: i32,
pub status: String,
pub cpu_usage: f32,
pub memory_usage: f32,
pub last_heartbeat: DateTime<Utc>,
}
/// Single iteration convergence result parsed from fort.9
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvCheckResult {
pub converged: bool,
pub max_relc: f64,
pub worst_depth: i32,
pub last_iter: Option<i32>,
pub n_depths: usize,
pub chmax: f64,
pub error: Option<String>,
}
/// Convergence stage summary recorded in conv.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageSummary {
pub label: String,
pub chmax: Option<f64>,
pub lte: String,
pub converged: bool,
pub best_max_relc: Option<f64>,
pub elapsed_sec: f64,
pub note: Option<String>,
}
/// Full execution summary for a grid point
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSummary {
pub name: String,
pub params: GridPointParams,
pub stages: Vec<StageSummary>,
pub converged: bool,
pub final_max_relc: Option<f64>,
pub final_chmax: Option<f64>,
pub seed: Option<String>,
pub atmosphere_has_nan: bool,
pub synspec_rc: Option<i32>,
pub synspec_error: Option<String>,
pub synspec_sec: Option<f64>,
pub elapsed_sec: f64,
pub note: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_name_formatting_and_decimal_precision() {
let p1 = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
};
assert_eq!(p1.model_name(), "t35000_g5.5_he-1_c-2_n-2_o-2");
let p2 = GridPointParams {
teff: 35000.0,
logg: 5.25,
loghe: -1.5,
logc: -2.75,
logn: -2.0,
logo: -1.25,
};
assert_eq!(p2.model_name(), "t35000_g5.25_he-1.5_c-2.75_n-2_o-1.25");
assert_ne!(p1.model_name(), p2.model_name());
}
#[test]
fn test_grid_point_status_display_and_conversion() {
assert_eq!(GridPointStatus::Pending.to_string(), "pending");
assert_eq!(GridPointStatus::Queued.to_string(), "queued");
assert_eq!(GridPointStatus::Running.to_string(), "running");
assert_eq!(GridPointStatus::Converged.to_string(), "converged");
assert_eq!(GridPointStatus::Failed.to_string(), "failed");
assert_eq!(GridPointStatus::from("queued"), GridPointStatus::Queued);
assert_eq!(GridPointStatus::from("converged"), GridPointStatus::Converged);
assert_eq!(GridPointStatus::from("done"), GridPointStatus::Converged);
assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed);
assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending);
}
}
+66
View File
@@ -0,0 +1,66 @@
use crate::config::StageConfig;
pub fn generate_nst_content(stage: &StageConfig) -> String {
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(),
];
if let Some(chmax) = stage.chmax {
line1_parts.push(format!("CHMAX={}", chmax));
}
if let Some(itek) = stage.itek {
line1_parts.push(format!("ITEK={}", itek));
}
line1_parts.push(format!("NITER={}", stage.niter));
let mut line2_parts = Vec::new();
if let Some(orelax) = stage.orelax {
line2_parts.push(format!("ORELAX={}", orelax));
}
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());
format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(","))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nst_generation() {
let stage = StageConfig {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
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);
assert!(content.contains("ND=50"));
assert!(content.contains("NITER=10"));
assert!(content.contains("IELCOR=-1"));
}
}
+417
View File
@@ -0,0 +1,417 @@
use crate::config::{StageConfig, SynspecConfig};
use crate::conv_check::{atmosphere_has_nan, check_fort9};
use crate::embedded::RuntimePaths;
use crate::fort55_writer::generate_fort55_content;
use crate::gen_input5::make_input5;
use crate::models::{GridPointParams, ModelSummary, StageSummary, TaskType};
use crate::nst_writer::generate_nst_content;
use anyhow::Result;
use tokio::fs::File;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command as AsyncCommand;
use std::time::Instant;
use tracing::{info, warn};
pub fn default_cold_chain() -> Vec<StageConfig> {
vec![
StageConfig {
label: "lte".to_string(),
lte: "T".to_string(),
ltgray: "T".to_string(),
ilvlin: 0,
require_converged: false,
niter: 0,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
orelax: None,
},
StageConfig {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 0,
require_converged: false,
niter: 10,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
orelax: None,
},
StageConfig {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
orelax: None,
},
]
}
pub fn default_seed_chain() -> Vec<StageConfig> {
vec![
StageConfig {
label: "seed_nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 0,
require_converged: false,
niter: 20,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: Some(0),
idlte: None,
iacc: None,
orelax: None,
},
StageConfig {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: Some(0),
idlte: None,
iacc: None,
orelax: None,
},
]
}
async fn run_child_async_with_timeout(
mut child: tokio::process::Child,
timeout_sec: u64,
) -> Result<std::process::ExitStatus> {
match tokio::time::timeout(tokio::time::Duration::from_secs(timeout_sec), child.wait()).await {
Ok(res) => Ok(res?),
Err(_) => {
let _ = child.start_kill();
let _ = child.wait().await;
anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec);
}
}
}
pub struct ExecutionRunner<'a> {
pub runtime: &'a RuntimePaths,
pub work_dir: PathBuf,
}
impl<'a> ExecutionRunner<'a> {
pub fn new(runtime: &'a RuntimePaths, work_dir: PathBuf) -> Self {
Self { runtime, work_dir }
}
pub async fn run_model(
&self,
params: &GridPointParams,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
) -> Result<ModelSummary> {
self.run_model_with_timeout(params, task_type, custom_chain, seed_atmos, synspec_cfg, 7200).await
}
pub async fn run_model_with_timeout(
&self,
params: &GridPointParams,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
timeout_sec: u64,
) -> Result<ModelSummary> {
let name = params.model_name();
let model_dir = self.work_dir.join(&name);
tokio::fs::create_dir_all(&model_dir).await?;
info!("开始物理计算网格模型 {} (类型: {:?})", name, task_type);
let t0 = Instant::now();
// 1. Data directory symlink setup
let link_data = model_dir.join("data");
if tokio::fs::symlink_metadata(&link_data).await.is_ok() || link_data.exists() {
let _ = tokio::fs::remove_file(&link_data).await;
}
#[cfg(unix)]
{
let abs_data_dir = tokio::fs::canonicalize(&self.runtime.data_dir).await.unwrap_or_else(|_| self.runtime.data_dir.clone());
if let Err(e) = std::os::unix::fs::symlink(&abs_data_dir, &link_data) {
warn!("构建 data 数据集软链时发生提示性告警: {}", e);
}
}
// 2. Initial fort.8 seed setup
let fort8 = model_dir.join("fort.8");
if fort8.exists() {
let _ = tokio::fs::remove_file(&fort8).await;
}
if let Some(seed_path) = seed_atmos {
if seed_path.is_file() {
if let Err(e) = tokio::fs::copy(seed_path, &fort8).await {
warn!("向工作沙盒引导填载首期收敛模型种子 fort.8 发生复制错误: {}", e);
}
}
}
// Clean fort.84 residue to prevent NATOMS Fortran crash
let fort84 = model_dir.join("fort.84");
if fort84.exists() {
let _ = tokio::fs::remove_file(&fort84).await;
}
let chain = custom_chain.unwrap_or_else(|| match task_type {
TaskType::ColdRun => default_cold_chain(),
TaskType::SeedStep => default_seed_chain(),
});
let mut stage_summaries = Vec::new();
let mut current_seed: Option<PathBuf> = seed_atmos.map(|p| p.to_path_buf());
let mut final_converged = false;
let mut final_chmax: Option<f64> = None;
let mut final_max_relc: Option<f64> = None;
for stage_def in &chain {
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,
);
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);
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
// Prepare fort.8 for this stage
if stage_def.ltgray == "T" {
if fort8.exists() {
let _ = tokio::fs::remove_file(&fort8).await;
}
} else if let Some(ref s_path) = current_seed {
if s_path.is_file() {
if let Err(e) = tokio::fs::copy(s_path, &fort8).await {
warn!("阶段 {} 重载候选近邻推算种子模型期间发生文件复制异常: {}", stage_def.label, e);
}
}
}
// Run tlusty.exe
let fin = File::open(&input5_path).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.6", name))).await?.into_std().await;
let ferr = File::create(model_dir.join(format!("{}.err", name))).await?.into_std().await;
let child = AsyncCommand::new(&self.runtime.tlusty_exe)
.current_dir(&model_dir)
.stdin(Stdio::from(fin))
.stdout(Stdio::from(fout))
.stderr(Stdio::from(ferr))
.kill_on_drop(true)
.spawn()?;
let status_res = run_child_async_with_timeout(child, timeout_sec).await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
warn!("tlusty 运行失败/超时: {}", e);
-1
}
};
let fort9 = model_dir.join("fort.9");
let fort7 = model_dir.join("fort.7");
let mut stage_summary = StageSummary {
label: stage_def.label.clone(),
chmax: stage_def.chmax,
lte: stage_def.lte.clone(),
converged: false,
best_max_relc: None,
elapsed_sec: stage_t0.elapsed().as_secs_f64(),
note: None,
};
if rc == 0 && fort7.is_file() {
let eff_chmax = stage_def.chmax.unwrap_or(0.001);
if fort9.is_file() {
let res = check_fort9(&fort9, eff_chmax);
stage_summary.converged = res.converged;
stage_summary.best_max_relc = Some(res.max_relc);
// Save fort.9 snapshot
let snap_name = format!("{}.{}_chmax{}.9", name, stage_def.label, eff_chmax);
let _ = tokio::fs::copy(&fort9, model_dir.join(snap_name)).await;
} else {
// NITER=0 grey start without fort.9
stage_summary.converged = true;
stage_summary.best_max_relc = Some(0.0);
stage_summary.note = Some("NITER=0 grey start".to_string());
}
// Copy fort.7 as stage seed
let stage_seed_path = model_dir.join(format!("{}.{}.7", name, stage_def.label));
let _ = tokio::fs::copy(&fort7, &stage_seed_path).await;
current_seed = Some(stage_seed_path);
} else {
stage_summary.note = Some(format!("tlusty rc={} or missing fort.7", rc));
}
final_chmax = stage_def.chmax;
final_converged = stage_summary.converged;
if let Some(r) = stage_summary.best_max_relc {
final_max_relc = Some(r);
}
stage_summaries.push(stage_summary);
if !final_converged && stage_def.require_converged {
warn!("阶段 {} 要求收敛但未达标,中止后续收敛链阶段", stage_def.label);
break;
}
}
// Final atmosphere file .7
let final_7 = model_dir.join(format!("{}.7", name));
if let Some(ref s_path) = current_seed {
if s_path.is_file() {
let _ = tokio::fs::copy(s_path, &final_7).await;
}
} else if model_dir.join("fort.7").is_file() {
let _ = tokio::fs::copy(model_dir.join("fort.7"), &final_7).await;
}
let atmo_has_nan = atmosphere_has_nan(&final_7);
if atmo_has_nan {
final_converged = false;
}
// Run synspec if final .7 atmosphere exists
let mut synspec_rc = None;
let mut synspec_err = None;
let mut synspec_sec = None;
if final_7.is_file() {
let syn_t0 = Instant::now();
let _ = tokio::fs::copy(&final_7, model_dir.join("fort.8")).await;
let _ = tokio::fs::remove_file(model_dir.join("fort.7")).await;
// Fort.55 parameter generation or symlink
let fort55_path = model_dir.join("fort.55");
let fort19_path = model_dir.join("fort.19");
let _ = tokio::fs::remove_file(&fort55_path).await;
let _ = tokio::fs::remove_file(&fort19_path).await;
let default_cfg = SynspecConfig {
wstart: 1400.0,
wend: 1410.0,
imode: 0,
idrv: 50,
ifreq: 1,
rel_cutoff: 0.0001,
abs_cutoff: 0.01,
};
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
let _ = tokio::fs::write(&fort55_path, &fort55_text).await;
#[cfg(unix)]
{
let abs_linelist = tokio::fs::canonicalize(&self.runtime.linelist).await.unwrap_or_else(|_| self.runtime.linelist.clone());
let _ = std::os::unix::fs::symlink(&abs_linelist, &fort19_path);
}
let input5_path = model_dir.join(format!("{}.5", name));
if input5_path.is_file() {
let fin = File::open(&input5_path).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.log", name))).await?.into_std().await;
let child = AsyncCommand::new(&self.runtime.synspec_exe)
.current_dir(&model_dir)
.stdin(Stdio::from(fin))
.stdout(Stdio::from(fout))
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()?;
let status_res = run_child_async_with_timeout(child, timeout_sec).await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
warn!("synspec 运行失败/超时: {}", e);
-1
}
};
synspec_rc = Some(rc);
synspec_sec = Some(syn_t0.elapsed().as_secs_f64());
// Copy/move outputs: fort.7 (Synspec spectrum) -> .spec, fort.17 -> .cont, fort.12 -> .iden
if model_dir.join("fort.7").is_file() {
let _ = tokio::fs::rename(model_dir.join("fort.7"), model_dir.join(format!("{}.spec", name))).await;
}
if model_dir.join("fort.17").is_file() {
let _ = tokio::fs::copy(model_dir.join("fort.17"), model_dir.join(format!("{}.cont", name))).await;
}
if model_dir.join("fort.12").is_file() {
let _ = tokio::fs::copy(model_dir.join("fort.12"), model_dir.join(format!("{}.iden", name))).await;
}
}
} else {
synspec_err = Some("No atmosphere .7 produced".to_string());
}
let elapsed_sec = t0.elapsed().as_secs_f64();
let summary = ModelSummary {
name,
params: params.clone(),
stages: stage_summaries,
converged: final_converged,
final_max_relc,
final_chmax,
seed: seed_atmos.map(|p| p.to_string_lossy().to_string()),
atmosphere_has_nan: atmo_has_nan,
synspec_rc,
synspec_error: synspec_err,
synspec_sec,
elapsed_sec,
note: if atmo_has_nan {
Some("Invalidated: atmosphere contains >10% NaN lines".to_string())
} else {
None
},
};
// Write conv.json
let json_text = serde_json::to_string_pretty(&summary)?;
tokio::fs::write(model_dir.join("conv.json"), json_text).await?;
Ok(summary)
}
}
+37
View File
@@ -0,0 +1,37 @@
use crate::models::GridPointParams;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct SeedMatch {
pub name: String,
pub path: PathBuf,
pub distance: f64,
}
pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams) -> (bool, f64) {
let d_teff = (cand.teff - target.teff).abs();
let d_logg = (cand.logg - target.logg).abs();
let d_loghe = (cand.loghe - target.loghe).abs();
let d_cno = (cand.logc - target.logc).abs()
+ (cand.logn - target.logn).abs()
+ (cand.logo - target.logo).abs();
if d_teff < 1.0 && d_logg < 0.01 && d_loghe < 0.01 {
(true, d_cno)
} else {
// 距离公式物理意义与标定阐释:
// 在恒星非局部热力学平衡(NLTE)辐射流体力学与光谱大气计算中,不同物理自由度对于迭代收敛过程的基本影响层级截然相反:
// 1. Teff (有效温度) 通常达数千至数十万 K,主导连续谱黑体势函数与强激发电离步阶,故除以 5000.0 归一化为基底主控距离量;
// 2. logg (表面重力加速度) 对静力学与辐射光致压差梯度的平衡破坏力极烈,压强差稍高会触发极大激波不平衡,因此乘上 2.0 予以最高维权惩罚;
// 3. loghe (氦丰度) 对自由电子密度与热库贡献次于 H-He 电离梯度,乘 0.5 作为次要控制项;
// 4. CNO 金属元素虽然影响紫外谱线辐射驱动但整体状态基本可作次优微扰微增系数看待,乘 0.1;
// 通过上述尺度正态映射可挑选得到高收敛继承性的初态迭代种子模型。
let global_d = (d_teff / 5000.0) + (d_logg * 2.0) + (d_loghe * 0.5) + (d_cno * 0.1);
(false, global_d)
}
}