AstroResearch/src/services/observation/gaia_xp/basis.rs
Asfmq 8f1ed6d08c feat: 观测层跨源扩展——ZTF/TESS 时域 + Gaia XP 光谱还原 + FITS 预览与统一全源检索
后端(新增 ~4400 行):
- 新增 IRSA/IPAC、MAST 两客户端:ZTF 光变曲线(IRSA REST,CSV,免认证)、
  TESS 光变(MAST TIC cone search + LC FITS),均复用 SSRF 安全重定向 + 重试
- Gaia DR3 XP_CONTINUOUS 光谱还原(gaia_xp/):逆向 GaiaXPy calibrate() 算法,
  55 个 Hermite 系数 → 采样光谱;design_matrix 启动时 OnceLock 预计算一次。
  因 fitsio crate 遇 PD(55) 变长数组列会 panic,改用 fitsio-sys 直连 CFITSIO
  原生读取;新增 fitsio/fitsio-sys(vendored,无需系统 cfitsio)
- FITS 预览解析层(preview.rs):跨 LAMOST/SDSS/DESI/Gaia XP 的异构 FITS
  (BinTable/Image/Hermite 系数)统一归一化为 ObservationPreview JSON
  (Spectrum/LightCurve/Photometry/Image 标签枚举,OCP 可扩展)
- 测光 fetcher(photometry.rs):2MASS/AllWISE/Pan-STARRS/Gaia 四源,
  均复用 VizieR/Gaia TAP 查询、零新 HTTP 代码,配置驱动表名/列名差异
- 统一全源检索(unified.rs):在 dispatch 之上叠加多目标 × 多源并发 cone 扇出,
  失败隔离,两阶段(检索聚合 → 勾选后复用现有 download 端点批量下载)
- registry 注册 6 个新 fetcher + list_all_keys() 暴露全 (Source,ProductSpec)
  组合供前端细粒度勾选;Source 枚举增 5 个变体
- 新增 3 条路由:GET /observation/preview、POST /observation/unified/{search,resolve}

前端(新增 ~1600 行):
- UnifiedSearchPanel:三模式目标输入(坐标/天体名/CSV)+ 源 chip 筛选,
  按 (source,product) 分组展示候选源并支持勾选批量下载
- SpectrumPlot:手写内联 SVG 光谱折线图(零第三方绘图库),多段叠加 + hover 取值
- ObservationPreviewRenderer + useObservationPreview:预览渲染接入
- useObservation/ObservationResultCard/App 联动统一检索状态与下载链路
2026-07-09 00:20:29 +08:00

294 lines
9.5 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.

// src/services/observation/gaia_xp/basis.rs
//
// Gaia XP 基函数配置解析 + Hermite 函数递归求值
//
// 配置文件include_str! 编译时打包,来自 GaiaXPy 包):
// {bp|rp}C03_{model}_bases.csv —— 单行 9 字段,含 inv_coef(55×55) + transf(55×55) 扁平数组
// {bp|rp}C03_{model}_dispersion.csv —— wl→pwl 映射97/95 点)
// {bp|rp}C03_{model}_response.csv —— wl→response 透过率1581 点)
//
// Hermite 函数probabilists',对齐 GaiaXPy __psi
// ψ_0(x) = π^(-1/4) · exp(-x²/2)
// ψ_1(x) = π^(-1/4) · exp(-x²/2) · √2 · x
// ψ_n(x) = √(2/n)·x·ψ_{n-1}(x) - √((n-1)/n)·ψ_{n-2}(x)
use anyhow::{anyhow, Context, Result};
/// 单波段配置bases + dispersion + response
pub struct BandConfig {
pub inv_coef: Vec<Vec<f64>>, // [55][55]
pub transf: Vec<Vec<f64>>, // [55][55]
pub disp_x: Vec<f64>, // dispersion 输入 wl
pub disp_y: Vec<f64>, // dispersion 输出 pwl
pub resp_x: Vec<f64>, // response 输入 wl
pub resp_y: Vec<f64>, // response 输出 透过率
pub scale: f64,
pub offset: f64,
}
/// 加载波段配置bp/rp
pub fn load_band_config(band: &str) -> Result<BandConfig> {
let (bases_csv, disp_csv, resp_csv) = match band {
"bp" => (
include_str!("config/bpC03_v375wi_bases.csv"),
include_str!("config/bpC03_v375wi_dispersion.csv"),
include_str!("config/bpC03_v375wi_response.csv"),
),
"rp" => (
include_str!("config/rpC03_v142r_bases.csv"),
include_str!("config/rpC03_v142r_dispersion.csv"),
include_str!("config/rpC03_v142r_response.csv"),
),
_ => return Err(anyhow!("未知波段: {}", band)),
};
let bases = parse_bases_csv(bases_csv)?;
let (disp_x, disp_y) = parse_xy_csv(disp_csv)?;
let (resp_x, resp_y) = parse_xy_csv(resp_csv)?;
// scale/offset 从 bases 的 pwl/norm 范围计算
let scale =
(bases.norm_range_max - bases.norm_range_min) / (bases.pwl_range_max - bases.pwl_range_min);
let offset = bases.norm_range_min - bases.pwl_range_min * scale;
Ok(BandConfig {
inv_coef: bases.inv_coef,
transf: bases.transf,
disp_x,
disp_y,
resp_x,
resp_y,
scale,
offset,
})
}
struct ParsedBases {
inv_coef: Vec<Vec<f64>>, // [55][55]
transf: Vec<Vec<f64>>, // [55][55]
pwl_range_min: f64,
pwl_range_max: f64,
norm_range_min: f64,
norm_range_max: f64,
}
/// 解析 bases CSV
///
/// 实测格式GaiaXPy bpC03_v375wi_bases.csv
/// 行0 = 表头9 列名)
/// 行1 = 数据9 个 CSV 字段,其中第 6/8 字段是引号包裹的括号数组 "(v1,v2,...,v3025)"
/// csv 引号使括号内的逗号不作为字段分隔符)
///
/// 字段顺序nBases, pwlRangeMin, pwlRangeMax, normRangeMin, normRangeMax,
/// nInverseBasesCoefficients, inverseBasesCoefficients, nTransformedBases, transformationMatrix
fn parse_bases_csv(csv: &str) -> Result<ParsedBases> {
// 用简单的状态机解析 CSV处理引号内的逗号取第 2 个非空行(数据行)
let rows = parse_csv_rows(csv);
let data_row = rows
.into_iter()
.filter(|r| !r.is_empty())
.nth(1) // 跳过表头
.ok_or_else(|| anyhow!("bases CSV 无数据行"))?;
if data_row.len() < 9 {
return Err(anyhow!("bases CSV 字段数不足: {}", data_row.len()));
}
let n_bases: usize = data_row[0].trim().parse().context("nBases")?;
let pwl_min: f64 = data_row[1].trim().parse().context("pwlRangeMin")?;
let pwl_max: f64 = data_row[2].trim().parse().context("pwlRangeMax")?;
let norm_min: f64 = data_row[3].trim().parse().context("normRangeMin")?;
let norm_max: f64 = data_row[4].trim().parse().context("normRangeMax")?;
// field[6] = inverseBasesCoefficients "(v1,v2,...)"field[8] = transformationMatrix
let inv_flat = parse_paren_array(&data_row[6]).context("解析 inverseBasesCoefficients")?;
let transf_flat = parse_paren_array(&data_row[8]).context("解析 transformationMatrix")?;
if inv_flat.len() != n_bases * n_bases {
return Err(anyhow!(
"inverseBasesCoefficients 长度 {} != {}×{}",
inv_flat.len(),
n_bases,
n_bases
));
}
if transf_flat.len() != n_bases * n_bases {
return Err(anyhow!(
"transformationMatrix 长度 {} != {}×{}",
transf_flat.len(),
n_bases,
n_bases
));
}
let inv_coef = reshape_square(&inv_flat, n_bases);
let transf = reshape_square(&transf_flat, n_bases);
Ok(ParsedBases {
inv_coef,
transf,
pwl_range_min: pwl_min,
pwl_range_max: pwl_max,
norm_range_min: norm_min,
norm_range_max: norm_max,
})
}
/// 简易 CSV 行解析(处理双引号内的逗号与换行)
fn parse_csv_rows(csv: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut row = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
for ch in csv.chars() {
match ch {
'"' => in_quotes = !in_quotes,
',' if !in_quotes => {
row.push(std::mem::take(&mut field));
}
'\n' if !in_quotes => {
row.push(std::mem::take(&mut field));
if !row.is_empty() {
rows.push(std::mem::take(&mut row));
}
}
'\r' if !in_quotes => {}
_ => field.push(ch),
}
}
if !field.is_empty() || !row.is_empty() {
row.push(field);
rows.push(row);
}
rows
}
/// 解析括号数组 "(v1,v2,...,vN)" → Vec<f64>
fn parse_paren_array(s: &str) -> Result<Vec<f64>> {
let s = s.trim();
let inner = s
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.ok_or_else(|| anyhow!("期望括号数组,得到: {}", &s[..s.len().min(40)]))?;
inner
.split(',')
.map(|t| {
t.trim()
.parse::<f64>()
.map_err(|e| anyhow!("数值解析失败 '{}': {}", t, e))
})
.collect()
}
/// 扁平数组 → n×n 矩阵(行优先)
fn reshape_square(flat: &[f64], n: usize) -> Vec<Vec<f64>> {
let mut m = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..n {
m[i][j] = flat[i * n + j];
}
}
m
}
/// 解析 dispersion/response CSV
///
/// 实测格式GaiaXPy bpC03_v375wi_dispersion.csv
/// 行0 = 所有 X 值逗号分隔97 或 1581 个)
/// 行1 = 所有 Y 值(逗号分隔,同数量)
/// 无表头
fn parse_xy_csv(csv: &str) -> Result<(Vec<f64>, Vec<f64>)> {
let rows = parse_csv_rows(csv);
let x_row = rows.first().ok_or_else(|| anyhow!("XY CSV 无数据行"))?;
let y_row = rows
.get(1)
.ok_or_else(|| anyhow!("XY CSV 缺第二行Y 值)"))?;
if x_row.len() != y_row.len() {
return Err(anyhow!(
"X/Y 行长度不匹配: {} vs {}",
x_row.len(),
y_row.len()
));
}
let x: Vec<f64> = x_row
.iter()
.map(|s| s.trim().parse::<f64>())
.collect::<Result<Vec<_>, _>>()
.context("解析 X 值")?;
let y: Vec<f64> = y_row
.iter()
.map(|s| s.trim().parse::<f64>())
.collect::<Result<Vec<_>, _>>()
.context("解析 Y 值")?;
if x.is_empty() {
return Err(anyhow!("XY CSV 无有效数据"));
}
Ok((x, y))
}
/// Hermite 函数 ψ_n(x)n=0..(max_n-1)3-term 递归
///
/// 对齐 GaiaXPy _evaluate_hermite_function / populate_design_matrix.__psi
/// ψ_0(x) = π^(-1/4) · exp(-x²/2)
/// ψ_1(x) = π^(-1/4) · exp(-x²/2) · √2 · x
/// ψ_n(x) = √(2/n)·x·ψ_{n-1}(x) - √((n-1)/n)·ψ_{n-2}(x)
pub fn hermite_functions(x: f64, max_n: usize) -> Vec<f64> {
let mut psi = vec![0.0; max_n];
if max_n == 0 {
return psi;
}
let sqrt_4_pi = std::f64::consts::PI.powf(-0.25); // π^(-1/4)
let g = (-x * x / 2.0).exp();
psi[0] = sqrt_4_pi * g;
if max_n == 1 {
return psi;
}
psi[1] = sqrt_4_pi * g * 2f64.sqrt() * x;
for n in 2..max_n {
let n_f = n as f64;
psi[n] = (2.0 / n_f).sqrt() * x * psi[n - 1] - ((n_f - 1.0) / n_f).sqrt() * psi[n - 2];
}
psi
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hermite_basic() {
let psi = hermite_functions(0.0, 55);
assert_eq!(psi.len(), 55);
// ψ_0(0) = π^(-1/4) ≈ 0.7511
assert!((psi[0] - std::f64::consts::PI.powf(-0.25)).abs() < 1e-10);
// ψ_1(0) = 0含 x 因子)
assert!(psi[1].abs() < 1e-15);
}
#[test]
fn test_hermite_recursion() {
// 在 x=1.0 处,所有值应有限
let psi = hermite_functions(1.0, 55);
for v in &psi {
assert!(v.is_finite());
}
}
#[test]
fn test_reshape_square() {
let flat = vec![1.0, 2.0, 3.0, 4.0];
let m = reshape_square(&flat, 2);
assert_eq!(m[0], vec![1.0, 2.0]);
assert_eq!(m[1], vec![3.0, 4.0]);
}
#[test]
#[ignore = "需要编译时打包的 CSV"]
fn test_load_bp_config() {
let cfg = load_band_config("bp").unwrap();
assert_eq!(cfg.inv_coef.len(), 55);
assert_eq!(cfg.inv_coef[0].len(), 55);
assert_eq!(cfg.transf.len(), 55);
assert!(!cfg.disp_x.is_empty());
assert!(!cfg.resp_x.is_empty());
}
}