Files
AstroResearch/src/services/observation/cutout.rs
T
fmq d6b064a490 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 科研功能路线图及实现状态
2026-09-07 21:50:32 +08:00

379 lines
11 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/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(),
})
}
}