数据分析层(新增 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 科研功能路线图及实现状态
379 lines
11 KiB
Rust
379 lines
11 KiB
Rust
// src/services/observation/cutout.rs
|
||
//
|
||
// 图像 Cutout 服务 —— SDSS / DSS / Pan-STARRS cutout API
|
||
//
|
||
// 支持:
|
||
// - SDSS SkyServer cutout(光学 2.5m)
|
||
// - STScI DSS(Digitized 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 cutout(SkyServer)
|
||
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 cutout(STScI)
|
||
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 cutout(via 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 fetcher(SDSS/DSS/Pan-STARRS)
|
||
///
|
||
/// 这不是一个标准的 ObservationFetcher(cutout 不通过 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(),
|
||
})
|
||
}
|
||
}
|