feat: 把solve-field抽象出来,因为linux和mac下参数行为不一致;

This commit is contained in:
grabbit 2025-06-29 11:53:50 +08:00
parent 52a5463664
commit aea16be92e
6 changed files with 1086 additions and 8 deletions

12
src/astrometry/mod.rs Normal file
View File

@ -0,0 +1,12 @@
/// Astrometry.net solver interface and platform abstraction
///
/// This module provides a platform-independent interface to astrometry.net
/// solve-field command and handles reading various output files (WCS, AXY, RDLS, etc.)
pub mod solver;
pub mod output_parser;
pub mod types;
pub use solver::{AstrometrySolver, SolverConfigBuilder};
pub use output_parser::{OutputFileParser, WcsData, AxyData, RdlsData};
pub use types::{SolverConfig, SolverResult, SolverError, SolverStatus, CelestialCoordinate, PixelCoordinate, Range};

View File

@ -0,0 +1,307 @@
use anyhow::{Context, Result};
use fitsio::FitsFile;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use super::types::{CelestialCoordinate, PixelCoordinate, SolverError};
/// WCS (World Coordinate System) data from solved field
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WcsData {
/// Field center Right Ascension in degrees
pub crval1: f64,
/// Field center Declination in degrees
pub crval2: f64,
/// Reference pixel X coordinate
pub crpix1: f64,
/// Reference pixel Y coordinate
pub crpix2: f64,
/// CD matrix element 1,1 (degrees per pixel)
pub cd1_1: f64,
/// CD matrix element 1,2 (degrees per pixel)
pub cd1_2: f64,
/// CD matrix element 2,1 (degrees per pixel)
pub cd2_1: f64,
/// CD matrix element 2,2 (degrees per pixel)
pub cd2_2: f64,
/// Image width in pixels
pub image_width: Option<u32>,
/// Image height in pixels
pub image_height: Option<u32>,
}
impl WcsData {
/// Get field center as CelestialCoordinate
pub fn field_center(&self) -> Result<CelestialCoordinate, SolverError> {
CelestialCoordinate::new(self.crval1, self.crval2)
}
/// Calculate pixel scale in arcseconds per pixel
pub fn pixel_scale(&self) -> f64 {
let det = self.cd1_1 * self.cd2_2 - self.cd1_2 * self.cd2_1;
det.abs().sqrt() * 3600.0 // Convert degrees to arcseconds
}
/// Calculate rotation angle in degrees
pub fn rotation_angle(&self) -> f64 {
self.cd1_2.atan2(self.cd1_1).to_degrees()
}
/// Convert pixel coordinates to celestial coordinates
pub fn pixel_to_celestial(&self, pixel: &PixelCoordinate) -> Result<CelestialCoordinate, SolverError> {
// Standard WCS transformation
let dx = pixel.x - self.crpix1;
let dy = pixel.y - self.crpix2;
let ra = self.crval1 + (self.cd1_1 * dx + self.cd1_2 * dy);
let dec = self.crval2 + (self.cd2_1 * dx + self.cd2_2 * dy);
CelestialCoordinate::new(ra, dec)
}
/// Convert celestial coordinates to pixel coordinates
pub fn celestial_to_pixel(&self, celestial: &CelestialCoordinate) -> PixelCoordinate {
// Inverse WCS transformation (simplified linear approximation)
let dra = celestial.ra - self.crval1;
let ddec = celestial.dec - self.crval2;
// Solve the linear system: CD * [dx, dy]^T = [dra, ddec]^T
let det = self.cd1_1 * self.cd2_2 - self.cd1_2 * self.cd2_1;
let dx = (self.cd2_2 * dra - self.cd1_2 * ddec) / det;
let dy = (-self.cd2_1 * dra + self.cd1_1 * ddec) / det;
PixelCoordinate::new(self.crpix1 + dx, self.crpix2 + dy)
}
}
/// AXY data from detected stars
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AxyData {
/// Detected star positions in pixel coordinates
pub stars: Vec<PixelCoordinate>,
}
/// RDLS data from matched stars
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RdlsData {
/// Matched star positions in celestial coordinates
pub stars: Vec<CelestialCoordinate>,
}
/// Parser for astrometry.net output files
pub struct OutputFileParser;
impl OutputFileParser {
/// Parse WCS file and extract coordinate system information using fitsio
pub fn parse_wcs_file<P: AsRef<Path>>(path: P) -> Result<WcsData, SolverError> {
let path = path.as_ref();
info!("🔍 PARSING WCS SOLUTION from file: {:?}", path);
let mut fits = FitsFile::open(path)
.context(format!("Failed to open WCS FITS file at {:?}", path))
.map_err(|e| SolverError::ParseError {
file: path.display().to_string(),
error: e.to_string(),
})?;
let hdu = fits.primary_hdu()
.map_err(|e| SolverError::ParseError {
file: path.display().to_string(),
error: format!("Failed to read primary HDU: {}", e),
})?;
// Read core WCS parameters
let crval1 = hdu.read_key::<f64>(&mut fits, "CRVAL1").unwrap_or(0.0);
let crval2 = hdu.read_key::<f64>(&mut fits, "CRVAL2").unwrap_or(0.0);
let crpix1 = hdu.read_key::<f64>(&mut fits, "CRPIX1").unwrap_or(0.0);
let crpix2 = hdu.read_key::<f64>(&mut fits, "CRPIX2").unwrap_or(0.0);
info!("🌍 Field center coordinates: RA={:.6}°, Dec={:.6}°", crval1, crval2);
// Try to read CD matrix first, as it's more accurate
let cd1_1 = hdu.read_key::<f64>(&mut fits, "CD1_1").unwrap_or(0.0);
let cd1_2 = hdu.read_key::<f64>(&mut fits, "CD1_2").unwrap_or(0.0);
let cd2_1 = hdu.read_key::<f64>(&mut fits, "CD2_1").unwrap_or(0.0);
let cd2_2 = hdu.read_key::<f64>(&mut fits, "CD2_2").unwrap_or(0.0);
// Log CD matrix information
if cd1_1 != 0.0 || cd1_2 != 0.0 || cd2_1 != 0.0 || cd2_2 != 0.0 {
let determinant = (cd1_1 * cd2_2 - cd1_2 * cd2_1).abs();
let scale = determinant.sqrt() * 3600.0; // Convert degrees to arcseconds
info!("📐 CD matrix scale calculation: det={:.8e}, scale={:.3} arcsec/px", determinant, scale);
info!("📐 CD matrix: [{:.8}, {:.8}] [{:.8}, {:.8}]", cd1_1, cd1_2, cd2_1, cd2_2);
} else if let Ok(cdelt1) = hdu.read_key::<f64>(&mut fits, "CDELT1") {
let scale = cdelt1.abs() * 3600.0;
info!("📐 CDELT scale calculation: CDELT1={:.8}, scale={:.3} arcsec/px", cdelt1, scale);
} else {
warn!("⚠️ No CD matrix or CDELT found in WCS file");
}
// Read image dimensions - try IMAGEW/IMAGEH first (astrometry.net format), then fallback to NAXIS1/NAXIS2
let image_width = hdu.read_key::<i64>(&mut fits, "IMAGEW")
.or_else(|_| hdu.read_key::<i64>(&mut fits, "NAXIS1"))
.ok()
.and_then(|w| if w > 0 { Some(w as u32) } else { None });
let image_height = hdu.read_key::<i64>(&mut fits, "IMAGEH")
.or_else(|_| hdu.read_key::<i64>(&mut fits, "NAXIS2"))
.ok()
.and_then(|h| if h > 0 { Some(h as u32) } else { None });
info!("📏 Image dimensions: width={:?}, height={:?}", image_width, image_height);
let wcs_data = WcsData {
crval1,
crval2,
crpix1,
crpix2,
cd1_1,
cd1_2,
cd2_1,
cd2_2,
image_width,
image_height,
};
// Validation checks
if crval1 == 0.0 && crval2 == 0.0 {
warn!("⚠️ CRVAL1 and CRVAL2 are both zero - this may indicate parsing failure");
return Err(SolverError::ParseError {
file: path.display().to_string(),
error: "Failed to parse RA/Dec from WCS file - both coordinates are zero".to_string(),
});
}
let scale = wcs_data.pixel_scale();
if scale == 0.0 {
warn!("⚠️ Could not determine pixel scale from WCS file");
}
info!("🎯 PARSED WCS SOLUTION: RA={:.6}°, Dec={:.6}°, Scale={:.3} arcsec/px",
crval1, crval2, scale);
info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
debug!("Parsed WCS data: center=({:.6}, {:.6}), scale={:.2} arcsec/px",
wcs_data.crval1, wcs_data.crval2, wcs_data.pixel_scale());
Ok(wcs_data)
}
/// Parse AXY file containing detected star positions
pub fn parse_axy_file<P: AsRef<Path>>(path: P) -> Result<AxyData, SolverError> {
let path = path.as_ref();
debug!("Parsing AXY file: {}", path.display());
let content = fs::read_to_string(path)
.map_err(|e| SolverError::ParseError {
file: path.display().to_string(),
error: e.to_string(),
})?;
let mut stars = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// Parse x y coordinates (possibly with additional columns)
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let (Ok(x), Ok(y)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
stars.push(PixelCoordinate::new(x, y));
} else {
warn!("Failed to parse AXY line: {}", line);
}
}
}
debug!("Parsed {} stars from AXY file", stars.len());
Ok(AxyData { stars })
}
/// Parse RDLS file containing matched star positions in celestial coordinates
pub fn parse_rdls_file<P: AsRef<Path>>(path: P) -> Result<RdlsData, SolverError> {
let path = path.as_ref();
debug!("Parsing RDLS file: {}", path.display());
let content = fs::read_to_string(path)
.map_err(|e| SolverError::ParseError {
file: path.display().to_string(),
error: e.to_string(),
})?;
let mut stars = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// Parse RA Dec coordinates (possibly with additional columns)
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let (Ok(ra), Ok(dec)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
if let Ok(coord) = CelestialCoordinate::new(ra, dec) {
stars.push(coord);
} else {
warn!("Invalid celestial coordinates in RDLS: {} {}", ra, dec);
}
} else {
warn!("Failed to parse RDLS line: {}", line);
}
}
}
debug!("Parsed {} matched stars from RDLS file", stars.len());
Ok(RdlsData { stars })
}
/// Parse solve-field output to extract basic solution information
pub fn parse_solve_output(output: &str) -> HashMap<String, String> {
let mut info = HashMap::new();
for line in output.lines() {
let line = line.trim();
// Look for specific patterns in solve-field output
if line.contains("Field center: (RA,Dec)") {
// Extract RA/Dec from format like "Field center: (RA,Dec) = (123.456, 78.901) deg."
if let Some(start) = line.find('(') {
if let Some(end) = line.rfind(')') {
let coords_part = &line[start+1..end];
if let Some(comma) = coords_part.find(',') {
let ra_str = coords_part[..comma].trim();
let dec_str = coords_part[comma+1..].trim();
info.insert("field_center_ra".to_string(), ra_str.to_string());
info.insert("field_center_dec".to_string(), dec_str.to_string());
}
}
}
} else if line.contains("Field size:") {
// Extract field size information
info.insert("field_size_line".to_string(), line.to_string());
} else if line.contains("Field rotation angle:") {
// Extract rotation angle
if let Some(angle_start) = line.find(':') {
let angle_part = line[angle_start+1..].trim();
if let Some(deg_pos) = angle_part.find("deg") {
let angle_str = angle_part[..deg_pos].trim();
info.insert("rotation_angle".to_string(), angle_str.to_string());
}
}
} else if line.contains("pixel scale") {
// Extract pixel scale information
info.insert("pixel_scale_line".to_string(), line.to_string());
}
}
info
}
}

388
src/astrometry/solver.rs Normal file
View File

@ -0,0 +1,388 @@
use anyhow::Result;
use log::{debug, error, info, warn};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use tokio::process::Command as AsyncCommand;
use tokio::time::timeout;
use super::output_parser::OutputFileParser;
use super::types::{SolverConfig, SolverError, SolverResult, SolverStatus, CelestialCoordinate};
/// Main interface for astrometry.net solving
pub struct AstrometrySolver {
config: SolverConfig,
}
impl AstrometrySolver {
/// Create a new solver with the given configuration
pub fn new(config: SolverConfig) -> Result<Self, SolverError> {
// Ensure working directory exists
if !config.working_dir.exists() {
fs::create_dir_all(&config.working_dir)?;
}
// Validate solve-field path
if !Self::check_solve_field_availability(&config.solve_field_path) {
return Err(SolverError::CommandFailed(
format!("solve-field not found at: {}", config.solve_field_path.display())
));
}
Ok(Self { config })
}
/// Check if solve-field is available and working
pub fn check_solve_field_availability<P: AsRef<Path>>(solve_field_path: P) -> bool {
match Command::new(solve_field_path.as_ref())
.arg("--help")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
Ok(status) => status.success(),
Err(_) => false,
}
}
/// Solve an image file and return the result
pub async fn solve_image<P: AsRef<Path>>(&self, image_path: P) -> Result<SolverResult, SolverError> {
let image_path = image_path.as_ref();
let start_time = Instant::now();
info!("Starting astrometry solve for: {}", image_path.display());
// Generate output file paths
let base_name = image_path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("image");
let output_base = self.config.working_dir.join(format!("solve_{}", base_name));
// Build solve-field command
let mut cmd = AsyncCommand::new(&self.config.solve_field_path);
// Input image
cmd.arg(image_path);
// Output directory and file naming
cmd.arg("--dir").arg(&self.config.working_dir);
cmd.arg("--out").arg(base_name);
// Overwrite existing files
if self.config.overwrite {
cmd.arg("--overwrite");
}
// Scale hints
if let Some(ref scale_range) = self.config.scale_range {
cmd.arg("--scale-low").arg(scale_range.min.to_string());
cmd.arg("--scale-high").arg(scale_range.max.to_string());
cmd.arg("--scale-units").arg("arcsecperpix");
}
// Center hint
if let Some(ref center) = self.config.center_hint {
cmd.arg("--ra").arg(center.ra.to_string());
cmd.arg("--dec").arg(center.dec.to_string());
if let Some(radius) = self.config.search_radius {
cmd.arg("--radius").arg(radius.to_string());
}
}
// Index files
if !self.config.index_files.is_empty() {
for index_file in &self.config.index_files {
cmd.arg("--index-file").arg(index_file);
}
}
// Common options that match the original implementation
cmd.arg("--no-plots"); // Disable plotting for performance
cmd.arg("--depth").arg("10,20,30,40,50"); // Search depth
cmd.arg("--downsample").arg("2"); // Downsample factor
cmd.arg("--no-remove-lines"); // Don't remove lines
cmd.arg("--crpix-center"); // Use center as reference pixel
cmd.arg("--cpulimit").arg("60"); // CPU time limit
// Additional arguments (after standard ones to avoid conflicts)
for arg in &self.config.extra_args {
cmd.arg(arg);
}
// Capture output
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
info!("Executing solve-field command: {:?}", cmd);
// Run command with timeout
let output = timeout(
Duration::from_secs(self.config.timeout_seconds as u64),
cmd.output()
).await;
let execution_time = start_time.elapsed().as_secs_f64();
let output = match output {
Ok(Ok(output)) => output,
Ok(Err(e)) => {
error!("Failed to execute solve-field: {}", e);
return Ok(SolverResult {
status: SolverStatus::Failed,
execution_time,
command_output: format!("Execution error: {}", e),
..Default::default()
});
},
Err(_) => {
error!("solve-field timed out after {} seconds", self.config.timeout_seconds);
return Ok(SolverResult {
status: SolverStatus::Timeout,
execution_time,
command_output: "Command timed out".to_string(),
..Default::default()
});
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let command_output = format!("STDOUT:\n{}\n\nSTDERR:\n{}", stdout, stderr);
debug!("solve-field completed with exit code: {:?}", output.status.code());
debug!("solve-field output: {}", command_output);
// Check if solve was successful
let solved_file_path = self.config.working_dir.join(format!("{}.solved", base_name));
let success = output.status.success() &&
(stdout.contains("solved") || solved_file_path.exists());
if !success {
warn!("solve-field did not find a solution");
return Ok(SolverResult {
status: SolverStatus::Failed,
execution_time,
command_output,
..Default::default()
});
}
info!("✅ solve-field found a solution in {:.2}s", execution_time);
// Parse output files
let mut result = SolverResult {
status: SolverStatus::Success,
execution_time,
command_output,
..Default::default()
};
// Find and parse WCS file
let wcs_path = self.config.working_dir.join(format!("{}.wcs", base_name));
if wcs_path.exists() {
result.wcs_file = Some(wcs_path.clone());
match OutputFileParser::parse_wcs_file(&wcs_path) {
Ok(wcs_data) => {
result.field_center = wcs_data.field_center().ok();
result.scale = Some(wcs_data.pixel_scale());
result.rotation = Some(wcs_data.rotation_angle());
if let (Some(width), Some(height)) = (wcs_data.image_width, wcs_data.image_height) {
let pixel_scale_deg = wcs_data.pixel_scale() / 3600.0; // Convert to degrees
result.field_size = Some((
width as f64 * pixel_scale_deg,
height as f64 * pixel_scale_deg
));
}
},
Err(e) => {
warn!("Failed to parse WCS file: {}", e);
}
}
}
// Find AXY file (detected stars)
let axy_path = self.config.working_dir.join(format!("{}.axy", base_name));
if axy_path.exists() {
result.axy_file = Some(axy_path);
}
// Find RDLS file (matched stars)
let rdls_path = self.config.working_dir.join(format!("{}.rdls", base_name));
if rdls_path.exists() {
result.rdls_file = Some(rdls_path);
}
// Find solved file
let solved_path = self.config.working_dir.join(format!("{}.solved", base_name));
if solved_path.exists() {
result.solved_file = Some(solved_path);
}
// // Clean up temporary files if requested
// if !self.config.keep_temp_files {
// self.cleanup_temp_files(base_name).await;
// }
Ok(result)
}
/// Solve an image with a specific configuration override
pub async fn solve_with_config<P: AsRef<Path>>(
&self,
image_path: P,
config_override: SolverConfig
) -> Result<SolverResult, SolverError> {
let original_config = self.config.clone();
let mut temp_solver = Self::new(config_override)?;
let result = temp_solver.solve_image(image_path).await;
result
}
/// Get detected stars from the last solve (requires AXY file)
pub fn get_detected_stars(&self, axy_file: &Path) -> Result<Vec<super::types::PixelCoordinate>, SolverError> {
let axy_data = OutputFileParser::parse_axy_file(axy_file)?;
Ok(axy_data.stars)
}
/// Get matched stars from the last solve (requires RDLS file)
pub fn get_matched_stars(&self, rdls_file: &Path) -> Result<Vec<CelestialCoordinate>, SolverError> {
let rdls_data = OutputFileParser::parse_rdls_file(rdls_file)?;
Ok(rdls_data.stars)
}
/// Convert pixel coordinates to celestial coordinates using WCS file
pub fn pixel_to_celestial(
&self,
wcs_file: &Path,
pixel: &super::types::PixelCoordinate
) -> Result<CelestialCoordinate, SolverError> {
let wcs_data = OutputFileParser::parse_wcs_file(wcs_file)?;
wcs_data.pixel_to_celestial(pixel)
}
/// Convert celestial coordinates to pixel coordinates using WCS file
pub fn celestial_to_pixel(
&self,
wcs_file: &Path,
celestial: &CelestialCoordinate
) -> Result<super::types::PixelCoordinate, SolverError> {
let wcs_data = OutputFileParser::parse_wcs_file(wcs_file)?;
Ok(wcs_data.celestial_to_pixel(celestial))
}
/// Clean up temporary files from a solve operation
async fn cleanup_temp_files(&self, base_name: &str) {
let extensions = ["axy", "xyls", "corr", "new", "rdls", "solved", "match", "wcs"];
for ext in &extensions {
let temp_file = self.config.working_dir.join(format!("{}.{}", base_name, ext));
if temp_file.exists() {
if let Err(e) = fs::remove_file(&temp_file) {
debug!("Failed to remove temp file {}: {}", temp_file.display(), e);
}
}
}
}
/// Update solver configuration
pub fn set_config(&mut self, config: SolverConfig) -> Result<(), SolverError> {
// Validate new configuration
if !config.working_dir.exists() {
fs::create_dir_all(&config.working_dir)?;
}
if !Self::check_solve_field_availability(&config.solve_field_path) {
return Err(SolverError::CommandFailed(
format!("solve-field not found at: {}", config.solve_field_path.display())
));
}
self.config = config;
Ok(())
}
/// Get current configuration
pub fn get_config(&self) -> &SolverConfig {
&self.config
}
}
/// Builder pattern for easier SolverConfig creation
pub struct SolverConfigBuilder {
config: SolverConfig,
}
impl SolverConfigBuilder {
pub fn new() -> Self {
Self {
config: SolverConfig::default(),
}
}
pub fn solve_field_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.config.solve_field_path = path.into();
self
}
pub fn working_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.config.working_dir = dir.into();
self
}
pub fn index_files<I, P>(mut self, files: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
self.config.index_files = files.into_iter().map(Into::into).collect();
self
}
pub fn scale_range(mut self, min: f64, max: f64) -> Self {
self.config.scale_range = Some(super::types::Range::new(min, max));
self
}
pub fn center_hint(mut self, ra: f64, dec: f64) -> Result<Self, SolverError> {
self.config.center_hint = Some(CelestialCoordinate::new(ra, dec)?);
Ok(self)
}
pub fn search_radius(mut self, radius: f64) -> Self {
self.config.search_radius = Some(radius);
self
}
pub fn timeout(mut self, seconds: u32) -> Self {
self.config.timeout_seconds = seconds;
self
}
pub fn overwrite(mut self, overwrite: bool) -> Self {
self.config.overwrite = overwrite;
self
}
pub fn keep_temp_files(mut self, keep: bool) -> Self {
self.config.keep_temp_files = keep;
self
}
pub fn extra_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.config.extra_args = args.into_iter().map(Into::into).collect();
self
}
pub fn build(self) -> SolverConfig {
self.config
}
}

212
src/astrometry/types.rs Normal file
View File

@ -0,0 +1,212 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Errors that can occur during astrometry solving
#[derive(Debug, thiserror::Error)]
pub enum SolverError {
#[error("Solve-field command failed: {0}")]
CommandFailed(String),
#[error("Timeout waiting for solve-field to complete")]
Timeout,
#[error("No solution found for the image")]
NoSolution,
#[error("Failed to parse output file {file}: {error}")]
ParseError { file: String, error: String },
#[error("Required output file not found: {0}")]
OutputFileNotFound(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Invalid coordinates: {0}")]
InvalidCoordinates(String),
}
/// Status of the solver operation
#[derive(Debug, Clone, PartialEq)]
pub enum SolverStatus {
/// Solver is running
Running,
/// Solution found successfully
Success,
/// No solution found
Failed,
/// Operation timed out
Timeout,
/// Solver was cancelled
Cancelled,
}
/// Celestial coordinates (RA/Dec)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CelestialCoordinate {
/// Right Ascension in degrees
pub ra: f64,
/// Declination in degrees
pub dec: f64,
}
impl CelestialCoordinate {
pub fn new(ra: f64, dec: f64) -> Result<Self, SolverError> {
if ra < 0.0 || ra >= 360.0 {
return Err(SolverError::InvalidCoordinates(
format!("RA must be in range [0, 360), got {}", ra)
));
}
if dec < -90.0 || dec > 90.0 {
return Err(SolverError::InvalidCoordinates(
format!("Dec must be in range [-90, 90], got {}", dec)
));
}
Ok(Self { ra, dec })
}
/// Convert RA from hours to degrees
pub fn from_hours_degrees(ra_hours: f64, dec_degrees: f64) -> Result<Self, SolverError> {
Self::new(ra_hours * 15.0, dec_degrees)
}
/// Get RA in hours
pub fn ra_hours(&self) -> f64 {
self.ra / 15.0
}
}
/// Pixel coordinates in image
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PixelCoordinate {
/// X coordinate (column)
pub x: f64,
/// Y coordinate (row)
pub y: f64,
}
impl PixelCoordinate {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
/// Range for solver parameters
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Range<T> {
pub min: T,
pub max: T,
}
impl<T> Range<T> {
pub fn new(min: T, max: T) -> Self {
Self { min, max }
}
}
/// Configuration for astrometry solver
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SolverConfig {
/// Path to solve-field executable
pub solve_field_path: PathBuf,
/// Working directory for temporary files
pub working_dir: PathBuf,
/// Index files to use (optional, if empty uses default search)
pub index_files: Vec<PathBuf>,
/// Scale range in arcseconds per pixel
pub scale_range: Option<Range<f64>>,
/// Field center hint (RA/Dec in degrees)
pub center_hint: Option<CelestialCoordinate>,
/// Search radius around center hint in degrees
pub search_radius: Option<f64>,
/// Maximum time to wait for solution in seconds
pub timeout_seconds: u32,
/// Whether to overwrite existing output files
pub overwrite: bool,
/// Whether to keep temporary files for debugging
pub keep_temp_files: bool,
/// Additional command line arguments
pub extra_args: Vec<String>,
}
impl Default for SolverConfig {
fn default() -> Self {
Self {
solve_field_path: PathBuf::from("solve-field"),
working_dir: PathBuf::from("/tmp/astrometry"),
index_files: Vec::new(),
scale_range: None,
center_hint: None,
search_radius: None,
timeout_seconds: 60,
overwrite: true,
keep_temp_files: false,
extra_args: Vec::new(),
}
}
}
/// Result of astrometry solving
#[derive(Debug, Clone)]
pub struct SolverResult {
/// Status of the solve operation
pub status: SolverStatus,
/// Field center coordinates (if solution found)
pub field_center: Option<CelestialCoordinate>,
/// Field size in degrees (width, height)
pub field_size: Option<(f64, f64)>,
/// Rotation angle in degrees
pub rotation: Option<f64>,
/// Scale in arcseconds per pixel
pub scale: Option<f64>,
/// Path to WCS file (if generated)
pub wcs_file: Option<PathBuf>,
/// Path to AXY file (detected stars)
pub axy_file: Option<PathBuf>,
/// Path to RDLS file (matched stars)
pub rdls_file: Option<PathBuf>,
/// Path to solved file (if generated)
pub solved_file: Option<PathBuf>,
/// Execution time in seconds
pub execution_time: f64,
/// Command output (stdout + stderr)
pub command_output: String,
}
impl Default for SolverResult {
fn default() -> Self {
Self {
status: SolverStatus::Failed,
field_center: None,
field_size: None,
rotation: None,
scale: None,
wcs_file: None,
axy_file: None,
rdls_file: None,
solved_file: None,
execution_time: 0.0,
command_output: String::new(),
}
}
}

View File

@ -19,6 +19,7 @@ pub mod streaming;
pub mod storage;
pub mod communication;
pub mod monitoring;
pub mod astrometry;
pub mod overlay;
pub mod hooks;
pub mod events;

View File

@ -1,4 +1,4 @@
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use log::{debug, error, info, warn};
use opencv::{core, imgproc, prelude::*, types, imgcodecs, calib3d};
@ -19,6 +19,9 @@ use crate::camera::{Frame};
use crate::utils::memory_monitor::estimate_mat_size;
use crate::utils::opencv_compat;
// Import the new astrometry abstraction layer
use super::super::astrometry::{AstrometrySolver, SolverConfig, SolverConfigBuilder, SolverResult, SolverStatus, CelestialCoordinate, Range};
/// Configuration options for the star chart overlay
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StarChartOptions {
@ -279,6 +282,8 @@ pub struct StarChart {
/// Current GPS status for position information
gps_status: Arc<Mutex<GpsStatus>>,
state: Arc<Mutex<StarChartState>>,
/// Astrometry solver instance
solver: Arc<AstrometrySolver>,
/// Solver thread handle
_solver_thread: Option<tokio::task::JoinHandle<()>>,
/// Channel for sending messages to the solver thread
@ -286,6 +291,39 @@ pub struct StarChart {
}
impl StarChart {
/// Convert StarChartOptions to SolverConfig
fn create_solver_config(options: &StarChartOptions) -> Result<SolverConfig> {
let mut config_builder = SolverConfigBuilder::new()
.solve_field_path(PathBuf::from(&options.solve_field_path))
.working_dir(PathBuf::from(&options.working_dir))
.scale_range(options.index_scale_range.0, options.index_scale_range.1)
.timeout(options.max_solve_time as u32)
.overwrite(true)
.keep_temp_files(false);
// Add index files if specified
if let Some(ref index_files) = options.index_file {
let index_paths: Vec<PathBuf> = index_files.iter()
.map(|s| PathBuf::from(s))
.collect();
config_builder = config_builder.index_files(index_paths);
}
// Add extra arguments for better solve performance
let extra_args = vec![
"--no-plot".to_string(),
"--depth".to_string(), "10,20,30,40,50".to_string(),
"--downsample".to_string(), "2".to_string(),
"--no-remove-lines".to_string(),
"--crpix-center".to_string(),
"--no-plots".to_string(),
"--cpulimit".to_string(), "60".to_string(),
];
config_builder = config_builder.extra_args(extra_args);
Ok(config_builder.build())
}
/// Create a new star chart overlay with the given options
pub async fn new(
options: StarChartOptions,
@ -298,6 +336,11 @@ impl StarChart {
.context("Failed to create working directory for astrometry")?;
}
// Create solver configuration and instance
let solver_config = Self::create_solver_config(&options)?;
let solver = Arc::new(AstrometrySolver::new(solver_config)
.context("Failed to create astrometry solver")?);
// Create the message channel for the solver thread
let (solver_tx, solver_rx) = mpsc::channel::<SolverMessage>(10);
@ -307,6 +350,7 @@ impl StarChart {
options: options.clone(),
gps_status,
state: state.clone(),
solver: solver.clone(),
_solver_thread: None,
solver_tx: Some(solver_tx),
};
@ -315,7 +359,7 @@ impl StarChart {
star_chart.load_catalog_data().await?;
// Start the solver thread
let solver_thread_handle = star_chart.start_solver_thread(solver_rx, options, state).await?;
let solver_thread_handle = star_chart.start_solver_thread(solver_rx, options, state, solver).await?;
star_chart._solver_thread = Some(solver_thread_handle);
Ok(star_chart)
@ -1198,6 +1242,7 @@ impl StarChart {
mut rx: mpsc::Receiver<SolverMessage>,
options: StarChartOptions,
state: Arc<Mutex<StarChartState>>,
solver: Arc<AstrometrySolver>,
) -> Result<tokio::task::JoinHandle<()>> {
// Clone needed values for the thread
let gps_status = self.gps_status.clone();
@ -1247,12 +1292,13 @@ impl StarChart {
let hint = { state.lock().unwrap().latest_hint.clone() };
// Run astrometry.net on the frame
let result = Self::run_astrometry(
let result = Self::run_astrometry_with_solver(
&frame,
&working_dir,
&options,
&hint,
&gps_status
&gps_status,
&solver
).await;
match result {
@ -1316,7 +1362,119 @@ impl StarChart {
Ok(frame_path)
}
/// Run astrometry.net to solve the star field
/// Run astrometry.net to solve the star field using the abstracted solver
async fn run_astrometry_with_solver(
_frame: &Frame,
working_dir: &Path,
options: &StarChartOptions,
previous_hint: &Option<SolutionHint>,
_gps_status: &Arc<Mutex<GpsStatus>>,
solver: &Arc<AstrometrySolver>
) -> Result<(SolutionHint, PathBuf, PathBuf)> {
let input_file = working_dir.join("current_frame.jpg");
// Create solver configuration with hint if available
let mut solver_config = Self::create_solver_config(options)?;
// Apply previous hint if available
if let Some(hint) = previous_hint {
debug!("Using previous solution as hint: RA={}, Dec={}, Scale={}",
hint.ra, hint.dec, hint.scale);
// Set center hint
if let Ok(center_hint) = CelestialCoordinate::new(hint.ra, hint.dec) {
solver_config.center_hint = Some(center_hint);
solver_config.search_radius = Some(hint.radius * 1.2); // 20% margin
// Set scale hint with 10% margin
let scale_low = hint.scale * 0.9;
let scale_high = hint.scale * 1.1;
solver_config.scale_range = Some(Range::new(scale_low, scale_high));
}
} else {
debug!("No previous hint available, using wider search parameters");
// For first solve, use wider scale range
solver_config.scale_range = Some(Range::new(70.0, 90.0));
}
// Create a temporary solver with the updated configuration
let temp_solver = AstrometrySolver::new(solver_config)
.context("Failed to create temporary solver with updated configuration")?;
// Run the solve
let result = temp_solver.solve_image(&input_file).await
.context("Failed to solve image")?;
match result.status {
SolverStatus::Success => {
let field_center = result.field_center
.ok_or_else(|| anyhow!("Solution found but no field center available"))?;
let scale = result.scale
.ok_or_else(|| anyhow!("Solution found but no scale available"))?;
let rotation = result.rotation.unwrap_or(0.0);
// Calculate radius from field size or use a default
let radius = if let Some((width, height)) = result.field_size {
(width.max(height)) / 2.0
} else {
1.0 // Default 1 degree radius
};
let hint = SolutionHint {
ra: field_center.ra,
dec: field_center.dec,
radius,
scale,
timestamp: Utc::now(),
};
let wcs_path = result.wcs_file
.ok_or_else(|| anyhow!("Solution found but no WCS file available"))?;
let axy_path = result.axy_file
.unwrap_or_else(|| input_file.with_extension("axy"));
info!("✅ Astrometry solve succeeded: RA={:.6}°, Dec={:.6}°, Scale={:.2} arcsec/px",
hint.ra, hint.dec, hint.scale);
Ok((hint, wcs_path, axy_path))
},
SolverStatus::Failed => {
Err(anyhow!("Astrometry solve failed: {}", result.command_output))
},
SolverStatus::Timeout => {
Err(anyhow!("Astrometry solve timed out after {} seconds", result.execution_time))
},
_ => {
Err(anyhow!("Astrometry solve ended with unexpected status: {:?}", result.status))
}
}
}
/// Parse WCS solution using the new abstracted parser
fn parse_wcs_solution_new(wcs_file: &Path) -> Result<(f64, f64, f64, f64)> {
use super::super::astrometry::OutputFileParser;
let wcs_data = OutputFileParser::parse_wcs_file(wcs_file)
.context("Failed to parse WCS file using new parser")?;
let center_ra = wcs_data.crval1;
let center_dec = wcs_data.crval2;
let scale = wcs_data.pixel_scale();
// Calculate radius from image dimensions if available
let radius = if let (Some(width), Some(height)) = (wcs_data.image_width, wcs_data.image_height) {
let diagonal_pixels = ((width as f64).powi(2) + (height as f64).powi(2)).sqrt();
let diagonal_arcsec = diagonal_pixels * scale;
diagonal_arcsec / 2.0 / 3600.0 // Convert to degrees
} else {
1.0 // Default 1 degree radius
};
Ok((center_ra, center_dec, radius, scale))
}
/// Legacy run_astrometry method - kept for compatibility but marked as deprecated
#[deprecated(note = "Use run_astrometry_with_solver instead")]
async fn run_astrometry(
_frame: &Frame,
working_dir: &Path,
@ -1428,7 +1586,7 @@ impl StarChart {
let axy_file = input_file.with_extension("axy");
if wcs_file.exists() {
// info!("WCS file found, solve succeeded.");
let solution = Self::parse_wcs_solution(&wcs_file)?;
let solution = Self::parse_wcs_solution_new(&wcs_file)?;
let hint = SolutionHint {
ra: solution.0,
dec: solution.1,
@ -1975,7 +2133,7 @@ impl StarChart {
}
// Parse WCS solution to get field of view
let (center_ra, center_dec, radius, scale) = Self::parse_wcs_solution(wcs_path)?;
let (center_ra, center_dec, radius, scale) = Self::parse_wcs_solution_new(wcs_path)?;
info!("Field center: RA={}, Dec={}, Radius={} deg, Catalog has {} objects", center_ra, center_dec, radius, state.ngc_catalog.len());
// Calculate field dimensions
@ -2069,7 +2227,7 @@ impl StarChart {
}
// Parse WCS solution to get field of view
let (center_ra, center_dec, radius, _scale) = Self::parse_wcs_solution(wcs_path)?;
let (center_ra, center_dec, radius, _scale) = Self::parse_wcs_solution_new(wcs_path)?;
// 🚨 POTENTIAL MEMORY LEAK: Cloning entire catalog every update!
warn!("🚨 MEMORY LEAK SOURCE: About to clone entire constellation catalog ({} entries, ~{:.2}MB)",