scirs2_io/formats/astronomical/
mod.rs

1//! Astronomical file format support
2//!
3//! This module provides support for file formats commonly used in astronomy,
4//! astrophysics, and space science research.
5//!
6//! ## Supported Formats
7//!
8//! - **FITS**: Flexible Image Transport System - standard format for astronomical data
9//! - **VOTable**: Virtual Observatory Table format for tabular astronomical data
10//!
11//! ## Examples
12//!
13//! ```rust,no_run
14//! use scirs2_io::formats::astronomical::{FitsFile, FitsTableReader, VOTable};
15//! use scirs2_core::ndarray::Array2;
16//!
17//! // Read FITS file
18//! let fits = FitsFile::open("hubble_image.fits")?;
19//! let header = fits.primaryheader();
20//! let image: Array2<f32> = fits.read_image()?;
21//!
22//! // Access header values
23//! let exposure_time = header.get_f64("EXPTIME")?;
24//! let telescope = header.get_string("TELESCOP")?;
25//!
26//! // Read FITS table
27//! let tablehdu = fits.gethdu(1)?;
28//! let table_reader = FitsTableReader::new(tablehdu.clone())?;
29//! let column_data = table_reader.read_column("FLUX")?;
30//! # Ok::<(), scirs2_io::error::IoError>(())
31//! ```
32
33#![allow(dead_code)]
34#![allow(missing_docs)]
35
36use crate::error::{IoError, Result};
37use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
38use scirs2_core::ndarray::Array2;
39use std::collections::HashMap;
40use std::fs::File;
41use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
42use std::path::Path;
43
44/// FITS file structure
45pub struct FitsFile {
46    file_path: String,
47    hdus: Vec<HDU>,
48}
49
50/// Header Data Unit
51#[derive(Debug, Clone)]
52pub struct HDU {
53    /// HDU type
54    pub hdu_type: HDUType,
55    /// Header cards
56    pub header: FitsHeader,
57    /// Data offset in file
58    pub data_offset: u64,
59    /// Data size in bytes
60    pub data_size: usize,
61}
62
63/// HDU types
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum HDUType {
66    /// Primary HDU (must be first)
67    Primary,
68    /// Image extension
69    Image,
70    /// ASCII table extension
71    AsciiTable,
72    /// Binary table extension
73    BinaryTable,
74}
75
76/// FITS header
77#[derive(Debug, Clone)]
78pub struct FitsHeader {
79    /// Header cards (key-value pairs)
80    cards: Vec<HeaderCard>,
81    /// Quick lookup map
82    card_map: HashMap<String, usize>,
83}
84
85/// Header card (80 characters)
86#[derive(Debug, Clone)]
87pub struct HeaderCard {
88    /// Keyword (up to 8 characters)
89    pub keyword: String,
90    /// Value
91    pub value: CardValue,
92    /// Comment
93    pub comment: Option<String>,
94}
95
96/// FITS header card values
97#[derive(Debug, Clone, PartialEq)]
98pub enum CardValue {
99    /// Boolean value
100    Boolean(bool),
101    /// Integer value
102    Integer(i64),
103    /// Floating point value
104    Float(f64),
105    /// String value
106    String(String),
107    /// Complex value
108    Complex(f64, f64),
109    /// No value (comment card)
110    None,
111}
112
113impl FitsHeader {
114    /// Create a new empty header
115    pub fn new() -> Self {
116        Self {
117            cards: Vec::new(),
118            card_map: HashMap::new(),
119        }
120    }
121
122    /// Add a card to the header
123    pub fn add_card(&mut self, card: HeaderCard) {
124        let index = self.cards.len();
125        self.card_map.insert(card.keyword.clone(), index);
126        self.cards.push(card);
127    }
128
129    /// Get a card by keyword
130    pub fn get_card(&self, keyword: &str) -> Option<&HeaderCard> {
131        self.card_map.get(keyword).map(|&idx| &self.cards[idx])
132    }
133
134    /// Get a boolean value
135    pub fn get_bool(&self, keyword: &str) -> Result<bool> {
136        match self.get_card(keyword) {
137            Some(card) => match &card.value {
138                CardValue::Boolean(b) => Ok(*b),
139                _ => Err(IoError::ParseError(format!(
140                    "Keyword {keyword} is not a boolean"
141                ))),
142            },
143            None => Err(IoError::ParseError(format!("Keyword {keyword} not found"))),
144        }
145    }
146
147    /// Get an integer value
148    pub fn get_i64(&self, keyword: &str) -> Result<i64> {
149        match self.get_card(keyword) {
150            Some(card) => match &card.value {
151                CardValue::Integer(i) => Ok(*i),
152                _ => Err(IoError::ParseError(format!(
153                    "Keyword {keyword} is not an integer"
154                ))),
155            },
156            None => Err(IoError::ParseError(format!("Keyword {keyword} not found"))),
157        }
158    }
159
160    /// Get a float value
161    pub fn get_f64(&self, keyword: &str) -> Result<f64> {
162        match self.get_card(keyword) {
163            Some(card) => match &card.value {
164                CardValue::Float(f) => Ok(*f),
165                CardValue::Integer(i) => Ok(*i as f64),
166                _ => Err(IoError::ParseError(format!(
167                    "Keyword {keyword} is not a number"
168                ))),
169            },
170            None => Err(IoError::ParseError(format!("Keyword {keyword} not found"))),
171        }
172    }
173
174    /// Get a string value
175    pub fn get_string(&self, keyword: &str) -> Result<String> {
176        match self.get_card(keyword) {
177            Some(card) => match &card.value {
178                CardValue::String(s) => Ok(s.clone()),
179                _ => Err(IoError::ParseError(format!(
180                    "Keyword {keyword} is not a string"
181                ))),
182            },
183            None => Err(IoError::ParseError(format!("Keyword {keyword} not found"))),
184        }
185    }
186
187    /// Get all cards
188    pub fn cards(&self) -> &[HeaderCard] {
189        &self.cards
190    }
191
192    /// Check if a keyword exists
193    pub fn has_keyword(&self, keyword: &str) -> bool {
194        self.card_map.contains_key(keyword)
195    }
196}
197
198impl Default for FitsHeader {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204/// FITS data types
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum FitsDataType {
207    /// 8-bit unsigned integer
208    UInt8,
209    /// 16-bit signed integer
210    Int16,
211    /// 32-bit signed integer
212    Int32,
213    /// 64-bit signed integer
214    Int64,
215    /// 32-bit floating point
216    Float32,
217    /// 64-bit floating point
218    Float64,
219}
220
221impl FitsDataType {
222    /// Get size in bytes
223    pub fn byte_size(&self) -> usize {
224        match self {
225            FitsDataType::UInt8 => 1,
226            FitsDataType::Int16 => 2,
227            FitsDataType::Int32 => 4,
228            FitsDataType::Int64 => 8,
229            FitsDataType::Float32 => 4,
230            FitsDataType::Float64 => 8,
231        }
232    }
233
234    /// Get BITPIX value
235    pub fn bitpix(&self) -> i32 {
236        match self {
237            FitsDataType::UInt8 => 8,
238            FitsDataType::Int16 => 16,
239            FitsDataType::Int32 => 32,
240            FitsDataType::Int64 => 64,
241            FitsDataType::Float32 => -32,
242            FitsDataType::Float64 => -64,
243        }
244    }
245
246    /// From BITPIX value
247    pub fn frombitpix(bitpix: i32) -> Result<Self> {
248        match bitpix {
249            8 => Ok(FitsDataType::UInt8),
250            16 => Ok(FitsDataType::Int16),
251            32 => Ok(FitsDataType::Int32),
252            64 => Ok(FitsDataType::Int64),
253            -32 => Ok(FitsDataType::Float32),
254            -64 => Ok(FitsDataType::Float64),
255            _ => Err(IoError::ParseError(format!(
256                "Invalid BITPIX value: {bitpix}"
257            ))),
258        }
259    }
260}
261
262impl FitsFile {
263    /// Open a FITS file
264    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
265        let file_path = path.as_ref().to_string_lossy().to_string();
266        let mut file =
267            File::open(path.as_ref()).map_err(|_e| IoError::FileNotFound(file_path.clone()))?;
268
269        let mut hdus = Vec::new();
270        let mut offset = 0u64;
271
272        // Read all HDUs
273        loop {
274            file.seek(SeekFrom::Start(offset))
275                .map_err(|e| IoError::ParseError(format!("Failed to seek: {e}")))?;
276
277            // Read header
278            let header = Self::readheader(&mut file)?;
279
280            // Determine HDU type
281            let hdu_type = if hdus.is_empty() {
282                HDUType::Primary
283            } else {
284                match header.get_string("XTENSION").ok().as_deref() {
285                    Some("IMAGE") => HDUType::Image,
286                    Some("TABLE") => HDUType::AsciiTable,
287                    Some("BINTABLE") => HDUType::BinaryTable,
288                    _ => HDUType::Image,
289                }
290            };
291
292            // Calculate data size
293            let data_size = Self::calculate_data_size(&header)?;
294            let header_blocks = ((header.cards.len() + 35) / 36) as u64; // 36 cards per 2880-byte block
295            let data_offset = offset + header_blocks * 2880;
296
297            hdus.push(HDU {
298                hdu_type,
299                header,
300                data_offset,
301                data_size,
302            });
303
304            // Move to next HDU
305            let data_blocks = ((data_size + 2879) / 2880) as u64;
306            offset = data_offset + data_blocks * 2880;
307
308            // Check for END
309            if hdus
310                .last()
311                .unwrap()
312                .header
313                .cards
314                .iter()
315                .any(|c| c.keyword == "END")
316            {
317                // Check if there's more data
318                if file.seek(SeekFrom::Start(offset)).is_err() {
319                    break;
320                }
321
322                let mut test_buf = [0u8; 80];
323                match file.read_exact(&mut test_buf) {
324                    Ok(_) => {
325                        let test_str = String::from_utf8_lossy(&test_buf);
326                        if test_str.trim().is_empty() {
327                            break;
328                        }
329                    }
330                    Err(_) => break,
331                }
332            }
333        }
334
335        Ok(Self { file_path, hdus })
336    }
337
338    /// Read a header from the current file position
339    fn readheader<R: Read>(reader: &mut R) -> Result<FitsHeader> {
340        let mut header = FitsHeader::new();
341        let mut card_buf = [0u8; 80];
342
343        loop {
344            reader
345                .read_exact(&mut card_buf)
346                .map_err(|e| IoError::ParseError(format!("Failed to read header card: {e}")))?;
347
348            let cardstr = String::from_utf8_lossy(&card_buf);
349
350            // Parse card
351            if let Some(card) = Self::parseheader_card(&cardstr) {
352                if card.keyword == "END" {
353                    break;
354                }
355                header.add_card(card);
356            }
357        }
358
359        Ok(header)
360    }
361
362    /// Parse a single header card
363    fn parseheader_card(cardstr: &str) -> Option<HeaderCard> {
364        if cardstr.len() < 8 {
365            return None;
366        }
367
368        let keyword = cardstr[0..8].trim().to_string();
369
370        if keyword.is_empty() || keyword == "COMMENT" || keyword == "HISTORY" {
371            // Comment cards
372            return Some(HeaderCard {
373                keyword,
374                value: CardValue::None,
375                comment: Some(cardstr[8..].trim().to_string()),
376            });
377        }
378
379        if keyword == "END" {
380            return Some(HeaderCard {
381                keyword,
382                value: CardValue::None,
383                comment: None,
384            });
385        }
386
387        // Look for = at position 8
388        if cardstr.len() > 9 && &cardstr[8..9] == "=" {
389            let value_comment = &cardstr[10..];
390
391            // Parse value and comment
392            if let Some(slash_pos) = value_comment.find('/') {
393                let valuestr = value_comment[..slash_pos].trim();
394                let comment = value_comment[slash_pos + 1..].trim().to_string();
395                let value = Self::parse_card_value(valuestr);
396
397                Some(HeaderCard {
398                    keyword,
399                    value,
400                    comment: Some(comment),
401                })
402            } else {
403                let value = Self::parse_card_value(value_comment.trim());
404
405                Some(HeaderCard {
406                    keyword,
407                    value,
408                    comment: None,
409                })
410            }
411        } else {
412            None
413        }
414    }
415
416    /// Parse a card value
417    fn parse_card_value(valuestr: &str) -> CardValue {
418        // Boolean
419        if valuestr == "T" {
420            return CardValue::Boolean(true);
421        }
422        if valuestr == "F" {
423            return CardValue::Boolean(false);
424        }
425
426        // String (quoted)
427        if valuestr.starts_with('\'') && valuestr.ends_with('\'') {
428            let s = valuestr[1..valuestr.len() - 1].trim().to_string();
429            return CardValue::String(s);
430        }
431
432        // Try parsing as number
433        if let Ok(i) = valuestr.parse::<i64>() {
434            return CardValue::Integer(i);
435        }
436
437        if let Ok(f) = valuestr.parse::<f64>() {
438            return CardValue::Float(f);
439        }
440
441        // Default to string
442        CardValue::String(valuestr.to_string())
443    }
444
445    /// Calculate data size from header
446    fn calculate_data_size(header: &FitsHeader) -> Result<usize> {
447        // For images
448        if let Ok(bitpix) = header.get_i64("BITPIX") {
449            let mut size = (bitpix.abs() / 8) as usize;
450
451            // Get NAXIS
452            let naxis = header.get_i64("NAXIS").unwrap_or(0) as usize;
453
454            for i in 1..=naxis {
455                let axis_key = format!("NAXIS{i}");
456                if let Ok(axis_size) = header.get_i64(&axis_key) {
457                    size *= axis_size as usize;
458                }
459            }
460
461            return Ok(size);
462        }
463
464        // For tables
465        if let Ok(naxis2) = header.get_i64("NAXIS2") {
466            if let Ok(naxis1) = header.get_i64("NAXIS1") {
467                return Ok((naxis1 * naxis2) as usize);
468            }
469        }
470
471        Ok(0)
472    }
473
474    /// Get primary header
475    pub fn primaryheader(&self) -> &FitsHeader {
476        &self.hdus[0].header
477    }
478
479    /// Get number of HDUs
480    pub fn hdu_count(&self) -> usize {
481        self.hdus.len()
482    }
483
484    /// Get HDU by index
485    pub fn gethdu(&self, index: usize) -> Result<&HDU> {
486        self.hdus
487            .get(index)
488            .ok_or_else(|| IoError::ParseError(format!("HDU index {index} out of range")))
489    }
490
491    /// Read primary image as 2D array
492    pub fn read_image<T: FitsNumeric>(&self) -> Result<Array2<T>> {
493        self.readhdu_image(0)
494    }
495
496    /// Read image from specific HDU
497    pub fn readhdu_image<T: FitsNumeric>(&self, hduindex: usize) -> Result<Array2<T>> {
498        let hdu = self.gethdu(hduindex)?;
499
500        if hdu.hdu_type != HDUType::Primary && hdu.hdu_type != HDUType::Image {
501            return Err(IoError::ParseError(format!(
502                "HDU {hduindex} is not an image"
503            )));
504        }
505
506        let bitpix = hdu.header.get_i64("BITPIX")?;
507        let naxis = hdu.header.get_i64("NAXIS")?;
508
509        if naxis != 2 {
510            return Err(IoError::ParseError(format!(
511                "Expected 2D image, got {naxis}D"
512            )));
513        }
514
515        let naxis1 = hdu.header.get_i64("NAXIS1")? as usize;
516        let naxis2 = hdu.header.get_i64("NAXIS2")? as usize;
517
518        let mut file = File::open(&self.file_path)
519            .map_err(|_e| IoError::FileNotFound(self.file_path.clone()))?;
520
521        file.seek(SeekFrom::Start(hdu.data_offset))
522            .map_err(|e| IoError::ParseError(format!("Failed to seek to data: {e}")))?;
523
524        // Read data based on BITPIX
525        let datatype = FitsDataType::frombitpix(bitpix as i32)?;
526        let mut values = Vec::with_capacity(naxis1 * naxis2);
527
528        // FITS uses Fortran order (column-major), but we'll convert to row-major
529        for _ in 0..(naxis1 * naxis2) {
530            let value = T::read_fits(&mut file, datatype)?;
531            values.push(value);
532        }
533
534        // Reshape from FITS order to ndarray order
535        let array = Array2::from_shape_vec((naxis2, naxis1), values)
536            .map_err(|e| IoError::ParseError(format!("Failed to create array: {e}")))?;
537
538        Ok(array.t().to_owned())
539    }
540
541    /// Get image dimensions
542    pub fn image_dimensions(&self, hduindex: usize) -> Result<Vec<usize>> {
543        let hdu = self.gethdu(hduindex)?;
544        let naxis = hdu.header.get_i64("NAXIS")? as usize;
545
546        let mut dims = Vec::with_capacity(naxis);
547        for i in 1..=naxis {
548            let axis_key = format!("NAXIS{i}");
549            let size = hdu.header.get_i64(&axis_key)? as usize;
550            dims.push(size);
551        }
552
553        Ok(dims)
554    }
555}
556
557/// Trait for numeric types supported by FITS
558pub trait FitsNumeric: Default + Clone {
559    fn read_fits<R: Read>(reader: &mut R, datatype: FitsDataType) -> Result<Self>;
560    fn write_fits<W: Write>(&self, writer: &mut W, datatype: FitsDataType) -> Result<()>;
561}
562
563impl FitsNumeric for f32 {
564    fn read_fits<R: Read>(reader: &mut R, datatype: FitsDataType) -> Result<Self> {
565        match datatype {
566            FitsDataType::Float32 => reader
567                .read_f32::<BigEndian>()
568                .map_err(|e| IoError::ParseError(format!("Failed to read f32: {e}"))),
569            FitsDataType::Float64 => reader
570                .read_f64::<BigEndian>()
571                .map(|v| v as f32)
572                .map_err(|e| IoError::ParseError(format!("Failed to read f64: {e}"))),
573            FitsDataType::Int16 => reader
574                .read_i16::<BigEndian>()
575                .map(|v| v as f32)
576                .map_err(|e| IoError::ParseError(format!("Failed to read i16: {e}"))),
577            FitsDataType::Int32 => reader
578                .read_i32::<BigEndian>()
579                .map(|v| v as f32)
580                .map_err(|e| IoError::ParseError(format!("Failed to read i32: {e}"))),
581            _ => Err(IoError::ParseError(format!(
582                "Unsupported conversion from {datatype:?} to f32"
583            ))),
584        }
585    }
586
587    fn write_fits<W: Write>(&self, writer: &mut W, datatype: FitsDataType) -> Result<()> {
588        match datatype {
589            FitsDataType::Float32 => writer
590                .write_f32::<BigEndian>(*self)
591                .map_err(|e| IoError::FileError(format!("Failed to write f32: {e}"))),
592            _ => Err(IoError::FileError(format!(
593                "Unsupported conversion from f32 to {datatype:?}"
594            ))),
595        }
596    }
597}
598
599impl FitsNumeric for f64 {
600    fn read_fits<R: Read>(reader: &mut R, datatype: FitsDataType) -> Result<Self> {
601        match datatype {
602            FitsDataType::Float64 => reader
603                .read_f64::<BigEndian>()
604                .map_err(|e| IoError::ParseError(format!("Failed to read f64: {e}"))),
605            FitsDataType::Float32 => reader
606                .read_f32::<BigEndian>()
607                .map(|v| v as f64)
608                .map_err(|e| IoError::ParseError(format!("Failed to read f32: {e}"))),
609            FitsDataType::Int32 => reader
610                .read_i32::<BigEndian>()
611                .map(|v| v as f64)
612                .map_err(|e| IoError::ParseError(format!("Failed to read i32: {e}"))),
613            _ => Err(IoError::ParseError(format!(
614                "Unsupported conversion from {datatype:?} to f64"
615            ))),
616        }
617    }
618
619    fn write_fits<W: Write>(&self, writer: &mut W, datatype: FitsDataType) -> Result<()> {
620        match datatype {
621            FitsDataType::Float64 => writer
622                .write_f64::<BigEndian>(*self)
623                .map_err(|e| IoError::FileError(format!("Failed to write f64: {e}"))),
624            _ => Err(IoError::FileError(format!(
625                "Unsupported conversion from f64 to {datatype:?}"
626            ))),
627        }
628    }
629}
630
631/// FITS file writer
632pub struct FitsWriter {
633    writer: BufWriter<File>,
634    currenthdu: usize,
635}
636
637impl FitsWriter {
638    /// Create a new FITS file
639    pub fn create<P: AsRef<Path>>(path: P) -> Result<Self> {
640        let file = File::create(path.as_ref())
641            .map_err(|e| IoError::FileError(format!("Failed to create file: {e}")))?;
642
643        Ok(Self {
644            writer: BufWriter::new(file),
645            currenthdu: 0,
646        })
647    }
648
649    /// Write a primary HDU with 2D image data
650    pub fn write_image_2d<T: FitsNumeric>(
651        &mut self,
652        data: &Array2<T>,
653        datatype: FitsDataType,
654    ) -> Result<()> {
655        let mut header = FitsHeader::new();
656
657        // Mandatory keywords
658        header.add_card(HeaderCard {
659            keyword: "SIMPLE".to_string(),
660            value: CardValue::Boolean(true),
661            comment: Some("Standard FITS format".to_string()),
662        });
663
664        header.add_card(HeaderCard {
665            keyword: "BITPIX".to_string(),
666            value: CardValue::Integer(datatype.bitpix() as i64),
667            comment: Some("Number of bits per pixel".to_string()),
668        });
669
670        header.add_card(HeaderCard {
671            keyword: "NAXIS".to_string(),
672            value: CardValue::Integer(2),
673            comment: Some("Number of axes".to_string()),
674        });
675
676        let (rows, cols) = data.dim();
677        header.add_card(HeaderCard {
678            keyword: "NAXIS1".to_string(),
679            value: CardValue::Integer(cols as i64),
680            comment: Some("Length of axis 1".to_string()),
681        });
682
683        header.add_card(HeaderCard {
684            keyword: "NAXIS2".to_string(),
685            value: CardValue::Integer(rows as i64),
686            comment: Some("Length of axis 2".to_string()),
687        });
688
689        // Write header
690        self.writeheader(&header)?;
691
692        // Write data in FITS order (column-major)
693        for col in 0..cols {
694            for row in 0..rows {
695                data[[row, col]].write_fits(&mut self.writer, datatype)?;
696            }
697        }
698
699        // Pad to 2880-byte boundary
700        let data_bytes = rows * cols * datatype.byte_size();
701        let padding = (2880 - (data_bytes % 2880)) % 2880;
702        if padding > 0 {
703            let pad_bytes = vec![0u8; padding];
704            self.writer
705                .write_all(&pad_bytes)
706                .map_err(|e| IoError::FileError(format!("Failed to write padding: {e}")))?;
707        }
708
709        self.currenthdu += 1;
710
711        Ok(())
712    }
713
714    /// Write a header
715    fn writeheader(&mut self, header: &FitsHeader) -> Result<()> {
716        // Write header cards
717        for card in &header.cards {
718            self.writeheader_card(card)?;
719        }
720
721        // Write END card
722        self.writeheader_card(&HeaderCard {
723            keyword: "END".to_string(),
724            value: CardValue::None,
725            comment: None,
726        })?;
727
728        // Pad to 2880-byte boundary
729        let cards_written = header.cards.len() + 1; // +1 for END
730        let cards_per_block = 36;
731        let remaining = cards_per_block - (cards_written % cards_per_block);
732
733        if remaining < cards_per_block {
734            let blank_card = vec![b' '; 80];
735            for _ in 0..remaining {
736                self.writer
737                    .write_all(&blank_card)
738                    .map_err(|e| IoError::FileError(format!("Failed to write blank card: {e}")))?;
739            }
740        }
741
742        Ok(())
743    }
744
745    /// Write a single header card
746    fn writeheader_card(&mut self, card: &HeaderCard) -> Result<()> {
747        let mut cardstr = String::with_capacity(80);
748
749        // Keyword (8 characters, left-justified)
750        cardstr.push_str(&format!("{:<8}", card.keyword));
751
752        // Value
753        match &card.value {
754            CardValue::Boolean(b) => {
755                cardstr.push_str(&format!("= {:>20}", if *b { "T" } else { "F" }));
756            }
757            CardValue::Integer(i) => {
758                cardstr.push_str(&format!("= {i:>20}"));
759            }
760            CardValue::Float(f) => {
761                cardstr.push_str(&format!("= {f:>20.10E}"));
762            }
763            CardValue::String(s) => {
764                cardstr.push_str(&format!("= '{s:<18}'"));
765            }
766            CardValue::None => {
767                // No equals sign for comment cards
768            }
769            _ => {}
770        }
771
772        // Comment
773        if let Some(comment) = &card.comment {
774            if cardstr.len() < 31 {
775                cardstr.push_str(&" ".repeat(31 - cardstr.len()));
776            }
777            cardstr.push_str(" / ");
778            cardstr.push_str(comment);
779        }
780
781        // Pad to 80 characters
782        match cardstr.len().cmp(&80) {
783            std::cmp::Ordering::Less => cardstr.push_str(&" ".repeat(80 - cardstr.len())),
784            std::cmp::Ordering::Greater => cardstr.truncate(80),
785            std::cmp::Ordering::Equal => {}
786        }
787
788        self.writer
789            .write_all(cardstr.as_bytes())
790            .map_err(|e| IoError::FileError(format!("Failed to write header card: {e}")))
791    }
792
793    /// Flush the writer
794    pub fn flush(&mut self) -> Result<()> {
795        self.writer
796            .flush()
797            .map_err(|e| IoError::FileError(format!("Failed to flush: {e}")))
798    }
799
800    /// Close the file
801    pub fn close(mut self) -> Result<()> {
802        self.flush()
803    }
804}
805
806/// VOTable (Virtual Observatory Table) support
807pub struct VOTable {
808    /// Table metadata
809    pub metadata: HashMap<String, String>,
810    /// Column definitions
811    pub columns: Vec<VOTableColumn>,
812    /// Table data
813    pub data: Vec<Vec<VOTableValue>>,
814}
815
816/// VOTable column definition
817#[derive(Debug, Clone)]
818pub struct VOTableColumn {
819    /// Column name
820    pub name: String,
821    /// Data type
822    pub datatype: String,
823    /// Array size (for array columns)
824    pub arraysize: Option<String>,
825    /// Unit
826    pub unit: Option<String>,
827    /// Description
828    pub description: Option<String>,
829    /// UCD (Unified Content Descriptor)
830    pub ucd: Option<String>,
831}
832
833/// VOTable value types
834#[derive(Debug, Clone, PartialEq)]
835pub enum VOTableValue {
836    /// Boolean value
837    Boolean(bool),
838    /// Integer value
839    Integer(i64),
840    /// Float value
841    Float(f64),
842    /// Double value
843    Double(f64),
844    /// String value
845    String(String),
846    /// Array of values
847    Array(Vec<VOTableValue>),
848    /// Null value
849    Null,
850}
851
852impl VOTable {
853    /// Create a new empty VOTable
854    pub fn new() -> Self {
855        Self {
856            metadata: HashMap::new(),
857            columns: Vec::new(),
858            data: Vec::new(),
859        }
860    }
861
862    /// Add a column definition
863    pub fn add_column(&mut self, column: VOTableColumn) {
864        self.columns.push(column);
865    }
866
867    /// Add a row of data
868    pub fn add_row(&mut self, row: Vec<VOTableValue>) -> Result<()> {
869        if row.len() != self.columns.len() {
870            return Err(IoError::FileError(format!(
871                "Row has {} values but table has {} columns",
872                row.len(),
873                self.columns.len()
874            )));
875        }
876        self.data.push(row);
877        Ok(())
878    }
879
880    /// Get column by name
881    pub fn get_column(&self, name: &str) -> Option<usize> {
882        self.columns.iter().position(|c| c.name == name)
883    }
884
885    /// Get column data
886    pub fn get_column_data(&self, columnindex: usize) -> Result<Vec<&VOTableValue>> {
887        if columnindex >= self.columns.len() {
888            return Err(IoError::ParseError(format!(
889                "Column _index {columnindex} out of range"
890            )));
891        }
892
893        Ok(self.data.iter().map(|row| &row[columnindex]).collect())
894    }
895
896    /// Read VOTable from XML file (simplified)
897    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
898        // Simplified implementation
899        // In reality, would use an XML parser
900        Ok(Self::new())
901    }
902
903    /// Write VOTable to XML file (simplified)
904    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<()> {
905        let file = File::create(path.as_ref())
906            .map_err(|e| IoError::FileError(format!("Failed to create file: {e}")))?;
907        let mut writer = BufWriter::new(file);
908
909        // Write XML header
910        writeln!(writer, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
911            .map_err(|e| IoError::FileError(format!("Failed to write XML header: {e}")))?;
912        writeln!(
913            writer,
914            "<VOTABLE version=\"1.4\" xmlns=\"http://www.ivoa.net/xml/VOTable/v1.3\">"
915        )
916        .map_err(|e| IoError::FileError(format!("Failed to write VOTABLE tag: {e}")))?;
917
918        // Write resource
919        writeln!(writer, "  <RESOURCE>")
920            .map_err(|e| IoError::FileError(format!("Failed to write RESOURCE tag: {e}")))?;
921        writeln!(writer, "    <TABLE>")
922            .map_err(|e| IoError::FileError(format!("Failed to write TABLE tag: {e}")))?;
923
924        // Write fields
925        for column in &self.columns {
926            write!(
927                writer,
928                "      <FIELD name=\"{}\" datatype=\"{}\"",
929                column.name, column.datatype
930            )
931            .map_err(|e| IoError::FileError(format!("Failed to write FIELD: {e}")))?;
932
933            if let Some(unit) = &column.unit {
934                write!(writer, " unit=\"{unit}\"")
935                    .map_err(|e| IoError::FileError(format!("Failed to write unit: {e}")))?;
936            }
937
938            writeln!(writer, "/>")
939                .map_err(|e| IoError::FileError(format!("Failed to write FIELD close: {e}")))?;
940        }
941
942        // Write data
943        writeln!(writer, "      <DATA>")
944            .map_err(|e| IoError::FileError(format!("Failed to write DATA tag: {e}")))?;
945        writeln!(writer, "        <TABLEDATA>")
946            .map_err(|e| IoError::FileError(format!("Failed to write TABLEDATA tag: {e}")))?;
947
948        for row in &self.data {
949            write!(writer, "          <TR>")
950                .map_err(|e| IoError::FileError(format!("Failed to write TR: {e}")))?;
951
952            for value in row {
953                match value {
954                    VOTableValue::String(s) => write!(writer, "<TD>{s}</TD>"),
955                    VOTableValue::Integer(i) => write!(writer, "<TD>{i}</TD>"),
956                    VOTableValue::Float(f) | VOTableValue::Double(f) => {
957                        write!(writer, "<TD>{f}</TD>")
958                    }
959                    VOTableValue::Boolean(b) => {
960                        write!(writer, "<TD>{}</TD>", if *b { "true" } else { "false" })
961                    }
962                    VOTableValue::Null => write!(writer, "<TD/>"),
963                    VOTableValue::Array(_) => write!(writer, "<TD>[]</TD>"), // Simplified
964                }
965                .map_err(|e| IoError::FileError(format!("Failed to write TD: {e}")))?;
966            }
967
968            writeln!(writer, "</TR>")
969                .map_err(|e| IoError::FileError(format!("Failed to write TR close: {e}")))?;
970        }
971
972        writeln!(writer, "        </TABLEDATA>")
973            .map_err(|e| IoError::FileError(format!("Failed to close TABLEDATA: {e}")))?;
974        writeln!(writer, "      </DATA>")
975            .map_err(|e| IoError::FileError(format!("Failed to close DATA: {e}")))?;
976        writeln!(writer, "    </TABLE>")
977            .map_err(|e| IoError::FileError(format!("Failed to close TABLE: {e}")))?;
978        writeln!(writer, "  </RESOURCE>")
979            .map_err(|e| IoError::FileError(format!("Failed to close RESOURCE: {e}")))?;
980        writeln!(writer, "</VOTABLE>")
981            .map_err(|e| IoError::FileError(format!("Failed to close VOTABLE: {e}")))?;
982
983        writer
984            .flush()
985            .map_err(|e| IoError::FileError(format!("Failed to flush: {e}")))
986    }
987}
988
989impl Default for VOTable {
990    fn default() -> Self {
991        Self::new()
992    }
993}
994
995/// Coordinate transformation utilities for astronomical data
996#[derive(Debug, Clone)]
997pub struct GeoTransform {
998    /// Reference longitude (degrees)
999    pub ref_lon: f64,
1000    /// Reference latitude (degrees)
1001    pub ref_lat: f64,
1002    /// Pixel scale in longitude direction (degrees per pixel)
1003    pub lon_scale: f64,
1004    /// Pixel scale in latitude direction (degrees per pixel)
1005    pub latscale: f64,
1006}
1007
1008impl GeoTransform {
1009    /// Create a new coordinate transformation
1010    pub fn new(ref_lon: f64, ref_lat: f64, lon_scale: f64, latscale: f64) -> Self {
1011        Self {
1012            ref_lon,
1013            ref_lat,
1014            lon_scale,
1015            latscale,
1016        }
1017    }
1018
1019    /// Convert pixel coordinates to celestial coordinates
1020    pub fn pixel_to_geo(&self, px: f64, py: f64) -> (f64, f64) {
1021        let lon = self.ref_lon + px * self.lon_scale;
1022        let lat = self.ref_lat + py * self.latscale;
1023        (lon, lat)
1024    }
1025
1026    /// Convert celestial coordinates to pixel coordinates
1027    pub fn geo_to_pixel(&self, lon: f64, lat: f64) -> (f64, f64) {
1028        let px = (lon - self.ref_lon) / self.lon_scale;
1029        let py = (lat - self.ref_lat) / self.latscale;
1030        (px, py)
1031    }
1032
1033    /// Apply World Coordinate System transformation
1034    pub fn apply_wcs(&self, wcs: &WCSTransform) -> GeoTransform {
1035        Self {
1036            ref_lon: wcs.crval1,
1037            ref_lat: wcs.crval2,
1038            lon_scale: wcs.cdelt1,
1039            latscale: wcs.cdelt2,
1040        }
1041    }
1042}
1043
1044/// World Coordinate System parameters
1045#[derive(Debug, Clone)]
1046pub struct WCSTransform {
1047    /// Reference coordinate value at reference pixel (RA)
1048    pub crval1: f64,
1049    /// Reference coordinate value at reference pixel (Dec)
1050    pub crval2: f64,
1051    /// Reference pixel coordinate
1052    pub crpix1: f64,
1053    /// Reference pixel coordinate
1054    pub crpix2: f64,
1055    /// Coordinate increment at reference pixel
1056    pub cdelt1: f64,
1057    /// Coordinate increment at reference pixel
1058    pub cdelt2: f64,
1059    /// Coordinate transformation matrix
1060    pub cd_matrix: Option<[[f64; 2]; 2]>,
1061    /// Coordinate type (e.g., "RA---TAN", "DEC--TAN")
1062    pub ctype1: String,
1063    /// Coordinate type
1064    pub ctype2: String,
1065}
1066
1067impl WCSTransform {
1068    /// Create WCS transform from FITS header
1069    pub fn from_fitsheader(header: &FitsHeader) -> Result<Self> {
1070        Ok(Self {
1071            crval1: header.get_f64("CRVAL1").unwrap_or(0.0),
1072            crval2: header.get_f64("CRVAL2").unwrap_or(0.0),
1073            crpix1: header.get_f64("CRPIX1").unwrap_or(1.0),
1074            crpix2: header.get_f64("CRPIX2").unwrap_or(1.0),
1075            cdelt1: header.get_f64("CDELT1").unwrap_or(1.0),
1076            cdelt2: header.get_f64("CDELT2").unwrap_or(1.0),
1077            cd_matrix: None, // Could be extracted from CD1_1, CD1_2, etc.
1078            ctype1: header
1079                .get_string("CTYPE1")
1080                .unwrap_or("RA---TAN".to_string()),
1081            ctype2: header
1082                .get_string("CTYPE2")
1083                .unwrap_or("DEC--TAN".to_string()),
1084        })
1085    }
1086
1087    /// Convert pixel coordinates to world coordinates
1088    pub fn pixel_to_world(&self, px: f64, py: f64) -> (f64, f64) {
1089        // Apply linear transformation
1090        let dx = px - self.crpix1;
1091        let dy = py - self.crpix2;
1092
1093        let ra = self.crval1 + dx * self.cdelt1;
1094        let dec = self.crval2 + dy * self.cdelt2;
1095
1096        (ra, dec)
1097    }
1098
1099    /// Convert world coordinates to pixel coordinates
1100    pub fn world_to_pixel(&self, ra: f64, dec: f64) -> (f64, f64) {
1101        let dx = (ra - self.crval1) / self.cdelt1;
1102        let dy = (dec - self.crval2) / self.cdelt2;
1103
1104        let px = self.crpix1 + dx;
1105        let py = self.crpix2 + dy;
1106
1107        (px, py)
1108    }
1109}
1110
1111/// FITS table column reader
1112pub struct FitsTableReader {
1113    hdu: HDU,
1114}
1115
1116impl FitsTableReader {
1117    /// Create a new table reader
1118    pub fn new(hdu: HDU) -> Result<Self> {
1119        match hdu.hdu_type {
1120            HDUType::AsciiTable | HDUType::BinaryTable => Ok(Self { hdu }),
1121            _ => Err(IoError::ParseError("HDU is not a table".to_string())),
1122        }
1123    }
1124
1125    /// Read a column by name
1126    pub fn read_column(&self, columnname: &str) -> Result<Vec<VOTableValue>> {
1127        // Simplified implementation - would need full FITS table parsing
1128        let mut values = Vec::new();
1129
1130        // Mock some data based on column _name
1131        match columnname {
1132            "FLUX" => {
1133                for i in 0..100 {
1134                    values.push(VOTableValue::Float(1000.0 + i as f64 * 10.0));
1135                }
1136            }
1137            "RA" => {
1138                for i in 0..100 {
1139                    values.push(VOTableValue::Double(180.0 + i as f64 * 0.01));
1140                }
1141            }
1142            "DEC" => {
1143                for i in 0..100 {
1144                    values.push(VOTableValue::Double(45.0 + i as f64 * 0.005));
1145                }
1146            }
1147            _ => {
1148                return Err(IoError::ParseError(format!(
1149                    "Column '{columnname}' not found"
1150                )));
1151            }
1152        }
1153
1154        Ok(values)
1155    }
1156
1157    /// Get column names
1158    pub fn get_columnnames(&self) -> Result<Vec<String>> {
1159        // Would parse from FITS header keywords TTYPE1, TTYPE2, etc.
1160        Ok(vec![
1161            "FLUX".to_string(),
1162            "RA".to_string(),
1163            "DEC".to_string(),
1164        ])
1165    }
1166
1167    /// Get number of rows
1168    pub fn get_row_count(&self) -> Result<usize> {
1169        self.hdu.header.get_i64("NAXIS2").map(|n| n as usize)
1170    }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    use super::*;
1176    use tempfile::NamedTempFile;
1177
1178    #[test]
1179    fn test_fitsheader() {
1180        let mut header = FitsHeader::new();
1181
1182        header.add_card(HeaderCard {
1183            keyword: "SIMPLE".to_string(),
1184            value: CardValue::Boolean(true),
1185            comment: Some("Standard FITS".to_string()),
1186        });
1187
1188        header.add_card(HeaderCard {
1189            keyword: "NAXIS".to_string(),
1190            value: CardValue::Integer(2),
1191            comment: Some("Number of axes".to_string()),
1192        });
1193
1194        header.add_card(HeaderCard {
1195            keyword: "EXPTIME".to_string(),
1196            value: CardValue::Float(300.0),
1197            comment: Some("Exposure time in seconds".to_string()),
1198        });
1199
1200        assert!(header.get_bool("SIMPLE").unwrap());
1201        assert_eq!(header.get_i64("NAXIS").unwrap(), 2);
1202        assert_eq!(header.get_f64("EXPTIME").unwrap(), 300.0);
1203    }
1204
1205    #[test]
1206    fn test_geo_transform() {
1207        let transform = GeoTransform::new(0.0, 90.0, 0.25, -0.25);
1208
1209        let (lon, lat) = transform.pixel_to_geo(100.0, 100.0);
1210        assert_eq!(lon, 25.0);
1211        assert_eq!(lat, 65.0);
1212
1213        let (px, py) = transform.geo_to_pixel(25.0, 65.0);
1214        assert!((px - 100.0).abs() < 1e-10);
1215        assert!((py - 100.0).abs() < 1e-10);
1216    }
1217
1218    #[test]
1219    fn test_votable() {
1220        let mut votable = VOTable::new();
1221
1222        votable.add_column(VOTableColumn {
1223            name: "RA".to_string(),
1224            datatype: "double".to_string(),
1225            arraysize: None,
1226            unit: Some("deg".to_string()),
1227            description: Some("Right Ascension".to_string()),
1228            ucd: Some("pos.eq.ra".to_string()),
1229        });
1230
1231        votable.add_column(VOTableColumn {
1232            name: "DEC".to_string(),
1233            datatype: "double".to_string(),
1234            arraysize: None,
1235            unit: Some("deg".to_string()),
1236            description: Some("Declination".to_string()),
1237            ucd: Some("pos.eq.dec".to_string()),
1238        });
1239
1240        votable
1241            .add_row(vec![
1242                VOTableValue::Double(180.0),
1243                VOTableValue::Double(45.0),
1244            ])
1245            .unwrap();
1246
1247        assert_eq!(votable.columns.len(), 2);
1248        assert_eq!(votable.data.len(), 1);
1249        assert_eq!(votable.get_column("RA"), Some(0));
1250    }
1251
1252    #[test]
1253    fn test_fits_write_read() -> Result<()> {
1254        let temp_file = NamedTempFile::new().unwrap();
1255        let path = temp_file.path();
1256
1257        // Create test data
1258        let data = Array2::from_shape_fn((10, 20), |(i, j)| (i * 20 + j) as f32);
1259
1260        // Write FITS
1261        {
1262            let mut writer = FitsWriter::create(path)?;
1263            writer.write_image_2d(&data, FitsDataType::Float32)?;
1264            writer.close()?;
1265        }
1266
1267        // Note: Reading would require a proper FITS implementation
1268        // This is just a test of the writing interface
1269
1270        Ok(())
1271    }
1272}