feat: 科研分析层全栈落地——光谱/时域/运动学分析工具链 + JWST/X 射线数据源 + 定时文献同步

数据分析层(新增 services/{spectrum,timeseries,analysis}):
- 光谱参数提取 parameters.rs:LAMOST/SDSS/APOGEE/DESI FITS header 跨源归一化读取
  Teff/logg/[Fe/H]/RV 及 ASPCAP 20+ 元素丰度,rayon 并发批量提取
- 谱线测量 lines.rs:内置真空/空气波长谱线表,窗口内极值搜索 + 梯形法积分 EW + FWHM,支持自定义谱线
- 交叉相关测速 cross_correlate.rs:对数波长重采样对齐,内置 Pickles 模板按光谱型插值,
  CCF 峰值位置提取 RV 及不确定度
- 周期搜索 periodicity.rs:Lomb-Scargle 周期图(含 FAP 误报概率)+ BLS 凌星检测 + 相位折叠
- 变星分类 classification.rs:振幅/偏度/峰度/过零率/eta 等统计特征 + 规则分类(RR Lyrae/Cepheid/食双星/AGN 等)
- SED 拟合 sed.rs:多波段测光黑体模型拟合,输出 T_eff/半径/消光 A_V/光度及不确定度
- 运动学 kinematics.rs:视差+自行+RV → 银河系 UVW 空间速度,含移动星群成员概率(Banyan Σ 简化版)
- 化学丰度 chemistry.rs:[α/Fe] vs [Fe/H] 计算,厚盘/薄盘/晕星族判别
- 观测规划 observability.rs:目标升落时间/airmass/月相影响/曝光时间估算
- 赫罗图 hr_diagram.rs:Gaia TAP CMD 查询,新增 GET /api/analysis/hr-diagram 端点

数据获取层:
- JWST:clients/mast/jwst.rs 封装 MAST Portal 锥形检索 + JwstSpectrumFetcher(NIRSpec/MIRI 光谱)
- X 射线:clients/heasarc 封装 HEASARC TAP(ADQL)+ XMM-Newton/Chandra 光谱 fetcher
- 图像 cutout:SDSS SkyServer/STScI DSS/Pan-STARRS 三源 cutout + 发现图(Finding Chart)生成
- Source 枚举新增 Jwst/Xmm/Chandra 并注册 ObservationRegistry,前端 SOURCE_THEME 与筛选器同步三源

Agent 工具集(24→35):
- 新增 9 个分析工具:get_spectrum_parameters / measure_spectral_lines / measure_radial_velocity /
  find_period / classify_variable_star / fit_sed / analyze_kinematics / analyze_abundance_pattern / plan_observation
- batch_process:批量样本"查询→下载→分析→报告"流水线,并发控制防数据源速率限制
- literature_monitor:按 ADS 查询式/时间窗/最低引用数检查最新文献

定时文献同步:
- sync_queries 表新增 is_scheduled 列(migration 20260713)
- 新增 POST /sync/queries/:id/schedule 端点
- 服务启动时拉起每小时调度器,对 is_scheduled=1 的检索配置静默执行 ADS(entdate 增量)/arXiv 增量同步
- search_history 工具收敛至 services/search::search_agent_history,消除 FTS 查询逻辑重复

其他:
- plotting skill 由占位填充为完整科研绘图规范:光谱/光变/折叠曲线/CMD/SED/[α/Fe]/周期图/Mollweide/发现图 9 类 matplotlib 模板
- 删除死代码 streaming_executor.rs(929 行,仅剩 mod 声明引用,无调用方)
- 新增 docs/roadmap-research-features.md 科研功能路线图及实现状态
This commit is contained in:
fmq
2026-09-07 21:50:32 +08:00
parent eaf85707b5
commit d6b064a490
104 changed files with 12101 additions and 3301 deletions
+378
View File
@@ -0,0 +1,378 @@
// src/services/observation/cutout.rs
//
// 图像 Cutout 服务 —— SDSS / DSS / Pan-STARRS cutout API
//
// 支持:
// - SDSS SkyServer cutout(光学 2.5m
// - STScI DSSDigitized Sky Survey,多种底片)
// - Pan-STARRS cutout(光学 1.8m
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use tracing::info;
/// Cutout 数据源
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum CutoutSource {
/// SDSS optical
Sdss,
/// DSS (POSS-I, POSS-II, etc.)
Dss,
/// Pan-STARRS DR1
Panstarrs,
}
impl CutoutSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Sdss => "sdss",
Self::Dss => "dss",
Self::Panstarrs => "panstarrs",
}
}
}
/// Cutout 结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CutoutResult {
/// 图像数据(JPEG/PNG 字节)
pub image_bytes: Vec<u8>,
/// 图像格式
pub format: String,
/// 中心坐标
pub ra: f64,
pub dec: f64,
/// cutout 大小(角秒)
pub size_arcsec: f64,
/// 数据源
pub source: String,
/// 图像宽度(像素)
pub width: u32,
/// 图像高度(像素)
pub height: u32,
}
/// SDSS cutoutSkyServer
pub async fn sdss_cutout(ra: f64, dec: f64, size_arcsec: f64) -> Result<CutoutResult> {
let scale = size_arcsec / 14.0; // 14 arcsec/pixel for SDSS
let width = (size_arcsec / scale) as u32;
let url = format!(
"https://skyserver.sdss.org/dr18/SkyServerWS/ImgCutout/getjpeg?ra={}&dec={}&scale={:.4}&width={}&height={}",
ra, dec, scale, width, width
);
info!(
"[Cutout] SDSS: ra={:.4}, dec={:.4}, size={}\"",
ra, dec, size_arcsec
);
let resp = reqwest::get(&url)
.await
.map_err(|e| anyhow!("SDSS cutout 请求失败: {}", e))?;
if !resp.status().is_success() {
return Err(anyhow!("SDSS cutout 失败: {}", resp.status()));
}
let bytes = resp.bytes().await?.to_vec();
Ok(CutoutResult {
image_bytes: bytes,
format: "jpeg".to_string(),
ra,
dec,
size_arcsec,
source: "SDSS".to_string(),
width,
height: width,
})
}
/// DSS cutoutSTScI
pub async fn dss_cutout(ra: f64, dec: f64, size_arcsec: f64) -> Result<CutoutResult> {
let size_deg = size_arcsec / 3600.0;
let url = format!(
"https://archive.stsci.edu/cgi-bin/dss_search?faste&ra={}&dec={}&equinox=J2000&epoch=2000&width={}&height={}&nimages=1&format=jpeg",
ra, dec, size_deg, size_deg
);
info!(
"[Cutout] DSS: ra={:.4}, dec={:.4}, size={}\"",
ra, dec, size_arcsec
);
let resp = reqwest::get(&url)
.await
.map_err(|e| anyhow!("DSS cutout 请求失败: {}", e))?;
if !resp.status().is_success() {
return Err(anyhow!("DSS cutout 失败: {}", resp.status()));
}
let bytes = resp.bytes().await?.to_vec();
let pixel_size = size_arcsec / 1.0; // ~1 arcsec/pixel for DSS
let width = (size_arcsec / pixel_size) as u32;
Ok(CutoutResult {
image_bytes: bytes,
format: "jpeg".to_string(),
ra,
dec,
size_arcsec,
source: "DSS".to_string(),
width,
height: width,
})
}
/// Pan-STARRS cutoutvia VizieR / PS1 cutout service
pub async fn panstarrs_cutout(ra: f64, dec: f64, size_arcsec: f64) -> Result<CutoutResult> {
let size_deg = size_arcsec / 3600.0;
// PS1 cutout service
let url = format!(
"https://ps1images.stsci.edu/cgi-bin/ps1cutoutservice?ra={}&dec={}&size={}&format=fits&filters=g,r,i,z,y",
ra, dec, size_deg
);
info!(
"[Cutout] Pan-STARRS: ra={:.4}, dec={:.4}, size={}\"",
ra, dec, size_arcsec
);
let resp = reqwest::get(&url)
.await
.map_err(|e| anyhow!("PS1 cutout 请求失败: {}", e))?;
if !resp.status().is_success() {
return Err(anyhow!("PS1 cutout 失败: {}", resp.status()));
}
let bytes = resp.bytes().await?.to_vec();
Ok(CutoutResult {
image_bytes: bytes,
format: "fits".to_string(),
ra,
dec,
size_arcsec,
source: "Pan-STARRS".to_string(),
width: 0,
height: 0,
})
}
/// 统一 cutout 接口
pub async fn get_cutout(
source: CutoutSource,
ra: f64,
dec: f64,
size_arcsec: f64,
) -> Result<CutoutResult> {
match source {
CutoutSource::Sdss => sdss_cutout(ra, dec, size_arcsec).await,
CutoutSource::Dss => dss_cutout(ra, dec, size_arcsec).await,
CutoutSource::Panstarrs => panstarrs_cutout(ra, dec, size_arcsec).await,
}
}
/// Finding Chart 生成(基于 cutout + 标注)
pub async fn generate_finding_chart(
ra: f64,
dec: f64,
size_arcsec: f64,
source: CutoutSource,
_label: Option<&str>,
) -> Result<CutoutResult> {
let result = get_cutout(source, ra, dec, size_arcsec).await?;
// 保存到文件
let ext = match result.format.as_str() {
"jpeg" => "jpg",
"fits" => "fits",
_ => "png",
};
let file_name = format!("finding_chart_{:.4}_{:.4}.{}", ra, dec, ext);
let file_path = format!("finding_charts/{}", file_name);
let library_dir = std::env::var("LIBRARY_DIR").unwrap_or_else(|_| "library".to_string());
let full_path = std::path::Path::new(&library_dir).join(&file_path);
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
tokio::fs::write(&full_path, &result.image_bytes).await.ok();
Ok(result)
}
// ═══════════════════════════════════════════════════════════════
// CutoutFetcher —— ObservationFetcher trait 实现
// ═══════════════════════════════════════════════════════════════
use crate::api::AppState;
use crate::services::observation::cache::{
cached_files_total_size, fetch_observation_cache, log_write_failure, persist_bytes,
write_observation_cache,
};
use crate::services::observation::fetcher::{Candidate, ObservationFetcher};
use crate::services::observation::types::{
Artifact, ObservationProduct, ProductSpec, ProductType, Source,
};
use tracing::warn;
/// 图像 Cutout fetcherSDSS/DSS/Pan-STARRS
///
/// 这不是一个标准的 ObservationFetchercutout 不通过 TAP/VO 检索),
/// 而是通过 HTTP API 直接获取图像 cutout。
/// 注册为 (Source::Panstarrs, ProductType::Image) 的 fetcher
/// 坐标模式直接构造 cutout URL。
#[derive(Debug)]
pub struct CutoutFetcher;
#[async_trait::async_trait]
impl ObservationFetcher for CutoutFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Panstarrs, ProductType::Image)
}
fn subtypes(&self) -> &'static [&'static str] {
&["sdss", "dss", "panstarrs"]
}
fn suggested_max_radius_deg(&self) -> f64 {
0.0
}
fn hard_max_radius_deg(&self) -> f64 {
0.0
}
fn supports_coordinates(&self) -> bool {
true
}
fn supports_identifiers(&self) -> bool {
false
}
async fn cone_search_raw(
&self,
_state: &AppState,
ra: f64,
dec: f64,
_radius_deg: f64,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
) -> Result<Vec<Candidate>> {
// Cutout 不做 cone search,直接返回目标本身
Ok(vec![Candidate {
source: Source::Panstarrs,
source_id: format!("cutout_{:.4}_{:.4}", ra, dec),
label: format!("Cutout at ({:.4}, {:.4})", ra, dec),
ra: Some(ra),
dec: Some(dec),
distance: Some(0.0),
raw: None,
}])
}
async fn resolve_identifier(
&self,
_identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
) -> Result<Candidate> {
Err(anyhow!("Cutout 不支持标识符模式"))
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
_release: Option<&str>,
subtype: Option<&str>,
_version: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let ra = candidate.ra.unwrap_or(0.0);
let dec = candidate.dec.unwrap_or(0.0);
let sub = subtype.unwrap_or("sdss");
let product = ProductSpec::with_subtype(ProductType::Image, sub);
let cache_key = format!("cutout_{:.4}_{:.4}_{}", ra, dec, sub);
let source_label = candidate.label.clone();
// 1) 检查缓存
if !force {
if let Some((artifacts, meta)) =
fetch_observation_cache(&state.db, Source::Panstarrs, &product, &cache_key).await?
{
if cached_files_total_size(&state.config.storage.library_dir, &artifacts)
.await
.is_some()
{
info!("[Cutout] 缓存命中 (key={})", cache_key);
return Ok(ObservationProduct {
source: Source::Panstarrs,
product,
source_id: cache_key,
source_label,
artifacts,
source_meta: meta,
});
}
warn!("[Cutout] 缓存文件缺失,重新下载 (key={})", cache_key);
}
}
// 2) 真实获取
let source = match sub {
"dss" => CutoutSource::Dss,
"panstarrs" => CutoutSource::Panstarrs,
_ => CutoutSource::Sdss,
};
let result = get_cutout(source, ra, dec, 120.0).await?; // 2 arcmin default
let ext = match result.format.as_str() {
"jpeg" => "jpg",
"fits" => "fits",
_ => "png",
};
let file_name = format!("cutout_{:.4}_{:.4}.{}", ra, dec, ext);
let file_path = format!("Telescope/cutout/{}/{}", candidate.source_id, file_name);
persist_bytes(
&state.config.storage.library_dir,
&file_path,
&result.image_bytes,
)
.await?;
let artifacts = vec![Artifact {
band: None,
original_name: Some(file_name),
file_path: file_path.clone(),
file_url: crate::services::observation::cache::file_url_from_path(&file_path),
file_format: ext.to_string(),
size_bytes: result.image_bytes.len(),
cached: false,
}];
// 3) 写入缓存
if let Err(e) = write_observation_cache(
&state.db,
Source::Panstarrs,
&product,
&cache_key,
Some(ra),
Some(dec),
&artifacts,
None,
)
.await
{
log_write_failure("panstarrs", "image", e);
}
Ok(ObservationProduct {
source: Source::Panstarrs,
product,
source_id: cache_key,
source_label,
artifacts,
source_meta: candidate.raw.clone(),
})
}
}