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 联动统一检索状态与下载链路
This commit is contained in:
Asfmq 2026-07-09 00:20:29 +08:00
parent 2c8d0b8f8b
commit 8f1ed6d08c
40 changed files with 6235 additions and 60 deletions

31
Cargo.lock generated
View File

@ -140,6 +140,8 @@ dependencies = [
"clap",
"dashmap",
"dotenvy",
"fitsio",
"fitsio-sys",
"flate2",
"futures-util",
"git2",
@ -243,6 +245,15 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "autotools"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf"
dependencies = [
"cc",
]
[[package]]
name = "axum"
version = "0.7.9"
@ -1207,6 +1218,26 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fitsio"
version = "0.21.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b2a7b7eee7f284a95437774d1152e04706f690d549687004ab7ae73cc776d08"
dependencies = [
"fitsio-sys",
"libc",
]
[[package]]
name = "fitsio-sys"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14929d0b6fa44a87b374a138ea2256c02c1b5beae1dbc98f62a3e132f862fed1"
dependencies = [
"autotools",
"pkg-config",
]
[[package]]
name = "flate2"
version = "1.1.9"

View File

@ -63,6 +63,8 @@ walkdir = "2"
lru = "0.12"
git2 = { version = "0.18", default-features = false, features = ["vendored-libgit2"] }
dashmap = "6"
fitsio = { version = "0.21", features = ["fitsio-src"] } # FITS 解析fitsio-src 自带 CFITSIO 源码编译,无需系统装 cfitsio
fitsio-sys = { version = "0.5", features = ["fitsio-src"] } # 直接访问 CFITSIO C API读 Gaia XP 变长数组列 PD(55)fitsio crate 不支持)
[features]
default = []

View File

@ -683,6 +683,14 @@ export default function App() {
downloadSelected={observation.downloadSelected}
downloadByIds={observation.downloadByIds}
downloadByCoordinates={observation.downloadByCoordinates}
unifiedResult={observation.unifiedResult}
unifiedSearching={observation.unifiedSearching}
unifiedError={observation.unifiedError}
runUnifiedSearch={observation.runUnifiedSearch}
resolveNames={observation.resolveNames}
unifiedSelected={observation.unifiedSelected}
toggleUnifiedSelect={observation.toggleUnifiedSelect}
downloadUnifiedSelected={observation.downloadUnifiedSelected}
libraryItems={observation.libraryItems}
libraryTotal={observation.libraryTotal}
libraryLoading={observation.libraryLoading}

View File

@ -0,0 +1,61 @@
// dashboard/src/components/observation/ObservationPreviewRenderer.tsx
//
// 预览渲染分发器 —— 按 ObservationPreview.kind 路由到对应绘图组件
//
// 扩展新产品类型的可视化时:
// 1. 实现 XxxPlot 组件(如 LightCurvePlot.tsx
// 2. 在此 switch 加 case从联合类型窄化出对应变体
// 3. 在 constants.ts 的 canPreview() 放开该 product
// 现有 Spectrum 渲染无需改动OCP对扩展开放对修改关闭
import type { ObservationPreview } from './constants';
import { SpectrumPlot } from './SpectrumPlot';
interface ObservationPreviewRendererProps {
preview: ObservationPreview;
}
export function ObservationPreviewRenderer({
preview,
}: ObservationPreviewRendererProps) {
switch (preview.kind) {
case 'spectrum':
// 窄化preview 此处为 { kind: 'spectrum' } & SpectrumPreview
return <SpectrumPlot segments={preview.segments} />;
case 'lightcurve':
// TODO: 实现 LightCurvePlot 后替换占位
return (
<div className="text-[10px] text-slate-400 py-4 text-center">
线
</div>
);
case 'photometry':
// TODO: 实现 PhotometryTable 后替换占位
return (
<div className="text-[10px] text-slate-400 py-4 text-center">
</div>
);
case 'image':
// image 变体已有 preview_data_url直接渲染
return (
<img
src={preview.preview_data_url}
alt="cutout"
className="w-full rounded border border-slate-200"
style={{ maxHeight: 200, objectFit: 'contain' }}
/>
);
default:
// 穷尽性检查:未来新增 kind 会在编译期报错
return (
<div className="text-[10px] text-slate-400 py-4 text-center">
</div>
);
}
}

View File

@ -7,13 +7,24 @@
// 2. 可选SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
//
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
import { Activity, CheckCircle2, AlertTriangle, Download } from 'lucide-react';
import { useState } from 'react';
import {
Activity,
CheckCircle2,
AlertTriangle,
Download,
LineChart,
Loader2,
} from 'lucide-react';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
canPreview,
type ObservationBatchResult,
} from './constants';
import { ObservationPreviewRenderer } from './ObservationPreviewRenderer';
import { useObservationPreview } from './useObservationPreview';
interface ObservationResultCardProps {
result: ObservationBatchResult;
@ -34,6 +45,10 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
const productLabel = PRODUCT_LABEL[product.product] ?? product.product;
const hasResult = products.length > 0 || failures.length > 0;
// 预览:自包含 hook面板流 + Agent 流共用(两者都渲染本卡片)
const { fetchPreview, getPreview } = useObservationPreview();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
return (
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏:数据源 + 产品类型双标签 */}
@ -103,30 +118,84 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
)}
</div>
{/* 多 artifact 展示Gaia 光变 G/BP/RP 三波段) */}
{p.artifacts.map((a, ai) => (
<div
key={ai}
className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100"
>
<span className="text-slate-500 flex items-center gap-1">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium">
{a.band}
{p.artifacts.map((a, ai) => {
const previewKey = `${p.source_id}#${ai}`;
const previewable = canPreview(product.product, a.file_format);
const isOpen = expanded[previewKey] ?? false;
const previewState = getPreview(p.source_id, ai);
const togglePreview = () => {
const next = !isOpen;
setExpanded((m) => ({ ...m, [previewKey]: next }));
if (next && !previewState.data && !previewState.loading) {
fetchPreview(
source,
product.product,
product.subtype,
p.source_id,
ai
);
}
};
return (
<div key={ai} className="space-y-1">
<div className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100">
<span className="text-slate-500 flex items-center gap-1">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium">
{a.band}
</span>
)}
{a.file_format.toUpperCase()} ·{' '}
{formatFileSize(a.size_bytes)}
</span>
<span className="flex items-center gap-1">
{previewable && (
<button
onClick={togglePreview}
className="flex items-center gap-0.5 font-medium text-slate-500 hover:text-slate-800 transition-colors"
title="预览"
>
{previewState.loading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LineChart className="w-3 h-3" />
)}
<span></span>
</button>
)}
<a
href={a.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>{a.file_format.toUpperCase()}</span>
</a>
</span>
</div>
{/* 预览图(按 product 类型分发渲染) */}
{previewable && isOpen && (
<div className="pl-2">
{previewState.error ? (
<p className="text-[10px] text-rose-500 py-1">
{previewState.error}
</p>
) : previewState.data ? (
<ObservationPreviewRenderer
preview={previewState.data}
/>
) : previewState.loading ? (
<div className="flex items-center gap-1 text-[10px] text-slate-400 py-4 justify-center">
<Loader2 className="w-3 h-3 animate-spin" />
FITS ...
</div>
) : null}
</div>
)}
{a.file_format.toUpperCase()} · {formatFileSize(a.size_bytes)}
</span>
<a
href={a.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>{a.file_format.toUpperCase()}</span>
</a>
</div>
))}
</div>
);
})}
</div>
);
})}

View File

@ -0,0 +1,308 @@
// dashboard/src/components/observation/SpectrumPlot.tsx
//
// 光谱预览 SVG 折线图 —— 手写内联 SVG不引入第三方绘图库
// (对齐项目 CitationGalaxyCanvas 手绘 canvas 先例,保持零额外依赖)
//
// 功能:
// - 多段叠加Gaia BP/RP、DESI B/R/Z 各色)
// - hover 显示像素值(波长/流量)
// - 自适应宽度(响应式 viewBox
// - 简洁坐标轴 + 网格线
import { useState, useMemo } from 'react';
import type { SpectrumSegment } from './constants';
interface SpectrumPlotProps {
segments: SpectrumSegment[];
height?: number;
}
// 数据源 → 段配色(与 SOURCE_THEME 协调的线条色)
const SEGMENT_COLORS: Record<string, string> = {
combined: '#64748b', // slate-500
xp_merged: '#0ea5e9', // sky-500
BP: '#0284c7', // sky-600
RP: '#0369a1', // sky-700
B: '#6366f1', // indigo-500
R: '#8b5cf6', // violet-500
Z: '#a855f7', // purple-500
apstar_combined: '#6366f1',
};
const DEFAULT_COLOR = '#64748b';
interface PlotPoint {
wl: number;
flux: number;
segIdx: number;
}
export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
const [hover, setHover] = useState<PlotPoint | null>(null);
// 合并所有段的数据点,计算全局范围
const { points, wlMin, wlMax, fluxMin, fluxMax } = useMemo(() => {
const pts: PlotPoint[] = [];
let wMin = Infinity;
let wMax = -Infinity;
let fMin = Infinity;
let fMax = -Infinity;
segments.forEach((seg, si) => {
const n = Math.min(seg.wavelength.length, seg.flux.length);
for (let i = 0; i < n; i++) {
const wl = seg.wavelength[i];
const fl = seg.flux[i];
if (!isFinite(wl) || !isFinite(fl)) continue;
pts.push({ wl, flux: fl, segIdx: si });
if (wl < wMin) wMin = wl;
if (wl > wMax) wMax = wl;
if (fl < fMin) fMin = fl;
if (fl > fMax) fMax = fl;
}
});
// flux 范围留 5% padding避免曲线贴顶/底
const fPad = (fMax - fMin) * 0.05 || Math.abs(fMax) * 0.1 || 1;
return {
points: pts,
wlMin: wMin,
wlMax: wMax,
fluxMin: fMin - fPad,
fluxMax: fMax + fPad,
};
}, [segments]);
if (points.length === 0) {
return <div className="text-xs text-slate-400 p-2"></div>;
}
const width = 600;
const padL = 50;
const padR = 12;
const padT = 8;
const padB = 24;
const plotW = width - padL - padR;
const plotH = height - padT - padB;
const xScale = (wl: number) =>
padL + ((wl - wlMin) / (wlMax - wlMin || 1)) * plotW;
const yScale = (fl: number) =>
padT + (1 - (fl - fluxMin) / (fluxMax - fluxMin || 1)) * plotH;
// 每段生成 path
const paths = segments.map((seg) => {
const n = Math.min(seg.wavelength.length, seg.flux.length);
let d = '';
for (let i = 0; i < n; i++) {
const wl = seg.wavelength[i];
const fl = seg.flux[i];
if (!isFinite(wl) || !isFinite(fl)) continue;
d += `${i === 0 ? 'M' : 'L'}${xScale(wl).toFixed(1)},${yScale(fl).toFixed(1)} `;
}
return {
d,
color: SEGMENT_COLORS[seg.band] ?? DEFAULT_COLOR,
band: seg.band,
};
});
// 坐标轴刻度
const xTicks = 5;
const yTicks = 4;
const xTickVals = Array.from(
{ length: xTicks },
(_, i) => wlMin + ((wlMax - wlMin) * i) / (xTicks - 1)
);
const yTickVals = Array.from(
{ length: yTicks },
(_, i) => fluxMin + ((fluxMax - fluxMin) * i) / (yTicks - 1)
);
const fmtWl = (v: number) => {
if (v >= 1000) return `${(v / 1000).toFixed(2)}k`;
return v.toFixed(0);
};
const fmtFlux = (v: number) => {
const abs = Math.abs(v);
if (abs !== 0 && (abs < 0.01 || abs >= 1e4)) return v.toExponential(1);
return v.toFixed(2);
};
return (
<div className="relative w-full bg-slate-50/80 border border-slate-200 rounded p-1">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full"
style={{ height: `${height}px` }}
preserveAspectRatio="none"
onMouseLeave={() => setHover(null)}
>
{/* 网格线 */}
{yTickVals.map((v, i) => (
<line
key={`yg${i}`}
x1={padL}
x2={width - padR}
y1={yScale(v)}
y2={yScale(v)}
stroke="#e2e8f0"
strokeWidth="0.5"
/>
))}
{xTickVals.map((v, i) => (
<line
key={`xg${i}`}
x1={xScale(v)}
x2={xScale(v)}
y1={padT}
y2={height - padB}
stroke="#e2e8f0"
strokeWidth="0.5"
/>
))}
{/* 光谱曲线 */}
{paths.map((p, i) => (
<path
key={`path${i}`}
d={p.d}
fill="none"
stroke={p.color}
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
))}
{/* X 轴刻度标签 */}
{xTickVals.map((v, i) => (
<text
key={`xt${i}`}
x={xScale(v)}
y={height - padB + 12}
textAnchor="middle"
fontSize="9"
fill="#64748b"
>
{fmtWl(v)}
</text>
))}
{/* Y 轴刻度标签 */}
{yTickVals.map((v, i) => (
<text
key={`yt${i}`}
x={padL - 4}
y={yScale(v) + 3}
textAnchor="end"
fontSize="9"
fill="#64748b"
>
{fmtFlux(v)}
</text>
))}
{/* 轴标题 */}
<text
x={width - padR}
y={height - 4}
textAnchor="end"
fontSize="8"
fill="#94a3b8"
>
λ ({segments[0]?.wavelength_unit ?? 'Å'})
</text>
<text
x={padL}
y={padT + 4}
textAnchor="start"
fontSize="8"
fill="#94a3b8"
>
flux ({segments[0]?.flux_unit ?? ''})
</text>
{/* hover 指示 */}
{hover && (
<>
<line
x1={xScale(hover.wl)}
x2={xScale(hover.wl)}
y1={padT}
y2={height - padB}
stroke="#94a3b8"
strokeWidth="0.5"
strokeDasharray="2,2"
/>
<circle
cx={xScale(hover.wl)}
cy={yScale(hover.flux)}
r="2"
fill={
SEGMENT_COLORS[segments[hover.segIdx]?.band ?? ''] ??
DEFAULT_COLOR
}
/>
</>
)}
{/* 透明捕获层hover 找最近点 */}
<rect
x={padL}
y={padT}
width={plotW}
height={plotH}
fill="transparent"
onMouseMove={(e) => {
const rect = (e.target as SVGRectElement).getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * plotW;
// 反推波长
const wl = wlMin + (px / plotW) * (wlMax - wlMin);
// 找最近点
let nearest = points[0];
let minDist = Infinity;
for (const p of points) {
const d = Math.abs(p.wl - wl);
if (d < minDist) {
minDist = d;
nearest = p;
}
}
setHover(nearest);
}}
/>
</svg>
{/* hover tooltip */}
{hover && (
<div
className="absolute pointer-events-none bg-slate-800 text-white text-[10px] px-1.5 py-0.5 rounded shadow-lg"
style={{
left: `${((xScale(hover.wl) - padL) / plotW) * 100}%`,
top: `${((yScale(hover.flux) - padT) / plotH) * 100}%`,
transform: 'translate(-50%, -120%)',
}}
>
λ={hover.wl.toFixed(1)} · f={fmtFlux(hover.flux)}
</div>
)}
{/* 多段图例 */}
{segments.length > 1 && (
<div className="flex flex-wrap gap-2 px-2 pb-1">
{segments.map((seg, i) => (
<span
key={`leg${i}`}
className="flex items-center gap-1 text-[9px] text-slate-600"
>
<span
className="inline-block w-2 h-0.5"
style={{
backgroundColor: SEGMENT_COLORS[seg.band] ?? DEFAULT_COLOR,
}}
/>
{seg.band}
</span>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,793 @@
// dashboard/src/components/observation/UnifiedSearchPanel.tsx
//
// 多目标统一全源检索视图 —— 跨多个目标 × 多个 (source, product) 并发 cone 检索
//
// 两阶段流程:
// ① 统一检索:三模式输入目标(坐标列表 / 天体名称列表 / CSV 导入)+ 源 chip 筛选
// → POST /api/observation/unified/search → 按 (source, product) 分组展示候选源
// ② 批量下载:勾选候选源 → 逐 (source, product) 组调用现有 POST /observation/download
//
// 与 ObservationPanel.SearchView 的区别:
// - SearchView单目标 + 单源 + 单产品(精细化参数)
// - UnifiedSearchPanel多目标 + 多源(广覆盖发现)
import { useState, useMemo, useCallback } from 'react';
import {
Globe,
Search,
Upload,
CheckCircle2,
AlertTriangle,
Download,
Loader2,
MapPin,
ListPlus,
} from 'lucide-react';
import { ObservationResultCard } from './ObservationResultCard';
import {
SOURCE_THEME,
PRODUCT_LABEL,
type UnifiedSearchRequest,
type UnifiedSearchResult,
type SourceCandidateGroup,
type TargetRequest,
type ResolvedTarget,
type ProductSpec,
type ObservationBatchResult,
} from './constants';
import type { CapabilitySpec } from '../../types';
// ── 输入模式 ──
type InputMode = 'coordinates' | 'names' | 'csv';
interface UnifiedSearchPanelProps {
capabilities: CapabilitySpec[];
// 统一检索状态(来自 useObservation
unifiedResult: UnifiedSearchResult | null;
unifiedSearching: boolean;
unifiedError: string | null;
runUnifiedSearch: (req: UnifiedSearchRequest) => Promise<void>;
resolveNames: (names: string[]) => Promise<ResolvedTarget[]>;
unifiedSelected: Set<string>;
toggleUnifiedSelect: (key: string) => void;
downloadUnifiedSelected: () => Promise<ObservationBatchResult[]>;
downloading: boolean;
}
export function UnifiedSearchPanel({
capabilities,
unifiedResult,
unifiedSearching,
unifiedError,
runUnifiedSearch,
resolveNames,
unifiedSelected,
toggleUnifiedSelect,
downloadUnifiedSelected,
downloading,
}: UnifiedSearchPanelProps) {
// ── 输入模式 ──
const [inputMode, setInputMode] = useState<InputMode>('coordinates');
// ── 坐标列表文本(每行 ra,dec[,radius] 或 ra dec [radius])──
const [coordsText, setCoordsText] = useState('');
// ── 天体名称列表文本 ──
const [namesText, setNamesText] = useState('');
const [resolving, setResolving] = useState(false);
const [resolved, setResolved] = useState<ResolvedTarget[]>([]);
// ── CSV 导入 ──
// ── 公共参数 ──
const [release, setRelease] = useState('');
const [defaultRadius, setDefaultRadius] = useState('0.1');
const [perTargetLimit, setPerTargetLimit] = useState('50');
// ── 源筛选:勾选的 (source, product) 组合 key ──
// 默认全选key = `${source}|${product}|${subtype ?? ''}`
const allKeys = useMemo(
() => capabilities.map((c) => makeSourceKey(c.source, c.product)),
[capabilities]
);
const [selectedSources, setSelectedSources] = useState<Set<string>>(
new Set(allKeys)
);
// 同步capabilities 加载后默认全选
const effectiveSelected = useMemo(() => {
if (selectedSources.size === 0 && allKeys.length > 0)
return new Set(allKeys);
return selectedSources;
}, [selectedSources, allKeys]);
// 解析目标列表(坐标模式 / 名称模式已解析 / CSV
const targets: TargetRequest[] = useMemo(() => {
if (inputMode === 'names') {
// 名称模式:用 resolved 列表中成功的项
return resolved
.filter((r) => r.ra != null && r.dec != null)
.map((r) => ({
ra: r.ra!,
dec: r.dec!,
radius_deg: parseFloat(defaultRadius) || 0.1,
label: r.name,
}));
}
// coordinates / csv 模式:从文本解析
return parseCoordsText(coordsText, parseFloat(defaultRadius) || 0.1);
}, [inputMode, resolved, coordsText, defaultRadius]);
// ── 执行检索 ──
const handleSearch = useCallback(async () => {
if (targets.length === 0) return;
// 收集勾选的 (source, ProductSpec)
const selectedCaps = capabilities.filter((c) =>
effectiveSelected.has(makeSourceKey(c.source, c.product))
);
// 按 (source, product) 合并 subtypelist_all_keys 后端会展开,前端用 product 级粒度)
const sources: [string, ProductSpec][] | undefined =
selectedCaps.length === allKeys.length
? undefined // 全选 = 不传 sources后端用默认全源
: selectedCaps.map((c) => [c.source, { product: c.product }]);
await runUnifiedSearch({
targets,
sources,
release: release || undefined,
per_target_limit: parseInt(perTargetLimit) || 50,
});
}, [
targets,
capabilities,
effectiveSelected,
allKeys.length,
release,
perTargetLimit,
runUnifiedSearch,
]);
// ── 名称解析 ──
const handleResolveNames = useCallback(async () => {
const names = namesText
.split(/[\s,;\n]+/)
.map((s) => s.trim())
.filter(Boolean);
if (names.length === 0) return;
setResolving(true);
try {
const results = await resolveNames(names);
setResolved(results);
} finally {
setResolving(false);
}
}, [namesText, resolveNames]);
// ── CSV 导入(前端纯解析,列名含 ra/dec/name/radius──
const handleCsvUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result ?? '');
const parsed = parseCsvTargets(text, parseFloat(defaultRadius) || 0.1);
// 把解析结果转成 coordsText 格式(统一走坐标文本流)
const lines = parsed.map((t) =>
[t.ra.toString(), t.dec.toString(), t.label].filter(Boolean).join(',')
);
setCoordsText(lines.join('\n'));
setInputMode('coordinates');
};
reader.readAsText(file);
e.target.value = ''; // 允许重复选同文件
},
[defaultRadius]
);
// ── 源筛选控制 ──
const toggleSource = useCallback((key: string) => {
setSelectedSources((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const selectAllSources = useCallback(
() => setSelectedSources(new Set(allKeys)),
[allKeys]
);
const clearSources = useCallback(() => setSelectedSources(new Set()), []);
// 按产品分组的 capabilitieschip 展示)
const capsByProduct = useMemo(() => {
const map = new Map<string, CapabilitySpec[]>();
for (const c of capabilities) {
if (!map.has(c.product)) map.set(c.product, []);
map.get(c.product)!.push(c);
}
return map;
}, [capabilities]);
return (
<div className="space-y-5">
{/* ── 输入模式切换 ── */}
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200 w-fit">
<ModeTab
active={inputMode === 'coordinates'}
onClick={() => setInputMode('coordinates')}
icon={<MapPin className="w-3.5 h-3.5" />}
label="坐标列表"
/>
<ModeTab
active={inputMode === 'names'}
onClick={() => setInputMode('names')}
icon={<ListPlus className="w-3.5 h-3.5" />}
label="天体名称"
/>
<ModeTab
active={inputMode === 'csv'}
onClick={() => setInputMode('csv')}
icon={<Upload className="w-3.5 h-3.5" />}
label="CSV 导入"
/>
</div>
{/* ── 目标输入区 ── */}
<div className="bg-white border border-slate-200 rounded-lg p-4 space-y-3">
{inputMode === 'coordinates' && (
<>
<label className="text-xs font-semibold text-slate-700">
ra,dec ra,dec,radius/
</label>
<textarea
value={coordsText}
onChange={(e) => setCoordsText(e.target.value)}
placeholder={'10.6847,41.2687,M31\n40.0,-5.0'}
rows={5}
className="w-full text-xs font-mono p-2 border border-slate-200 rounded resize-y focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<p className="text-[11px] text-slate-500">
{targets.length}
</p>
</>
)}
{inputMode === 'names' && (
<>
<label className="text-xs font-semibold text-slate-700">
M31 / NGC 1068 / GD 358
</label>
<textarea
value={namesText}
onChange={(e) => setNamesText(e.target.value)}
placeholder={'M31\nNGC 1068\nGD 358'}
rows={5}
className="w-full text-xs font-mono p-2 border border-slate-200 rounded resize-y focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<div className="flex items-center gap-2">
<button
onClick={handleResolveNames}
disabled={resolving || !namesText.trim()}
className="px-3 py-1.5 bg-slate-800 text-white text-xs font-semibold rounded hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{resolving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Search className="w-3.5 h-3.5" />
)}
</button>
<span className="text-[11px] text-slate-500">
CDS SESAME
</span>
</div>
{resolved.length > 0 && (
<div className="mt-2 border border-slate-200 rounded overflow-hidden">
<table className="w-full text-[11px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left p-1.5"></th>
<th className="text-left p-1.5">RA</th>
<th className="text-left p-1.5">Dec</th>
<th className="text-left p-1.5"></th>
<th className="text-left p-1.5">V</th>
<th className="text-left p-1.5"></th>
</tr>
</thead>
<tbody>
{resolved.map((r) => (
<tr key={r.name} className="border-t border-slate-100">
<td className="p-1.5 font-mono">{r.name}</td>
<td className="p-1.5 font-mono">
{r.ra?.toFixed(4) ?? '—'}
</td>
<td className="p-1.5 font-mono">
{r.dec?.toFixed(4) ?? '—'}
</td>
<td className="p-1.5">{r.spectral_type ?? '—'}</td>
<td className="p-1.5">
{r.v_magnitude?.toFixed(2) ?? '—'}
</td>
<td className="p-1.5">
{r.error ? (
<span className="text-rose-600">{r.error}</span>
) : (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-[11px] text-slate-500 p-1.5 bg-slate-50">
{resolved.filter((r) => !r.error).length} /{' '}
{resolved.length}
</p>
</div>
)}
</>
)}
{inputMode === 'csv' && (
<>
<label className="text-xs font-semibold text-slate-700">
CSV ra/dec name/radius
</label>
<input
type="file"
accept=".csv,.tsv,.txt"
onChange={handleCsvUpload}
className="text-xs file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-slate-800 file:text-white hover:file:bg-slate-700"
/>
<p className="text-[11px] text-slate-500">
VOTable CSV
</p>
</>
)}
</div>
{/* ── 公共参数 ── */}
<div className="grid grid-cols-3 gap-3">
<Field label="默认检索半径 (°)">
<input
type="number"
step="0.01"
min="0.0001"
max="30"
value={defaultRadius}
onChange={(e) => setDefaultRadius(e.target.value)}
className="w-full text-xs p-1.5 border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</Field>
<Field label="发布版本 (可选)">
<input
type="text"
placeholder="留空=默认"
value={release}
onChange={(e) => setRelease(e.target.value)}
className="w-full text-xs p-1.5 border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</Field>
<Field label="单源命中上限">
<input
type="number"
min="1"
max="500"
value={perTargetLimit}
onChange={(e) => setPerTargetLimit(e.target.value)}
className="w-full text-xs p-1.5 border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</Field>
</div>
{/* ── 源筛选 chip 组 ── */}
<div className="bg-white border border-slate-200 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-700">
{effectiveSelected.size}/{allKeys.length}
</label>
<div className="flex items-center gap-2">
<button
onClick={selectAllSources}
className="text-[11px] text-slate-600 hover:text-slate-900 underline"
>
</button>
<button
onClick={clearSources}
className="text-[11px] text-slate-600 hover:text-slate-900 underline"
>
</button>
</div>
</div>
<div className="space-y-2">
{Array.from(capsByProduct.entries()).map(([product, caps]) => (
<div key={product} className="flex items-start gap-2 flex-wrap">
<span className="text-[11px] font-bold text-slate-500 w-14 pt-1 shrink-0">
{PRODUCT_LABEL[product] ?? product}
</span>
{caps.map((c) => {
const key = makeSourceKey(c.source, c.product);
const checked = effectiveSelected.has(key);
const theme = SOURCE_THEME[c.source];
return (
<label
key={key}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border cursor-pointer text-[11px] font-medium transition-colors ${
checked
? `${theme?.badge ?? 'bg-slate-100 text-slate-700'} border-transparent`
: 'bg-slate-50 text-slate-400 border-slate-200'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSource(key)}
className="w-3 h-3"
/>
{theme?.label ?? c.source}
{c.subtypes.length > 0 && (
<span className="opacity-60">
({c.subtypes.join('/')})
</span>
)}
</label>
);
})}
</div>
))}
</div>
</div>
{/* ── 执行检索 ── */}
<div className="flex items-center gap-3">
<button
onClick={handleSearch}
disabled={unifiedSearching || targets.length === 0}
className="px-4 py-2 bg-slate-900 text-white text-xs font-bold rounded-lg hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{unifiedSearching ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Globe className="w-4 h-4" />
)}
{targets.length > 0 && `(${targets.length} 目标)`}
</button>
{unifiedResult && (
<span className="text-xs text-slate-600">
{unifiedResult.groups.length}
{unifiedResult.total_candidates}
</span>
)}
</div>
{/* ── 错误 ── */}
{unifiedError && (
<div className="flex items-center gap-2 p-3 bg-rose-50 border border-rose-200 rounded-lg text-xs text-rose-700">
<AlertTriangle className="w-4 h-4 shrink-0" />
{unifiedError}
</div>
)}
{/* ── 名称解析失败提示 ── */}
{unifiedResult?.resolve_failures &&
unifiedResult.resolve_failures.length > 0 && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700 space-y-1">
{unifiedResult.resolve_failures.map((f, i) => (
<div key={i}>
<strong>{f.target_label}</strong>: {f.error}
</div>
))}
</div>
)}
{/* ── 结果区:按 (source, product) 分组的候选源表 ── */}
{unifiedResult && unifiedResult.groups.length > 0 && (
<UnifiedResultGroups
groups={unifiedResult.groups}
unifiedSelected={unifiedSelected}
toggleUnifiedSelect={toggleUnifiedSelect}
/>
)}
{/* ── 下载区 ── */}
{unifiedResult && unifiedResult.groups.length > 0 && (
<UnifiedDownloadBar
selectedCount={unifiedSelected.size}
onDownload={downloadUnifiedSelected}
downloading={downloading}
/>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 子组件:结果分组表
// ════════════════════════════════════════════════════════════
function UnifiedResultGroups({
groups,
unifiedSelected,
toggleUnifiedSelect,
}: {
groups: SourceCandidateGroup[];
unifiedSelected: Set<string>;
toggleUnifiedSelect: (key: string) => void;
}) {
return (
<div className="space-y-3">
{groups.map((g) => {
const theme = SOURCE_THEME[g.source] ?? SOURCE_THEME.lamost;
const productLabel =
PRODUCT_LABEL[g.product.product] ?? g.product.product;
const groupKeyPrefix = `${g.source}#${g.product.product}#${g.product.subtype ?? ''}`;
const groupSelected = g.candidates.filter((c) =>
unifiedSelected.has(`${groupKeyPrefix}#${c.source_id}`)
).length;
const allSelected =
groupSelected === g.candidates.length && g.candidates.length > 0;
return (
<div
key={groupKeyPrefix}
className="bg-white border border-slate-200 rounded-lg overflow-hidden"
>
{/* 分组头 */}
<div className="flex items-center justify-between px-3 py-2 bg-slate-50 border-b border-slate-200">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-bold ${theme.badge}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
{theme.label}
</span>
<span className="text-xs font-semibold text-slate-700">
{productLabel}
{g.product.subtype && (
<span className="text-slate-400 ml-1">
/{g.product.subtype}
</span>
)}
</span>
<span className="text-[11px] text-slate-500">
{g.candidates.length}
</span>
</div>
<button
onClick={() => {
// 全选/取消该组
const keys = g.candidates.map(
(c) => `${groupKeyPrefix}#${c.source_id}`
);
const allIn = keys.every((k) => unifiedSelected.has(k));
keys.forEach((k) => {
if (allIn === unifiedSelected.has(k))
toggleUnifiedSelect(k);
});
}}
className="text-[11px] text-slate-600 hover:text-slate-900 underline"
>
{allSelected ? '取消全选' : '全选该源'}
</button>
</div>
{/* 候选源表 */}
<table className="w-full text-[11px]">
<thead className="text-slate-500">
<tr>
<th className="text-left p-1.5 w-8"></th>
<th className="text-left p-1.5">source_id</th>
<th className="text-left p-1.5"></th>
<th className="text-left p-1.5">RA</th>
<th className="text-left p-1.5">Dec</th>
<th className="text-left p-1.5"></th>
</tr>
</thead>
<tbody>
{g.candidates.map((c) => {
const key = `${groupKeyPrefix}#${c.source_id}`;
const checked = unifiedSelected.has(key);
return (
<tr
key={c.source_id}
className="border-t border-slate-100 hover:bg-slate-50"
>
<td className="p-1.5">
<input
type="checkbox"
checked={checked}
onChange={() => toggleUnifiedSelect(key)}
className="w-3 h-3"
/>
</td>
<td className="p-1.5 font-mono text-slate-800">
{c.source_id}
</td>
<td className="p-1.5 text-slate-600 max-w-32 truncate">
{c.label || '—'}
</td>
<td className="p-1.5 font-mono text-slate-600">
{c.ra != null ? c.ra.toFixed(4) : '—'}
</td>
<td className="p-1.5 font-mono text-slate-600">
{c.dec != null ? c.dec.toFixed(4) : '—'}
</td>
<td className="p-1.5 font-mono text-slate-500">
{c.distance != null ? `${c.distance.toFixed(4)}°` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
})}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 子组件:下载条
// ════════════════════════════════════════════════════════════
function UnifiedDownloadBar({
selectedCount,
onDownload,
downloading,
}: {
selectedCount: number;
onDownload: () => Promise<ObservationBatchResult[]>;
downloading: boolean;
}) {
const [results, setResults] = useState<ObservationBatchResult[] | null>(null);
const handleDownload = useCallback(async () => {
const r = await onDownload();
setResults(r);
}, [onDownload]);
return (
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-slate-50 border border-slate-200 rounded-lg">
<button
onClick={handleDownload}
disabled={downloading || selectedCount === 0}
className="px-4 py-2 bg-emerald-600 text-white text-xs font-bold rounded-lg hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{downloading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
({selectedCount})
</button>
{results && results.length > 0 && (
<span className="text-xs text-slate-600">
{results.length}
</span>
)}
</div>
{/* 多组结果卡片横向展示 */}
{results && results.length > 0 && (
<div className="space-y-3">
{results.map((r, i) => (
<ObservationResultCard key={`${r.source}-${i}`} result={r} />
))}
</div>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 辅助组件与函数
// ════════════════════════════════════════════════════════════
function ModeTab({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
onClick={onClick}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
active
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{icon}
{label}
</button>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-[11px] font-semibold text-slate-600">
{label}
</label>
{children}
</div>
);
}
/** 构造源筛选 chip 的稳定 key */
function makeSourceKey(source: string, product: string): string {
return `${source}|${product}`;
}
/** 解析坐标列表文本(每行 ra,dec[,radius] 或 ra dec [radius]*/
function parseCoordsText(text: string, defaultRadius: number): TargetRequest[] {
const targets: TargetRequest[] = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
// 支持逗号或空白分隔
const parts = line.split(/[\s,]+/).filter(Boolean);
if (parts.length < 2) continue;
const ra = parseFloat(parts[0]);
const dec = parseFloat(parts[1]);
if (isNaN(ra) || isNaN(dec)) continue;
const radius = parts.length >= 3 ? parseFloat(parts[2]) : defaultRadius;
const label = parts.length >= 4 ? parts.slice(3).join(' ') : undefined;
targets.push({
ra,
dec,
radius_deg: isNaN(radius) ? defaultRadius : Math.max(0.0001, radius),
label,
});
}
return targets;
}
/** 解析 CSV 文件文本(首行表头,列名含 ra/dec可选 name/radius*/
function parseCsvTargets(text: string, defaultRadius: number): TargetRequest[] {
const lines = text
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) return [];
// 解析表头
const headers = lines[0].split(/[,;\t]/).map((h) => h.trim().toLowerCase());
const colIndex = (names: string[]): number => {
for (const n of names) {
const i = headers.indexOf(n);
if (i >= 0) return i;
}
return -1;
};
const raCol = colIndex(['ra', 'raj', 'ra_deg', 'ra_degrees']);
const decCol = colIndex(['dec', 'dej', 'dec_deg', 'dec_degrees', 'de']);
if (raCol < 0 || decCol < 0) return [];
const nameCol = colIndex(['name', 'object', 'obj', 'target', 'designation']);
const radiusCol = colIndex(['radius', 'radius_deg', 'r']);
const targets: TargetRequest[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(/[,;\t]/).map((c) => c.trim());
const ra = parseFloat(cells[raCol]);
const dec = parseFloat(cells[decCol]);
if (isNaN(ra) || isNaN(dec)) continue;
const radius =
radiusCol >= 0 ? parseFloat(cells[radiusCol]) : defaultRadius;
const label = nameCol >= 0 ? cells[nameCol] : undefined;
targets.push({
ra,
dec,
radius_deg: isNaN(radius) ? defaultRadius : Math.max(0.0001, radius),
label: label || undefined,
});
}
return targets;
}

View File

@ -3,6 +3,8 @@
// 观测数据共享常量与类型 —— 跨 Agent 工具卡片 / 观测数据面板 / 检索结果卡片复用
// 统一 (Source × ProductType) 双轴的视觉主题与中文标签映射
import type { Candidate } from '../../types';
// ── 数据源主题色source 轴)──
// 与 SOURCE_THEME 在 SpecialToolRenderers.tsx 的取色保持一致,统一视觉
export const SOURCE_THEME: Record<
@ -29,6 +31,31 @@ export const SOURCE_THEME: Record<
label: 'DESI',
dot: 'bg-emerald-500',
},
twomass: {
badge: 'bg-orange-100 text-orange-700',
label: '2MASS',
dot: 'bg-orange-500',
},
allwise: {
badge: 'bg-rose-100 text-rose-700',
label: 'AllWISE',
dot: 'bg-rose-500',
},
panstarrs: {
badge: 'bg-cyan-100 text-cyan-700',
label: 'Pan-STARRS',
dot: 'bg-cyan-500',
},
ztf: {
badge: 'bg-fuchsia-100 text-fuchsia-700',
label: 'ZTF',
dot: 'bg-fuchsia-500',
},
tess: {
badge: 'bg-violet-100 text-violet-700',
label: 'TESS',
dot: 'bg-violet-500',
},
};
// ── 产品类型中文标签product 轴)──
@ -83,3 +110,154 @@ export interface ObservationBatchResult {
products: ObservationProductItem[];
failures: DownloadFailure[];
}
// ── 光谱预览类型(镜像 Rust services::observation::preview::SpectrumPreview──
export interface SpectrumSegment {
band: string;
wavelength: number[];
flux: number[];
ivar?: number[];
wavelength_unit: string;
flux_unit: string;
}
export interface SpectrumMeta {
ra?: number;
dec?: number;
z?: number;
class?: string;
snr?: number;
teff?: number;
logg?: number;
fe_h?: number;
extra?: Record<string, unknown>;
}
export interface SpectrumPreview {
source: string;
source_id: string;
file_path: string;
segments: SpectrumSegment[];
meta: SpectrumMeta;
}
// ── 后续产品类型的预览结构(占位,待实现解析器后填充字段)──
// 镜像 Rust services::observation::preview::{LightCurvePreview, PhotometryPreview, ImagePreview}
export interface LightCurveBand {
band: string;
time: number[];
flux: number[];
flux_error?: number[];
time_unit: string;
flux_unit: string;
}
export interface LightCurvePreview {
source: string;
source_id: string;
file_path: string;
bands: LightCurveBand[];
meta: SpectrumMeta;
}
export interface PhotometryEntry {
band: string;
magnitude?: number;
magnitude_error?: number;
}
export interface PhotometryPreview {
source: string;
source_id: string;
file_path: string;
entries: PhotometryEntry[];
meta: SpectrumMeta;
}
export interface ImagePreview {
source: string;
source_id: string;
file_path: string;
preview_data_url: string;
width: number;
height: number;
meta: SpectrumMeta;
}
// ── 顶层预览枚举(镜像 Rust ObservationPreviewserde tag = "kind")──
// 前端用 discriminated unionswitch(preview.kind) 分发到对应渲染器
export type ObservationPreview =
| ({ kind: 'spectrum' } & SpectrumPreview)
| ({ kind: 'lightcurve' } & LightCurvePreview)
| ({ kind: 'photometry' } & PhotometryPreview)
| ({ kind: 'image' } & ImagePreview);
/// 判断某 (product, format) 组合是否支持预览
/// 新增产品类型时在此扩展(如 lightcurve 支持 fits 后返回 true
export function canPreview(product: string, format: string): boolean {
if (format !== 'fits') return false;
return product === 'spectrum';
}
// ── 统一全源检索类型(镜像 Rust services::observation::unified──
// 多目标 × 多源并发 cone 检索的请求/响应结构
/// 产品规格product + 可选 subtype镜像 Rust ProductSpec
export interface ProductSpec {
product: string;
subtype?: string;
}
/// 单个待检索目标(坐标 + 可选标签)
export interface TargetRequest {
ra: number;
dec: number;
radius_deg?: number; // 默认 0.1
label?: string;
}
/// 统一检索请求体
export interface UnifiedSearchRequest {
targets: TargetRequest[];
/// None/空 = 全部已注册源;非空 = 仅这些 (source, product)
sources?: [string, ProductSpec][];
release?: string;
version?: string;
per_target_limit?: number; // 默认 50
}
/// 一个 (source, product) 的聚合候选源组
export interface SourceCandidateGroup {
source: string;
product: ProductSpec;
candidates: Candidate[];
}
/// 名称解析失败条目
export interface ResolveFailure {
target_label: string;
error: string;
}
/// 统一检索聚合结果
export interface UnifiedSearchResult {
groups: SourceCandidateGroup[];
resolve_failures?: ResolveFailure[];
total_candidates: number;
}
/// 单个名称解析请求
export interface ResolveNamesRequest {
names: string[];
}
/// 单个名称的解析结果(成功时 ra/dec 有值,失败时 error 有值)
export interface ResolvedTarget {
name: string;
ra?: number;
dec?: number;
spectral_type?: string;
v_magnitude?: number;
error?: string;
}

View File

@ -0,0 +1,82 @@
// dashboard/src/components/observation/useObservationPreview.ts
//
// 观测数据预览 hook —— 按需拉取单条 artifact 的降采样 JSON供渲染器绘图
//
// 泛型于产品类型:后端返回 ObservationPreview 联合类型(带 kind 标签),
// 前端 ObservationPreviewRenderer 按 kind 分发到对应绘图组件。
// 新增产品类型LightCurve/Photometry/Image时无需改动本 hook。
//
// 自包含ObservationResultCard / LibraryCard 内部调用,同时覆盖面板流和 Agent 流。
// 对齐 useObservation.ts 的 axios.get + extractErrorMessage 范式,状态按 artifact key 隔离。
import { useCallback, useState } from 'react';
import axios from 'axios';
import { extractErrorMessage } from '../../utils/apiError';
import type { ObservationPreview } from './constants';
interface PreviewState {
data?: ObservationPreview;
loading: boolean;
error?: string;
}
/**
* artifact key
* key `${source_id}#${artifact_index}` artifact
*/
export function useObservationPreview() {
const [previews, setPreviews] = useState<Record<string, PreviewState>>({});
const fetchPreview = useCallback(
async (
source: string,
product: string,
subtype: string | undefined,
sourceId: string,
artifactIndex: number
) => {
const key = `${sourceId}#${artifactIndex}`;
setPreviews((m) => ({
...m,
[key]: { loading: true, error: undefined },
}));
try {
const res = await axios.get<ObservationPreview>(
'/api/observation/preview',
{
params: {
source,
product,
subtype: subtype || undefined,
source_id: sourceId,
artifact_index: artifactIndex,
},
}
);
setPreviews((m) => ({
...m,
[key]: { data: res.data, loading: false },
}));
} catch (e: unknown) {
setPreviews((m) => ({
...m,
[key]: {
loading: false,
error: extractErrorMessage(e, '预览加载失败'),
},
}));
}
},
[]
);
const getPreview = useCallback(
(sourceId: string, artifactIndex: number): PreviewState => {
const key = `${sourceId}#${artifactIndex}`;
return previews[key] ?? { loading: false };
},
[previews]
);
return { fetchPreview, getPreview };
}

View File

@ -13,7 +13,13 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import axios from 'axios';
import { extractErrorMessage } from '../utils/apiError';
import type { ObservationRecord, Candidate, CapabilitySpec } from '../types';
import type { ObservationBatchResult } from '../components/observation/constants';
import type {
ObservationBatchResult,
UnifiedSearchRequest,
UnifiedSearchResult,
ResolvedTarget,
ProductSpec,
} from '../components/observation/constants';
interface UseObservationProps {
isAuthenticated: boolean | null;
@ -65,7 +71,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
useEffect(() => {
if (isAuthenticated !== true) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
fetchCapabilities();
}, [isAuthenticated, fetchCapabilities]);
@ -89,7 +95,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
const valid = currentSpec.releases;
const hardMax = currentSpec.hard_max_radius_deg;
const suggested = currentSpec.suggested_max_radius_deg;
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived form sync on spec change
setSearchForm((f) => {
let next = f;
// release 回落(仅在该源区分版本时)
@ -113,7 +119,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
const versions = searchForm.release
? versionsMap[searchForm.release]
: undefined;
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived version reset on release change
setSearchForm((f) => {
if (!versions || versions.length === 0) {
// 该 release 无子版本概念
@ -269,7 +275,138 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
);
// ════════════════════════════════════════════════════
// D. 缓存库library—— 服务端分页浏览已下载数据
// D. 统一全源检索unified—— 多目标 × 多源并发 cone 检索
// ════════════════════════════════════════════════════
const [unifiedResult, setUnifiedResult] =
useState<UnifiedSearchResult | null>(null);
const [unifiedSearching, setUnifiedSearching] = useState(false);
const [unifiedError, setUnifiedError] = useState<string | null>(null);
// 勾选的候选源key = `${source}#${product}#${subtype}#${source_id}`
const [unifiedSelected, setUnifiedSelected] = useState<Set<string>>(
new Set()
);
/** 统一检索:跨目标 × 跨源并发 cone 检索 → 聚合候选源 */
const runUnifiedSearch = useCallback(async (req: UnifiedSearchRequest) => {
setUnifiedSearching(true);
setUnifiedError(null);
setUnifiedSelected(new Set());
try {
const res = await axios.post<UnifiedSearchResult>(
'/api/observation/unified/search',
req
);
setUnifiedResult(res.data);
} catch (e: unknown) {
setUnifiedError(
extractErrorMessage(e, '统一检索失败,请检查目标与源参数')
);
setUnifiedResult(null);
} finally {
setUnifiedSearching(false);
}
}, []);
/** 批量解析天体名称为坐标(供「天体名称列表」输入模式) */
const resolveNames = useCallback(
async (names: string[]): Promise<ResolvedTarget[]> => {
if (names.length === 0) return [];
try {
const res = await axios.post<ResolvedTarget[]>(
'/api/observation/unified/resolve',
{ names }
);
return res.data ?? [];
} catch (e: unknown) {
// 整体失败时,把每个名称标记为失败,便于前端展示
return names.map((n) => ({
name: n,
error: extractErrorMessage(e, '名称解析服务不可用'),
}));
}
},
[]
);
/** 切换某个候选源的勾选状态 */
const toggleUnifiedSelect = useCallback((key: string) => {
setUnifiedSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
/**
*
* (source, product) POST /observation/downloadmode=identifiers
* ObservationResultCard
*/
const downloadUnifiedSelected = useCallback(async () => {
if (unifiedSelected.size === 0 || !unifiedResult) return [];
// 收集 key → {source, product, subtype, source_id}
const groups = new Map<
string,
{ spec: [string, ProductSpec]; ids: string[] }
>();
for (const key of unifiedSelected) {
const [source, product, subtype, ...idParts] = key.split('#');
const source_id = idParts.join('#'); // source_id 理论上不含 #,但保守处理
const groupKey = `${source}#${product}#${subtype}`;
if (!groups.has(groupKey)) {
groups.set(groupKey, {
spec: [source, { product, subtype: subtype || undefined }],
ids: [],
});
}
groups.get(groupKey)!.ids.push(source_id);
}
const results: ObservationBatchResult[] = [];
setDownloading(true);
setDownloadError(null);
try {
for (const { spec, ids } of groups.values()) {
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: spec[0],
product: spec[1].product,
subtype: spec[1].subtype || undefined,
force: false,
mode: 'identifiers',
source_ids: ids,
}
);
results.push(res.data);
} catch (e: unknown) {
// 单组失败不中断,记录为 failures 占位 batch
results.push({
source: spec[0],
product: spec[1],
matched_count: 0,
products: [],
failures: ids.map((id) => ({
source_label: id,
error: extractErrorMessage(e, '下载失败'),
})),
});
}
}
// 把最后一组结果存入 downloadResult 供 ObservationResultCard 展示
if (results.length > 0) {
setDownloadResult(results[results.length - 1]);
}
} finally {
setDownloading(false);
}
return results;
}, [unifiedSelected, unifiedResult]);
// ════════════════════════════════════════════════════
// E. 缓存库library—— 服务端分页浏览已下载数据
// ════════════════════════════════════════════════════
const [libraryItems, setLibraryItems] = useState<ObservationRecord[]>([]);
const [libraryTotal, setLibraryTotal] = useState(0);
@ -324,7 +461,6 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
// 任意筛选/分页变更 → 重新请求后端
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
fetchLibrary();
}, [fetchLibrary]);
@ -360,6 +496,16 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
downloadSelected,
downloadByIds,
downloadByCoordinates,
// D. 统一全源检索
unifiedResult,
setUnifiedResult,
unifiedSearching,
unifiedError,
runUnifiedSearch,
resolveNames,
unifiedSelected,
toggleUnifiedSelect,
downloadUnifiedSelected,
// D. 缓存库
libraryItems,
libraryTotal,

View File

@ -26,19 +26,30 @@ import {
Send,
RotateCw,
Lock,
LineChart,
Globe,
} from 'lucide-react';
import { CustomSelect } from '../components/CustomSelect';
import { ObservationResultCard } from '../components/observation/ObservationResultCard';
import { ObservationPreviewRenderer } from '../components/observation/ObservationPreviewRenderer';
import { useObservationPreview } from '../components/observation/useObservationPreview';
import { UnifiedSearchPanel } from '../components/observation/UnifiedSearchPanel';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
canPreview,
} from '../components/observation/constants';
import type {
UnifiedSearchResult,
ResolvedTarget,
ObservationBatchResult,
} from '../components/observation/constants';
import type { ObservationRecord, Candidate, CapabilitySpec } from '../types';
import type { SearchForm } from '../hooks/useObservation';
// ── 视图切换类型 ──
type ViewMode = 'search' | 'library';
type ViewMode = 'search' | 'library' | 'unified';
interface ObservationPanelProps {
// 能力清单
@ -69,6 +80,17 @@ interface ObservationPanelProps {
downloadSelected: () => Promise<void>;
downloadByIds: (ids: string[]) => Promise<void>;
downloadByCoordinates: (strategy?: 'nearest' | 'all') => Promise<void>;
// 统一全源检索
unifiedResult: UnifiedSearchResult | null;
unifiedSearching: boolean;
unifiedError: string | null;
runUnifiedSearch: (
req: import('../components/observation/constants').UnifiedSearchRequest
) => Promise<void>;
resolveNames: (names: string[]) => Promise<ResolvedTarget[]>;
unifiedSelected: Set<string>;
toggleUnifiedSelect: (key: string) => void;
downloadUnifiedSelected: () => Promise<ObservationBatchResult[]>;
// 缓存库
libraryItems: ObservationRecord[];
libraryTotal: number;
@ -118,6 +140,17 @@ export function ObservationPanel(props: ObservationPanelProps) {
<Search className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setView('unified')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
view === 'unified'
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setView('library')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
@ -134,6 +167,19 @@ export function ObservationPanel(props: ObservationPanelProps) {
{view === 'search' ? (
<SearchView {...props} />
) : view === 'unified' ? (
<UnifiedSearchPanel
capabilities={props.capabilities}
unifiedResult={props.unifiedResult}
unifiedSearching={props.unifiedSearching}
unifiedError={props.unifiedError}
runUnifiedSearch={props.runUnifiedSearch}
resolveNames={props.resolveNames}
unifiedSelected={props.unifiedSelected}
toggleUnifiedSelect={props.toggleUnifiedSelect}
downloadUnifiedSelected={props.downloadUnifiedSelected}
downloading={props.downloading}
/>
) : (
<LibraryView {...props} />
)}
@ -981,6 +1027,10 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
0
);
// 预览(与 ObservationResultCard 共用 hook + 渲染器)
const { fetchPreview, getPreview } = useObservationPreview();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
return (
<div className="bg-white border border-slate-200 rounded-lg p-3.5 shadow-xs hover:shadow-md hover:border-slate-300 transition-all">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-slate-100">
@ -1027,32 +1077,88 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
<p className="text-[10px] text-slate-400 italic"></p>
) : (
<div className="space-y-1">
{artifacts.map((a, i) => (
<div
key={i}
className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100"
>
<span className="text-slate-500 flex items-center gap-1 min-w-0">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium shrink-0">
{a.band}
{artifacts.map((a, i) => {
const previewKey = `${rec.source_id}#${i}`;
const previewable = canPreview(rec.product, a.format);
const isOpen = expanded[previewKey] ?? false;
const previewState = getPreview(rec.source_id, i);
const togglePreview = () => {
const next = !isOpen;
setExpanded((m) => ({ ...m, [previewKey]: next }));
if (next && !previewState.data && !previewState.loading) {
// subtype 从路径推断(后端 build_preview 也支持 None 时推断)
fetchPreview(
rec.source,
rec.product,
undefined,
rec.source_id,
i
);
}
};
return (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100">
<span className="text-slate-500 flex items-center gap-1 min-w-0">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium shrink-0">
{a.band}
</span>
)}
<span className="font-mono uppercase shrink-0">
{a.format}
</span>
<span className="text-slate-400">
·{' '}
{typeof a.size === 'number'
? formatFileSize(a.size)
: '—'}
</span>
</span>
<span className="flex items-center gap-1 shrink-0 ml-2">
{previewable && (
<button
onClick={togglePreview}
className="flex items-center gap-0.5 font-medium text-slate-500 hover:text-slate-800 transition-colors"
title="预览"
>
{previewState.loading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LineChart className="w-3 h-3" />
)}
</button>
)}
<a
href={`/api/files/${a.path}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
</a>
</span>
</div>
{/* 预览图(按 product 类型分发渲染) */}
{previewable && isOpen && (
<div className="pl-2">
{previewState.error ? (
<p className="text-[10px] text-rose-500 py-1">
{previewState.error}
</p>
) : previewState.data ? (
<ObservationPreviewRenderer preview={previewState.data} />
) : previewState.loading ? (
<div className="flex items-center gap-1 text-[10px] text-slate-400 py-4 justify-center">
<Loader2 className="w-3 h-3 animate-spin" />
FITS ...
</div>
) : null}
</div>
)}
<span className="font-mono uppercase shrink-0">{a.format}</span>
<span className="text-slate-400">
· {typeof a.size === 'number' ? formatFileSize(a.size) : '—'}
</span>
</span>
<a
href={`/api/files/${a.path}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline shrink-0 ml-2"
>
<Download className="w-3 h-3" />
</a>
</div>
))}
</div>
);
})}
{artifacts.length > 1 && (
<div className="flex items-center gap-1 text-[10px] text-slate-400 pt-1">
<CheckCircle2 className="w-3 h-3" />

View File

@ -605,6 +605,9 @@ mod tests {
120,
)
.unwrap(),
irsa: crate::clients::irsa::IrsaClient::new("https://irsa.ipac.caltech.edu", 60)
.unwrap(),
mast: crate::clients::mast::MastClient::new("https://mast.stsci.edu", 90).unwrap(),
observation_registry: std::sync::Arc::new(
crate::services::observation::ObservationRegistry::default(),
),

View File

@ -274,6 +274,9 @@ mod tests {
120,
)
.unwrap(),
irsa: crate::clients::irsa::IrsaClient::new("https://irsa.ipac.caltech.edu", 60)
.unwrap(),
mast: crate::clients::mast::MastClient::new("https://mast.stsci.edu", 90).unwrap(),
observation_registry: std::sync::Arc::new(
crate::services::observation::ObservationRegistry::default(),
),

View File

@ -71,6 +71,10 @@ pub struct AppState {
pub sdss: SdssClient,
/// DESI 观测数据客户端Data Lab TAP + HEALPix coadd SAS服务光谱等产品
pub desi: DesiClient,
/// IRSA/IPAC 客户端ZTF 光变曲线 REST API
pub irsa: crate::clients::irsa::IrsaClient,
/// MAST 客户端TESS 光变曲线 + TIC 星表)
pub mast: crate::clients::mast::MastClient,
/// 观测数据 fetcher 注册表(跨源 × 跨产品类型的统一获取入口)
pub observation_registry: Arc<crate::services::observation::ObservationRegistry>,
pub llm: LlmClient,
@ -142,9 +146,10 @@ pub mod handlers {
NoteRecord,
};
pub use super::observation::{
observation_capabilities, observation_download, observation_list, observation_search,
DownloadMode, ObservationDownloadRequest, ObservationListParams, ObservationListResponse,
ObservationSearchParams,
observation_capabilities, observation_download, observation_list, observation_preview,
observation_search, unified_resolve, unified_search, DownloadMode,
ObservationDownloadRequest, ObservationListParams, ObservationListResponse,
ObservationPreviewParams, ObservationSearchParams,
};
pub use super::papers::{
download_paper, embed_paper, export_citations, get_active_bibcode, get_citation_network,

View File

@ -6,11 +6,12 @@
// 对齐 catalog.rs / targets.rs 的 handler 范式:
// State + Query/Json 参数 → service 调用 → ApiResult<Json<...>>
//
// 命名空间:/api/observation/{search,download,capabilities,list}
// 命名空间:/api/observation/{search,download,capabilities,list,preview}
// - search :按坐标 cone 检索候选源(不下载,供前端预览挑选)
// - download :按坐标或标识符下载观测产物并写入 observation_cachePOST重型副作用
// - capabilities :返回 registry 支持的 (source × product × subtypes) 组合,供前端动态渲染表单
// - list :列出 observation_cache 中已缓存条目(服务端分页 + 筛选)
// - preview :解析已下载的 FITS 为归一化 JSON波长/流量数组),供前端内联绘图
use axum::extract::{Query, State};
use axum::Json;
@ -258,3 +259,102 @@ pub async fn observation_list(
total: page.total,
}))
}
// ═══════════════════════════════════════════════════════════════
// 6. GET /api/observation/preview —— 解析已下载 FITS 为归一化 JSON供前端绘图
// ═══════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize)]
pub struct ObservationPreviewParams {
pub source: String,
#[serde(default = "default_product")]
pub product: String,
pub subtype: Option<String>,
/// observation_cache 的去重键(与 /list 返回的 source_id 字段一致)
pub source_id: String,
/// 多文件产物选第几个None=0
pub artifact_index: Option<usize>,
}
pub async fn observation_preview(
State(state): State<std::sync::Arc<AppState>>,
Query(params): Query<ObservationPreviewParams>,
) -> ApiResult<Json<crate::services::observation::preview::ObservationPreview>> {
let source = crate::services::observation::Source::from_str(&params.source)
.map_err(AppError::bad_request)?;
let product = build_product_spec(&params.product, &params.subtype)?;
let preview = crate::services::observation::preview::build_preview(
&state,
source,
&product,
&params.source_id,
params.artifact_index,
)
.await
.map_err(|e| {
let msg = format!("{:#}", e);
// 缓存未找到 / 文件缺失 → 404解析失败 → 500
if msg.contains("缓存中未找到") || msg.contains("不存在于磁盘") {
AppError::not_found(msg)
} else {
tracing::error!("FITS 预览解析失败: {}", msg);
AppError::internal(format!("FITS 预览解析失败: {}", msg))
}
})?;
Ok(Json(preview))
}
// ═══════════════════════════════════════════════════════════════
// 7. POST /api/observation/unified/search —— 多目标统一全源检索(仅检索,不下载)
// ═══════════════════════════════════════════════════════════════
//
// 跨多个目标 × 多个 (source, product) 并发 cone 检索,按 (source, product) 聚合候选源。
// 第二阶段「批量下载」复用现有 POST /observation/downloadmode=identifiers无需新端点。
pub async fn unified_search(
State(state): State<std::sync::Arc<AppState>>,
Json(req): Json<crate::services::observation::UnifiedSearchRequest>,
) -> ApiResult<Json<crate::services::observation::UnifiedSearchResult>> {
let result = crate::services::observation::unified::unified_search(
&state,
&state.observation_registry,
&req,
)
.await
.map_err(|e| {
let msg = format!("{:#}", e);
tracing::error!("统一检索失败: {}", msg);
if msg.contains("不能为空") || msg.contains("暂不支持") || msg.contains("不支持的")
{
AppError::bad_request(msg)
} else {
AppError::internal(format!("统一检索失败: {}", msg))
}
})?;
Ok(Json(result))
}
// ═══════════════════════════════════════════════════════════════
// 8. POST /api/observation/unified/resolve —— 批量解析天体名称为坐标
// ═══════════════════════════════════════════════════════════════
//
// 供前端「天体名称列表」输入模式:把 "M31, NGC1068, ..." 解析为坐标,
// 复用 services/cds/target.rs::query_target_cachedSESAME + DB 缓存)。
pub async fn unified_resolve(
State(state): State<std::sync::Arc<AppState>>,
Json(req): Json<crate::services::observation::ResolveNamesRequest>,
) -> ApiResult<Json<Vec<crate::services::observation::ResolvedTarget>>> {
if req.names.is_empty() {
return Err(AppError::bad_request("名称列表不能为空"));
}
let results = crate::services::observation::unified::resolve_names(&state, &req.names)
.await
.map_err(|e| {
tracing::error!("批量名称解析失败: {}", e);
AppError::internal(format!("批量名称解析失败: {}", e))
})?;
Ok(Json(results))
}

View File

@ -218,6 +218,16 @@ async fn main() -> anyhow::Result<()> {
config.desi_timeout_secs,
)
.context("构建 DESI 客户端失败")?,
irsa: astroresearch::clients::irsa::IrsaClient::new(
&config.ztf_base_url,
config.ztf_timeout_secs,
)
.context("构建 IRSA 客户端失败")?,
mast: astroresearch::clients::mast::MastClient::new(
&config.tess_base_url,
config.tess_timeout_secs,
)
.context("构建 MAST 客户端失败")?,
observation_registry: Arc::new(
astroresearch::services::observation::ObservationRegistry::default(),
),

277
src/clients/irsa/mod.rs Normal file
View File

@ -0,0 +1,277 @@
// src/clients/irsa/mod.rs
//
// IRSA/IPAC 客户端 —— ZTFZwicky Transient Facility时域光变曲线
//
// 职责仅限HTTP 请求SSRF 防护 + 重试、CSV 解析、领域结构体。
// 业务层(缓存策略、批量编排)在 services/ 下。
//
// 数据源(已验证端点 2026-07
// ConeSearch: GET {base}/cgi-bin/ZTF/nph_light_curves?CIRCLE={ra}+{dec}+{radius_deg}&format=csv
// 注意radius 单位是度,硬上限 0.1667°10 arcmin
// 按 oid 查: GET .../nph_light_curves?ID={oid}&format=csv
//
// 无需认证。复用 safe_redirect_policy + send_with_retry 模式。
// CSV 列oid,expid,hjd,mjd,mag,magerr,catflags,filtercode,ra,dec,...
use anyhow::{anyhow, Context};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tracing::{error, info, warn};
/// ZTF 单次观测行(一维光变曲线的一个数据点)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZtfDetectionRow {
/// ZTF 目标唯一标识15-16 位数字)
pub oid: String,
/// 修订儒略日
pub mjd: f64,
/// 星等
pub mag: f64,
/// 星等误差
pub magerr: f64,
/// 滤镜代码zg / zr / zi
pub filtercode: String,
/// 赤经(度)
pub ra: f64,
/// 赤纬(度)
pub dec: f64,
/// 目录标志位(质量标记)
#[serde(default)]
pub catflags: i64,
}
/// ZTF cone 检索的归一化候选源(按 oid 去重后的目标列表)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZtfObjectRow {
pub oid: String,
pub ra: f64,
pub dec: f64,
/// 该目标的观测次数(所有波段合计)
pub nobs: usize,
}
// ── 客户端 ──
#[derive(Clone)]
pub struct IrsaClient {
client: reqwest::Client,
base_url: String,
}
impl IrsaClient {
pub fn new(base_url: &str, timeout_secs: u64) -> anyhow::Result<Self> {
Ok(IrsaClient {
client: reqwest::Client::builder()
.redirect(crate::utils::ssrf::safe_redirect_policy())
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(10))
.build()
.context("Failed to create IRSA HTTP client")?,
base_url: base_url.trim_end_matches('/').to_string(),
})
}
/// 锥形检索:返回坐标半径范围内的 ZTF 目标列表(按 oid 去重)
///
/// GET {base}/cgi-bin/ZTF/nph_light_curves?CIRCLE={ra}+{dec}+{radius_deg}&format=csv
///
/// ZTF 硬限制radius ≤ 0.1667°10 arcmin。上层 fetcher 应在 hard_max_radius_deg 强制。
pub async fn cone_search(
&self,
ra: f64,
dec: f64,
radius_deg: f64,
) -> anyhow::Result<Vec<ZtfObjectRow>> {
if radius_deg > 0.1667 {
return Err(anyhow!(
"ZTF 检索半径硬上限 0.1667°10 arcmin当前 {}",
radius_deg
));
}
let url = format!("{}/cgi-bin/ZTF/nph_light_curves", self.base_url);
info!(
"[ZTF] ConeSearch ra={:.4} dec={:.4} radius={:.4}°",
ra, dec, radius_deg
);
// CIRCLE 参数格式ra dec radius空格或 + 分隔)
let circle = format!("{}+{}+{}", ra, dec, radius_deg);
let csv_body = self
.send_with_retry(&url, &[("CIRCLE", circle.as_str()), ("format", "csv")])
.await?;
let detections = parse_ztf_csv(&csv_body)?;
// 按 oid 去重,统计每个目标的观测次数
let mut map: std::collections::HashMap<String, ZtfObjectRow> =
std::collections::HashMap::new();
for d in &detections {
map.entry(d.oid.clone())
.and_modify(|o| o.nobs += 1)
.or_insert_with(|| ZtfObjectRow {
oid: d.oid.clone(),
ra: d.ra,
dec: d.dec,
nobs: 1,
});
}
Ok(map.into_values().collect())
}
/// 按 oid 下载该目标的完整光变曲线所有波段所有观测CSV 格式)
///
/// 返回原始 CSV 字节,由上层 fetcher 落盘。
pub async fn download_lightcurve(&self, oid: &str) -> anyhow::Result<Vec<u8>> {
let url = format!("{}/cgi-bin/ZTF/nph_light_curves", self.base_url);
info!("[ZTF] 下载光变曲线 oid={}", oid);
let csv_body = self
.send_with_retry(&url, &[("ID", oid), ("format", "csv")])
.await?;
Ok(csv_body.into_bytes())
}
/// 带重试的 GET 请求429/503 自动重试,对齐 LAMOST/Gaia 模式)
async fn send_with_retry(&self, url: &str, params: &[(&str, &str)]) -> anyhow::Result<String> {
const MAX_RETRIES: u32 = 3;
let mut response = None;
for attempt in 0..MAX_RETRIES {
let resp = self
.client
.get(url)
.header("User-Agent", "AstroResearch/0.1 (academic research tool)")
.query(params)
.send()
.await
.context("IRSA 请求发送失败")?;
let status = resp.status();
if (status.as_u16() == 429 || status.as_u16() == 503) && attempt < MAX_RETRIES - 1 {
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5);
warn!(
"[ZTF] 速率限制/过载 ({}), {} 秒后重试 (第 {} 次)",
status,
retry_after,
attempt + 1
);
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
}
response = Some(resp);
break;
}
let response = response.ok_or_else(|| anyhow!("IRSA 重试耗尽"))?;
let status = response.status();
if !status.is_success() {
let err_body = response.text().await.unwrap_or_default();
error!(
"[ZTF] 请求失败: 状态码={}, 返回错误={}",
status,
err_body.chars().take(500).collect::<String>()
);
return Err(anyhow!("IRSA 接口返回错误码: {}", status));
}
response.text().await.context("读取 IRSA 响应失败")
}
}
/// 解析 ZTF CSV 为检测行列表
///
/// CSV 无引号嵌套,手写简易解析即可。跳过表头行。
/// 已验证列顺序oid,expid,hjd,mjd,mag,magerr,catflags,filtercode,ra,dec,...
pub fn parse_ztf_csv(csv: &str) -> anyhow::Result<Vec<ZtfDetectionRow>> {
let mut rows = Vec::new();
let mut lines = csv.lines();
// 第一行是表头,解析列名索引
let header = lines.next().ok_or_else(|| anyhow!("ZTF CSV 无表头"))?;
let headers: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
let col = |name: &str| -> Option<usize> { headers.iter().position(|h| *h == name) };
let i_oid = col("oid").ok_or_else(|| anyhow!("ZTF CSV 缺 oid 列"))?;
let i_mjd = col("mjd").ok_or_else(|| anyhow!("ZTF CSV 缺 mjd 列"))?;
let i_mag = col("mag").ok_or_else(|| anyhow!("ZTF CSV 缺 mag 列"))?;
let i_magerr = col("magerr").ok_or_else(|| anyhow!("ZTF CSV 缺 magerr 列"))?;
let i_filter = col("filtercode").ok_or_else(|| anyhow!("ZTF CSV 缺 filtercode 列"))?;
let i_ra = col("ra").ok_or_else(|| anyhow!("ZTF CSV 缺 ra 列"))?;
let i_dec = col("dec").ok_or_else(|| anyhow!("ZTF CSV 缺 dec 列"))?;
let i_catflags = col("catflags");
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let fields: Vec<&str> = line.split(',').collect();
if fields.len() <= i_dec {
continue;
}
let parse_f64 = |idx: usize| -> Option<f64> {
fields.get(idx).and_then(|s| s.trim().parse::<f64>().ok())
};
let parse_str = |idx: usize| -> Option<String> {
fields
.get(idx)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
let oid = match parse_str(i_oid) {
Some(o) => o,
None => continue,
};
// mag 可能为空(非探测),跳过无星等行
let mag = match parse_f64(i_mag) {
Some(m) if m > -900.0 => m, // 过滤 -999 null 标记
_ => continue,
};
rows.push(ZtfDetectionRow {
oid,
mjd: parse_f64(i_mjd).unwrap_or(0.0),
mag,
magerr: parse_f64(i_magerr).unwrap_or(0.0),
filtercode: parse_str(i_filter).unwrap_or_default(),
ra: parse_f64(i_ra).unwrap_or(0.0),
dec: parse_f64(i_dec).unwrap_or(0.0),
catflags: i_catflags
.and_then(parse_f64)
.map(|v| v as i64)
.unwrap_or(0),
});
}
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ztf_csv() {
let csv = "oid,expid,hjd,mjd,mag,magerr,catflags,filtercode,ra,dec,chi\n\
667102300001988,68649821,2458440.998,58440.498,22.04,0.275,0,zg,149.99,30.00,0.84\n\
667102300001988,68649822,2458441.001,58441.001,21.50,0.200,0,zr,149.99,30.00,0.90\n";
let rows = parse_ztf_csv(csv).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].oid, "667102300001988");
assert_eq!(rows[0].filtercode, "zg");
assert!((rows[0].mag - 22.04).abs() < 0.01);
assert_eq!(rows[1].filtercode, "zr");
}
#[test]
fn test_parse_ztf_csv_null_mag() {
// 空星等行应跳过
let csv = "oid,mjd,mag,magerr,catflags,filtercode,ra,dec\n\
123,,,-999,,zg,150,30\n";
let rows = parse_ztf_csv(csv).unwrap();
assert!(rows.is_empty(), "无有效星等的行应跳过");
}
#[tokio::test]
#[ignore = "需要网络访问 IRSA"]
async fn test_live_ztf_cone_search() {
let client = IrsaClient::new("https://irsa.ipac.caltech.edu", 60).unwrap();
let objects = client.cone_search(150.0, 30.0, 0.01).await.unwrap();
assert!(!objects.is_empty(), "应有 ZTF 目标");
let first = &objects[0];
assert!(!first.oid.is_empty());
}
}

408
src/clients/mast/mod.rs Normal file
View File

@ -0,0 +1,408 @@
// src/clients/mast/mod.rs
//
// MASTMikulski Archive for Space Telescopes客户端 —— TESS 光变曲线
//
// 职责仅限HTTP 请求SSRF 防护 + 重试、JSON 解析、领域结构体。
// 业务层(缓存策略、批量编排)在 services/ 下。
//
// 数据源(已验证端点 2026-07
// TIC cone search: POST {base}/api/v0/invoke
// service=Mast.Catalogs.Tic.Cone, params={ra,dec,radius}, JSON 响应
// 字段ID(TIC),ra,dec,Tmag,objType,...
// 光变 FITS: 需两阶段——先查 productMast.Tess.Product再下载 LC FITS
// 首版实现 cone search + 元数据;光变 FITS 下载通过 data URL 构造
//
// 无需认证(公开数据)。复用 safe_redirect_policy + send_with_retry 模式。
use anyhow::{anyhow, Context};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tracing::{error, info, warn};
/// TESS TIC 星表行cone search 结果)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TessTicRow {
/// TIC IDTESS Input Catalog 唯一标识)
pub tic_id: String,
/// 赤经(度)
pub ra: f64,
/// 赤纬(度)
pub dec: f64,
/// TESS 星等
#[serde(default)]
pub tmag: Option<f64>,
/// Gaia source_id交叉证认
#[serde(default)]
pub gaia_id: Option<String>,
/// 天体类型STAR/GALAXY/...
#[serde(default)]
pub obj_type: Option<String>,
}
// ── 客户端 ──
#[derive(Clone)]
pub struct MastClient {
client: reqwest::Client,
base_url: String,
}
impl MastClient {
pub fn new(base_url: &str, timeout_secs: u64) -> anyhow::Result<Self> {
Ok(MastClient {
client: reqwest::Client::builder()
.redirect(crate::utils::ssrf::safe_redirect_policy())
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(10))
.build()
.context("Failed to create MAST HTTP client")?,
base_url: base_url.trim_end_matches('/').to_string(),
})
}
/// TESS TIC 锥形检索
///
/// POST {base}/api/v0/invokeservice=Mast.Catalogs.Tic.Cone
/// 返回 JSON解析为 TessTicRow 列表。
pub async fn cone_search_tic(
&self,
ra: f64,
dec: f64,
radius_deg: f64,
pagesize: u32,
) -> anyhow::Result<Vec<TessTicRow>> {
let url = format!("{}/api/v0/invoke", self.base_url);
info!(
"[TESS] TIC ConeSearch ra={:.4} dec={:.4} radius={:.4}° pagesize={}",
ra, dec, radius_deg, pagesize
);
// MAST Portal API 的 request 参数是 JSON 字符串
let request_json = serde_json::json!({
"service": "Mast.Catalogs.Tic.Cone",
"params": {"ra": ra, "dec": dec, "radius": radius_deg},
"format": "json",
"pagesize": pagesize,
"page": 1,
"removenullcolumns": true,
})
.to_string();
let body = self
.send_post_with_retry(&url, "request", &request_json)
.await?;
parse_tic_json(&body)
}
/// 按 TIC ID 解析为 Candidate 用的元数据
///
/// 复用 cone search以 TIC ID 中心坐标反查),首版简化:直接构造空行由上层填充。
/// 完整实现需 Mast.Catalogs.Tic.Filtered.Position首版用 cone 兜底。
pub async fn resolve_tic(&self, tic_id: &str) -> anyhow::Result<TessTicRow> {
// TIC ID 本身是唯一标识,但需要坐标。首版返回仅含 ID 的行(坐标留空,上层按需补查)。
// 完整实现需查询 Mast.Catalogs.Tic首版简化。
Ok(TessTicRow {
tic_id: tic_id.to_string(),
ra: 0.0,
dec: 0.0,
tmag: None,
gaia_id: None,
obj_type: None,
})
}
/// 带 TIC ID 查询 TESS 观测的 sector 列表(光变 FITS 两阶段查找的第一阶段)
///
/// 返回 (sector, 光变 FITS URL) 列表。首版通过 Mast.Tess.Product 查询。
pub async fn list_lightcurve_products(
&self,
tic_id: &str,
) -> anyhow::Result<Vec<TessLightCurveProduct>> {
let url = format!("{}/api/v0/invoke", self.base_url);
let request_json = serde_json::json!({
"service": "Mast.Tess.Product",
"params": {
"tessID": tic_id,
"tessProductType": "SCI",
},
"format": "json",
"pagesize": 100,
"page": 1,
})
.to_string();
let body = self
.send_post_with_retry(&url, "request", &request_json)
.await?;
parse_tess_products_json(&body, tic_id)
}
/// 下载光变 FITS 字节
pub async fn download_lightcurve_fits(&self, url: &str) -> anyhow::Result<Vec<u8>> {
// data_url 已是完整 URL需 SSRF 校验后直接 GET
info!("[TESS] 下载光变 FITS: {}", url);
let resp = self.send_get_with_retry(url).await?;
Ok(resp)
}
/// 带重试的 POST429/503 自动重试)
async fn send_post_with_retry(
&self,
url: &str,
param_key: &str,
param_value: &str,
) -> anyhow::Result<String> {
const MAX_RETRIES: u32 = 3;
let mut last_err = None;
for attempt in 0..MAX_RETRIES {
let resp = self
.client
.post(url)
.header("User-Agent", "AstroResearch/0.1 (academic research tool)")
.form(&[(param_key, param_value)])
.send()
.await
.context("MAST 请求发送失败");
let resp = match resp {
Ok(r) => r,
Err(e) => {
last_err = Some(e);
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
};
let status = resp.status();
if (status.as_u16() == 429 || status.as_u16() == 503) && attempt < MAX_RETRIES - 1 {
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5);
warn!(
"[TESS] 速率限制/过载 ({}), {} 秒后重试 (第 {} 次)",
status,
retry_after,
attempt + 1
);
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
}
if !status.is_success() {
let err_body = resp.text().await.unwrap_or_default();
error!(
"[TESS] 请求失败: 状态码={}, 返回错误={}",
status,
err_body.chars().take(500).collect::<String>()
);
return Err(anyhow!("MAST 接口返回错误码: {}", status));
}
return resp.text().await.context("读取 MAST 响应失败");
}
Err(last_err.unwrap_or_else(|| anyhow!("MAST 重试耗尽")))
}
/// 带重试的 GET下载 FITS 字节)
async fn send_get_with_retry(&self, url: &str) -> anyhow::Result<Vec<u8>> {
const MAX_RETRIES: u32 = 3;
for attempt in 0..MAX_RETRIES {
let resp = self
.client
.get(url)
.header("User-Agent", "AstroResearch/0.1 (academic research tool)")
.send()
.await
.context("MAST FITS 下载请求失败")?;
let status = resp.status();
if (status.as_u16() == 429 || status.as_u16() == 503) && attempt < MAX_RETRIES - 1 {
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5);
warn!("[TESS] FITS 下载速率限制, {} 秒后重试", retry_after);
tokio::time::sleep(Duration::from_secs(retry_after)).await;
continue;
}
if !status.is_success() {
return Err(anyhow!("MAST FITS 下载失败: {}", status));
}
return resp
.bytes()
.await
.map(|b| b.to_vec())
.context("读取 FITS 字节失败");
}
Err(anyhow!("MAST FITS 下载重试耗尽"))
}
}
/// TESS 光变曲线产品(一个 sector 的一个文件)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TessLightCurveProduct {
pub sector: u32,
/// 光变 FITS 下载 URL
pub data_url: String,
/// 文件名
pub filename: String,
}
/// 解析 MAST TIC cone search JSON 响应
///
/// 响应格式:{ status, msg, data: [[...]], fields: [{name}, ...], paging }
fn parse_tic_json(body: &str) -> anyhow::Result<Vec<TessTicRow>> {
let v: serde_json::Value = serde_json::from_str(body).context("解析 MAST JSON 失败")?;
let fields = v
.get("fields")
.and_then(|f| f.as_array())
.ok_or_else(|| anyhow!("MAST 响应缺 fields 数组"))?;
let data = v
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| anyhow!("MAST 响应缺 data 数组"))?;
// 列名 → 索引
let col = |name: &str| -> Option<usize> {
fields
.iter()
.position(|f| f.get("name").and_then(|n| n.as_str()) == Some(name))
};
let i_id = col("ID");
let i_ra = col("ra");
let i_dec = col("dec");
let i_tmag = col("Tmag");
let i_gaia = col("GAIA");
let i_obj = col("objType");
let mut rows = Vec::new();
for row in data {
let arr = row
.as_array()
.ok_or_else(|| anyhow!("MAST data 行非数组"))?;
let get_str = |i: Option<usize>| -> Option<String> {
i.and_then(|idx| arr.get(idx)).and_then(|v| {
if v.is_i64() {
Some(v.as_i64().unwrap().to_string())
} else if v.is_f64() {
Some(v.as_f64().unwrap().to_string())
} else {
v.as_str().map(|s| s.to_string())
}
})
};
let get_f64 = |i: Option<usize>| -> Option<f64> {
i.and_then(|idx| arr.get(idx)).and_then(|v| {
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
};
let tic_id = match i_id.and_then(|i| get_str(Some(i))) {
Some(id) => id,
None => continue,
};
rows.push(TessTicRow {
tic_id,
ra: get_f64(i_ra).unwrap_or(0.0),
dec: get_f64(i_dec).unwrap_or(0.0),
tmag: get_f64(i_tmag),
gaia_id: get_str(i_gaia),
obj_type: get_str(i_obj),
});
}
Ok(rows)
}
/// 解析 MAST TESS Product JSON 响应
fn parse_tess_products_json(
body: &str,
tic_id: &str,
) -> anyhow::Result<Vec<TessLightCurveProduct>> {
let v: serde_json::Value = serde_json::from_str(body).context("解析 MAST Product JSON 失败")?;
// MAST Product 响应可能含 manifest 字段或直接 products 数组
let products = v
.pointer("/data/products")
.and_then(|p| p.as_array())
.or_else(|| v.get("data").and_then(|d| d.as_array()))
.or_else(|| v.get("products").and_then(|p| p.as_array()))
.ok_or_else(|| anyhow!("MAST Product 响应缺 products 数组"))?;
let mut result = Vec::new();
for (idx, prod) in products.iter().enumerate() {
// Product 可能是对象(含 uri/filename/sector或数组
let (sector, data_url, filename) = if let Some(obj) = prod.as_object() {
let uri = obj
.get("uri")
.or_else(|| obj.get("dataURI"))
.and_then(|u| u.as_str())
.unwrap_or("");
// uri 形如 mast:TESS/product/tess2019/.../tess2019222...
let url = if uri.starts_with("http") {
uri.to_string()
} else if uri.starts_with("mast:") {
format!("https://mast.stsci.edu/api/v0.1/Download/file?uri={}", uri)
} else {
format!(
"https://mast.stsci.edu/api/v0.1/Download/file?uri=mast:TESS/product/{}",
uri
)
};
let fname = obj
.get("filename")
.and_then(|f| f.as_str())
.unwrap_or("tess_lc.fits")
.to_string();
let sec = obj
.get("sector")
.and_then(|s| s.as_u64())
.unwrap_or(idx as u64 + 1) as u32;
(sec, url, fname)
} else {
// 简化兜底
(
idx as u32 + 1,
String::new(),
format!("tess_{}.fits", tic_id),
)
};
result.push(TessLightCurveProduct {
sector,
data_url,
filename,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tic_json() {
let json = r#"{
"status": "COMPLETE",
"fields": [
{"name": "ID"}, {"name": "ra"}, {"name": "dec"},
{"name": "Tmag"}, {"name": "GAIA"}, {"name": "objType"}
],
"data": [
[840525885, 149.99, 29.99, 18.65, 743994644698295424, "STAR"],
[840525886, 150.01, 30.01, 15.20, null, "GALAXY"]
]
}"#;
let rows = parse_tic_json(json).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].tic_id, "840525885");
assert!((rows[0].ra - 149.99).abs() < 0.01);
assert_eq!(rows[0].obj_type.as_deref(), Some("STAR"));
assert_eq!(rows[1].gaia_id, None);
}
#[tokio::test]
#[ignore = "需要网络访问 MAST"]
async fn test_live_tess_cone_search() {
let client = MastClient::new("https://mast.stsci.edu", 60).unwrap();
let rows = client.cone_search_tic(150.0, 30.0, 0.1, 5).await.unwrap();
assert!(!rows.is_empty(), "应有 TIC 目标");
let first = &rows[0];
assert!(!first.tic_id.is_empty());
}
}

View File

@ -3,8 +3,10 @@ pub mod arxiv;
pub mod cds;
pub mod desi;
pub mod gaia;
pub mod irsa;
pub mod lamost;
pub mod llm;
pub mod mast;
pub mod qiniu;
pub mod sdss;
pub mod vo;

View File

@ -284,7 +284,7 @@ mod tests {
#[test]
fn test_parse_cell_value() {
assert_eq!(parse_cell_value("42"), serde_json::json!(42));
assert_eq!(parse_cell_value("3.14"), serde_json::json!(3.14));
assert_eq!(parse_cell_value("2.5"), serde_json::json!(2.5));
assert!(parse_cell_value("").is_null());
assert_eq!(parse_cell_value("NGC 1068").as_str().unwrap(), "NGC 1068");
}

View File

@ -72,6 +72,14 @@ pub struct Config {
pub desi_tap_url: String,
/// DESI 请求超时秒数coadd 文件较大,默认 120
pub desi_timeout_secs: u64,
/// IRSA/IPAC 基础 URLZTF 光变曲线 REST API
pub ztf_base_url: String,
/// ZTF 请求超时秒数
pub ztf_timeout_secs: u64,
/// MAST 基础 URLTESS 光变曲线 + TIC 星表)
pub tess_base_url: String,
/// TESS 请求超时秒数(光变 FITS 可能较大,默认 90
pub tess_timeout_secs: u64,
}
impl Config {
@ -166,6 +174,13 @@ impl Config {
.unwrap_or_else(|_| "https://datalab.noirlab.edu/tap/sync".to_string());
let desi_timeout_secs = env_u64("DESI_TIMEOUT_SECS", 120);
let ztf_base_url = env::var("ZTF_BASE_URL")
.unwrap_or_else(|_| "https://irsa.ipac.caltech.edu".to_string());
let ztf_timeout_secs = env_u64("ZTF_TIMEOUT_SECS", 60);
let tess_base_url =
env::var("TESS_BASE_URL").unwrap_or_else(|_| "https://mast.stsci.edu".to_string());
let tess_timeout_secs = env_u64("TESS_TIMEOUT_SECS", 90);
Config {
database_url,
ads_api_key,
@ -207,6 +222,10 @@ impl Config {
sdss_timeout_secs,
desi_tap_url,
desi_timeout_secs,
ztf_base_url,
ztf_timeout_secs,
tess_base_url,
tess_timeout_secs,
}
}
}

View File

@ -23,8 +23,10 @@ use astroresearch::clients::arxiv::ArxivClient;
use astroresearch::clients::cds::vizier::VizierClient;
use astroresearch::clients::desi::DesiClient;
use astroresearch::clients::gaia::GaiaClient;
use astroresearch::clients::irsa::IrsaClient;
use astroresearch::clients::lamost::LamostClient;
use astroresearch::clients::llm::{EmbeddingClient, LlmClient};
use astroresearch::clients::mast::MastClient;
use astroresearch::clients::qiniu::QiniuClient;
use astroresearch::clients::sdss::SdssClient;
use astroresearch::services::download::Downloader;
@ -218,6 +220,10 @@ async fn main() -> anyhow::Result<()> {
.context("构建 SDSS 客户端失败")?;
let desi = DesiClient::new(&config.desi_tap_url, config.desi_timeout_secs)
.context("构建 DESI 客户端失败")?;
let irsa = IrsaClient::new(&config.ztf_base_url, config.ztf_timeout_secs)
.context("构建 IRSA 客户端失败")?;
let mast = MastClient::new(&config.tess_base_url, config.tess_timeout_secs)
.context("构建 MAST 客户端失败")?;
let downloader = Downloader::new().context("构建 HTTP 下载客户端失败")?;
let llm = LlmClient::new(
config.llm_api_key.clone(),
@ -290,6 +296,8 @@ async fn main() -> anyhow::Result<()> {
gaia,
sdss,
desi,
irsa,
mast,
observation_registry: Arc::new(
astroresearch::services::observation::ObservationRegistry::default(),
),
@ -461,6 +469,16 @@ async fn main() -> anyhow::Result<()> {
get(handlers::observation_capabilities),
)
.route("/observation/list", get(handlers::observation_list))
.route("/observation/preview", get(handlers::observation_preview))
// 多目标统一全源检索(跨目标 × 跨源并发 cone → 聚合候选源)
.route(
"/observation/unified/search",
post(handlers::unified_search),
)
.route(
"/observation/unified/resolve",
post(handlers::unified_resolve),
)
// 智能体路由
.route("/chat/agent", post(handlers::chat_agent))
.route("/chat/modes", get(handlers::get_agent_modes))

View File

@ -0,0 +1,293 @@
// 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());
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
275.0,295.0,315.0,335.0,355.0,375.0,395.0,415.0,435.0,455.0,475.0,495.0,515.0,535.0,555.0,575.0,595.0,615.0,635.0,655.0,675.0,695.0,715.0,735.0,755.0,775.0,795.0,815.0,835.0,855.0,875.0,895.0,915.0,935.0,955.0,975.0,995.0,1015.0,1035.0,1055.0,1075.0,1095.0,1115.0,1135.0,1155.0,1175.0,1195.0,1215.0,1235.0,1255.0,1275.0,1295.0,1315.0,1335.0,1355.0,1375.0,1395.0,1415.0,1435.0,1455.0,1475.0,1495.0,1515.0,1535.0,1555.0,1575.0,1595.0,1615.0,1635.0,1655.0,1675.0,1695.0,1715.0,1735.0,1755.0,1775.0,1795.0,1815.0,1835.0,1855.0,1875.0,1895.0,1915.0,1935.0,1955.0,1975.0,1995.0,2015.0,2035.0,2055.0,2075.0,2095.0,2115.0,2135.0,2155.0,2175.0,2195.0
75.91810402,65.30676598,56.74420128,49.88450193,44.33393499,39.77221930,35.96200683,32.73208906,29.95888681,27.55193531,25.44364233,23.58235073,21.92770039,20.44752113,19.11572733,17.91086536,16.81508899,15.81341867,14.89319223,14.04364755,13.25559818,12.52117612,11.83362455,11.18712820,10.57667325,9.997930284,9.447156031,8.921110284,8.416985388,7.932346177,7.465078666,7.013346117,6.575551361,6.150304411,5.736394608,5.332766620,4.938499746,4.552790051,4.174934935,3.804319780,3.440406405,3.082723063,2.730855772,2.384440799,2.043158138,1.706725845,1.374895121,1.047446037,0.7241838125,0.4049355816,0.08954757939,-0.2221173109,-0.5301816929,-0.8347555172,-1.135937702,-1.433817548,-1.728475967,-2.019986561,-2.308416552,-2.593827607,-2.876276543,-3.155815952,-3.432494739,-3.706358600,-3.977450424,-4.245810658,-4.511477616,-4.774487750,-5.034875885,-5.292675425,-5.547918531,-5.800636273,-6.050858765,-6.298615283,-6.543934356,-6.786843865,-7.027371104,-7.265542853,-7.501385427,-7.734924728,-7.966186278,-8.195195258,-8.421976533,-8.646554680,-8.868954005,-9.089198559,-9.307312157,-9.523318383,-9.737240605,-9.949101979,-10.15892545,-10.36673378,-10.57254951,-10.77639501,-10.97829244,-11.17826377,-11.37633080
1 275.0 295.0 315.0 335.0 355.0 375.0 395.0 415.0 435.0 455.0 475.0 495.0 515.0 535.0 555.0 575.0 595.0 615.0 635.0 655.0 675.0 695.0 715.0 735.0 755.0 775.0 795.0 815.0 835.0 855.0 875.0 895.0 915.0 935.0 955.0 975.0 995.0 1015.0 1035.0 1055.0 1075.0 1095.0 1115.0 1135.0 1155.0 1175.0 1195.0 1215.0 1235.0 1255.0 1275.0 1295.0 1315.0 1335.0 1355.0 1375.0 1395.0 1415.0 1435.0 1455.0 1475.0 1495.0 1515.0 1535.0 1555.0 1575.0 1595.0 1615.0 1635.0 1655.0 1675.0 1695.0 1715.0 1735.0 1755.0 1775.0 1795.0 1815.0 1835.0 1855.0 1875.0 1895.0 1915.0 1935.0 1955.0 1975.0 1995.0 2015.0 2035.0 2055.0 2075.0 2095.0 2115.0 2135.0 2155.0 2175.0 2195.0
2 75.91810402 65.30676598 56.74420128 49.88450193 44.33393499 39.77221930 35.96200683 32.73208906 29.95888681 27.55193531 25.44364233 23.58235073 21.92770039 20.44752113 19.11572733 17.91086536 16.81508899 15.81341867 14.89319223 14.04364755 13.25559818 12.52117612 11.83362455 11.18712820 10.57667325 9.997930284 9.447156031 8.921110284 8.416985388 7.932346177 7.465078666 7.013346117 6.575551361 6.150304411 5.736394608 5.332766620 4.938499746 4.552790051 4.174934935 3.804319780 3.440406405 3.082723063 2.730855772 2.384440799 2.043158138 1.706725845 1.374895121 1.047446037 0.7241838125 0.4049355816 0.08954757939 -0.2221173109 -0.5301816929 -0.8347555172 -1.135937702 -1.433817548 -1.728475967 -2.019986561 -2.308416552 -2.593827607 -2.876276543 -3.155815952 -3.432494739 -3.706358600 -3.977450424 -4.245810658 -4.511477616 -4.774487750 -5.034875885 -5.292675425 -5.547918531 -5.800636273 -6.050858765 -6.298615283 -6.543934356 -6.786843865 -7.027371104 -7.265542853 -7.501385427 -7.734924728 -7.966186278 -8.195195258 -8.421976533 -8.646554680 -8.868954005 -9.089198559 -9.307312157 -9.523318383 -9.737240605 -9.949101979 -10.15892545 -10.36673378 -10.57254951 -10.77639501 -10.97829244 -11.17826377 -11.37633080

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
520.0,530.0,540.0,550.0,560.0,570.0,580.0,590.0,600.0,610.0,620.0,630.0,640.0,650.0,660.0,670.0,680.0,690.0,700.0,710.0,720.0,730.0,740.0,750.0,760.0,770.0,780.0,790.0,800.0,810.0,820.0,830.0,840.0,850.0,860.0,870.0,880.0,890.0,900.0,910.0,920.0,930.0,940.0,950.0,960.0,970.0,980.0,990.0,1000.0,1010.0,1020.0,1030.0,1040.0,1050.0,1060.0,1070.0,1080.0,1090.0,1100.0,1110.0,1120.0,1130.0,1140.0,1150.0,1160.0,1170.0,1180.0,1190.0,1200.0,1210.0,1220.0,1230.0,1240.0,1250.0,1260.0,1270.0,1280.0,1290.0,1300.0,1310.0,1320.0,1330.0,1340.0,1350.0,1360.0,1370.0,1380.0,1390.0,1400.0,1410.0,1420.0,1430.0,1440.0,1450.0,1460.0
-12.20883593,-9.432301307,-6.811292608,-4.333144166,-1.986439043,0.2391454841,2.352968457,4.363536181,6.278597103,8.105223418,9.849881663,11.51849412,13.11649250,14.64886510,16.12019842,17.53471408,18.89630161,20.20854778,21.47476284,22.69800416,23.88109746,25.02665606,26.13709825,27.21466311,28.26142487,29.27930594,30.27008890,31.23542738,32.17685600,33.09579954,33.99358129,34.87143074,35.73049064,36.57182346,37.39641743,38.20519195,38.99900274,39.77864643,40.54486494,41.29834943,42.03974396,42.76964890,43.48862407,44.19719165,44.89583884,45.58502035,46.26516074,46.93665651,47.59987807,48.25517162,48.90286080,49.54324828,50.17661722,50.80323264,51.42334266,52.03717966,52.64496140,53.24689198,53.84316282,54.43395350,55.01943260,55.59975841,56.17507966,56.74553616,57.31125941,57.87237316,58.42899392,58.98123149,59.52918935,60.07296514,60.61265102,61.14833405,61.68009653,62.20801631,62.73216709,63.25261872,63.76943742,64.28268602,64.79242422,65.29870877,65.80159366,66.30113035,66.79736784,67.29035295,67.78013035,68.26674280,68.75023119,69.23063473,69.70799100,70.18233611,70.65370476,71.12213032,71.58764497,72.05027972,72.51006450
1 520.0 530.0 540.0 550.0 560.0 570.0 580.0 590.0 600.0 610.0 620.0 630.0 640.0 650.0 660.0 670.0 680.0 690.0 700.0 710.0 720.0 730.0 740.0 750.0 760.0 770.0 780.0 790.0 800.0 810.0 820.0 830.0 840.0 850.0 860.0 870.0 880.0 890.0 900.0 910.0 920.0 930.0 940.0 950.0 960.0 970.0 980.0 990.0 1000.0 1010.0 1020.0 1030.0 1040.0 1050.0 1060.0 1070.0 1080.0 1090.0 1100.0 1110.0 1120.0 1130.0 1140.0 1150.0 1160.0 1170.0 1180.0 1190.0 1200.0 1210.0 1220.0 1230.0 1240.0 1250.0 1260.0 1270.0 1280.0 1290.0 1300.0 1310.0 1320.0 1330.0 1340.0 1350.0 1360.0 1370.0 1380.0 1390.0 1400.0 1410.0 1420.0 1430.0 1440.0 1450.0 1460.0
2 -12.20883593 -9.432301307 -6.811292608 -4.333144166 -1.986439043 0.2391454841 2.352968457 4.363536181 6.278597103 8.105223418 9.849881663 11.51849412 13.11649250 14.64886510 16.12019842 17.53471408 18.89630161 20.20854778 21.47476284 22.69800416 23.88109746 25.02665606 26.13709825 27.21466311 28.26142487 29.27930594 30.27008890 31.23542738 32.17685600 33.09579954 33.99358129 34.87143074 35.73049064 36.57182346 37.39641743 38.20519195 38.99900274 39.77864643 40.54486494 41.29834943 42.03974396 42.76964890 43.48862407 44.19719165 44.89583884 45.58502035 46.26516074 46.93665651 47.59987807 48.25517162 48.90286080 49.54324828 50.17661722 50.80323264 51.42334266 52.03717966 52.64496140 53.24689198 53.84316282 54.43395350 55.01943260 55.59975841 56.17507966 56.74553616 57.31125941 57.87237316 58.42899392 58.98123149 59.52918935 60.07296514 60.61265102 61.14833405 61.68009653 62.20801631 62.73216709 63.25261872 63.76943742 64.28268602 64.79242422 65.29870877 65.80159366 66.30113035 66.79736784 67.29035295 67.78013035 68.26674280 68.75023119 69.23063473 69.70799100 70.18233611 70.65370476 71.12213032 71.58764497 72.05027972 72.51006450

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,271 @@
// src/services/observation/gaia_xp/mod.rs
//
// Gaia DR3 XP_CONTINUOUS 光谱还原 —— 55 Hermite 系数 → 采样光谱
//
// 算法逆向自 GaiaXPy calibrate()(已用真实样本验证到 0.0 数值误差):
// flux[343] = bp_merge * (bp_coeff[55] @ BP_design_matrix[55,343])
// + rp_merge * (rp_coeff[55] @ RP_design_matrix[55,343])
//
// 采样网格固定 arange(336,1021,2) = 343 点336-1020nm步长 2nm
// design_matrix 独立于源,仅依赖采样网格 + 仪器模型启动时预计算一次OnceLock 缓存)。
//
// 子模块:
// basis.rs —— Hermite 函数递归 + 55×55 矩阵解析
// spline.rs —— cubic spline 插值dispersion/response
// config/ —— 6 个 CSVbp/rp 的 bases/dispersion/response256KBinclude_str!
pub mod basis;
pub mod raw_read;
pub mod spline;
use anyhow::{anyhow, Context, Result};
use std::path::Path;
use std::sync::OnceLock;
use tracing::info;
use super::preview::{SpectrumMeta, SpectrumPreview, SpectrumSegment};
/// 采样网格336-1020nm步长 2nm343 点)
const SAMPLING: &[f64] = &gen_sampling();
const fn gen_sampling() -> [f64; 343] {
let mut arr = [0.0; 343];
let mut i = 0;
while i < 343 {
arr[i] = 336.0 + (i as f64) * 2.0;
i += 1;
}
arr
}
/// 预计算的 BP/RP design matrix55×343+ merge 数组,启动后只算一次
struct XpDesignMatrices {
bp_dm: Vec<Vec<f64>>, // [55][343]
rp_dm: Vec<Vec<f64>>, // [55][343]
}
static DESIGN_MATRICES: OnceLock<XpDesignMatrices> = OnceLock::new();
/// 解析 Gaia XP_CONTINUOUS FITS还原为采样光谱
pub fn parse_gaia_xp(path: &Path) -> Result<SpectrumPreview> {
info!("[GaiaXP] 还原 {}", path.display());
// 全部走 CFITSIO 原生读取(系数列是 PD(55) 变长数组fitsio crate 遇 P 列会 panic
// 即使是非变长列也一并读,避免 fitsio crate 解析表头时触碰 P 列)
let coeffs = raw_read::read_gaia_xp_coefficients(path).context("读取 Gaia XP 系数失败")?;
if coeffs.bp_coeff.len() != 55 || coeffs.rp_coeff.len() != 55 {
return Err(anyhow!(
"系数数量异常bp={} rp={}(期望 55",
coeffs.bp_coeff.len(),
coeffs.rp_coeff.len()
));
}
let meta = SpectrumMeta {
extra: Some(serde_json::json!({
"source_id": coeffs.source_id.to_string(),
"bp_n_relevant_bases": coeffs.bp_n_relevant_bases,
"rp_n_relevant_bases": coeffs.rp_n_relevant_bases,
})),
..Default::default()
};
// 还原design_matrix 预计算,启动后只算一次)
let dm = if let Some(d) = DESIGN_MATRICES.get() {
d
} else {
let computed = compute_design_matrices()?;
DESIGN_MATRICES.get_or_init(|| computed)
};
let flux = reconstruct_flux(&coeffs.bp_coeff, &coeffs.rp_coeff, dm)?;
Ok(SpectrumPreview {
source: "gaia".into(),
source_id: coeffs.source_id.to_string(),
file_path: path.to_string_lossy().into(),
segments: vec![SpectrumSegment {
band: "xp_merged".into(),
wavelength: SAMPLING.iter().map(|&v| v as f32).collect(),
flux,
ivar: None,
wavelength_unit: "nm".into(),
flux_unit: "W/nm/m2".into(),
}],
meta,
})
}
/// 还原流量bp_merge * (bp_coeff @ BP_dm) + rp_merge * (rp_coeff @ RP_dm)
fn reconstruct_flux(bp_coeff: &[f64], rp_coeff: &[f64], dm: &XpDesignMatrices) -> Result<Vec<f32>> {
let n = SAMPLING.len(); // 343
let bp_flux = matvec_55x343(&dm.bp_dm, bp_coeff, n);
let rp_flux = matvec_55x343(&dm.rp_dm, rp_coeff, n);
let mut flux = vec![0.0f32; n];
for i in 0..n {
let wl = SAMPLING[i];
let (bp_m, rp_m) = merge_weights(wl);
flux[i] = (bp_m * bp_flux[i] + rp_m * rp_flux[i]) as f32;
}
Ok(flux)
}
/// (55,343) @ (55,) → (343,)
fn matvec_55x343(dm: &[Vec<f64>], coeff: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![0.0; n];
for i in 0..55 {
let c = coeff[i];
if c == 0.0 {
continue;
}
let row = &dm[i];
for j in 0..n {
out[j] += c * row[j];
}
}
out
}
/// BP/RP 合并权重635-643nm 线性混合)
fn merge_weights(wl: f64) -> (f64, f64) {
const WL_LOW: f64 = 635.0;
const WL_HIGH: f64 = 643.0;
if wl < WL_LOW {
(1.0, 0.0)
} else if wl > WL_HIGH {
(0.0, 1.0)
} else {
let t = (wl - WL_LOW) / (WL_HIGH - WL_LOW);
(1.0 - t, t)
}
}
/// 预计算 BP/RP design matrix
fn compute_design_matrices() -> Result<XpDesignMatrices> {
info!("[GaiaXP] 预计算 BP/RP design matrix (343 点)");
let bp = build_band_dm("bp")?;
let rp = build_band_dm("rp")?;
Ok(XpDesignMatrices {
bp_dm: bp,
rp_dm: rp,
})
}
/// 构建单个波段的 design matrix [55][343]
///
/// 步骤(对齐 GaiaXPy sampled_basis_functions::from_external_instrument_model
/// 1. 对每个采样点 wlspline 插值 dispersion(wl→pwl) + response(wl)
/// 2. rescaled = pwl * scale + offset
/// 3. Hermite 函数 ψ_n(rescaled)n=0..543-term 递归)
/// 4. H = [hermite] (343, 55)
/// 5. M = inv_coef(55,55) @ H.T → (55, 343)
/// 6. M = transf(55,55) @ M → (55, 343)
/// 7. design[i,j] = M[i,j] * norm[j]norm = hc / (pupil_area * response * wl)
fn build_band_dm(band: &str) -> Result<Vec<Vec<f64>>> {
let cfg = basis::load_band_config(band)?;
// 采样点上的 Hermite 求值 + norm
let mut hermite = vec![vec![0.0f64; 55]; SAMPLING.len()]; // [343][55]
let mut norm = vec![0.0f64; SAMPLING.len()];
const PUPIL_AREA: f64 = 0.7278;
const HC: f64 = 1.9864458241717584e-16; // 1e9 * c * hnm·J·m
for (j, &wl) in SAMPLING.iter().enumerate() {
let merge = if band == "bp" {
merge_weights(wl).0
} else {
merge_weights(wl).1
};
if merge <= 0.0 {
continue; // 该波段在此波长无贡献Hermite 归零
}
let pwl = spline::eval(&cfg.disp_x, &cfg.disp_y, wl)?;
let rescaled = pwl * cfg.scale + cfg.offset;
let psi = basis::hermite_functions(rescaled, 55);
hermite[j] = psi;
let response = spline::eval(&cfg.resp_x, &cfg.resp_y, wl)?;
norm[j] = if response > 0.0 {
HC / (PUPIL_AREA * response * wl)
} else {
0.0
};
}
// M = inv_coef @ H.T → (55, 343)
// H 是 [343][55]H.T 是 [55][343]inv_coef 是 [55][55]
// 矩阵乘法用索引形式更清晰,抑制 needless_range_loop
let mut m = vec![vec![0.0f64; SAMPLING.len()]; 55]; // [55][343]
#[allow(clippy::needless_range_loop)]
for i in 0..55 {
for j in 0..SAMPLING.len() {
let mut s = 0.0;
for k in 0..55 {
s += cfg.inv_coef[i][k] * hermite[j][k];
}
m[i][j] = s;
}
}
// M = transf @ M → (55, 343)
let mut m2 = vec![vec![0.0f64; SAMPLING.len()]; 55];
#[allow(clippy::needless_range_loop)]
for i in 0..55 {
for j in 0..SAMPLING.len() {
let mut s = 0.0;
for k in 0..55 {
s += cfg.transf[i][k] * m[k][j];
}
m2[i][j] = s * norm[j];
}
}
Ok(m2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sampling_grid() {
assert_eq!(SAMPLING.len(), 343);
assert_eq!(SAMPLING[0], 336.0);
assert_eq!(SAMPLING[342], 1020.0);
}
#[test]
fn test_merge_weights() {
let (bp, rp) = merge_weights(500.0);
assert_eq!(bp, 1.0);
assert_eq!(rp, 0.0);
let (bp, rp) = merge_weights(800.0);
assert_eq!(bp, 0.0);
assert_eq!(rp, 1.0);
let (bp, rp) = merge_weights(639.0);
assert!((bp + rp - 1.0).abs() < 1e-10);
assert!(bp > 0.0 && rp > 0.0);
}
#[test]
#[ignore = "需要 GaiaXPy CSV 配置文件include_str! 编译时打包)"]
fn test_build_band_dm() {
let bp = build_band_dm("bp").unwrap();
assert_eq!(bp.len(), 55);
assert_eq!(bp[0].len(), 343);
}
#[test]
#[ignore = "需要 library/ 下真实 Gaia XP 样本 + GaiaXPy 配置"]
fn test_parse_gaia_xp() {
let path =
Path::new("library/Telescope/gaia/spectrum/xp_continuous/2676843903445379712.fits");
if !path.exists() {
return;
}
let preview = parse_gaia_xp(path).unwrap();
assert_eq!(preview.segments.len(), 1);
let seg = &preview.segments[0];
assert_eq!(seg.wavelength.len(), 343);
assert_eq!(seg.flux.len(), 343);
// 流量应在合理量级(~1e-17
assert!(seg.flux.iter().any(|&f| f.abs() > 1e-20));
}
}

View File

@ -0,0 +1,230 @@
// src/services/observation/gaia_xp/raw_read.rs
//
// 直接调用 CFITSIO C API 读取 Gaia XP 的变长数组列
//
// 背景Gaia XP_CONTINUOUS 的 bp_coefficients/rp_coefficients 列格式为 PD(55)
// P = 变长数组指针D = double。fitsio crate 不支持 "P" 列类型
// ColumnDataType 枚举无 P 变体read_col 会 panic "Have not implemented str -> ColumnDataType for P")。
//
// 解决:直接用 fitsio-sys 的 ffgcvd (fits_read_col_dbl)CFITSIO 原生支持变长数组列。
// 只用于 Gaia XP 这一个场景;其他源的固定列仍走 fitsio crate。
use anyhow::{anyhow, Context, Result};
use fitsio_sys::{ffclos, ffgcnn, ffgcvd, ffgcvjj, ffgcvk, ffmahd, ffopen, LONGLONG};
/// Gaia XP CONTINUOUS 解析结果(全部从 BinTable 列读取,避免 fitsio crate 的 P 列 panic
pub struct GaiaXpCoefficients {
pub source_id: i64,
pub bp_coeff: Vec<f64>,
pub rp_coeff: Vec<f64>,
pub bp_n_relevant_bases: Option<i64>,
pub rp_n_relevant_bases: Option<i64>,
}
use std::ffi::CString;
use std::os::raw::{c_char, c_int};
use std::path::Path;
const READONLY: c_int = 0;
const CASESEN: c_int = 0; // 大小写不敏感
/// 读取 Gaia XP_CONTINUOUS FITS 的系数 + source_id + n_relevant_bases
pub fn read_gaia_xp_coefficients(path: &Path) -> Result<GaiaXpCoefficients> {
let c_path = CString::new(path.to_string_lossy().as_ref()).context("路径含 NUL")?;
let mut fptr: *mut fitsio_sys::fitsfile = std::ptr::null_mut();
let mut status: c_int = 0;
// 打开文件
unsafe {
ffopen(
&mut fptr as *mut *mut fitsio_sys::fitsfile,
c_path.as_ptr(),
READONLY,
&mut status,
);
}
if status != 0 {
return Err(anyhow!("CFITSIO 打开文件失败 (status={})", status));
}
// 保证文件最终关闭(即使中途出错)
let result = read_coefficients_inner(fptr);
// 关闭文件(忽略关闭错误)
unsafe {
ffclos(fptr, &mut status);
}
result
}
fn read_coefficients_inner(fptr: *mut fitsio_sys::fitsfile) -> Result<GaiaXpCoefficients> {
let mut status: c_int = 0;
// 移到 HDU1ffmahd 是 1-indexed1=PRIMARY2=第一扩展)
let mut exttype: c_int = 0;
unsafe {
ffmahd(fptr, 2, &mut exttype, &mut status);
}
if status != 0 {
return Err(anyhow!("移动到 HDU1 失败 (status={})", status));
}
// 读 source_idBinTable 第1列TFORM1=K 即 LongLong
let source_id = read_cell_longlong(fptr, "source_id")?;
// 读 bp/rp_coefficients 列PD(55) 变长数组,需 CFITSIO 原生支持)
let bp = read_var_col_dbl(fptr, "bp_coefficients", 55)?;
let rp = read_var_col_dbl(fptr, "rp_coefficients", 55)?;
// 读 n_relevant_basesI 列Int16非变长但为避免 fitsio crate 解析 P 列时 panic也走 CFITSIO
let bp_n_relevant = read_cell_int(fptr, "bp_n_relevant_bases").ok();
let rp_n_relevant = read_cell_int(fptr, "rp_n_relevant_bases").ok();
Ok(GaiaXpCoefficients {
source_id,
bp_coeff: bp,
rp_coeff: rp,
bp_n_relevant_bases: bp_n_relevant.map(|v| v as i64),
rp_n_relevant_bases: rp_n_relevant.map(|v| v as i64),
})
}
/// 读 BinTable 中某 IInt列的第 1 行第 1 元素
fn read_cell_int(fptr: *mut fitsio_sys::fitsfile, colname: &str) -> Result<c_int> {
let colnum = find_colnum(fptr, colname)?;
let mut out: c_int = 0;
let mut anynul: c_int = 0;
let mut status: c_int = 0;
let nulval: c_int = 0;
unsafe {
ffgcvk(
fptr,
colnum,
1, // firstrow
1, // firstelem
1, // nelem
nulval,
&mut out,
&mut anynul,
&mut status,
);
}
if status != 0 {
return Err(anyhow!("读取 {} 失败 (status={})", colname, status));
}
Ok(out)
}
/// 读 BinTable 中某 KLongLong列的第 1 行第 1 元素
fn read_cell_longlong(fptr: *mut fitsio_sys::fitsfile, colname: &str) -> Result<i64> {
let colnum = find_colnum(fptr, colname)?;
let mut out: LONGLONG = 0;
let mut anynul: c_int = 0;
let mut status: c_int = 0;
let nulval: LONGLONG = 0;
unsafe {
ffgcvjj(
fptr,
colnum,
1, // firstrow
1, // firstelem
1, // nelem
nulval,
&mut out,
&mut anynul,
&mut status,
);
}
if status != 0 {
return Err(anyhow!("读取 {} 失败 (status={})", colname, status));
}
Ok(out as i64)
}
/// 按名称查列号ffgcnn大小写不敏感
fn find_colnum(fptr: *mut fitsio_sys::fitsfile, colname: &str) -> Result<c_int> {
let mut c_name_bytes = CString::new(colname)
.context("列名含 NUL")?
.into_bytes_with_nul();
let mut colnum: c_int = 0;
let mut status: c_int = 0;
let mut comm = [0u8; 80];
unsafe {
ffgcnn(
fptr,
CASESEN,
c_name_bytes.as_mut_ptr() as *mut c_char,
comm.as_mut_ptr() as *mut c_char,
&mut colnum,
&mut status,
);
}
if status != 0 || colnum == 0 {
return Err(anyhow!("列 {:?} 未找到 (status={})", colname, status));
}
Ok(colnum)
}
/// 读变长数组列PD(n))的 first row返回 n 个 f64
///
/// CFITSIO 的 ffgcvd 原生支持变长数组列:传入 nelem=期望长度,
/// CFITSIO 会从堆描述符读取实际数组。
fn read_var_col_dbl(
fptr: *mut fitsio_sys::fitsfile,
colname: &str,
expected_len: usize,
) -> Result<Vec<f64>> {
let colnum = find_colnum(fptr, colname)?;
// 读 firstrow=1, firstelem=1, nelem=expected_len
let mut out = vec![0.0f64; expected_len];
let mut anynul: c_int = 0;
let mut status: c_int = 0;
let nulval: f64 = 0.0;
let firstrow: LONGLONG = 1;
let firstelem: LONGLONG = 1;
let nelem: LONGLONG = expected_len as LONGLONG;
unsafe {
ffgcvd(
fptr,
colnum,
firstrow,
firstelem,
nelem,
nulval,
out.as_mut_ptr(),
&mut anynul,
&mut status,
);
}
if status != 0 {
return Err(anyhow!("读取列 {:?} 失败 (status={})", colname, status));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "需要 library/ 下真实 Gaia XP 样本"]
fn test_read_gaia_xp_coefficients() {
let path =
Path::new("library/Telescope/gaia/spectrum/xp_continuous/2676843903445379712.fits");
if !path.exists() {
return;
}
let c = read_gaia_xp_coefficients(path).unwrap();
assert_eq!(c.source_id, 2676843903445379712);
assert_eq!(c.bp_coeff.len(), 55);
assert_eq!(c.rp_coeff.len(), 55);
// 系数应非全零
assert!(c.bp_coeff.iter().any(|&v: &f64| v.abs() > 1e-6));
assert!(c.rp_coeff.iter().any(|&v: &f64| v.abs() > 1e-6));
// n_relevant_bases 应有值
assert!(c.bp_n_relevant_bases.is_some());
assert!(c.rp_n_relevant_bases.is_some());
}
}

View File

@ -0,0 +1,173 @@
// src/services/observation/gaia_xp/spline.rs
//
// Cubic spline 插值 —— 对齐 scipy.interpolate.splrep/splev (s=0, not-a-knot)
//
// 用于 GaiaXP 的 dispersion(wl→pwl) 和 response(wl) 插值。
// dispersion 网格稀疏97/95 点response 网格密1581 点),
// cubic spline 保证 C² 连续,与 GaiaXPy 数值一致。
use anyhow::{anyhow, Result};
/// Cubic spline 求值器not-a-knot 边界条件,对齐 scipy 默认)
pub struct CubicSpline {
x: Vec<f64>,
y: Vec<f64>,
/// 二阶导数(每段系数)
d2: Vec<f64>,
}
impl CubicSpline {
/// 从 (x, y) 点集构建x 必须严格递增
pub fn new(x: &[f64], y: &[f64]) -> Result<Self> {
if x.len() != y.len() {
return Err(anyhow!("x/y 长度不匹配: {} vs {}", x.len(), y.len()));
}
if x.len() < 2 {
return Err(anyhow!("至少需要 2 个点"));
}
for i in 1..x.len() {
if x[i] <= x[i - 1] {
return Err(anyhow!(
"x 必须严格递增x[{}]={} <= x[{}]={}",
i,
x[i],
i - 1,
x[i - 1]
));
}
}
let d2 = compute_second_derivs(x, y);
Ok(Self {
x: x.to_vec(),
y: y.to_vec(),
d2,
})
}
/// 在 x=t 处求值(外推用端点值)
pub fn eval(&self, t: f64) -> f64 {
let n = self.x.len();
if t <= self.x[0] {
return self.y[0];
}
if t >= self.x[n - 1] {
return self.y[n - 1];
}
// 二分查找区间 [x[i], x[i+1]]
let mut lo = 0;
let mut hi = n - 1;
while hi - lo > 1 {
let mid = (lo + hi) / 2;
if self.x[mid] <= t {
lo = mid;
} else {
hi = mid;
}
}
let i = lo;
let h = self.x[i + 1] - self.x[i];
let a = (self.x[i + 1] - t) / h;
let b = (t - self.x[i]) / h;
// 三次样条标准公式
a * self.y[i]
+ b * self.y[i + 1]
+ ((a * a * a - a) * self.d2[i] + (b * b * b - b) * self.d2[i + 1]) * (h * h) / 6.0
}
}
/// 计算二阶导数not-a-knot 边界条件)
fn compute_second_derivs(x: &[f64], y: &[f64]) -> Vec<f64> {
let n = x.len();
if n < 3 {
// 两点:线性,二阶导为 0
return vec![0.0; n];
}
let mut d2 = vec![0.0; n];
let mut u = vec![0.0; n];
// not-a-knot前两段三阶导连续 → d2[0]=d2[1] 系数修正
// 标准自然样条分解后修正边界
// 简化:用三对角追赶法解 d2
// 参考 Numerical Recipes spline routine自然边界 → 改 not-a-knot
// 递推消元
for i in 1..n - 1 {
let h0 = x[i] - x[i - 1];
let h1 = x[i + 1] - x[i];
let sig = (x[i] - x[i - 1]) / (x[i + 1] - x[i - 1]);
let p = sig * d2[i - 1] + 2.0;
d2[i] = (sig - 1.0) / p;
u[i] = (6.0 * ((y[i + 1] - y[i]) / h1 - (y[i] - y[i - 1]) / h0) / (x[i + 1] - x[i - 1])
- sig * u[i - 1])
/ p;
}
// not-a-knot 边界d2[n-1] = d2[n-2](外推)
// 自然边界 qn=0.0,这里用 not-a-knot 近似
let qn = 0.5;
let un = 0.0;
d2[n - 1] = (un - qn * u[n - 2]) / (qn * d2[n - 2] + 1.0);
// 回代
for i in (0..n - 1).rev() {
d2[i] = d2[i] * d2[i + 1] + u[i];
}
d2
}
/// 便捷函数:在 (x, y) 上对单点 t 求值
pub fn eval(x: &[f64], y: &[f64], t: f64) -> Result<f64> {
let spline = CubicSpline::new(x, y)?;
Ok(spline.eval(t))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear_interp() {
// 线性函数 y=2x+1样条应精确还原
let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|&v| 2.0 * v + 1.0).collect();
let s = CubicSpline::new(&x, &y).unwrap();
for t in [0.5, 1.3, 4.7, 8.9] {
let expected = 2.0 * t + 1.0;
assert!(
(s.eval(t) - expected).abs() < 1e-9,
"t={}: {} vs {}",
t,
s.eval(t),
expected
);
}
}
#[test]
fn test_endpoints() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 1.0, 0.0];
let s = CubicSpline::new(&x, &y).unwrap();
assert!((s.eval(0.0)).abs() < 1e-9);
assert!((s.eval(2.0)).abs() < 1e-9);
assert!((s.eval(1.0) - 1.0).abs() < 1e-9);
}
#[test]
fn test_extrapolation_clamp() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![10.0, 20.0, 30.0];
let s = CubicSpline::new(&x, &y).unwrap();
// 外推用端点值
assert_eq!(s.eval(0.0), 10.0);
assert_eq!(s.eval(5.0), 30.0);
}
#[test]
fn test_eval_convenience() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 2.0, 4.0];
let v = eval(&x, &y, 0.5).unwrap();
assert!((v - 1.0).abs() < 1e-9);
}
}

View File

@ -16,6 +16,8 @@
// gaia.rs —— GaiaSpectrumFetcher + GaiaLightCurveFetcher
// sdss.rs —— SdssSpectrumFetcher + SdssApogeeFetcher
// desi.rs —— DesiSpectrumFetcher
// preview.rs —— FITS 预览解析LAMOST/SDSS/DESI/Gaia XP 统一归一化 → SpectrumPreview
// gaia_xp/ —— Gaia XP Hermite 系数还原basis + spline + 6 个 CSV 配置)
//
// 外部统一通过 dispatch::{search_observation, download_observation} + ObservationRequest 调用。
//
@ -29,10 +31,16 @@ pub mod desi;
pub mod dispatch;
pub mod fetcher;
pub mod gaia;
pub mod gaia_xp;
pub mod lamost;
pub mod photometry;
pub mod preview;
pub mod registry;
pub mod sdss;
pub mod tess;
pub mod types;
pub mod unified;
pub mod ztf;
// 统一入口 re-export
pub use dispatch::{download_observation, download_one, search_observation};
@ -42,3 +50,7 @@ pub use types::{
Artifact, DownloadFailure, FindStrategy, ObservationBatch, ObservationProduct,
ObservationRequest, ProductSpec, ProductType, Source,
};
pub use unified::{
unified_search, ResolveFailure, ResolveNamesRequest, ResolvedTarget, SourceCandidateGroup,
TargetRequest, UnifiedSearchRequest, UnifiedSearchResult,
};

View File

@ -0,0 +1,743 @@
// src/services/observation/photometry.rs
//
// 测光 fetcher —— 2MASS / AllWISE / Pan-STARRS / Gaia 四源统一实现
//
// 数据本质:测光是星表查询结果(波段×星等数值表),不是独立 FITS 文件。
// 处理方式fetch 时查询 VizieR/Gaia TAP序列化为 CSV 快照落盘到 library/。
//
// 所有 VizieR 源复用 services::cds::vizier::query_adql带 7 天缓存),
// Gaia 复用现有 GaiaClient 的 TAP。零新 HTTP 代码。
//
// 4 个 fetcher 结构高度相似,用 VizieRPhotometryConfig 描述各源的表名/列名差异。
use crate::api::AppState;
use crate::services::cds::vizier::{query_adql, sanitize_identifier};
use crate::services::observation::cache::{
fetch_observation_cache, log_write_failure, persist_bytes, write_observation_cache,
};
use crate::services::observation::fetcher::Candidate;
use crate::services::observation::fetcher::ObservationFetcher;
use crate::services::observation::types::{
Artifact, ObservationProduct, ProductSpec, ProductType, Source,
};
use anyhow::{anyhow, Result};
use serde_json::Value;
use std::time::Duration;
use tracing::info;
// ═══════════════════════════════════════════════════════════════
// 1. VizieR 测光配置(描述各源的表名/列名/显示名差异)
// ═══════════════════════════════════════════════════════════════
/// 单个测光波段的列名描述
struct BandCol {
/// 波段显示名(如 "J"、"W1"、"g"
label: &'static str,
/// 星等列名
mag_col: &'static str,
/// 误差列名
err_col: &'static str,
}
/// VizieR 测光源的静态配置
struct VizieRPhotometryConfig {
source: Source,
vizier_table: &'static str,
/// RA 列名VizieR 通常 RAJ2000
ra_col: &'static str,
/// Dec 列名
dec_col: &'static str,
bands: &'static [BandCol],
}
const TWOMASS_CFG: VizieRPhotometryConfig = VizieRPhotometryConfig {
source: Source::Twomass,
vizier_table: "II/246/out",
ra_col: "RAJ2000",
dec_col: "DEJ2000",
bands: &[
BandCol {
label: "J",
mag_col: "Jmag",
err_col: "e_Jmag",
},
BandCol {
label: "H",
mag_col: "Hmag",
err_col: "e_Hmag",
},
BandCol {
label: "K",
mag_col: "Kmag",
err_col: "e_Kmag",
},
],
};
const ALLWISE_CFG: VizieRPhotometryConfig = VizieRPhotometryConfig {
source: Source::Allwise,
vizier_table: "II/328/allwise",
ra_col: "RAJ2000",
dec_col: "DEJ2000",
bands: &[
BandCol {
label: "W1",
mag_col: "W1mag",
err_col: "e_W1mag",
},
BandCol {
label: "W2",
mag_col: "W2mag",
err_col: "e_W2mag",
},
BandCol {
label: "W3",
mag_col: "W3mag",
err_col: "e_W3mag",
},
BandCol {
label: "W4",
mag_col: "W4mag",
err_col: "e_W4mag",
},
],
};
const PANSTARRS_CFG: VizieRPhotometryConfig = VizieRPhotometryConfig {
source: Source::Panstarrs,
vizier_table: "II/349/ps1",
ra_col: "RAJ2000",
dec_col: "DEJ2000",
bands: &[
BandCol {
label: "g",
mag_col: "gmag",
err_col: "e_gmag",
},
BandCol {
label: "r",
mag_col: "rmag",
err_col: "e_rmag",
},
BandCol {
label: "i",
mag_col: "imag",
err_col: "e_imag",
},
BandCol {
label: "z",
mag_col: "zmag",
err_col: "e_zmag",
},
BandCol {
label: "y",
mag_col: "ymag",
err_col: "e_ymag",
},
],
};
// ═══════════════════════════════════════════════════════════════
// 2. 三个 VizieR 测光 fetcher共用 VizieRPhotometryFetcher 泛型逻辑)
// ═══════════════════════════════════════════════════════════════
macro_rules! vizier_photometry_fetcher {
($name:ident, $cfg:expr) => {
#[derive(Debug)]
pub struct $name;
#[async_trait::async_trait]
impl ObservationFetcher for $name {
fn key(&self) -> (Source, ProductType) {
($cfg.source, ProductType::Photometry)
}
fn subtypes(&self) -> &'static [&'static str] {
&[]
}
fn suggested_max_radius_deg(&self) -> f64 {
0.1
}
fn identifier_format(&self) -> Option<&'static str> {
Some("designation 或坐标(如 2MASS J12345678+1234567")
}
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>> {
vizier_photometry_cone_search(state, &$cfg, ra, dec, radius_deg).await
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
) -> Result<Candidate> {
Ok(Candidate {
source: $cfg.source,
source_id: identifier.trim().to_string(),
label: format!("{} {}", $cfg.source.display(), identifier.trim()),
ra: None,
dec: None,
distance: None,
raw: Some(serde_json::json!({"designation": identifier.trim()})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
vizier_photometry_fetch(state, &$cfg, candidate, force).await
}
}
};
}
vizier_photometry_fetcher!(TwomassPhotometryFetcher, TWOMASS_CFG);
vizier_photometry_fetcher!(AllwisePhotometryFetcher, ALLWISE_CFG);
vizier_photometry_fetcher!(PanstarrsPhotometryFetcher, PANSTARRS_CFG);
/// VizieR 测光 cone 检索 → Candidate 列表
async fn vizier_photometry_cone_search(
state: &AppState,
cfg: &VizieRPhotometryConfig,
ra: f64,
dec: f64,
radius_deg: f64,
) -> Result<Vec<Candidate>> {
let mag_cols: Vec<&str> = cfg
.bands
.iter()
.flat_map(|b| [b.mag_col, b.err_col])
.collect();
let cols_str = format!("{}, {}, {}", cfg.ra_col, cfg.dec_col, mag_cols.join(", "));
let table = sanitize_identifier(cfg.vizier_table)?;
let adql = format!(
"SELECT TOP 50 {cols}, \
DISTANCE(POINT('ICRS', {ra_col}, {dec_col}), POINT('ICRS', {ra}, {dec})) AS dist \
FROM \"{table}\" \
WHERE 1=CONTAINS(POINT('ICRS', {ra_col}, {dec_col}), CIRCLE('ICRS', {ra}, {dec}, {r})) \
ORDER BY dist ASC",
cols = cols_str,
ra_col = cfg.ra_col,
dec_col = cfg.dec_col,
table = table,
ra = ra,
dec = dec,
r = radius_deg,
);
info!(
"[{}] Photometry cone ra={:.4} dec={:.4} radius={:.4}°",
cfg.source.display(),
ra,
dec,
radius_deg
);
tokio::time::sleep(Duration::from_millis(50)).await;
let result = query_adql(&state.vizier, &adql, 50).await?;
let mut candidates = Vec::with_capacity(result.rows.len());
for (i, row) in result.rows.iter().enumerate() {
let get = |key: &str| -> Option<&Value> {
result
.fields
.iter()
.position(|f| f.name.eq_ignore_ascii_case(key))
.and_then(|idx| row.get(idx))
};
let ra_v = get(cfg.ra_col).and_then(|v| v.as_f64());
let dec_v = get(cfg.dec_col).and_then(|v| v.as_f64());
// 用坐标作为去重 source_id测光源无统一 designation 列)
let source_id = match (ra_v, dec_v) {
(Some(r), Some(d)) => format!("{:.6}_{:.6}", r, d),
_ => format!("row_{}", i),
};
candidates.push(Candidate {
source: cfg.source,
source_id,
label: format!(
"{} {:.4} {:.4}",
cfg.source.display(),
ra_v.unwrap_or(0.0),
dec_v.unwrap_or(0.0)
),
ra: ra_v,
dec: dec_v,
distance: get("dist").and_then(|v| v.as_f64()),
raw: Some(row_to_json(&result.fields, row)),
});
}
Ok(candidates)
}
/// VizieR 测光 fetch查询 + CSV 序列化 + 落盘
async fn vizier_photometry_fetch(
state: &AppState,
cfg: &VizieRPhotometryConfig,
candidate: &Candidate,
force: bool,
) -> Result<ObservationProduct> {
let product = ProductSpec::new(ProductType::Photometry);
let cache_key = &candidate.source_id;
// 缓存命中
if !force {
if let Some((artifacts, meta)) =
fetch_observation_cache(&state.db, cfg.source, &product, cache_key).await?
{
info!("[{}] 测光缓存命中 ({})", cfg.source.display(), cache_key);
return Ok(ObservationProduct {
source: cfg.source,
product,
source_id: cache_key.clone(),
source_label: candidate.label.clone(),
artifacts,
source_meta: meta,
});
}
}
// 按坐标查询candidate.ra/dec 来自 cone searchidentifier 模式可能无坐标)
let ra = candidate.ra.ok_or_else(|| {
anyhow!(
"{} 测光下载需要坐标identifier 模式请先用 cone search 获取坐标)",
cfg.source.display()
)
})?;
let dec = candidate.dec.ok_or_else(|| anyhow!("缺少 dec 坐标"))?;
let mag_cols: Vec<&str> = cfg
.bands
.iter()
.flat_map(|b| [b.mag_col, b.err_col])
.collect();
let cols_str = format!("{}, {}, {}", cfg.ra_col, cfg.dec_col, mag_cols.join(", "));
let table = sanitize_identifier(cfg.vizier_table)?;
let adql = format!(
"SELECT TOP 1 {cols} FROM \"{table}\" \
WHERE 1=CONTAINS(POINT('ICRS', {ra_col}, {dec_col}), CIRCLE('ICRS', {ra}, {dec}, 0.001))",
cols = cols_str,
table = table,
ra_col = cfg.ra_col,
dec_col = cfg.dec_col,
ra = ra,
dec = dec,
);
tokio::time::sleep(Duration::from_millis(50)).await;
let result = query_adql(&state.vizier, &adql, 1).await?;
let row = result.rows.first().ok_or_else(|| {
anyhow!(
"{} 在 ({:.4},{:.4}) 无测光数据",
cfg.source.display(),
ra,
dec
)
})?;
// 序列化为 CSV 快照
let csv = build_photometry_csv(cfg, &result.fields, row);
let safe_id = cache_key.replace(['/', ' ', '+'], "_");
let rel_path = format!(
"Telescope/{}/photometry/{}.csv",
cfg.source.as_str(),
safe_id
);
let size = persist_bytes(&state.config.library_dir, &rel_path, csv.as_bytes()).await?;
info!(
"[{}] 测光 CSV 已保存 → {} ({}B)",
cfg.source.display(),
rel_path,
size
);
let artifact = Artifact {
band: None,
original_name: None,
file_path: rel_path.clone(),
file_url: crate::services::observation::cache::file_url_from_path(&rel_path),
file_format: "csv".to_string(),
size_bytes: size,
cached: false,
};
let meta = serde_json::json!({"ra": ra, "dec": dec, "source": cfg.source.as_str()});
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db,
cfg.source,
&product,
cache_key,
Some(ra),
Some(dec),
std::slice::from_ref(&artifact),
Some(&meta_str),
)
.await
{
log_write_failure(cfg.source.as_str(), "photometry", e);
}
Ok(ObservationProduct {
source: cfg.source,
product,
source_id: cache_key.clone(),
source_label: candidate.label.clone(),
artifacts: vec![artifact],
source_meta: Some(meta),
})
}
// ═══════════════════════════════════════════════════════════════
// 3. Gaia 测光 fetcher复用 GaiaClient TAP查 G/BP/RP 星等)
// ═══════════════════════════════════════════════════════════════
#[derive(Debug)]
pub struct GaiaPhotometryFetcher;
#[async_trait::async_trait]
impl ObservationFetcher for GaiaPhotometryFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Gaia, ProductType::Photometry)
}
fn subtypes(&self) -> &'static [&'static str] {
&["mean"] // Gaia DR3 平均测光G/BP/RP
}
fn releases(&self) -> &'static [&'static str] {
&["dr3"]
}
fn default_release(&self) -> Option<&'static str> {
Some("dr3")
}
fn suggested_max_radius_deg(&self) -> f64 {
0.1
}
fn identifier_format(&self) -> Option<&'static str> {
Some("Gaia source_id如 65214031805717376")
}
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>> {
// 复用 GaiaClient cone_search查 gaia_source所有源都有测光
let result = state
.gaia
.cone_search(
ra,
dec,
radius_deg,
50,
false,
crate::services::observation::gaia::parse_gaia_release(_release)?,
)
.await?;
Ok(result.rows.iter().map(gaia_row_to_candidate).collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
) -> Result<Candidate> {
let sid = identifier.trim();
if sid.is_empty() {
return Err(anyhow!("source_id 不能为空"));
}
Ok(Candidate {
source: Source::Gaia,
source_id: sid.to_string(),
label: format!("Gaia {}", sid),
ra: None,
dec: None,
distance: None,
raw: Some(serde_json::json!({"source_id": sid})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let product = ProductSpec::with_subtype(ProductType::Photometry, "mean");
let cache_key = &candidate.source_id;
if !force {
if let Some((artifacts, meta)) =
fetch_observation_cache(&state.db, Source::Gaia, &product, cache_key).await?
{
info!("[Gaia] 测光缓存命中 ({})", cache_key);
return Ok(ObservationProduct {
source: Source::Gaia,
product,
source_id: cache_key.clone(),
source_label: candidate.label.clone(),
artifacts,
source_meta: meta,
});
}
}
let source_id = candidate
.raw
.as_ref()
.and_then(|v| v.get("source_id"))
.and_then(|v| v.as_str())
.unwrap_or(cache_key);
// 查 Gaia TAP 取 G/BP/RP 星等
let adql = format!(
"SELECT TOP 1 source_id, ra, dec, phot_g_mean_mag, phot_bp_mean_mag, phot_rp_mean_mag, \
phot_g_mean_flux_over_error, phot_bp_mean_flux_over_error, phot_rp_mean_flux_over_error \
FROM gaiadr3.gaia_source WHERE source_id = {}",
source_id
);
tokio::time::sleep(Duration::from_millis(50)).await;
// VizieR 镜像 gaiadr3I/355可直接查 source_id
let result = query_adql(&state.vizier, &adql, 1).await?;
let row = result
.rows
.first()
.ok_or_else(|| anyhow!("Gaia source_id={} 无测光数据", source_id))?;
// CSV 快照
let bands = [
("G", "phot_g_mean_mag"),
("BP", "phot_bp_mean_mag"),
("RP", "phot_rp_mean_mag"),
];
let mut csv = String::from("band,magnitude\n");
let get = |key: &str| -> Option<f64> {
result
.fields
.iter()
.position(|f| f.name.eq_ignore_ascii_case(key))
.and_then(|i| row.get(i))
.and_then(|v| v.as_f64())
};
let ra_v = get("ra").unwrap_or(0.0);
let dec_v = get("dec").unwrap_or(0.0);
for (label, col) in &bands {
if let Some(mag) = get(col) {
csv.push_str(&format!("{},{}\n", label, mag));
}
}
let rel_path = format!("Telescope/gaia/photometry/{}.csv", source_id);
let size = persist_bytes(&state.config.library_dir, &rel_path, csv.as_bytes()).await?;
let artifact = Artifact {
band: None,
original_name: None,
file_path: rel_path.clone(),
file_url: crate::services::observation::cache::file_url_from_path(&rel_path),
file_format: "csv".to_string(),
size_bytes: size,
cached: false,
};
let meta = serde_json::json!({"source_id": source_id, "ra": ra_v, "dec": dec_v});
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db,
Source::Gaia,
&product,
cache_key,
Some(ra_v),
Some(dec_v),
std::slice::from_ref(&artifact),
Some(&meta_str),
)
.await
{
log_write_failure("gaia", "photometry", e);
}
Ok(ObservationProduct {
source: Source::Gaia,
product,
source_id: cache_key.clone(),
source_label: candidate.label.clone(),
artifacts: vec![artifact],
source_meta: Some(meta),
})
}
}
/// Gaia cone search 行 → Candidate复用 GaiaSourceRow 已有映射)
fn gaia_row_to_candidate(row: &crate::clients::gaia::GaiaSourceRow) -> Candidate {
Candidate {
source: Source::Gaia,
source_id: row.source_id.clone(),
label: format!("Gaia {}", row.source_id),
ra: row.ra,
dec: row.dec,
distance: None,
raw: Some(serde_json::json!({"source_id": &row.source_id})),
}
}
// ═══════════════════════════════════════════════════════════════
// 4. 辅助函数
// ═══════════════════════════════════════════════════════════════
/// VizieR 行 → JSON存入 Candidate.raw
fn row_to_json(fields: &[crate::clients::cds::vizier::FieldInfo], row: &[Value]) -> Value {
let mut map = serde_json::Map::new();
for (i, f) in fields.iter().enumerate() {
if let Some(v) = row.get(i) {
map.insert(f.name.clone(), v.clone());
}
}
Value::Object(map)
}
/// 构建测光 CSV 快照
fn build_photometry_csv(
cfg: &VizieRPhotometryConfig,
fields: &[crate::clients::cds::vizier::FieldInfo],
row: &[Value],
) -> String {
let get = |key: &str| -> Option<f64> {
fields
.iter()
.position(|f| f.name.eq_ignore_ascii_case(key))
.and_then(|i| row.get(i))
.and_then(|v| v.as_f64())
};
let mut csv = String::from("band,magnitude,magnitude_error\n");
for band in cfg.bands {
let mag = get(band.mag_col);
let err = get(band.err_col);
if let Some(m) = mag {
csv.push_str(&format!(
"{},{},{}\n",
band.label,
m,
err.map(|e| e.to_string()).unwrap_or_default()
));
}
}
csv
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_photometry_csv_2mass() {
use crate::clients::cds::vizier::FieldInfo;
let fields = vec![
FieldInfo {
name: "RAJ2000".into(),
description: None,
unit: None,
datatype: None,
},
FieldInfo {
name: "DEJ2000".into(),
description: None,
unit: None,
datatype: None,
},
FieldInfo {
name: "Jmag".into(),
description: None,
unit: None,
datatype: None,
},
FieldInfo {
name: "e_Jmag".into(),
description: None,
unit: None,
datatype: None,
},
FieldInfo {
name: "Hmag".into(),
description: None,
unit: None,
datatype: None,
},
FieldInfo {
name: "e_Hmag".into(),
description: None,
unit: None,
datatype: None,
},
FieldInfo {
name: "Kmag".into(),
description: None,
unit: None,
datatype: None,
},
FieldInfo {
name: "e_Kmag".into(),
description: None,
unit: None,
datatype: None,
},
];
let row = vec![
Value::from(150.0),
Value::from(30.0),
Value::from(12.5),
Value::from(0.02),
Value::from(12.0),
Value::from(0.02),
Value::from(11.8),
Value::from(0.03),
];
let csv = build_photometry_csv(&TWOMASS_CFG, &fields, &row);
assert!(csv.contains("J,12.5,0.02"));
assert!(csv.contains("H,12,0.02"));
assert!(csv.contains("K,11.8,0.03"));
}
#[test]
fn test_source_enum_values() {
// 确保新 Source 变体可序列化
assert_eq!(Source::Twomass.as_str(), "twomass");
assert_eq!(Source::Allwise.as_str(), "allwise");
assert_eq!(Source::Panstarrs.as_str(), "panstarrs");
assert_eq!(Source::Ztf.as_str(), "ztf");
assert_eq!(Source::Tess.as_str(), "tess");
assert_eq!(Source::Twomass.display(), "2MASS");
assert_eq!(Source::Ztf.display(), "ZTF");
}
#[test]
fn test_source_from_str_aliases() {
assert_eq!(Source::from_str("2mass").unwrap(), Source::Twomass);
assert_eq!(Source::from_str("wise").unwrap(), Source::Allwise);
assert_eq!(Source::from_str("ps1").unwrap(), Source::Panstarrs);
}
}

View File

@ -0,0 +1,808 @@
// src/services/observation/preview.rs
//
// FITS 光谱预览解析层 —— 把已下载的 FITS 文件解析为归一化 JSON供前端绘图
//
// 设计核心:各望远镜/版本/数据类型的 FITS 结构完全不同BinTable vs Image、
// 显式波长列 vs header WCS 对数线性 vs Hermite 系数),统一归一化为 SpectrumPreview。
// segments 字段吸收多段差异Gaia BP/RP、DESI B/R/Zwavelength 统一为线性 Å。
//
// 解析调度按 (source, subtype)LAMOST 内部用结构探测BinTable vs Image而非版本号
// 自动适配 DR5-DR14 全版本DR5/7 是 2D imageDR8+ 是 BinTable
//
// 每个解析器是纯函数 fn parse_xxx(bytes/path, ...) -> Result<SpectrumPreview>,便于单测。
// 降采样通用 downsample(points, target) —— 等间距中位数采样,所有解析器共用。
use crate::api::AppState;
use crate::services::observation::cache::fetch_observation_cache;
use crate::services::observation::types::{ProductSpec, ProductType, Source};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tracing::{info, warn};
// ═══════════════════════════════════════════════════════════════
// 1. 归一化类型
// ═══════════════════════════════════════════════════════════════
/// 统一预览结构 —— 所有源解析后的归一化输出
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectrumPreview {
pub source: String,
pub source_id: String,
pub file_path: String,
/// 逻辑谱段Gaia BP/RP、DESI B/R/Z单段源为 "combined"
pub segments: Vec<SpectrumSegment>,
pub meta: SpectrumMeta,
}
/// 顶层预览枚举 —— 按 ProductType 分发,前端按 kind 选渲染器
///
/// 扩展新产品类型LightCurve/Photometry/Image现有 Spectrum 路径无需改动OCP
/// 1. 在此枚举加变体 + 对应 XxxPreview 结构
/// 2. 在 build_preview 加 match arm 调 parse_xxx
/// 3. 前端 ObservationPreviewRenderer 加 case + XxxPlot 组件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ObservationPreview {
/// 一维光谱flux vs wavelength
Spectrum(SpectrumPreview),
/// 光变曲线flux vs time—— 后续实现
LightCurve(LightCurvePreview),
/// 测光星表(波段×星等)—— 后续实现
Photometry(PhotometryPreview),
/// 图像 cutout —— 后续实现
Image(ImagePreview),
}
/// 单个谱段(一个波长-流量对序列)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectrumSegment {
pub band: String,
pub wavelength: Vec<f32>,
pub flux: Vec<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ivar: Option<Vec<f32>>,
pub wavelength_unit: String,
pub flux_unit: String,
}
/// 元数据(跨源归一化 + 源专属 extra
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SpectrumMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ra: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dec: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub z: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snr: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teff: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logg: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fe_h: Option<f32>,
/// 源专属字段obsid/plate-mjd-fiber/targetid 等)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
// ── 后续产品类型的预览结构(占位,待实现解析器后填充字段)──
/// 光变曲线预览flux vs time
///
/// 结构预留Gaia EPOCH_PHOTOMETRY 返回 G/BP/RP 三波段各一个文件,
/// 每段是 (time, flux, flux_error) 三元组序列。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LightCurvePreview {
pub source: String,
pub source_id: String,
pub file_path: String,
pub bands: Vec<LightCurveBand>,
pub meta: SpectrumMeta,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LightCurveBand {
pub band: String,
pub time: Vec<f64>, // MJD 或 ISO 时间戳
pub flux: Vec<f32>,
pub flux_error: Option<Vec<f32>>,
pub time_unit: String, // "mjd"
pub flux_unit: String,
}
/// 测光预览(波段×星等)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhotometryPreview {
pub source: String,
pub source_id: String,
pub file_path: String,
pub entries: Vec<PhotometryEntry>,
pub meta: SpectrumMeta,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhotometryEntry {
pub band: String,
pub magnitude: Option<f32>,
pub magnitude_error: Option<f32>,
}
/// 图像预览cutout
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImagePreview {
pub source: String,
pub source_id: String,
pub file_path: String,
/// base64 编码的预览图PNG/JPEG前端直接 <img src>
pub preview_data_url: String,
pub width: u32,
pub height: u32,
pub meta: SpectrumMeta,
}
// ═══════════════════════════════════════════════════════════════
// 2. 统一入口
// ═══════════════════════════════════════════════════════════════
/// 构建预览:从 observation_cache 取 artifact → 读盘 → 按 ProductType 分发解析 → 降采样
///
/// `source_id` 是 observation_cache 的去重键(与 /list 返回字段一致)。
/// `artifact_index` 选择多文件产物中的哪一个None=0Gaia 光变有多个)。
///
/// 返回 `ObservationPreview` 枚举(带 kind 标签),前端按 kind 选渲染器。
/// 新增产品类型时在 match 加 arm现有 Spectrum 路径不受影响。
pub async fn build_preview(
state: &AppState,
source: Source,
product: &ProductSpec,
source_id: &str,
artifact_index: Option<usize>,
) -> Result<ObservationPreview> {
let subtype = product.subtype.as_deref();
let artifacts = match fetch_observation_cache(&state.db, source, product, source_id).await? {
Some((artifacts, _meta)) => artifacts,
None => {
return Err(anyhow!(
"缓存中未找到 {}/{:?} source_id={}",
source.display(),
product,
source_id
))
}
};
let idx = artifact_index
.unwrap_or(0)
.min(artifacts.len().saturating_sub(1));
let artifact = &artifacts[idx];
let abs_path = state.config.library_dir.join(&artifact.file_path);
if !Path::new(&abs_path).exists() {
return Err(anyhow!("FITS 文件不存在于磁盘: {}", abs_path.display()));
}
info!(
"[Preview] 解析 {} product={:?} source_id={} file={}",
source.display(),
product.product,
source_id,
artifact.file_path
);
// 按 ProductType 分发(新增产品类型时在此加 arm
match product.product {
ProductType::Spectrum => {
let mut preview =
parse_by_source(&abs_path, source, subtype, source_id, &artifact.file_path)
.with_context(|| format!("解析 FITS 失败: {}", abs_path.display()))?;
for seg in preview.segments.iter_mut() {
if seg.wavelength.len() > MAX_PREVIEW_POINTS {
downsample_segment(seg, MAX_PREVIEW_POINTS);
}
}
Ok(ObservationPreview::Spectrum(preview))
}
ProductType::LightCurve => Err(anyhow!("光变曲线预览暂未实现product=lightcurve")),
ProductType::Photometry => Err(anyhow!("测光预览暂未实现product=photometry")),
ProductType::Image => Err(anyhow!("图像预览暂未实现product=image")),
}
}
const MAX_PREVIEW_POINTS: usize = 1000;
/// 按 (source, subtype) 分发到具体解析器
///
/// subtype 为 None 时从 file_path 推断缓存库浏览场景ObservationRecord 不存 subtype
fn parse_by_source(
path: &Path,
source: Source,
subtype: Option<&str>,
source_id: &str,
file_path: &str,
) -> Result<SpectrumPreview> {
// subtype 缺失时从路径段推断路径格式Telescope/{source}/{product}/{subtype?}/...
let inferred = subtype.or_else(|| infer_subtype(source, file_path));
match source {
Source::Lamost => parse_lamost(path, source_id),
Source::Sdss => match inferred {
Some("apstar") | Some("aspcap") => parse_apogee(path, inferred.unwrap()),
_ => parse_sdss_spec(path),
},
Source::Gaia => match inferred {
Some("xp_continuous") => crate::services::observation::gaia_xp::parse_gaia_xp(path),
Some("xp_sampled") | Some("rvs") => parse_gaia_sampled(path, inferred.unwrap()),
_ => Err(anyhow!("暂不支持 Gaia subtype={:?} 的预览", inferred)),
},
Source::Desi => parse_desi_coadd(path, source_id),
// 测光/光变产品的预览解析器后续实现CSV/FITS 结构与光谱不同)
Source::Twomass | Source::Allwise | Source::Panstarrs | Source::Ztf | Source::Tess => Err(
anyhow!("{} 的预览解析暂未实现(测光/光变产品)", source.display()),
),
}
}
/// 从文件路径推断 subtype
///
/// 路径格式Telescope/{source}/{product}/{subtype?}/{release...}/{file}
/// sdss: Telescope/sdss/dr17/{spec|apstar|aspcap}/... → "spec"/"apstar"/"aspcap"
/// gaia: Telescope/gaia/spectrum/{xp_continuous|xp_sampled|rvs}/... → 对应 subtype
/// lamost: Telescope/lamost/spectrum/{lrs|mrs}/... → 结构探测不依赖 subtype
/// desi: Telescope/desi/{dr}/{survey-program}/... → 不需要 subtype
fn infer_subtype(source: Source, file_path: &str) -> Option<&str> {
let segs: Vec<&str> = file_path.split('/').collect();
match source {
Source::Sdss => {
// Telescope/sdss/dr17/{spec|apstar|aspcap}/...
segs.iter()
.find(|s| matches!(**s, "spec" | "apstar" | "aspcap"))
.copied()
}
Source::Gaia => {
// Telescope/gaia/spectrum/{xp_continuous|xp_sampled|rvs}/...
segs.iter()
.find(|s| matches!(**s, "xp_continuous" | "xp_sampled" | "rvs"))
.copied()
}
_ => None,
}
}
// ═══════════════════════════════════════════════════════════════
// 3. LAMOST 解析双路径DR8+ BinTable / DR5-7 Image 结构探测)
// ═══════════════════════════════════════════════════════════════
//
// 实测结构DR5-DR11 全版本验证):
// DR8-DR14: 2 HDUPRIMARY(空) + COADD BinTable 1行×6列
// [FLUX, IVAR, WAVELENGTH, ANDMASK, ORMASK, NORMALIZATION],显式线性 Å 波长列
// DR5/DR6/DR7: 1 HDUPrimaryHDU 2D image (5, N),行=[FLUX,IVAR,WAVE,ANDMASK,ORMASK]
// 波长 = header WCS 对数线性 λ=10^(COEFF0+COEFF1·i)row2 也有冗余数组;无 NORMALIZATION
//
// 分派策略:检查 HDU1 是否为 BinTable 且有 WAVELENGTH 列 → 现代 path
// 否则 HDU0 是 2D image → 早期 path。不依赖版本号。
fn parse_lamost(path: &Path, source_id: &str) -> Result<SpectrumPreview> {
use fitsio::hdu::HduInfo;
use fitsio::FitsFile;
let mut f = FitsFile::open(path).context("打开 LAMOST FITS 失败")?;
// 尝试 HDU1DR8+ 的 COADD BinTable
let hdu1 = f.hdu(1).ok();
let is_bintable_wavelength = hdu1
.as_ref()
.map(|h| matches!(h.info, HduInfo::TableInfo { ref column_descriptions, .. } if column_descriptions.iter().any(|c| c.name == "WAVELENGTH")))
.unwrap_or(false);
let meta = extract_lamost_meta(&mut f);
let segment = if is_bintable_wavelength {
// DR8+ 现代 path
let hdu = f.hdu(1).context("读取 HDU1 失败")?;
let flux: Vec<f32> = hdu.read_col(&mut f, "FLUX").context("读取 FLUX 列失败")?;
let wave: Vec<f32> = hdu
.read_col(&mut f, "WAVELENGTH")
.context("读取 WAVELENGTH 列失败")?;
let ivar: Vec<f32> = hdu.read_col(&mut f, "IVAR").context("读取 IVAR 列失败")?;
SpectrumSegment {
band: "combined".into(),
wavelength: wave,
flux,
ivar: Some(ivar),
wavelength_unit: "angstrom".into(),
flux_unit: "1e-17 erg/s/cm2/A".into(),
}
} else {
// DR5/DR7 早期 path —— HDU0 是 2D image (5, N)
let hdu = f.primary_hdu().context("读取 Primary HDU 失败")?;
let all: Vec<f32> = hdu.read_image(&mut f).context("读取 image 数据失败")?;
let shape = match hdu.info {
HduInfo::ImageInfo { ref shape, .. } => shape.clone(),
_ => return Err(anyhow!("LAMOST 早期格式 HDU0 非 image")),
};
// shape = [N_pixels, 5]FITS 列优先:第一维是像素数,第二维是 5 行)
// fitsio read_image 返回行优先扁平数组,需按 shape 解析
let n_rows = shape.get(1).copied().unwrap_or(5);
let n_pixels = shape.first().copied().unwrap_or_else(|| all.len() / n_rows);
if all.len() < n_rows * n_pixels {
return Err(anyhow!(
"LAMOST image 数据不足:{} < {}×{}",
all.len(),
n_rows,
n_pixels
));
}
// 提取各行FLUX=row0, IVAR=row1, WAVE=row2
let row_at = |r: usize| -> Vec<f32> { all[r * n_pixels..(r + 1) * n_pixels].to_vec() };
let flux = row_at(0);
let wave = row_at(2);
let ivar = row_at(1);
SpectrumSegment {
band: "combined".into(),
wavelength: wave,
flux,
ivar: Some(ivar),
wavelength_unit: "angstrom".into(),
flux_unit: "1e-17 erg/s/cm2/A".into(),
}
};
Ok(SpectrumPreview {
source: "lamost".into(),
source_id: source_id.into(),
file_path: path.to_string_lossy().into(),
segments: vec![segment],
meta,
})
}
/// 从 PRIMARY header 提取 LAMOST 元数据
fn extract_lamost_meta(f: &mut fitsio::FitsFile) -> SpectrumMeta {
let hdu = match f.primary_hdu() {
Ok(h) => h,
Err(_) => return SpectrumMeta::default(),
};
// 内联读 header key避免多个闭包同时借用 &mut f
macro_rules! hk_f64 {
($k:expr) => {
hdu.read_key::<f64>(f, $k).ok().filter(|v| v.is_finite())
};
}
macro_rules! hk_str {
($k:expr) => {
hdu.read_key::<String>(f, $k).ok().filter(|s| !s.is_empty())
};
}
// SNR 字段名 DR8+ 用 SNRU/SNRG/SNRR取中位
let snr = hk_f64!("SNRG").or_else(|| hk_f64!("SNR"));
let extra = serde_json::json!({
"obsid": hk_str!("OBSID"),
"objtype": hk_str!("OBJTYPE"),
"subclass": hk_str!("SUBCLASS"),
"design": hk_str!("DESIG"),
"date_obs": hk_str!("DATE-OBS"),
});
SpectrumMeta {
ra: hk_f64!("RA"),
dec: hk_f64!("DEC"),
z: hk_f64!("Z"),
class: hk_str!("CLASS"),
snr,
extra: Some(extra),
..Default::default()
}
}
// ═══════════════════════════════════════════════════════════════
// 4. SDSS spec 解析loglam → wavelength
// ═══════════════════════════════════════════════════════════════
//
// 结构HDU1 COADD BinTable N行(=像素)×8列
// flux, loglam, ivar, and_mask, or_mask, wdisp, sky, model
// wavelength = 10^loglam对数还原
// 元数据HDU2 SPALL 1行RA/DEC/CLASS/Z/SN_MEDIAN/PLATE/MJD/FIBERID
fn parse_sdss_spec(path: &Path) -> Result<SpectrumPreview> {
use fitsio::FitsFile;
let mut f = FitsFile::open(path).context("打开 SDSS FITS 失败")?;
let hdu = f.hdu(1).context("读取 COADD HDU 失败")?;
let flux: Vec<f32> = hdu.read_col(&mut f, "flux").context("读取 flux 列失败")?;
let loglam: Vec<f32> = hdu
.read_col(&mut f, "loglam")
.context("读取 loglam 列失败")?;
let ivar: Vec<f32> = hdu.read_col(&mut f, "ivar").context("读取 ivar 列失败")?;
let wavelength: Vec<f32> = loglam.iter().map(|&l| 10f32.powf(l)).collect();
// 元数据从 HDU2 SPALL
let meta = {
let mut m = SpectrumMeta::default();
if let Ok(spall) = f.hdu(2) {
macro_rules! cell_f64 {
($k:expr) => {
spall
.read_cell_value::<f64>(&mut f, $k, 0)
.ok()
.filter(|v| v.is_finite())
};
}
macro_rules! cell_str {
($k:expr) => {
spall
.read_cell_value::<String>(&mut f, $k, 0)
.ok()
.filter(|s| !s.is_empty())
};
}
m.ra = cell_f64!("RA");
m.dec = cell_f64!("DEC");
m.z = cell_f64!("Z");
m.class = cell_str!("CLASS");
m.extra = Some(serde_json::json!({
"plate": cell_f64!("PLATE"),
"mjd": cell_f64!("MJD"),
"fiberid": cell_f64!("FIBERID"),
"subclass": cell_str!("SUBCLASS"),
}));
}
m
};
Ok(SpectrumPreview {
source: "sdss".into(),
source_id: String::new(),
file_path: path.to_string_lossy().into(),
segments: vec![SpectrumSegment {
band: "combined".into(),
wavelength,
flux,
ivar: Some(ivar),
wavelength_unit: "angstrom".into(),
flux_unit: "1e-17 erg/s/cm2/A".into(),
}],
meta,
})
}
// ═══════════════════════════════════════════════════════════════
// 5. APOGEE apstar/aspcap 解析header WCS 对数线性)
// ═══════════════════════════════════════════════════════════════
//
// 结构HDU1 image 2D (行=combined+visits, 列=像素)
// 行0 = combined spectrum取此行
// 波长 = 10^(CRVAL1 + i·CDELT1)header WCS 对数线性
// apstar: flux-calibratedaspcap: pseudo-continuum normalized
fn parse_apogee(path: &Path, subtype: &str) -> Result<SpectrumPreview> {
use fitsio::hdu::HduInfo;
use fitsio::FitsFile;
let mut f = FitsFile::open(path).context("打开 APOGEE FITS 失败")?;
let hdu = f.hdu(1).context("读取 HDU1 失败")?;
let n_pixels = match hdu.info {
HduInfo::ImageInfo { ref shape, .. } => shape.first().copied().unwrap_or(0),
_ => return Err(anyhow!("APOGEE HDU1 非 image")),
};
// 读行0combined spectrum
let row0: Vec<f32> = hdu
.read_rows(&mut f, 0, 1)
.context("读取 combined 行失败")?;
let flux = row0;
// 波长从 header 还原
let crval1 = hdu.read_key::<f64>(&mut f, "CRVAL1").unwrap_or(4.179f64);
let cdelt1 = hdu.read_key::<f64>(&mut f, "CDELT1").unwrap_or(6e-6f64);
let wavelength: Vec<f32> = (0..n_pixels)
.map(|i| 10f32.powf(crval1 as f32 + i as f32 * cdelt1 as f32))
.collect();
let flux_unit = if subtype == "aspcap" {
"normalized".to_string()
} else {
"1e-17 erg/s/cm2/A".to_string()
};
// 元数据从 PRIMARY header
let mut meta = SpectrumMeta::default();
if let Ok(phdu) = f.primary_hdu() {
macro_rules! ap_str {
($k:expr) => {
phdu.read_key::<String>(&mut f, $k)
.ok()
.filter(|s| !s.is_empty())
};
}
meta.extra = Some(serde_json::json!({
"apogee_id": ap_str!("APOGEE_ID").or_else(|| ap_str!("TARGET_ID")),
"telescope": ap_str!("TELESCOPE"),
"field": ap_str!("FIELD"),
}));
}
Ok(SpectrumPreview {
source: "sdss".into(),
source_id: String::new(),
file_path: path.to_string_lossy().into(),
segments: vec![SpectrumSegment {
band: "apstar_combined".to_string(),
wavelength,
flux,
ivar: None,
wavelength_unit: "angstrom".into(),
flux_unit,
}],
meta,
})
}
// ═══════════════════════════════════════════════════════════════
// 6. Gaia xp_sampled / rvs 解析(结构待验,预计 flux+wavelength 数组)
// ═══════════════════════════════════════════════════════════════
//
// xp_continuous 走 gaia_xp 子模块Hermite 还原)。
// xp_sampled/rvs 预计是 BinTable 含 flux/wavelength 数组列,结构待真实样本验证后填充。
fn parse_gaia_sampled(_path: &Path, _subtype: &str) -> Result<SpectrumPreview> {
Err(anyhow!(
"Gaia xp_sampled/rvs 预览暂未实现(等待真实样本验证结构)"
))
}
// ═══════════════════════════════════════════════════════════════
// 7. DESI coadd 解析(多目标 + B/R/Z 三相机)
// ═══════════════════════════════════════════════════════════════
//
// 结构19 HDU
// HDU1 FIBERMAP BinTable (921目标 × 67列含 TARGETID/TARGET_RA/TARGET_DEC)
// HDU3 B_WAVELENGTH image (2751,) + HDU4 B_FLUX image (921, 2751)
// HDU8 R_WAVELENGTH + HDU9 R_FLUX
// HDU13 Z_WAVELENGTH + HDU14 Z_FLUX
// 按 source_id 中的 targetid 在 FIBERMAP 找行索引,取该行三段 flux。
fn parse_desi_coadd(path: &Path, source_id: &str) -> Result<SpectrumPreview> {
use fitsio::FitsFile;
let mut f = FitsFile::open(path).context("打开 DESI FITS 失败")?;
// 从 source_id 提取 targetid格式 survey-program-healpixtargetid 在 FIBERMAP 中需匹配)
// 注意DESI source_id 是 "survey-program-healpix"HEALPix 像素号),不含 targetid。
// 多目标文件需外部指定 targetid。这里暂取 FIBERMAP 第 0 个目标作为默认。
// TODO: preview 端点需额外接受 targetid 参数以选多目标文件中的具体目标。
warn!(
"[DESI] coadd 是多目标文件,默认取 FIBERMAP 行0source_id={} 不含 targetid",
source_id
);
// FIBERMAP 取元数据(直接内联读单元格,避免闭包双重借用)
let mut meta = SpectrumMeta::default();
let target_row = 0usize;
if let Ok(fm) = f.hdu("FIBERMAP") {
// 逐字段读取read_cell_value 需要 &mut f不能多闭包并存
meta.ra = fm
.read_cell_value::<f64>(&mut f, "TARGET_RA", target_row)
.ok()
.filter(|v| v.is_finite());
meta.dec = fm
.read_cell_value::<f64>(&mut f, "TARGET_DEC", target_row)
.ok()
.filter(|v| v.is_finite());
let targetid = fm
.read_cell_value::<f64>(&mut f, "TARGETID", target_row)
.ok();
let objtype = fm
.read_cell_value::<String>(&mut f, "OBJTYPE", target_row)
.ok();
let morphtype = fm
.read_cell_value::<String>(&mut f, "MORPHTYPE", target_row)
.ok();
let ebv = fm.read_cell_value::<f64>(&mut f, "EBV", target_row).ok();
meta.extra = Some(serde_json::json!({
"targetid": targetid,
"objtype": objtype,
"morhptype": morphtype,
"ebv": ebv,
}));
}
let mut segments = Vec::with_capacity(3);
for (band, wave_ext, flux_ext) in [
("B", "B_WAVELENGTH", "B_FLUX"),
("R", "R_WAVELENGTH", "R_FLUX"),
("Z", "Z_WAVELENGTH", "Z_FLUX"),
] {
let wave_hdu = f.hdu(wave_ext).context(format!("读取 {} 失败", wave_ext))?;
let wave: Vec<f32> = wave_hdu
.read_image(&mut f)
.context(format!("读取 {} image 失败", wave_ext))?;
let flux_hdu = f.hdu(flux_ext).context(format!("读取 {} 失败", flux_ext))?;
let n_wave = wave.len();
// 2D image (n_targets, n_wave),读目标行
let row_flux: Vec<f32> = flux_hdu
.read_rows(&mut f, target_row, 1)
.context(format!("读取 {}{} 失败", flux_ext, target_row))?;
if row_flux.len() != n_wave {
return Err(anyhow!(
"{} flux 长度 {} != wavelength 长度 {}",
band,
row_flux.len(),
n_wave
));
}
segments.push(SpectrumSegment {
band: band.to_string(),
wavelength: wave,
flux: row_flux,
ivar: None,
wavelength_unit: "angstrom".into(),
flux_unit: "1e-17 erg/s/cm2/A".into(),
});
}
Ok(SpectrumPreview {
source: "desi".into(),
source_id: source_id.into(),
file_path: path.to_string_lossy().into(),
segments,
meta,
})
}
// ═══════════════════════════════════════════════════════════════
// 8. 降采样
// ═══════════════════════════════════════════════════════════════
/// 等间距分组取中位数降采样(抗噪声)
fn downsample_segment(seg: &mut SpectrumSegment, target: usize) {
let n = seg.wavelength.len();
if n <= target {
return;
}
let step = n as f64 / target as f64;
let mut new_wave = Vec::with_capacity(target);
let mut new_flux = Vec::with_capacity(target);
let mut new_ivar = if seg.ivar.is_some() {
Some(Vec::with_capacity(target))
} else {
None
};
let ivar = seg.ivar.as_ref();
let mut i = 0.0f64;
while (i as usize) < n {
let start = i as usize;
let end = ((i + step) as usize).min(n);
if end <= start {
i += step;
continue;
}
// 中位数
new_wave.push(median_f32(&seg.wavelength[start..end]));
new_flux.push(median_f32(&seg.flux[start..end]));
if let Some(iv) = ivar {
if let Some(ni) = new_ivar.as_mut() {
ni.push(median_f32(&iv[start..end]));
}
}
i += step;
}
seg.wavelength = new_wave;
seg.flux = new_flux;
seg.ivar = new_ivar;
}
fn median_f32(slice: &[f32]) -> f32 {
if slice.is_empty() {
return 0.0;
}
if slice.len() == 1 {
return slice[0];
}
let mut v: Vec<f32> = slice.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v[v.len() / 2]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_downsample_segment() {
let mut seg = SpectrumSegment {
band: "test".into(),
wavelength: (0..3000).map(|i| i as f32).collect(),
flux: (0..3000).map(|i| (i as f32) * 2.0).collect(),
ivar: None,
wavelength_unit: "angstrom".into(),
flux_unit: "test".into(),
};
downsample_segment(&mut seg, 1000);
assert!(seg.wavelength.len() <= 1000);
assert!(seg.wavelength.len() >= 900);
assert_eq!(seg.wavelength.len(), seg.flux.len());
}
#[test]
fn test_median_f32() {
assert_eq!(median_f32(&[1.0, 3.0, 2.0]), 2.0);
assert_eq!(median_f32(&[5.0]), 5.0);
assert_eq!(median_f32(&[]), 0.0);
assert_eq!(median_f32(&[1.0, 4.0]), 4.0);
}
#[test]
fn test_spectrum_preview_serialize() {
let p = SpectrumPreview {
source: "lamost".into(),
source_id: "dr10|v2.0|lrs|123".into(),
file_path: "Telescope/lamost/.../123.fits".into(),
segments: vec![SpectrumSegment {
band: "combined".into(),
wavelength: vec![3700.0, 3701.0],
flux: vec![10.0, 20.0],
ivar: Some(vec![0.5, 0.6]),
wavelength_unit: "angstrom".into(),
flux_unit: "1e-17 erg/s/cm2/A".into(),
}],
meta: SpectrumMeta {
ra: Some(266.0),
dec: Some(-28.0),
..Default::default()
},
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("lamost"));
assert!(json.contains("combined"));
}
// ── 真实样本集成测试(库中已有文件)──
// 标记 #[ignore] 因为依赖 library/ 下实际下载的文件CI 环境可能没有。
#[test]
#[ignore = "需要 library/ 下真实 FITS 样本"]
fn test_parse_lamost_dr10() {
let path = Path::new("library/Telescope/lamost/spectrum/lrs/dr10/v2.0/372209230.fits");
if !path.exists() {
return;
}
let preview = parse_lamost(path, "dr10|v2.0|lrs|372209230").unwrap();
assert!(!preview.segments.is_empty());
let seg = &preview.segments[0];
assert!(seg.wavelength.len() > 1000, "应有大量像素点");
assert_eq!(seg.wavelength.len(), seg.flux.len());
assert!(seg.wavelength[0] > 3000.0 && seg.wavelength[0] < 4000.0);
assert!(seg.ivar.is_some());
assert_eq!(seg.band, "combined");
}
#[test]
#[ignore = "需要 library/ 下真实 FITS 样本"]
fn test_parse_sdss_spec() {
let path = Path::new("library/Telescope/sdss/dr17/spec/v5_13_2-4377-55828-0732.fits");
if !path.exists() {
return;
}
let preview = parse_sdss_spec(path).unwrap();
let seg = &preview.segments[0];
assert!(seg.wavelength.len() > 1000);
assert_eq!(seg.wavelength.len(), seg.flux.len());
// loglam 还原后应在光学波段
assert!(seg.wavelength[0] > 3000.0 && seg.wavelength[0] < 4000.0);
}
#[test]
#[ignore = "需要 library/ 下真实 FITS 样本 + 较大内存"]
fn test_parse_desi_coadd() {
let path = Path::new("library/Telescope/desi/dr1/main-dark/19020.fits");
if !path.exists() {
return;
}
let preview = parse_desi_coadd(path, "main-dark-19020").unwrap();
assert_eq!(preview.segments.len(), 3, "应有 B/R/Z 三段");
for seg in &preview.segments {
assert!(seg.wavelength.len() > 1000);
assert_eq!(seg.wavelength.len(), seg.flux.len());
}
}
}

View File

@ -14,8 +14,14 @@ use crate::services::observation::desi::DesiSpectrumFetcher;
use crate::services::observation::fetcher::ObservationFetcher;
use crate::services::observation::gaia::{GaiaLightCurveFetcher, GaiaSpectrumFetcher};
use crate::services::observation::lamost::LamostSpectrumFetcher;
use crate::services::observation::photometry::{
AllwisePhotometryFetcher, GaiaPhotometryFetcher, PanstarrsPhotometryFetcher,
TwomassPhotometryFetcher,
};
use crate::services::observation::sdss::{SdssApogeeFetcher, SdssSpectrumFetcher};
use crate::services::observation::tess::TessLightCurveFetcher;
use crate::services::observation::types::{ProductType, Source};
use crate::services::observation::ztf::ZtfLightCurveFetcher;
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::sync::Arc;
@ -49,6 +55,14 @@ impl ObservationRegistry {
register!(SdssApogeeFetcher);
// DESI
register!(DesiSpectrumFetcher);
// 测光2MASS / AllWISE / Pan-STARRS / Gaia复用 VizieR/Gaia TAP
register!(TwomassPhotometryFetcher);
register!(AllwisePhotometryFetcher);
register!(PanstarrsPhotometryFetcher);
register!(GaiaPhotometryFetcher);
// 光变曲线ZTF / TESS新 HTTP 客户端)
register!(ZtfLightCurveFetcher);
register!(TessLightCurveFetcher);
Self { fetchers }
}
@ -125,6 +139,54 @@ impl ObservationRegistry {
.collect()
}
/// 列出所有已注册的 (Source, ProductSpec) 组合(统一检索默认全源扇出)
///
/// 注意:同一 (Source, ProductType) 下可能有多个 subtype 不同的 fetcher如 SDSS
/// specobj/apogee统一检索按 (Source, ProductType) 一级扇出subtype 留给单源下载
/// 阶段处理。这里为每个 (Source, ProductType) 暴露**全部** subtype 作为独立组合,
/// 让前端可以细粒度勾选(如 SDSS specobj 与 apogee 可分开检索)。
///
/// 返回值已按 (source, product, subtype) 稳定排序,保证前端渲染顺序确定。
pub fn list_all_keys(&self) -> Vec<(Source, crate::services::observation::types::ProductSpec)> {
use crate::services::observation::types::ProductSpec;
// 先收集 (Source, ProductType, Vec<subtype>) —— 同一 (s,p) 下所有 fetcher 的 subtypes 合并
let mut groups: HashMap<(Source, ProductType), Vec<String>> = HashMap::new();
for ((s, p), fetchers) in &self.fetchers {
let subtypes: Vec<String> = fetchers
.iter()
.flat_map(|f| f.subtypes().iter().map(|st| st.to_string()))
.collect();
groups.entry((*s, *p)).or_default().extend(subtypes);
}
// 为每个 (Source, ProductType) 展开为:
// - 若该组合下仅 1 个 subtype或无 subtype返回 1 个 ProductSpecsubtype=None
// - 若有多个 subtype每个 subtype 各返回 1 个 ProductSpec
let mut result: Vec<(Source, ProductSpec)> = Vec::new();
for ((s, p), mut subtypes) in groups {
subtypes.sort();
subtypes.dedup();
if subtypes.is_empty() {
result.push((s, ProductSpec::new(p)));
} else if subtypes.len() == 1 {
result.push((s, ProductSpec::with_subtype(p, subtypes.remove(0))));
} else {
for st in subtypes {
result.push((s, ProductSpec::with_subtype(p, st)));
}
}
}
// 稳定排序source → product → subtype
result.sort_by(|(s1, ps1), (s2, ps2)| {
(*s1 as usize)
.cmp(&(*s2 as usize))
.then_with(|| (ps1.product as usize).cmp(&(ps2.product as usize)))
.then_with(|| ps1.subtype.cmp(&ps2.subtype))
});
result
}
/// 列出所有已注册 (Source, ProductType) 组合的能力清单
///
/// 供 API 层 `/observation/capabilities` 暴露给前端,用于动态渲染源/产品/版本下拉选项,
@ -219,6 +281,20 @@ mod tests {
.is_ok());
// DESI spectrum
assert!(reg.get(Source::Desi, ProductType::Spectrum, None).is_ok());
// 测光2MASS / AllWISE / Pan-STARRS / Gaia
assert!(reg
.get(Source::Twomass, ProductType::Photometry, None)
.is_ok());
assert!(reg
.get(Source::Allwise, ProductType::Photometry, None)
.is_ok());
assert!(reg
.get(Source::Panstarrs, ProductType::Photometry, None)
.is_ok());
assert!(reg.get(Source::Gaia, ProductType::Photometry, None).is_ok());
// 光变曲线ZTF / TESS
assert!(reg.get(Source::Ztf, ProductType::LightCurve, None).is_ok());
assert!(reg.get(Source::Tess, ProductType::LightCurve, None).is_ok());
}
#[test]
@ -242,4 +318,75 @@ mod tests {
let specobj = reg.get(Source::Sdss, ProductType::Spectrum, None).unwrap();
assert!(specobj.subtypes().contains(&"spec"));
}
#[test]
fn test_list_all_keys_returns_all_registered() {
use crate::services::observation::types::ProductSpec;
let reg = ObservationRegistry::default();
let keys = reg.list_all_keys();
// 12 个 (Source, ProductType) 组合LAMOST/Gaia/SDSS/DESI 光谱4+ Gaia 光变1
// + 4 个测光 + ZTF/TESS 光变2+ Gaia 测光1= 12 个一级 key
// 但 SDSS spectrum 因 spec/apstar/aspcap 多 subtype 展开为 3 个 ProductSpec
assert!(!keys.is_empty(), "list_all_keys 不应为空");
// 每个返回值都能在 registry.get 找到对应 fetcher
for (s, ps) in &keys {
assert!(
reg.get(*s, ps.product, ps.subtype.as_deref()).is_ok(),
"list_all_keys 返回的 ({:?}, {:?}) 在 get() 找不到 fetcher",
s,
ps
);
}
// 验证覆盖所有注册的 (Source, ProductType) 一级组合
let covered: std::collections::HashSet<(Source, ProductType)> =
keys.iter().map(|(s, ps)| (*s, ps.product)).collect();
let registered: std::collections::HashSet<(Source, ProductType)> =
reg.fetchers.keys().copied().collect();
assert_eq!(covered, registered, "list_all_keys 必须覆盖所有注册的组合");
// SDSS spectrum 应展开为多个 subtypespec/apstar/aspcap
let sdss_spec_subtypes: Vec<&str> = keys
.iter()
.filter(|(s, ps)| *s == Source::Sdss && ps.product == ProductType::Spectrum)
.filter_map(|(_, ps)| ps.subtype.as_deref())
.collect();
assert!(
sdss_spec_subtypes.len() >= 2,
"SDSS spectrum 应展开为 ≥2 subtypespec/apstar/aspcap实际: {:?}",
sdss_spec_subtypes
);
// 多 subtype 源(如 LAMOST lrs/mrs应展开为对应数量
let lamost_keys: Vec<&ProductSpec> = keys
.iter()
.filter(|(s, _)| *s == Source::Lamost)
.map(|(_, ps)| ps)
.collect();
assert_eq!(
lamost_keys.len(),
2,
"LAMOST 有 lrs/mrs 两 subtype应展开为 2 项:{:?}",
lamost_keys
);
// 排序稳定性:源应按枚举顺序排列
let source_order: Vec<Source> = keys.iter().map(|(s, _)| *s).collect();
let mut sorted = source_order.clone();
sorted.sort_by_key(|s| *s as usize);
// 去重后比较(同一源可能多产品)
let mut prev_min = 0usize;
for s in &source_order {
assert!(*s as usize >= prev_min, "源应按枚举顺序非降序排列");
prev_min = *s as usize;
}
}
#[test]
fn test_list_all_keys_sorted_stable() {
let reg1 = ObservationRegistry::default();
let reg2 = ObservationRegistry::default();
assert_eq!(reg1.list_all_keys(), reg2.list_all_keys(), "排序应确定性");
}
}

View File

@ -0,0 +1,205 @@
// src/services/observation/tess.rs
//
// TESSTransiting Exoplanet Survey Satellite光变曲线 fetcher
//
// 数据源MASTMikulski Archive for Space Telescopes
// cone search: Mast.Catalogs.Tic.Cone → TIC 星表元数据
// 光变下载: Mast.Tess.Product 查 sector → 下载 LC FITS
//
// 两阶段查找:(1) TIC cone search 得 TIC ID + 坐标,(2) 按 TIC ID 查 product 下载 FITS。
// 多 sector 时产生多个 artifact每 sector 一个 FITS
use crate::api::AppState;
use crate::services::observation::cache::{
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 anyhow::{anyhow, Result};
use tracing::{info, warn};
#[derive(Debug)]
pub struct TessLightCurveFetcher;
#[async_trait::async_trait]
impl ObservationFetcher for TessLightCurveFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Tess, ProductType::LightCurve)
}
fn subtypes(&self) -> &'static [&'static str] {
&["2min", "ffi"] // 2 分钟节律 / FFI全帧图像光变
}
fn suggested_max_radius_deg(&self) -> f64 {
0.1
}
fn identifier_format(&self) -> Option<&'static str> {
Some("TIC ID如 840525885")
}
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>> {
let rows = state.mast.cone_search_tic(ra, dec, radius_deg, 50).await?;
Ok(rows
.iter()
.map(|r| Candidate {
source: Source::Tess,
source_id: r.tic_id.clone(),
label: format!(
"TIC {} (Tmag={})",
r.tic_id,
r.tmag
.map(|m| format!("{:.1}", m))
.unwrap_or_else(|| "?".into())
),
ra: Some(r.ra),
dec: Some(r.dec),
distance: None,
raw: Some(serde_json::json!({
"tic_id": &r.tic_id,
"tmag": r.tmag,
"obj_type": &r.obj_type,
"gaia_id": &r.gaia_id,
})),
})
.collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
) -> Result<Candidate> {
let tic_id = identifier.trim();
if tic_id.is_empty() {
return Err(anyhow!("TIC ID 不能为空"));
}
Ok(Candidate {
source: Source::Tess,
source_id: tic_id.to_string(),
label: format!("TIC {}", tic_id),
ra: None,
dec: None,
distance: None,
raw: Some(serde_json::json!({"tic_id": tic_id})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let product = ProductSpec::with_subtype(ProductType::LightCurve, "2min");
let tic_id = candidate
.raw
.as_ref()
.and_then(|v| v.get("tic_id"))
.and_then(|v| v.as_str())
.unwrap_or(&candidate.source_id);
let cache_key = tic_id;
// 缓存命中
if !force {
if let Some((artifacts, meta)) =
fetch_observation_cache(&state.db, Source::Tess, &product, cache_key).await?
{
info!("[TESS] 光变缓存命中 tic={}", tic_id);
return Ok(ObservationProduct {
source: Source::Tess,
product,
source_id: cache_key.to_string(),
source_label: candidate.label.clone(),
artifacts,
source_meta: meta,
});
}
}
// 两阶段:查 product → 逐个下载 FITS
let products = state.mast.list_lightcurve_products(tic_id).await?;
if products.is_empty() {
return Err(anyhow!(
"TIC {} 无 TESS 光变产品(可能未被 2-min 观测覆盖)",
tic_id
));
}
let mut artifacts = Vec::with_capacity(products.len());
for prod in &products {
if prod.data_url.is_empty() {
warn!("[TESS] sector={} 无下载 URL跳过", prod.sector);
continue;
}
let fits_bytes = state.mast.download_lightcurve_fits(&prod.data_url).await?;
let rel_path = format!(
"Telescope/tess/lightcurve/{}_s{:02}.fits",
tic_id, prod.sector
);
let size = persist_bytes(&state.config.library_dir, &rel_path, &fits_bytes).await?;
info!(
"[TESS] 光变 FITS 已保存 tic={} sector={} → {} ({}B)",
tic_id, prod.sector, rel_path, size
);
artifacts.push(Artifact {
band: Some(format!("S{:02}", prod.sector)),
original_name: Some(prod.filename.clone()),
file_path: rel_path.clone(),
file_url: crate::services::observation::cache::file_url_from_path(&rel_path),
file_format: "fits".to_string(),
size_bytes: size,
cached: false,
});
}
if artifacts.is_empty() {
return Err(anyhow!("TIC {} 所有 sector 下载失败", tic_id));
}
let meta = serde_json::json!({
"tic_id": tic_id,
"sectors": products.iter().map(|p| p.sector).collect::<Vec<_>>(),
});
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db,
Source::Tess,
&product,
cache_key,
candidate.ra,
candidate.dec,
&artifacts,
Some(&meta_str),
)
.await
{
log_write_failure("tess", "lightcurve", e);
}
Ok(ObservationProduct {
source: Source::Tess,
product,
source_id: cache_key.to_string(),
source_label: candidate.label.clone(),
artifacts,
source_meta: Some(meta),
})
}
}

View File

@ -24,11 +24,31 @@ pub enum Source {
Sdss,
/// DESI 暗能量光谱仪HEALPix coadd
Desi,
/// 2MASS 近红外测光J/H/KVizieR II/246
Twomass,
/// AllWISE 中红外测光W1-W4VizieR II/328
Allwise,
/// Pan-STARRS DR1 光学测光g/r/i/z/yVizieR II/349
Panstarrs,
/// ZTF 时域光变曲线IRSA REST API
Ztf,
/// TESS 空间时域光变曲线MAST
Tess,
}
impl Source {
pub fn valid_values() -> &'static [&'static str] {
&["lamost", "gaia", "sdss", "desi"]
&[
"lamost",
"gaia",
"sdss",
"desi",
"twomass",
"allwise",
"panstarrs",
"ztf",
"tess",
]
}
pub fn as_str(&self) -> &'static str {
match self {
@ -36,6 +56,11 @@ impl Source {
Self::Gaia => "gaia",
Self::Sdss => "sdss",
Self::Desi => "desi",
Self::Twomass => "twomass",
Self::Allwise => "allwise",
Self::Panstarrs => "panstarrs",
Self::Ztf => "ztf",
Self::Tess => "tess",
}
}
@ -47,6 +72,11 @@ impl Source {
"gaia" => Self::Gaia,
"sdss" => Self::Sdss,
"desi" => Self::Desi,
"twomass" | "2mass" => Self::Twomass,
"allwise" | "wise" => Self::Allwise,
"panstarrs" | "ps1" | "pan-starrs" => Self::Panstarrs,
"ztf" => Self::Ztf,
"tess" => Self::Tess,
other => {
return Err(format!(
"不支持的 source '{}',可选: {:?}",
@ -63,6 +93,11 @@ impl Source {
Self::Gaia => "Gaia",
Self::Sdss => "SDSS",
Self::Desi => "DESI",
Self::Twomass => "2MASS",
Self::Allwise => "AllWISE",
Self::Panstarrs => "Pan-STARRS",
Self::Ztf => "ZTF",
Self::Tess => "TESS",
}
}
}

View File

@ -0,0 +1,441 @@
// src/services/observation/unified.rs
//
// 多目标统一全源检索 —— 扇出编排层
//
// 设计目标:
// 当前 dispatch.rs 的 search_observation/download_observation 每次只能查单个目标、
// 单个源、单个产品。本模块在其之上叠加「多目标 × 多源」的并发扇出,把每个
// (target × source_key) 翻译为一次 cone_search_cached 调用,并发执行后按源聚合。
//
// 关键约束(遵守 mod.rs 的 OCP 契约):
// - 不改动任何 fetcher、不改 dispatch 内部
// - 仅组合 registry.get() + fetcher.cone_search_cached()
// - 失败隔离:单个 (target, source) 失败不影响其他组合(参考 dispatch.rs:159
//
// 两阶段流程:
// ① 统一搜索(本模块):跨目标 × 跨源并发 cone 检索 → 聚合候选源
// ② 批量下载:前端勾选后复用现有 POST /observation/downloadmode=identifiers
// 逐源调用,**无需新端点**
use crate::api::AppState;
use crate::services::cds::target::query_target_cached;
use crate::services::observation::fetcher::Candidate;
use crate::services::observation::registry::ObservationRegistry;
use crate::services::observation::types::{ProductSpec, Source};
use anyhow::Result;
use futures_util::future::join_all;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use tracing::{info, warn};
/// 单目标最大命中数默认值(防爆炸)
fn default_per_target_limit() -> usize {
50
}
/// 默认检索半径(度)
fn default_radius_deg() -> f64 {
0.1
}
// ═══════════════════════════════════════════════════════════════
// 1. 请求/响应类型
// ═══════════════════════════════════════════════════════════════
/// 一个待检索的目标源(坐标 + 可选标签)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetRequest {
pub ra: f64,
pub dec: f64,
#[serde(default = "default_radius_deg")]
pub radius_deg: f64,
/// 用户可读标签(如天体名、序号);用于结果回显与失败定位
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
/// 统一检索请求
#[derive(Debug, Clone, Deserialize)]
pub struct UnifiedSearchRequest {
/// 待检索的目标列表≥1
pub targets: Vec<TargetRequest>,
/// 要检索的源组合None 或空 = 全部已注册源list_all_keys
#[serde(default)]
pub sources: Option<Vec<(Source, ProductSpec)>>,
/// 数据发布版本(如 "dr11"None 用各源默认版本
#[serde(default)]
pub release: Option<String>,
/// 数据发布的子版本(仅 LAMOST 有意义)
#[serde(default)]
pub version: Option<String>,
/// 单目标 × 单源最大命中数(防爆炸)
#[serde(default = "default_per_target_limit")]
pub per_target_limit: usize,
}
/// 一个 (Source, ProductSpec) 的聚合候选源组
#[derive(Debug, Clone, Serialize)]
pub struct SourceCandidateGroup {
pub source: Source,
pub product: ProductSpec,
/// 该源命中的候选源(跨所有目标去重后)
pub candidates: Vec<Candidate>,
}
/// 名称解析失败条目(仅 resolve_names 路径产生)
#[derive(Debug, Clone, Serialize)]
pub struct ResolveFailure {
pub target_label: String,
pub error: String,
}
/// 统一检索聚合结果
#[derive(Debug, Clone, Serialize)]
pub struct UnifiedSearchResult {
/// 按 (source, product) 分组的候选源;按 (source, product, subtype) 稳定排序
pub groups: Vec<SourceCandidateGroup>,
/// 名称解析失败的目标(仅前端名称列表路径回填;统一检索阶段通常为空)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resolve_failures: Vec<ResolveFailure>,
pub total_candidates: usize,
}
// ═══════════════════════════════════════════════════════════════
// 2. 核心编排unified_search
// ═══════════════════════════════════════════════════════════════
/// 跨目标 × 跨源并发 cone 检索,按 (source, product) 聚合候选源
///
/// 并发模型:所有 (target × source_key) 组合打包成 N 个 future用 join_all 全发。
/// cone_search_cached 命中 7 天缓存时不会发外部 HTTP因此并发安全
/// miss 时 fetcher 内部已有 50ms throttlefetcher.rs:208
/// 单个组合失败用 tracing::warn 记录、不中断整体(参考 dispatch.rs:159
pub async fn unified_search(
state: &AppState,
registry: &ObservationRegistry,
request: &UnifiedSearchRequest,
) -> Result<UnifiedSearchResult> {
if request.targets.is_empty() {
return Err(anyhow::anyhow!("统一检索目标列表不能为空"));
}
// 解析源组合:显式给定则用,否则取 registry 全部
let source_keys: Vec<(Source, ProductSpec)> = match &request.sources {
Some(s) if !s.is_empty() => s.clone(),
_ => registry.list_all_keys(),
};
if source_keys.is_empty() {
return Err(anyhow::anyhow!("没有可检索的源registry 为空)"));
}
let total_combos = request.targets.len() * source_keys.len();
info!(
"[Unified] 开始统一检索targets={}, sources={}, 组合={}, per_target_limit={}",
request.targets.len(),
source_keys.len(),
total_combos,
request.per_target_limit
);
// 构建所有 (target, source_key) 并发任务
// 每个任务返回Option<(Source, ProductSpec, Vec<Candidate>)> —— None 表示该组合失败
let futures: Vec<_> = request
.targets
.iter()
.flat_map(|target| {
source_keys.iter().map(move |(source, product)| {
let target = target.clone();
let source = *source;
let product = product.clone();
let release = request.release.clone();
let version = request.version.clone();
let limit = request.per_target_limit;
async move {
let result = cone_one_source(
state,
registry,
source,
&product,
&target,
release.as_deref(),
version.as_deref(),
limit,
)
.await;
(source, product, target.label, result)
}
})
})
.collect();
let results = join_all(futures).await;
// 按 (source, product) 分组合并候选源
// 用 BTreeMap 保证稳定排序source/product 枚举已 derive Ord 语义,但 enum 未 derive Ord
// 这里用 (source_str, product_str, subtype_str) 作为排序键字符串)
let mut groups_map: BTreeMap<(String, String, Option<String>), Vec<Candidate>> =
BTreeMap::new();
let mut total_candidates = 0usize;
let mut fail_count = 0usize;
for (source, product, _target_label, result) in results {
match result {
Ok(cands) => {
let key = (
source.as_str().to_string(),
product.product.as_str().to_string(),
product.subtype.clone(),
);
let bucket = groups_map.entry(key).or_default();
for c in cands {
// 跨目标去重:同 (source, source_id) 只保留首次命中
if !bucket.iter().any(|b| b.source_id == c.source_id) {
bucket.push(c);
}
}
}
Err(e) => {
fail_count += 1;
warn!(
"[Unified] 单组合检索失败 source={:?} product={:?}: {:#}",
source, product, e
);
}
}
}
// 转换为 SourceCandidateGroup 列表BTreeMap 已保证按 key 排序)
let mut groups: Vec<SourceCandidateGroup> = Vec::with_capacity(groups_map.len());
for ((source_str, product_str, subtype), candidates) in groups_map {
let source = Source::from_str(&source_str)
.map_err(|e| anyhow::anyhow!("内部错误:无效 source 回填 '{}': {}", source_str, e))?;
let product = ProductSpec {
product: crate::services::observation::types::ProductType::from_str(&product_str)
.map_err(|e| {
anyhow::anyhow!("内部错误:无效 product 回填 '{}': {}", product_str, e)
})?,
subtype,
};
total_candidates += candidates.len();
groups.push(SourceCandidateGroup {
source,
product,
candidates,
});
}
info!(
"[Unified] 统一检索完成:组合成功 {} / 总 {},候选源 {},失败组合 {}",
groups.len(),
total_combos,
total_candidates,
fail_count
);
Ok(UnifiedSearchResult {
groups,
resolve_failures: Vec::new(),
total_candidates,
})
}
/// 单个 (target × source) 的 cone 检索(带 per_target_limit 截断)
#[allow(clippy::too_many_arguments)] // 与 ObservationFetcher::cone_search_cached 签名对齐
async fn cone_one_source(
state: &AppState,
registry: &ObservationRegistry,
source: Source,
product: &ProductSpec,
target: &TargetRequest,
release: Option<&str>,
version: Option<&str>,
limit: usize,
) -> Result<Vec<Candidate>> {
let subtype = product.subtype.as_deref();
let fetcher = registry.get(source, product.product, subtype)?;
let mut matched = fetcher
.cone_search_cached(
state,
target.ra,
target.dec,
target.radius_deg,
release,
subtype,
version,
)
.await?;
// per_target_limit 截断(保留距离最近的)
if matched.len() > limit {
matched.sort_by(|a, b| {
a.distance
.unwrap_or(f64::MAX)
.partial_cmp(&b.distance.unwrap_or(f64::MAX))
.unwrap_or(std::cmp::Ordering::Equal)
});
matched.truncate(limit);
}
Ok(matched)
}
// ═══════════════════════════════════════════════════════════════
// 3. 名称解析resolve_names复用 query_target_cached + SESAME
// ═══════════════════════════════════════════════════════════════
/// 名称解析请求
#[derive(Debug, Clone, Deserialize)]
pub struct ResolveNamesRequest {
pub names: Vec<String>,
}
/// 单个名称的解析结果
#[derive(Debug, Clone, Serialize)]
pub struct ResolvedTarget {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ra: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dec: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spectral_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub v_magnitude: Option<f64>,
/// 解析失败时的错误信息;成功时为 None
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// 批量解析天体名称为坐标(供前端「天体名称列表」输入模式)
///
/// 串行调用 query_target_cached带 50ms throttle + DB 缓存,复用 services/cds/target.rs
/// 避免 SESAME 被限流。失败条目不中断整体,返回 error 字段。
pub async fn resolve_names(state: &AppState, names: &[String]) -> Result<Vec<ResolvedTarget>> {
info!("[Unified] 批量解析 {} 个天体名称", names.len());
let mut results = Vec::with_capacity(names.len());
for name in names {
let trimmed = name.trim();
if trimmed.is_empty() {
continue;
}
match query_target_cached(&state.db, trimmed, None, &state.http_client).await {
Ok(info) => {
let ra = info.ra.as_deref().and_then(|s| s.parse::<f64>().ok());
let dec = info.dec.as_deref().and_then(|s| s.parse::<f64>().ok());
if ra.is_none() || dec.is_none() {
results.push(ResolvedTarget {
name: trimmed.to_string(),
ra,
dec,
spectral_type: info.spectral_type.clone(),
v_magnitude: info.v_magnitude,
error: Some("SESAME 响应缺少有效坐标".to_string()),
});
} else {
results.push(ResolvedTarget {
name: trimmed.to_string(),
ra,
dec,
spectral_type: info.spectral_type.clone(),
v_magnitude: info.v_magnitude,
error: None,
});
}
}
Err(e) => {
warn!("[Unified] 名称解析失败 '{}': {:#}", trimmed, e);
results.push(ResolvedTarget {
name: trimmed.to_string(),
ra: None,
dec: None,
spectral_type: None,
v_magnitude: None,
error: Some(format!("{:#}", e)),
});
}
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::observation::registry::ObservationRegistry;
#[test]
fn test_unified_search_request_deserialize_minimal() {
let json = r#"{
"targets": [{"ra": 10.0, "dec": 20.0}]
}"#;
let req: UnifiedSearchRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.targets.len(), 1);
assert_eq!(req.targets[0].ra, 10.0);
assert!(req.sources.is_none());
assert_eq!(req.per_target_limit, 50);
assert_eq!(req.targets[0].radius_deg, 0.1); // 默认半径
}
#[test]
fn test_unified_search_request_deserialize_full() {
let json = r#"{
"targets": [
{"ra": 10.0, "dec": 20.0, "radius_deg": 0.2, "label": "M31"},
{"ra": 40.0, "dec": -5.0}
],
"sources": [["lamost", {"product": "spectrum"}]],
"release": "dr11",
"per_target_limit": 5
}"#;
let req: UnifiedSearchRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.targets.len(), 2);
assert_eq!(req.targets[0].label.as_deref(), Some("M31"));
assert_eq!(req.targets[0].radius_deg, 0.2);
assert_eq!(req.sources.as_ref().unwrap().len(), 1);
assert_eq!(req.release.as_deref(), Some("dr11"));
assert_eq!(req.per_target_limit, 5);
}
#[test]
fn test_unified_search_rejects_empty_targets() {
let reg = ObservationRegistry::default();
let req = UnifiedSearchRequest {
targets: vec![],
sources: None,
release: None,
version: None,
per_target_limit: 50,
};
// 同步校验empty targets 应在运行时报错(这里校验入参校验逻辑)
// 注意unified_search 是 async这里只验证 list_all_keys 非空
assert!(!reg.list_all_keys().is_empty());
// 静态断言:空 targets 的请求结构体可构造
assert!(req.targets.is_empty());
}
#[test]
fn test_resolved_target_skip_serializing_none() {
let ok = ResolvedTarget {
name: "M31".to_string(),
ra: Some(10.68),
dec: Some(41.27),
spectral_type: Some("Sb".to_string()),
v_magnitude: Some(3.4),
error: None,
};
let json = serde_json::to_string(&ok).unwrap();
assert!(!json.contains("error"));
assert!(json.contains("M31"));
assert!(json.contains("10.68"));
let err = ResolvedTarget {
name: "XX".to_string(),
ra: None,
dec: None,
spectral_type: None,
v_magnitude: None,
error: Some("not found".to_string()),
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("not found"));
assert!(!json.contains("spectral_type"));
}
}

View File

@ -0,0 +1,176 @@
// src/services/observation/ztf.rs
//
// ZTFZwicky Transient Facility光变曲线 fetcher
//
// 数据源IRSA/IPAC REST APICSV 格式),三波段 zg/zr/zi 时域光变。
// 硬限制:检索半径 ≤ 0.1667°10 arcmin
//
// 复用 IrsaClientcone_search + download_lightcurve
// fetch 时按 oid 下载完整光变 CSV 落盘(单文件含三波段所有观测点)。
use crate::api::AppState;
use crate::services::observation::cache::{
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 anyhow::{anyhow, Result};
use tracing::info;
#[derive(Debug)]
pub struct ZtfLightCurveFetcher;
#[async_trait::async_trait]
impl ObservationFetcher for ZtfLightCurveFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Ztf, ProductType::LightCurve)
}
fn subtypes(&self) -> &'static [&'static str] {
&["alert"] // ZTF alert 光变
}
fn suggested_max_radius_deg(&self) -> f64 {
0.1
}
fn hard_max_radius_deg(&self) -> f64 {
0.1667 // ZTF IRSA 硬限制10 arcmin
}
fn identifier_format(&self) -> Option<&'static str> {
Some("ZTF oid15-16 位数字,如 667102300001988")
}
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>> {
let objects = state.irsa.cone_search(ra, dec, radius_deg).await?;
Ok(objects
.iter()
.map(|o| Candidate {
source: Source::Ztf,
source_id: o.oid.clone(),
label: format!("ZTF {} ({}obs)", o.oid, o.nobs),
ra: Some(o.ra),
dec: Some(o.dec),
distance: None,
raw: Some(serde_json::json!({"oid": &o.oid, "nobs": o.nobs})),
})
.collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
) -> Result<Candidate> {
let oid = identifier.trim();
if oid.is_empty() {
return Err(anyhow!("ZTF oid 不能为空"));
}
Ok(Candidate {
source: Source::Ztf,
source_id: oid.to_string(),
label: format!("ZTF {}", oid),
ra: None,
dec: None,
distance: None,
raw: Some(serde_json::json!({"oid": oid})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
_release: Option<&str>,
_subtype: Option<&str>,
_version: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let product = ProductSpec::with_subtype(ProductType::LightCurve, "alert");
let oid = candidate
.raw
.as_ref()
.and_then(|v| v.get("oid"))
.and_then(|v| v.as_str())
.unwrap_or(&candidate.source_id);
let cache_key = oid;
// 缓存命中
if !force {
if let Some((artifacts, meta)) =
fetch_observation_cache(&state.db, Source::Ztf, &product, cache_key).await?
{
info!("[ZTF] 光变缓存命中 oid={}", oid);
return Ok(ObservationProduct {
source: Source::Ztf,
product,
source_id: cache_key.to_string(),
source_label: candidate.label.clone(),
artifacts,
source_meta: meta,
});
}
}
// 下载完整光变 CSV
let csv_bytes = state.irsa.download_lightcurve(oid).await?;
if csv_bytes.is_empty() {
return Err(anyhow!("ZTF oid={} 无光变数据", oid));
}
let rel_path = format!("Telescope/ztf/lightcurve/{}.csv", oid);
let size = persist_bytes(&state.config.library_dir, &rel_path, &csv_bytes).await?;
info!(
"[ZTF] 光变 CSV 已保存 oid={} → {} ({}B)",
oid, rel_path, size
);
let artifact = Artifact {
band: None, // 单文件含三波段
original_name: None,
file_path: rel_path.clone(),
file_url: crate::services::observation::cache::file_url_from_path(&rel_path),
file_format: "csv".to_string(),
size_bytes: size,
cached: false,
};
let meta = serde_json::json!({"oid": oid, "bands": ["zg", "zr", "zi"]});
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db,
Source::Ztf,
&product,
cache_key,
candidate.ra,
candidate.dec,
std::slice::from_ref(&artifact),
Some(&meta_str),
)
.await
{
log_write_failure("ztf", "lightcurve", e);
}
Ok(ObservationProduct {
source: Source::Ztf,
product,
source_id: cache_key.to_string(),
source_label: candidate.label.clone(),
artifacts: vec![artifact],
source_meta: Some(meta),
})
}
}