1#![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
44pub struct FitsFile {
46 file_path: String,
47 hdus: Vec<HDU>,
48}
49
50#[derive(Debug, Clone)]
52pub struct HDU {
53 pub hdu_type: HDUType,
55 pub header: FitsHeader,
57 pub data_offset: u64,
59 pub data_size: usize,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum HDUType {
66 Primary,
68 Image,
70 AsciiTable,
72 BinaryTable,
74}
75
76#[derive(Debug, Clone)]
78pub struct FitsHeader {
79 cards: Vec<HeaderCard>,
81 card_map: HashMap<String, usize>,
83}
84
85#[derive(Debug, Clone)]
87pub struct HeaderCard {
88 pub keyword: String,
90 pub value: CardValue,
92 pub comment: Option<String>,
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub enum CardValue {
99 Boolean(bool),
101 Integer(i64),
103 Float(f64),
105 String(String),
107 Complex(f64, f64),
109 None,
111}
112
113impl FitsHeader {
114 pub fn new() -> Self {
116 Self {
117 cards: Vec::new(),
118 card_map: HashMap::new(),
119 }
120 }
121
122 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 pub fn get_card(&self, keyword: &str) -> Option<&HeaderCard> {
131 self.card_map.get(keyword).map(|&idx| &self.cards[idx])
132 }
133
134 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 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 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 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 pub fn cards(&self) -> &[HeaderCard] {
189 &self.cards
190 }
191
192 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum FitsDataType {
207 UInt8,
209 Int16,
211 Int32,
213 Int64,
215 Float32,
217 Float64,
219}
220
221impl FitsDataType {
222 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 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 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 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 loop {
274 file.seek(SeekFrom::Start(offset))
275 .map_err(|e| IoError::ParseError(format!("Failed to seek: {e}")))?;
276
277 let header = Self::readheader(&mut file)?;
279
280 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 let data_size = Self::calculate_data_size(&header)?;
294 let header_blocks = ((header.cards.len() + 35) / 36) as u64; 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 let data_blocks = ((data_size + 2879) / 2880) as u64;
306 offset = data_offset + data_blocks * 2880;
307
308 if hdus
310 .last()
311 .unwrap()
312 .header
313 .cards
314 .iter()
315 .any(|c| c.keyword == "END")
316 {
317 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 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 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 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 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 if cardstr.len() > 9 && &cardstr[8..9] == "=" {
389 let value_comment = &cardstr[10..];
390
391 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 fn parse_card_value(valuestr: &str) -> CardValue {
418 if valuestr == "T" {
420 return CardValue::Boolean(true);
421 }
422 if valuestr == "F" {
423 return CardValue::Boolean(false);
424 }
425
426 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 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 CardValue::String(valuestr.to_string())
443 }
444
445 fn calculate_data_size(header: &FitsHeader) -> Result<usize> {
447 if let Ok(bitpix) = header.get_i64("BITPIX") {
449 let mut size = (bitpix.abs() / 8) as usize;
450
451 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 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 pub fn primaryheader(&self) -> &FitsHeader {
476 &self.hdus[0].header
477 }
478
479 pub fn hdu_count(&self) -> usize {
481 self.hdus.len()
482 }
483
484 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 pub fn read_image<T: FitsNumeric>(&self) -> Result<Array2<T>> {
493 self.readhdu_image(0)
494 }
495
496 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 let datatype = FitsDataType::frombitpix(bitpix as i32)?;
526 let mut values = Vec::with_capacity(naxis1 * naxis2);
527
528 for _ in 0..(naxis1 * naxis2) {
530 let value = T::read_fits(&mut file, datatype)?;
531 values.push(value);
532 }
533
534 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 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
557pub 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
631pub struct FitsWriter {
633 writer: BufWriter<File>,
634 currenthdu: usize,
635}
636
637impl FitsWriter {
638 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 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 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 self.writeheader(&header)?;
691
692 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 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 fn writeheader(&mut self, header: &FitsHeader) -> Result<()> {
716 for card in &header.cards {
718 self.writeheader_card(card)?;
719 }
720
721 self.writeheader_card(&HeaderCard {
723 keyword: "END".to_string(),
724 value: CardValue::None,
725 comment: None,
726 })?;
727
728 let cards_written = header.cards.len() + 1; 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 fn writeheader_card(&mut self, card: &HeaderCard) -> Result<()> {
747 let mut cardstr = String::with_capacity(80);
748
749 cardstr.push_str(&format!("{:<8}", card.keyword));
751
752 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 }
769 _ => {}
770 }
771
772 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 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 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 pub fn close(mut self) -> Result<()> {
802 self.flush()
803 }
804}
805
806pub struct VOTable {
808 pub metadata: HashMap<String, String>,
810 pub columns: Vec<VOTableColumn>,
812 pub data: Vec<Vec<VOTableValue>>,
814}
815
816#[derive(Debug, Clone)]
818pub struct VOTableColumn {
819 pub name: String,
821 pub datatype: String,
823 pub arraysize: Option<String>,
825 pub unit: Option<String>,
827 pub description: Option<String>,
829 pub ucd: Option<String>,
831}
832
833#[derive(Debug, Clone, PartialEq)]
835pub enum VOTableValue {
836 Boolean(bool),
838 Integer(i64),
840 Float(f64),
842 Double(f64),
844 String(String),
846 Array(Vec<VOTableValue>),
848 Null,
850}
851
852impl VOTable {
853 pub fn new() -> Self {
855 Self {
856 metadata: HashMap::new(),
857 columns: Vec::new(),
858 data: Vec::new(),
859 }
860 }
861
862 pub fn add_column(&mut self, column: VOTableColumn) {
864 self.columns.push(column);
865 }
866
867 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 pub fn get_column(&self, name: &str) -> Option<usize> {
882 self.columns.iter().position(|c| c.name == name)
883 }
884
885 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 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
898 Ok(Self::new())
901 }
902
903 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 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 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 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 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>"), }
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#[derive(Debug, Clone)]
997pub struct GeoTransform {
998 pub ref_lon: f64,
1000 pub ref_lat: f64,
1002 pub lon_scale: f64,
1004 pub latscale: f64,
1006}
1007
1008impl GeoTransform {
1009 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 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 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 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#[derive(Debug, Clone)]
1046pub struct WCSTransform {
1047 pub crval1: f64,
1049 pub crval2: f64,
1051 pub crpix1: f64,
1053 pub crpix2: f64,
1055 pub cdelt1: f64,
1057 pub cdelt2: f64,
1059 pub cd_matrix: Option<[[f64; 2]; 2]>,
1061 pub ctype1: String,
1063 pub ctype2: String,
1065}
1066
1067impl WCSTransform {
1068 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, 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 pub fn pixel_to_world(&self, px: f64, py: f64) -> (f64, f64) {
1089 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 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
1111pub struct FitsTableReader {
1113 hdu: HDU,
1114}
1115
1116impl FitsTableReader {
1117 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 pub fn read_column(&self, columnname: &str) -> Result<Vec<VOTableValue>> {
1127 let mut values = Vec::new();
1129
1130 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 pub fn get_columnnames(&self) -> Result<Vec<String>> {
1159 Ok(vec![
1161 "FLUX".to_string(),
1162 "RA".to_string(),
1163 "DEC".to_string(),
1164 ])
1165 }
1166
1167 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 let data = Array2::from_shape_fn((10, 20), |(i, j)| (i * 20 + j) as f32);
1259
1260 {
1262 let mut writer = FitsWriter::create(path)?;
1263 writer.write_image_2d(&data, FitsDataType::Float32)?;
1264 writer.close()?;
1265 }
1266
1267 Ok(())
1271 }
1272}