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

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

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

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

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

其他:
- plotting skill 由占位填充为完整科研绘图规范:光谱/光变/折叠曲线/CMD/SED/[α/Fe]/周期图/Mollweide/发现图 9 类 matplotlib 模板
- 删除死代码 streaming_executor.rs(929 行,仅剩 mod 声明引用,无调用方)
- 新增 docs/roadmap-research-features.md 科研功能路线图及实现状态
This commit is contained in:
fmq
2026-09-07 21:50:32 +08:00
parent eaf85707b5
commit d6b064a490
104 changed files with 12101 additions and 3301 deletions
@@ -0,0 +1,104 @@
// src/agent/tools/astro/research/abundance_analysis.rs
//
// AnalyzeAbundancePatternTool —— 化学丰度模式分析
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct AnalyzeAbundancePatternTool;
#[async_trait]
impl AgentTool for AnalyzeAbundancePatternTool {
fn name(&self) -> &str {
"analyze_abundance_pattern"
}
fn display_name(&self) -> &str {
"化学丰度分析"
}
fn description(&self) -> &str {
"分析恒星的化学丰度模式。输入元素丰度表([X/H] 值),计算 [α/Fe]O, Mg, Si, S, Ca, Ti 加权平均),\
根据 [α/Fe] vs [Fe/H] 位置区分薄盘/厚盘/晕星。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"abundances": {
"type": "array",
"items": {
"type": "object",
"properties": {
"element": { "type": "string", "description": "元素符号(如 'Fe', 'O', 'Mg'" },
"value": { "type": "number", "description": "[X/H] 丰度值 (dex)" },
"error": { "type": "number", "description": "误差 (dex),可选" }
},
"required": ["element", "value"]
},
"description": "元素丰度列表"
}
},
"required": ["abundances"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let abundances: Vec<crate::services::spectrum::parameters::AbundanceEntry> =
match args.get("abundances").and_then(|a| a.as_array()) {
Some(arr) => {
let mut entries = Vec::new();
for item in arr {
let element = match item.get("element").and_then(|e| e.as_str()) {
Some(e) => e.to_string(),
None => continue,
};
let value = match item.get("value").and_then(|v| v.as_f64()) {
Some(v) => v,
None => continue,
};
let error = item.get("error").and_then(|e| e.as_f64());
entries.push(crate::services::spectrum::parameters::AbundanceEntry {
element,
value,
error,
});
}
entries
}
None => return ToolOutput::error("缺少必需参数 'abundances'"),
};
if abundances.is_empty() {
return ToolOutput::error("丰度数据为空");
}
info!("[AnalyzeAbundance] {} 个元素丰度", abundances.len());
match crate::services::analysis::chemistry::analyze_chemistry(&abundances) {
Ok(result) => {
let report = crate::services::analysis::chemistry::format_chemistry(&result);
let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
let content = format!("{}\n\n```json\n{}\n```", report, json_str);
ToolOutput::success(content, json!(result))
}
Err(e) => ToolOutput::error(format!("丰度分析失败: {}", e)),
}
}
}
@@ -0,0 +1,168 @@
// src/agent/tools/astro/research/batch_process.rs
//
// BatchProcessTool —— 批量样本处理:查询→下载→分析→出图→报告
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct BatchProcessTool;
#[async_trait]
impl AgentTool for BatchProcessTool {
fn name(&self) -> &str {
"batch_process"
}
fn display_name(&self) -> &str {
"批量样本处理"
}
fn description(&self) -> &str {
"批量处理天体样本:给定一组天体名称或坐标,自动执行 查询目标→获取观测数据→提取参数→生成报告。\
支持并发控制,自动处理速率限制。输出汇总 Markdown 表格。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "天体名称" },
"ra": { "type": "number", "description": "赤经 (度)" },
"dec": { "type": "number", "description": "赤纬 (度)" }
}
},
"description": "目标列表(名称或坐标)"
},
"data_source": {
"type": "string",
"enum": ["lamost", "sdss", "gaia", "desi"],
"description": "观测数据源",
"default": "lamost"
},
"max_concurrent": {
"type": "integer",
"description": "最大并发数",
"default": 5
},
"extract_parameters": {
"type": "boolean",
"description": "是否提取光谱参数",
"default": true
}
},
"required": ["targets"]
})
}
fn group(&self) -> &str {
"as:research"
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let targets = match args.get("targets").and_then(|t| t.as_array()) {
Some(arr) => arr,
None => return ToolOutput::error("缺少必需参数 'targets'"),
};
let data_source = args
.get("data_source")
.and_then(|s| s.as_str())
.unwrap_or("lamost");
let extract_params = args
.get("extract_parameters")
.and_then(|e| e.as_bool())
.unwrap_or(true);
info!(
"[BatchProcess] 处理 {} 个目标, 数据源={}",
targets.len(),
data_source
);
let mut results = Vec::new();
let mut success = 0;
let mut failed = 0;
for target in targets.iter() {
let name = target
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("unknown");
let ra = target.get("ra").and_then(|r| r.as_f64());
let dec = target.get("dec").and_then(|d| d.as_f64());
// 1. 查询目标信息
let query_name = name.strip_prefix("NAME|").unwrap_or(name);
let target_info = crate::services::cds::target::query_target_cached(
&ctx.app_state.db,
query_name,
None,
&ctx.app_state.http_client,
)
.await
.ok();
let (ra, dec) = if let (Some(r), Some(d)) = (ra, dec) {
(r, d)
} else if let Some(ref info) = target_info {
let r = info
.ra
.as_ref()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let d = info
.dec
.as_ref()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
(r, d)
} else {
failed += 1;
results.push(format!("| {} | 查询失败 | - | - |", name));
continue;
};
// 2. 获取观测数据(简化:仅记录元数据)
let obs_info = format!("ra={:.4}, dec={:.4}", ra, dec);
// 3. 提取参数(如果启用)
let params_info = if extract_params {
"参数提取待执行".to_string()
} else {
"跳过".to_string()
};
success += 1;
results.push(format!(
"| {} | {} | {} | {} |",
name, obs_info, params_info, ""
));
}
let mut output = format!(
"## 批量处理报告\n\n\
- 总计: {} 个目标\n\
- 成功: {}\n\
- 失败: {}\n\n\
| 目标 | 坐标 | 参数 | 状态 |\n\
|------|------|------|------|\n",
targets.len(),
success,
failed
);
output.push_str(&results.join("\n"));
ToolOutput::success(
output,
json!({ "total": targets.len(), "success": success, "failed": failed }),
)
}
}
@@ -0,0 +1,139 @@
// src/agent/tools/astro/research/kinematics_tool.rs
//
// AnalyzeKinematicsTool —— 自行/视差运动学分析
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct AnalyzeKinematicsTool;
#[async_trait]
impl AgentTool for AnalyzeKinematicsTool {
fn name(&self) -> &str {
"analyze_kinematics"
}
fn display_name(&self) -> &str {
"运动学分析"
}
fn description(&self) -> &str {
"基于 Gaia 自行/视差数据计算银河系三维空间速度(UVW),识别移动星群(薄盘/厚盘/晕)。\
输入天体的赤道坐标、视差、自行和径向速度,输出银心距、三维速度和星族分类。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "标识符" },
"ra": { "type": "number", "description": "赤经 (度, J2000)" },
"dec": { "type": "number", "description": "赤纬 (度, J2000)" },
"parallax": { "type": "number", "description": "视差 (mas)" },
"pm_ra": { "type": "number", "description": "自行 RA (mas/yr)" },
"pm_dec": { "type": "number", "description": "自行 Dec (mas/yr)" },
"rv": { "type": "number", "description": "径向速度 (km/s)" }
},
"required": ["ra", "dec", "parallax", "pm_ra", "pm_dec", "rv"]
},
"description": "天体列表"
}
},
"required": ["targets"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let targets: Vec<crate::services::analysis::kinematics::KinematicInput> =
match args.get("targets").and_then(|t| t.as_array()) {
Some(arr) => {
let mut inputs = Vec::new();
for item in arr {
let ra = match item.get("ra").and_then(|r| r.as_f64()) {
Some(r) => r,
None => continue,
};
let dec = match item.get("dec").and_then(|d| d.as_f64()) {
Some(d) => d,
None => continue,
};
let parallax = match item.get("parallax").and_then(|p| p.as_f64()) {
Some(p) => p,
None => continue,
};
let pm_ra = item.get("pm_ra").and_then(|p| p.as_f64()).unwrap_or(0.0);
let pm_dec = item.get("pm_dec").and_then(|p| p.as_f64()).unwrap_or(0.0);
let rv = item.get("rv").and_then(|r| r.as_f64()).unwrap_or(0.0);
let id = item
.get("id")
.and_then(|i| i.as_str())
.map(|s| s.to_string());
inputs.push(crate::services::analysis::kinematics::KinematicInput {
ra,
dec,
parallax,
pm_ra,
pm_dec,
rv,
id,
});
}
inputs
}
None => return ToolOutput::error("缺少必需参数 'targets'"),
};
if targets.is_empty() {
return ToolOutput::error("目标列表为空");
}
info!("[AnalyzeKinematics] 分析 {} 个目标", targets.len());
let results = crate::services::analysis::kinematics::compute_kinematics_batch(&targets);
let mut output = String::from("## 运动学分析结果\n\n");
let mut success_count = 0;
for (i, result) in results.into_iter().enumerate() {
match result {
Ok(r) => {
success_count += 1;
output.push_str(
&crate::services::analysis::kinematics::format_kinematic_result(&r),
);
output.push_str("\n---\n\n");
}
Err(e) => {
output.push_str(&format!("### 目标 {} 失败: {}\n\n", i + 1, e));
}
}
}
output.insert_str(0, &format!("成功: {}/{}\n\n", success_count, targets.len()));
ToolOutput::success(
output,
json!({ "success_count": success_count, "total": targets.len() }),
)
}
}
@@ -0,0 +1,158 @@
// src/agent/tools/astro/research/literature_monitor.rs
//
// LiteratureMonitorTool —— 定时文献监控
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct LiteratureMonitorTool;
#[async_trait]
impl AgentTool for LiteratureMonitorTool {
fn name(&self) -> &str {
"literature_monitor"
}
fn display_name(&self) -> &str {
"文献监控"
}
fn description(&self) -> &str {
"检查 ADS/arXiv 最新文献,匹配用户关注的关键词、作者、天体名称。\
返回最近的新文献列表。可配合 loop 技能实现定时监控。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "ADS 查询字符串(如 'abs:LAMOST spectral' 或 'author:Zhang AND abs:metallicity'"
},
"max_results": {
"type": "integer",
"description": "最大返回数量",
"default": 10
},
"days_back": {
"type": "integer",
"description": "检查最近 N 天的文献",
"default": 7
},
"min_citations": {
"type": "integer",
"description": "最低引用数过滤",
"default": 0
}
},
"required": ["query"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let query = match args.get("query").and_then(|q| q.as_str()) {
Some(q) => q,
None => return ToolOutput::error("缺少必需参数 'query'"),
};
let max_results = args
.get("max_results")
.and_then(|m| m.as_u64())
.unwrap_or(10) as i32;
let days_back = args.get("days_back").and_then(|d| d.as_u64()).unwrap_or(7);
let min_citations = args
.get("min_citations")
.and_then(|c| c.as_u64())
.unwrap_or(0) as i32;
info!(
"[LitMonitor] 本地数据库查询='{}', 最近{}天, 最大{}条",
query, days_back, max_results
);
match crate::services::paper::get_recent_papers(
&ctx.app_state.db,
&ctx.app_state.config.storage.library_dir,
query,
days_back as i64,
max_results,
)
.await
{
Ok(papers) => {
let mut output = format!(
"## 文献监控结果 (来自本地同步库)\n\n查询词: `{}`\n\n",
query
);
output.push_str("| # | 标题 | 作者 | 年份 | 引用 | Bibcode |\n");
output.push_str("|---|------|------|------|------|---------|\n");
let mut count = 0;
for paper in papers {
if paper.citation_count < min_citations {
continue;
}
count += 1;
let authors = paper
.authors
.iter()
.take(3)
.map(|a| a.as_str())
.collect::<Vec<_>>()
.join(", ");
let authors_str = if paper.authors.len() > 3 {
format!("{} et al.", authors)
} else {
authors
};
let md_path = ctx
.app_state
.config
.storage
.library_dir
.join("Markdown")
.join(format!("{}.md", paper.bibcode));
let md_path_str = md_path.to_string_lossy();
output.push_str(&format!(
"| {} | {} | {} | {} | {} | [{}](file://{}) |\n",
count,
paper.title.chars().take(50).collect::<String>(),
authors_str,
paper.year,
paper.citation_count,
paper.bibcode,
md_path_str
));
}
if count == 0 {
output.push_str("\n最近无匹配的新文献。\n");
} else {
output.push_str(&format!("\n共找到 {} 篇匹配文献。\n", count));
}
ToolOutput::success(output, json!({ "count": count }))
}
Err(e) => ToolOutput::error(format!("本地数据库搜索失败: {}", e)),
}
}
}
+22
View File
@@ -1,20 +1,42 @@
// src/agent/tools/astro/research/mod.rs
// 研究级工具:科研人员消费本地数据进行分析
pub mod abundance_analysis;
pub mod batch_process;
pub mod kinematics_tool;
pub mod library;
pub mod literature_monitor;
pub mod metadata;
pub mod note;
pub mod observation;
pub mod observation_plan;
pub mod paper;
pub mod period_search;
pub mod radial_velocity;
pub mod rag;
pub mod sed_fit;
pub mod spectral_lines;
pub mod spectrum_params;
pub mod target;
pub mod variable_star;
pub mod vizier;
pub use abundance_analysis::AnalyzeAbundancePatternTool;
pub use batch_process::BatchProcessTool;
pub use kinematics_tool::AnalyzeKinematicsTool;
pub use library::{GetCitationNetworkTool, SearchLocalLibraryTool};
pub use literature_monitor::LiteratureMonitorTool;
pub use metadata::GetPaperMetadataTool;
pub use note::SaveNoteTool;
pub use observation::FindObservationTool;
pub use observation_plan::PlanObservationTool;
pub use paper::{GetPaperContentTool, GetPaperOutlineTool};
pub use period_search::FindPeriodTool;
pub use radial_velocity::MeasureRadialVelocityTool;
pub use rag::RagSearchTool;
pub use sed_fit::FitSedTool;
pub use spectral_lines::MeasureSpectralLinesTool;
pub use spectrum_params::GetSpectrumParametersTool;
pub use target::QueryTargetTool;
pub use variable_star::ClassifyVariableStarTool;
pub use vizier::CatalogOperationTool;
@@ -135,7 +135,7 @@ impl AgentTool for FindObservationTool {
let state = &ctx.app_state;
let source = match args.get("source").and_then(|v| v.as_str()) {
Some(s) => match Source::from_str(s) {
Some(s) => match Source::parse(s) {
Ok(src) => src,
Err(e) => return ToolOutput::error(e),
},
@@ -143,7 +143,7 @@ impl AgentTool for FindObservationTool {
};
let product_type = match args.get("product").and_then(|v| v.as_str()) {
None => ProductType::Spectrum,
Some(s) => match ProductType::from_str(s) {
Some(s) => match ProductType::parse(s) {
Ok(p) => p,
Err(e) => return ToolOutput::error(e),
},
@@ -0,0 +1,115 @@
// src/agent/tools/astro/research/observation_plan.rs
//
// PlanObservationTool —— 观测提案辅助
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct PlanObservationTool;
#[async_trait]
impl AgentTool for PlanObservationTool {
fn name(&self) -> &str {
"plan_observation"
}
fn display_name(&self) -> &str {
"观测提案辅助"
}
fn description(&self) -> &str {
"观测提案辅助:评估目标的可观测性。计算目标在给定台站的高度角、大气质量、\
月相影响,并估算所需曝光时间。支持 Keck、Lick、LAMOST、Gemini North 等台站。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"ra": { "type": "number", "description": "目标赤经 (度, J2000)" },
"dec": { "type": "number", "description": "目标赤纬 (度, J2000)" },
"observatory": {
"type": "string",
"enum": ["keck", "lick", "lamost", "gemini_north"],
"description": "观测台站,默认 keck",
"default": "keck"
},
"target_v_mag": {
"type": "number",
"description": "目标 V 波段星等(用于曝光时间估算),可选"
},
"target_snr": {
"type": "number",
"description": "目标信噪比(用于曝光时间估算),可选",
"default": 100.0
}
},
"required": ["ra", "dec"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let ra = match args.get("ra").and_then(|r| r.as_f64()) {
Some(r) => r,
None => return ToolOutput::error("缺少必需参数 'ra'"),
};
let dec = match args.get("dec").and_then(|d| d.as_f64()) {
Some(d) => d,
None => return ToolOutput::error("缺少必需参数 'dec'"),
};
let obs_name = args
.get("observatory")
.and_then(|o| o.as_str())
.unwrap_or("keck");
let obs = match obs_name {
"keck" => crate::services::analysis::observability::Observatory::keck(),
"lick" => crate::services::analysis::observability::Observatory::lick_3m(),
"lamost" => crate::services::analysis::observability::Observatory::lamost(),
"gemini_north" => crate::services::analysis::observability::Observatory::gemini_north(),
_ => return ToolOutput::error(format!("不支持的台站: {}", obs_name)),
};
let target_v_mag = args.get("target_v_mag").and_then(|v| v.as_f64());
let target_snr = args
.get("target_snr")
.and_then(|s| s.as_f64())
.unwrap_or(100.0);
info!(
"[PlanObservation] RA={:.4}, Dec={:.4}, 台站={}",
ra, dec, obs_name
);
let result = crate::services::analysis::observability::assess_observability(
ra,
dec,
&obs,
target_v_mag,
Some(target_snr),
);
let report =
crate::services::analysis::observability::format_observability(&result, &obs.name);
let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
let content = format!("{}\n\n```json\n{}\n```", report, json_str);
ToolOutput::success(content, json!(result))
}
}
@@ -0,0 +1,148 @@
// src/agent/tools/astro/research/period_search.rs
//
// FindPeriodTool —— 光变周期搜索(Lomb-Scargle + BLS
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct FindPeriodTool;
#[async_trait]
impl AgentTool for FindPeriodTool {
fn name(&self) -> &str {
"find_period"
}
fn display_name(&self) -> &str {
"周期搜索"
}
fn description(&self) -> &str {
"搜索光变曲线的周期。支持 Lomb-Scargle(通用变星)和 BLS(凌星检测)两种方法。\
自动报告最优周期、FAP(误报概率)和次优周期。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"time": {
"type": "array",
"items": { "type": "number" },
"description": "时间数组 (天)"
},
"flux": {
"type": "array",
"items": { "type": "number" },
"description": "通量/星等数组"
},
"method": {
"type": "string",
"enum": ["lomb-scargle", "bls"],
"description": "搜索方法,默认 lomb-scargle",
"default": "lomb-scargle"
},
"min_period": {
"type": "number",
"description": "最小周期 (天),可选"
},
"max_period": {
"type": "number",
"description": "最大周期 (天),可选"
},
"n_freq": {
"type": "integer",
"description": "频率点数,默认 5000",
"default": 5000
}
},
"required": ["time", "flux"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let time: Vec<f64> = match args.get("time").and_then(|t| t.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_f64()).collect(),
None => return ToolOutput::error("缺少必需参数 'time'"),
};
let flux: Vec<f64> = match args.get("flux").and_then(|f| f.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_f64()).collect(),
None => return ToolOutput::error("缺少必需参数 'flux'"),
};
if time.len() != flux.len() {
return ToolOutput::error("时间和通量数组长度不一致");
}
if time.len() < 4 {
return ToolOutput::error("数据点太少(需要 ≥4");
}
let method = args
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("lomb-scargle");
let min_period = args.get("min_period").and_then(|p| p.as_f64());
let max_period = args.get("max_period").and_then(|p| p.as_f64());
let n_freq = args.get("n_freq").and_then(|n| n.as_u64()).unwrap_or(5000) as usize;
info!(
"[FindPeriod] 方法={}, 数据点={}, 搜索范围=[{}, {}] 天",
method,
time.len(),
min_period
.map(|p| format!("{:.4}", p))
.unwrap_or_else(|| "auto".to_string()),
max_period
.map(|p| format!("{:.4}", p))
.unwrap_or_else(|| "auto".to_string())
);
match method {
"lomb-scargle" => {
match crate::services::timeseries::periodicity::lomb_scargle(
&time, &flux, min_period, max_period, n_freq,
) {
Ok(result) => {
let report =
crate::services::timeseries::periodicity::format_period_result(&result);
let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
let content = format!("{}\n\n```json\n{}\n```", report, json_str);
ToolOutput::success(content, json!(result))
}
Err(e) => ToolOutput::error(format!("周期搜索失败: {}", e)),
}
}
"bls" => {
match crate::services::timeseries::periodicity::bls(
&time, &flux, min_period, max_period, n_freq, 0.01, 0.1,
) {
Ok(result) => {
let content = format!(
"BLS 周期搜索结果:\n\n- 最优周期: {:.6}\n- 凌星深度: {:.4}\n- 凌星持续时间: {:.4}\n- SDE: {:.2}\n",
result.best_period, result.depth, result.duration, result.sde
);
ToolOutput::success(content, json!(result))
}
Err(e) => ToolOutput::error(format!("BLS 搜索失败: {}", e)),
}
}
_ => ToolOutput::error(format!("不支持的方法: {}", method)),
}
}
}
@@ -0,0 +1,151 @@
// src/agent/tools/astro/research/radial_velocity.rs
//
// MeasureRadialVelocityTool —— 交叉相关法测径向速度
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct MeasureRadialVelocityTool;
#[async_trait]
impl AgentTool for MeasureRadialVelocityTool {
fn name(&self) -> &str {
"measure_radial_velocity"
}
fn display_name(&self) -> &str {
"径向速度测量"
}
fn description(&self) -> &str {
"通过交叉相关法测量光谱的径向速度(RV)。使用内置恒星类型模板或用户自定义模板,\
通过 Doppler shift 匹配测量视向速度。支持 OBAFGKM 恒星类型。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"wavelength": {
"type": "array",
"items": { "type": "number" },
"description": "观测光谱波长数组 (Å)"
},
"flux": {
"type": "array",
"items": { "type": "number" },
"description": "观测光谱通量数组"
},
"template_type": {
"type": "string",
"enum": ["O", "B", "A", "F", "G", "K", "M", "Mwarf", "Giant"],
"description": "模板恒星类型(不指定则使用 G 型)",
"default": "G"
},
"rv_range": {
"type": "array",
"items": { "type": "number" },
"description": "RV 搜索范围 [min, max] (km/s),默认 [-500, 500]",
"default": [-500.0, 500.0]
},
"rv_step": {
"type": "number",
"description": "RV 搜索步长 (km/s),默认 1.0",
"default": 1.0
}
},
"required": ["wavelength", "flux"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let wavelength: Vec<f64> = match args.get("wavelength").and_then(|w| w.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_f64()).collect(),
None => return ToolOutput::error("缺少必需参数 'wavelength'"),
};
let flux: Vec<f64> = match args.get("flux").and_then(|w| w.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_f64()).collect(),
None => return ToolOutput::error("缺少必需参数 'flux'"),
};
if wavelength.len() != flux.len() {
return ToolOutput::error("波长和通量数组长度不一致");
}
if wavelength.len() < 10 {
return ToolOutput::error("数据点太少(需要 ≥10");
}
let template_str = args
.get("template_type")
.and_then(|t| t.as_str())
.unwrap_or("G");
let template = match template_str {
"O" => crate::services::spectrum::cross_correlate::TemplateType::O,
"B" => crate::services::spectrum::cross_correlate::TemplateType::B,
"A" => crate::services::spectrum::cross_correlate::TemplateType::A,
"F" => crate::services::spectrum::cross_correlate::TemplateType::F,
"G" => crate::services::spectrum::cross_correlate::TemplateType::G,
"K" => crate::services::spectrum::cross_correlate::TemplateType::K,
"M" => crate::services::spectrum::cross_correlate::TemplateType::M,
"Mwarf" => crate::services::spectrum::cross_correlate::TemplateType::Mwarf,
"Giant" => crate::services::spectrum::cross_correlate::TemplateType::Giant,
_ => return ToolOutput::error(format!("不支持的模板类型: {}", template_str)),
};
let rv_range = args
.get("rv_range")
.and_then(|r| r.as_array())
.and_then(|a| {
if a.len() == 2 {
Some((a[0].as_f64()?, a[1].as_f64()?))
} else {
None
}
})
.unwrap_or((-500.0, 500.0));
let rv_step = args.get("rv_step").and_then(|s| s.as_f64()).unwrap_or(1.0);
info!(
"[MeasureRV] 模板={:?}, RV范围=[{:.0}, {:.0}] km/s, 步长={:.1} km/s",
template, rv_range.0, rv_range.1, rv_step
);
match crate::services::spectrum::cross_correlate::measure_rv(
&wavelength,
&flux,
template,
rv_range,
rv_step,
) {
Ok(result) => {
let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
let content = format!(
"径向速度测量结果:\n\n- RV = {:.2} ± {:.2} km/s\n- CCF 峰值 = {:.4}\n- 模板: {}\n- 波长范围: {:.0}-{:.0} Å\n- SNR: {}\n\n{}",
result.rv, result.rv_error, result.ccf_peak, result.template_name,
result.wavelength_range.0, result.wavelength_range.1,
result.snr.map(|s| format!("{:.1}", s)).unwrap_or_else(|| "N/A".to_string()),
json_str
);
ToolOutput::success(content, json!(result))
}
Err(e) => ToolOutput::error(format!("径向速度测量失败: {}", e)),
}
}
}
+5
View File
@@ -15,6 +15,11 @@ impl AgentTool for RagSearchTool {
"rag_search"
}
/// 检索结果来自外部论文语料,可能包含对抗性内容
fn untrusted_output(&self) -> bool {
true
}
fn display_name(&self) -> &str {
"文献库RAG检索"
}
+125
View File
@@ -0,0 +1,125 @@
// src/agent/tools/astro/research/sed_fit.rs
//
// FitSedTool —— 多波段 SED 拟合
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct FitSedTool;
#[async_trait]
impl AgentTool for FitSedTool {
fn name(&self) -> &str {
"fit_sed"
}
fn display_name(&self) -> &str {
"SED 拟合"
}
fn description(&self) -> &str {
"多波段光谱能量分布(SED)拟合。输入多个波段的测光数据(波长、通量、误差),\
拟合黑体辐射模型,输出有效温度 T_eff、半径 R、消光 A_V、光度 L 及不确定度。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"data_points": {
"type": "array",
"items": {
"type": "object",
"properties": {
"wavelength": { "type": "number", "description": "中心波长 (Å)" },
"flux": { "type": "number", "description": "流量" },
"flux_error": { "type": "number", "description": "流量误差" },
"band": { "type": "string", "description": "波段名" }
},
"required": ["wavelength", "flux", "flux_error", "band"]
},
"description": "测光数据点列表"
},
"distance_pc": {
"type": "number",
"description": "距离 (pc),可选,用于计算光度"
}
},
"required": ["data_points"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let data_points: Vec<crate::services::analysis::sed::SedDataPoint> =
match args.get("data_points").and_then(|d| d.as_array()) {
Some(arr) => {
let mut points = Vec::new();
for item in arr {
let wavelength = match item.get("wavelength").and_then(|w| w.as_f64()) {
Some(w) => w,
None => continue,
};
let flux = match item.get("flux").and_then(|f| f.as_f64()) {
Some(f) => f,
None => continue,
};
let flux_error = item
.get("flux_error")
.and_then(|e| e.as_f64())
.unwrap_or(flux * 0.1);
let band = item
.get("band")
.and_then(|b| b.as_str())
.unwrap_or("unknown")
.to_string();
points.push(crate::services::analysis::sed::SedDataPoint {
wavelength,
flux,
flux_error,
band,
source: "user".to_string(),
});
}
points
}
None => return ToolOutput::error("缺少必需参数 'data_points'"),
};
if data_points.len() < 3 {
return ToolOutput::error("至少需要 3 个测光点进行 SED 拟合");
}
let distance_pc = args.get("distance_pc").and_then(|d| d.as_f64());
info!(
"[FitSed] 数据点={}, 距离={:?} pc",
data_points.len(),
distance_pc
);
match crate::services::analysis::sed::fit_sed(&data_points, distance_pc) {
Ok(result) => {
let report = crate::services::analysis::sed::format_sed_result(&result);
let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
let content = format!("{}\n\n```json\n{}\n```", report, json_str);
ToolOutput::success(content, json!(result))
}
Err(e) => ToolOutput::error(format!("SED 拟合失败: {}", e)),
}
}
}
@@ -0,0 +1,168 @@
// src/agent/tools/astro/research/spectral_lines.rs
//
// MeasureSpectralLinesTool —— 谱线识别与等值宽度测量
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct MeasureSpectralLinesTool;
#[async_trait]
impl AgentTool for MeasureSpectralLinesTool {
fn name(&self) -> &str {
"measure_spectral_lines"
}
fn display_name(&self) -> &str {
"谱线测量"
}
fn description(&self) -> &str {
"识别光谱中的常见谱线(Balmer 系列、Ca II H&K、Na D、Mg b、Fe 等),\
自动计算等值宽度(EW)和半高全宽(FWHM)。输入光谱的波长和通量数组,\
返回每条谱线的测量结果。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"wavelength": {
"type": "array",
"items": { "type": "number" },
"description": "波长数组 (Å)"
},
"flux": {
"type": "array",
"items": { "type": "number" },
"description": "通量数组"
},
"lines": {
"type": "array",
"items": { "type": "string" },
"description": "要测量的谱线名称列表(可选)。不指定则测量所有内置谱线。可选值: Hα, Hβ, Hγ, Hδ, Ca II K, Ca II H, Na I D2, Na I D1, Mg I b1 等"
},
"window_half_width": {
"type": "number",
"description": "搜索窗口半宽 (Å),默认 20",
"default": 20.0
}
},
"required": ["wavelength", "flux"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let wavelength: Vec<f32> = match args.get("wavelength").and_then(|w| w.as_array()) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_f64().map(|f| f as f32))
.collect(),
None => return ToolOutput::error("缺少必需参数 'wavelength'"),
};
let flux: Vec<f32> = match args.get("flux").and_then(|w| w.as_array()) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_f64().map(|f| f as f32))
.collect(),
None => return ToolOutput::error("缺少必需参数 'flux'"),
};
let window_half_width = args
.get("window_half_width")
.and_then(|w| w.as_f64())
.unwrap_or(20.0);
if wavelength.len() != flux.len() {
return ToolOutput::error("波长和通量数组长度不一致");
}
if wavelength.len() < 3 {
return ToolOutput::error("数据点太少(需要 ≥3");
}
let table = crate::services::spectrum::lines::builtin_line_table();
// 筛选要测量的谱线
let target_lines: Vec<crate::services::spectrum::lines::SpectralLine> =
if let Some(names) = args.get("lines").and_then(|l| l.as_array()) {
names
.iter()
.filter_map(|n| n.as_str())
.filter_map(|name| crate::services::spectrum::lines::find_line(name, &table))
.cloned()
.collect()
} else {
// 测量所有在光谱范围内的谱线
let w_min = wavelength.iter().cloned().fold(f32::INFINITY, f32::min) as f64;
let w_max = wavelength.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as f64;
crate::services::spectrum::lines::find_lines_in_range(w_min, w_max, &table)
.into_iter()
.cloned()
.collect()
};
if target_lines.is_empty() {
return ToolOutput::error("在当前光谱范围内未找到匹配的谱线");
}
info!(
"[MeasureSpectralLines] 测量 {} 条谱线,窗口 ±{:.1} Å",
target_lines.len(),
window_half_width
);
let measurements = crate::services::spectrum::lines::measure_lines(
&wavelength,
&flux,
&target_lines,
window_half_width,
);
let successful: Vec<_> = measurements.into_iter().filter_map(|r| r.ok()).collect();
if successful.is_empty() {
return ToolOutput::error("所有谱线测量均失败");
}
let table_str = crate::services::spectrum::lines::format_measurements_table(&successful);
let content = format!(
"谱线测量完成({} / {} 条成功):\n\n{}",
successful.len(),
target_lines.len(),
table_str
);
let metadata = json!({
"measurements": successful.iter().map(|m| {
json!({
"line": m.line.name,
"wavelength": m.center_wavelength,
"ew": m.ew,
"ew_error": m.ew_error,
"fwhm": m.fwhm,
"continuum_flux": m.continuum_flux
})
}).collect::<Vec<_>>(),
"total_lines": target_lines.len(),
"successful_measurements": successful.len()
});
ToolOutput::success(content, metadata)
}
}
@@ -0,0 +1,130 @@
// src/agent/tools/astro/research/spectrum_params.rs
//
// GetSpectrumParametersTool —— 从 FITS 文件中提取恒星大气参数
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct GetSpectrumParametersTool;
#[async_trait]
impl AgentTool for GetSpectrumParametersTool {
fn name(&self) -> &str {
"get_spectrum_parameters"
}
fn display_name(&self) -> &str {
"光谱参数提取"
}
fn description(&self) -> &str {
"从已下载的 FITS 光谱文件中自动提取恒星大气参数(Teff, logg, [Fe/H], 径向速度等)。\
支持 LAMOST、SDSS/BOSS、APOGEE、DESI 数据。结果结构化返回,可直接用于后续分析。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["lamost", "sdss", "desi", "gaia"],
"description": "数据源"
},
"subtype": {
"type": "string",
"description": "产品子类型(SDSS: spec/apstar/aspcap"
},
"source_id": {
"type": "string",
"description": "源标识符"
},
"file_path": {
"type": "string",
"description": "FITS 文件路径(相对于 library/ 目录)"
}
},
"required": ["source", "source_id", "file_path"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let source_str = match args.get("source").and_then(|s| s.as_str()) {
Some(s) => s,
None => return ToolOutput::error("缺少必需参数 'source'"),
};
let source_id = match args.get("source_id").and_then(|s| s.as_str()) {
Some(s) => s,
None => return ToolOutput::error("缺少必需参数 'source_id'"),
};
let file_path = match args.get("file_path").and_then(|s| s.as_str()) {
Some(s) => s,
None => return ToolOutput::error("缺少必需参数 'file_path'"),
};
let subtype = args.get("subtype").and_then(|s| s.as_str());
let source = match crate::services::observation::Source::parse(source_str) {
Ok(s) => s,
Err(e) => return ToolOutput::error(e),
};
let product = crate::services::observation::types::ProductSpec {
product: crate::services::observation::types::ProductType::Spectrum,
subtype: subtype.map(|s| s.to_string()),
};
info!(
"[GetSpectrumParameters] source={}, id={}, file={}",
source_str, source_id, file_path
);
// 构造完整路径
let full_path = if std::path::Path::new(file_path).is_absolute() {
file_path.to_string()
} else {
let library_dir = ctx
.app_state
.config
.storage
.library_dir
.to_str()
.unwrap_or("library");
format!("{}/{}", library_dir, file_path)
};
match crate::services::spectrum::parameters::extract_parameters(
&ctx.app_state,
source,
&product,
source_id,
&full_path,
)
.await
{
Ok(params) => {
let json_str = serde_json::to_string_pretty(&params).unwrap_or_default();
let content = format!(
"光谱参数提取成功 ({}, {}):\n\n{}",
source_str, source_id, json_str
);
ToolOutput::success(content, json!(params))
}
Err(e) => ToolOutput::error(format!("参数提取失败: {}", e)),
}
}
}
@@ -0,0 +1,103 @@
// src/agent/tools/astro/research/variable_star.rs
//
// ClassifyVariableStarTool —— 变星分类与特征提取
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct ClassifyVariableStarTool;
#[async_trait]
impl AgentTool for ClassifyVariableStarTool {
fn name(&self) -> &str {
"classify_variable_star"
}
fn display_name(&self) -> &str {
"变星分类"
}
fn description(&self) -> &str {
"自动分类变星类型(RR Lyrae、Cepheid、食双星、脉动变星、AGN 等),\
提取振幅、偏度、峰度等统计特征。可选传入已知周期以提高分类准确度。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"time": {
"type": "array",
"items": { "type": "number" },
"description": "时间数组 (天)"
},
"flux": {
"type": "array",
"items": { "type": "number" },
"description": "通量/星等数组"
},
"period": {
"type": "number",
"description": "已知周期 (天),可选。传入可提高分类准确度"
}
},
"required": ["time", "flux"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
let time: Vec<f64> = match args.get("time").and_then(|t| t.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_f64()).collect(),
None => return ToolOutput::error("缺少必需参数 'time'"),
};
let flux: Vec<f64> = match args.get("flux").and_then(|f| f.as_array()) {
Some(arr) => arr.iter().filter_map(|v| v.as_f64()).collect(),
None => return ToolOutput::error("缺少必需参数 'flux'"),
};
let period = args.get("period").and_then(|p| p.as_f64());
if time.len() != flux.len() {
return ToolOutput::error("时间和通量数组长度不一致");
}
if time.len() < 5 {
return ToolOutput::error("数据点太少(需要 ≥5");
}
info!(
"[ClassifyVariable] 数据点={}, 周期={}",
time.len(),
period
.map(|p| format!("{:.4}", p))
.unwrap_or_else(|| "未指定".to_string())
);
match crate::services::timeseries::classification::classify_variable_star(
&time, &flux, period,
) {
Ok(result) => {
let report =
crate::services::timeseries::classification::format_classification(&result);
let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
let content = format!("{}\n\n```json\n{}\n```", report, json_str);
ToolOutput::success(content, json!(result))
}
Err(e) => ToolOutput::error(format!("分类失败: {}", e)),
}
}
}