├── .github ├── codecov.yml ├── dependabot.yml └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── CDA │ └── science │ │ └── ao23 │ │ └── cat9 │ │ └── 25975 │ │ └── primary │ │ └── acisf25975N002_evt2.fits ├── M17 │ └── 502nmos.fits ├── benchmarks │ └── many_hdu.fits └── eagle_nebula │ ├── 502nmos.fits │ └── eagle_composite.jpg ├── benches └── fits_benchmark.rs ├── src ├── coordinates │ ├── frames.rs │ ├── lookup.rs │ ├── lookup_config.rs │ └── mod.rs ├── cosmology │ └── mod.rs ├── fits │ ├── hdu_types.rs │ ├── header.rs │ ├── header_value │ │ ├── bitpix.rs │ │ ├── mod.rs │ │ └── tform.rs │ └── mod.rs └── lib.rs └── tests ├── coordinates.rs └── fits.rs /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | changes: false 4 | patch: false 5 | project: false 6 | 7 | ignore: 8 | - "tests" 9 | 10 | comment: 11 | layout: "reach,diff,files" 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: cargo 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | ignore: 12 | - dependency-name: "*" 13 | update-types: 14 | - "version-update:semver-patch" 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | permissions: 2 | contents: read 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - release 9 | pull_request: 10 | branches: 11 | - main 12 | - release 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 16 | cancel-in-progress: true 17 | 18 | name: Build 19 | 20 | jobs: 21 | fmt: 22 | name: Fmt 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: dtolnay/rust-toolchain@stable 27 | with: 28 | components: rustfmt 29 | - run: cargo fmt --check 30 | 31 | clippy: 32 | name: Clippy 33 | permissions: 34 | contents: read 35 | checks: write 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: dtolnay/rust-toolchain@stable 40 | with: 41 | components: clippy 42 | - uses: actions-rs/clippy-check@v1 43 | with: 44 | args: --all-features 45 | token: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | doc: 48 | name: Doc 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: dtolnay/rust-toolchain@nightly 53 | - run: cargo doc --no-deps --all-features 54 | env: 55 | RUSTDOCFLAGS: --cfg docsrs 56 | 57 | hack: 58 | name: Feature Unionization 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: dtolnay/rust-toolchain@stable 63 | - uses: taiki-e/install-action@cargo-hack 64 | - run: cargo hack --feature-powerset check 65 | 66 | msrv: 67 | name: MSRV 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: dtolnay/rust-toolchain@master 72 | with: 73 | toolchain: 1.72.1 74 | - run: cargo check --all-features 75 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | permissions: 2 | contents: read 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - release 9 | pull_request: 10 | branches: 11 | - main 12 | - release 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 16 | cancel-in-progress: true 17 | 18 | name: Test 19 | 20 | jobs: 21 | test: 22 | name: Test 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: dtolnay/rust-toolchain@stable 27 | - if: hashFiles('Cargo.lock') == '' 28 | run: cargo generate-lockfile 29 | - run: cargo test --locked --all-features 30 | 31 | minimal: 32 | name: Minimal versions 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: dtolnay/rust-toolchain@stable 37 | - uses: dtolnay/rust-toolchain@nightly 38 | - run: rustup default stable 39 | - run: cargo +nightly update -Zdirect-minimal-versions 40 | - run: cargo test --locked --all-features 41 | 42 | coverage: 43 | name: Coverage 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | # nightly is required for --doctests, see cargo-llvm-cov#2 48 | - uses: dtolnay/rust-toolchain@nightly 49 | with: 50 | components: llvm-tools-preview 51 | - uses: taiki-e/install-action@cargo-llvm-cov 52 | - if: hashFiles('Cargo.lock') == '' 53 | run: cargo generate-lockfile 54 | - run: cargo +nightly llvm-cov --locked --all-features --lcov --doctests --output-path lcov.info 55 | - uses: codecov/codecov-action@v4 56 | with: 57 | fail_ci_if_error: true 58 | token: ${{ secrets.CODECOV_TOKEN }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.1.1] 4 | 5 | Set MSRV, stricter dependency versions, better workflows 6 | 7 | ### Changed 8 | 9 | - Switched from chrono to hifitime 10 | - Switched from measurements to uom 11 | - Included `coordinates` feature by default 12 | 13 | ## [0.1.0] 14 | 15 | Initial FITS feature release 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "astro-rs" 3 | version = "0.1.1" 4 | edition = "2021" 5 | rust-version = "1.72.1" 6 | license = "MIT" 7 | description = "Astronomy utils" 8 | repository = "https://github.com/eta077/astro-rs" 9 | readme = "README.md" 10 | keywords = ["astronomy", "astrophysics", "fits", "utility"] 11 | categories = ["aerospace", "data-structures", "parser-implementations", "science"] 12 | 13 | exclude = [ 14 | ".github", 15 | "assets", 16 | ] 17 | 18 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 19 | 20 | [package.metadata.docs.rs] 21 | all-features = true 22 | rustdoc-args = ["--cfg", "docsrs"] 23 | 24 | [dependencies] 25 | hifitime = { version = "3.9", optional = true } 26 | once_cell = { version = "1.19", optional = true } 27 | regex = { version = "1.7", optional = true } 28 | reqwest = { version = "0.12", optional = true } 29 | rust_decimal = { version = "1.33" } 30 | thiserror = { version = "1.0.7" } 31 | uom = { version = "0.36", optional = true, default-features = false, features = ["autoconvert", "si", "std", "f64"] } 32 | urlencoding = { version = "2.1", optional = true } 33 | 34 | [dev-dependencies] 35 | criterion = { version = "0.5", features = ["html_reports"] } 36 | image = { version = "0.25", default-features = false, features = ["jpeg"] } 37 | tokio-test = { version = "0.4" } 38 | 39 | [features] 40 | default = ["coordinates", "fits"] 41 | coordinates = ["dep:hifitime", "dep:once_cell", "dep:regex", "dep:reqwest", "dep:uom", "dep:urlencoding"] 42 | cosmology = [] 43 | fits = [] 44 | 45 | [[bench]] 46 | name = "fits_benchmark" 47 | harness = false 48 | 49 | [profile.bench] 50 | debug = true 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 John Nystrom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub Build Status (branch)](https://img.shields.io/github/actions/workflow/status/eta077/astro-rs/build.yml?branch=release) 2 | ![GitHub Test Status (branch)](https://img.shields.io/github/actions/workflow/status/eta077/astro-rs/test.yml?branch=release&label=test) 3 | [![codecov](https://codecov.io/gh/eta077/astro-rs/branch/release/graph/badge.svg)](https://codecov.io/gh/eta077/astro-rs) 4 | [![docs.rs](https://img.shields.io/docsrs/astro-rs)](https://docs.rs/astro-rs/latest/astro_rs/) 5 | 6 | # astro-rs 7 | This library provides utilities to interact with astronomical data. 8 | 9 | Inspired by Astropy ( / ) 10 | 11 | # Goals 12 | ## General goals 13 | * Achieve feature compatibility with the Astropy library 14 | * Equal or surpass the Astropy benchmarks 15 | 16 | ## Technical goals 17 | * Use pure Rust as opposed to wrapping other libraries 18 | * Deserialize as lazily as possible 19 | * Balance 'tight' () types and adherance to APIs with graceful handling of deviation 20 | 21 | # Testing 22 | Test assets are from the following sources: 23 | * 24 | * 25 | 26 | # Licensing 27 | * Original code is licensed under the MIT license 28 | * `astropy` is licensed under BSD-3-Clause 29 | * `hifitime` is licensed under Apache-2.0 30 | 31 | # MSRV 32 | This crate's Minimum Supported Rust Version is `1.72.1`. 33 | -------------------------------------------------------------------------------- /assets/CDA/science/ao23/cat9/25975/primary/acisf25975N002_evt2.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eta077/astro-rs/8b2276c30fcc8d50aa6c470b080e1f2aac85cc15/assets/CDA/science/ao23/cat9/25975/primary/acisf25975N002_evt2.fits -------------------------------------------------------------------------------- /assets/M17/502nmos.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eta077/astro-rs/8b2276c30fcc8d50aa6c470b080e1f2aac85cc15/assets/M17/502nmos.fits -------------------------------------------------------------------------------- /assets/eagle_nebula/502nmos.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eta077/astro-rs/8b2276c30fcc8d50aa6c470b080e1f2aac85cc15/assets/eagle_nebula/502nmos.fits -------------------------------------------------------------------------------- /assets/eagle_nebula/eagle_composite.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eta077/astro-rs/8b2276c30fcc8d50aa6c470b080e1f2aac85cc15/assets/eagle_nebula/eagle_composite.jpg -------------------------------------------------------------------------------- /benches/fits_benchmark.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "fits")] 2 | use {criterion::black_box, std::fs::File, std::io::BufReader}; 3 | 4 | use criterion::{criterion_group, criterion_main, Criterion}; 5 | 6 | #[allow(unused_variables)] 7 | fn bench_get_header(c: &mut Criterion) { 8 | #[cfg(feature = "fits")] 9 | { 10 | use astro_rs::fits::*; 11 | c.bench_function("get header from fits file", |b| { 12 | b.iter(|| { 13 | let fits_file = black_box(File::open("assets/benchmarks/many_hdu.fits").unwrap()); 14 | let fits_file_reader = BufReader::new(fits_file); 15 | 16 | let mut hdu_list = HduList::new(fits_file_reader); 17 | hdu_list.first_mut().unwrap().header.clone() 18 | }) 19 | }); 20 | } 21 | } 22 | 23 | #[allow(unused_variables)] 24 | fn bench_get_header_20(c: &mut Criterion) { 25 | #[cfg(feature = "fits")] 26 | { 27 | use astro_rs::fits::*; 28 | c.bench_function("get 20th header from fits file", |b| { 29 | b.iter(|| { 30 | let fits_file = black_box(File::open("assets/benchmarks/many_hdu.fits").unwrap()); 31 | let fits_file_reader = BufReader::new(fits_file); 32 | 33 | let mut hdu_list = HduList::new(fits_file_reader); 34 | hdu_list.get_by_index(20).unwrap().header.clone() 35 | }) 36 | }); 37 | } 38 | } 39 | 40 | criterion_group!(benches, bench_get_header, bench_get_header_20); 41 | criterion_main!(benches); 42 | -------------------------------------------------------------------------------- /src/coordinates/frames.rs: -------------------------------------------------------------------------------- 1 | use hifitime::Epoch; 2 | use thiserror::Error; 3 | 4 | use super::{EarthLocation, EquatorialCoord}; 5 | 6 | /// An enumeration of errors that can occur while converting coordinates from one frame to another. 7 | #[derive(Debug, Error)] 8 | pub enum AstroConversionError {} 9 | 10 | /// Coordinates in the International Celestial Reference System. 11 | #[derive(Debug, Default, Clone, Copy, PartialEq)] 12 | pub struct Icrs { 13 | /// The coordinate value 14 | pub coords: EquatorialCoord, 15 | } 16 | 17 | impl Icrs { 18 | /// Creates a new Icrs with the coordinate values rounded to the specified decimal place. 19 | pub fn round(&self, dp: u32) -> Self { 20 | Self { 21 | coords: self.coords.round(dp), 22 | } 23 | } 24 | 25 | /// Converts coordinates from ICRS to observed AltAz coordinates. 26 | pub fn to_alt_az( 27 | &self, 28 | _date_time: &Epoch, 29 | _location: &EarthLocation, 30 | ) -> Result { 31 | Ok(AltAz::default()) 32 | } 33 | } 34 | 35 | /// Coordinates with respect to the WGS84 ellipsoid. 36 | #[derive(Debug, Default, Clone, Copy, PartialEq)] 37 | pub struct AltAz { 38 | /// The coordinate value 39 | pub coords: EquatorialCoord, 40 | } 41 | -------------------------------------------------------------------------------- /src/coordinates/lookup.rs: -------------------------------------------------------------------------------- 1 | use super::frames::Icrs; 2 | use super::lookup_config::SesameConfig; 3 | use super::EquatorialCoord; 4 | 5 | use once_cell::sync::OnceCell; 6 | use regex::Regex; 7 | use reqwest::Client; 8 | use thiserror::Error; 9 | use uom::si::angle::{degree, Angle}; 10 | use urlencoding::encode; 11 | 12 | static SESAME_CONFIG: OnceCell = OnceCell::new(); 13 | static SESAME_PARSER: OnceCell = OnceCell::new(); 14 | 15 | fn init_sesame_parser() -> Regex { 16 | Regex::new(r"%J\s*([0-9\.]+)\s*([\+\-\.0-9]+)").unwrap() 17 | } 18 | 19 | /// An enumeration of errors that can occur while performing a coordinate lookup. 20 | #[derive(Debug, Error)] 21 | pub enum AstroLookupError { 22 | /// Indicates the environmental variables contributing to the SESAME configuration are invalid. 23 | #[error("Invalid configuration: {reason}")] 24 | InvalidConfiguration { 25 | /// The reason the configuration is invalid. 26 | reason: String, 27 | }, 28 | /// Indicates an error occurred while obtaining the coordinate data. 29 | #[error(transparent)] 30 | NetworkError(#[from] reqwest::Error), 31 | /// Indicates an error occurred while parsing the coordinate data. 32 | #[error("{reason}")] 33 | ParseError { 34 | /// The reason coordinate data parsing failed. 35 | reason: String, 36 | }, 37 | /// Indicates coordinate data for the given name could not be found. 38 | #[error("Could not find coordinate data for {name}")] 39 | InvalidName { 40 | /// The name for which data could not be found. 41 | name: String, 42 | }, 43 | } 44 | 45 | /// Fetches the coordinates of an object with the given identifier. 46 | /// 47 | /// # Examples 48 | /// 49 | /// ``` 50 | /// use astro_rs::coordinates::{self, *}; 51 | /// use uom::si::angle::radian; 52 | /// use uom::si::f64::Angle; 53 | /// 54 | /// let m33_coords = tokio_test::block_on(async { coordinates::lookup_by_name("M33").await })?; 55 | /// assert_eq!(m33_coords.round(4), Icrs { 56 | /// coords: EquatorialCoord { 57 | /// ra: Angle::new::(0.4095), 58 | /// dec: Angle::new::(0.5351) 59 | /// }, 60 | /// }); 61 | /// 62 | /// let no_coords = tokio_test::block_on(async { 63 | /// coordinates::lookup_by_name("something that should not resolve").await 64 | /// }); 65 | /// assert!(no_coords.is_err()); 66 | /// # Ok::<(), astro_rs::coordinates::AstroLookupError>(()) 67 | /// ``` 68 | pub async fn lookup_by_name(name: &str) -> Result { 69 | let sesame_config = SESAME_CONFIG.get_or_init(SesameConfig::init); 70 | let sesame_parser = SESAME_PARSER.get_or_init(init_sesame_parser); 71 | let client = Client::new(); 72 | 73 | let mut err_result = Err(AstroLookupError::InvalidConfiguration { 74 | reason: String::from("No configured SESAME URLs"), 75 | }); 76 | 77 | for url in &sesame_config.urls { 78 | let uri_string = [ 79 | url.as_str(), 80 | if url.ends_with('/') { "" } else { "/" }, 81 | "~", 82 | sesame_config.database.to_str(), 83 | "?", 84 | &encode(name), 85 | ] 86 | .concat(); 87 | 88 | let result = lookup_by_uri(name, sesame_parser, &client, uri_string).await; 89 | 90 | if result.is_ok() { 91 | return result; 92 | } else { 93 | err_result = result; 94 | } 95 | } 96 | 97 | err_result 98 | } 99 | 100 | async fn lookup_by_uri( 101 | name: &str, 102 | sesame_parser: &Regex, 103 | client: &Client, 104 | uri_string: String, 105 | ) -> Result { 106 | let response = client.get(&uri_string).send().await?; 107 | let body_string = response.text().await?; 108 | 109 | if let Some(cap) = sesame_parser.captures(&body_string) { 110 | let ra_string = &cap[1]; 111 | let dec_string = &cap[2]; 112 | 113 | let ra: f64 = ra_string 114 | .parse() 115 | .map_err(|_| AstroLookupError::ParseError { 116 | reason: ["Could not parse ra value: ", ra_string].concat(), 117 | })?; 118 | let dec: f64 = dec_string 119 | .parse() 120 | .map_err(|_| AstroLookupError::ParseError { 121 | reason: ["Could not parse dec value: ", dec_string].concat(), 122 | })?; 123 | 124 | let coords = EquatorialCoord { 125 | ra: Angle::new::(ra), 126 | dec: Angle::new::(dec), 127 | }; 128 | return Ok(Icrs { coords }); 129 | } 130 | 131 | Err(AstroLookupError::InvalidName { 132 | name: name.to_owned(), 133 | }) 134 | } 135 | -------------------------------------------------------------------------------- /src/coordinates/lookup_config.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::error::Error; 3 | 4 | /// An enumeration of SESAME database options 5 | #[allow(missing_docs)] 6 | pub enum SesameDatabase { 7 | /// Indicates all available databases should be queried 8 | All, 9 | Simbad, 10 | Ned, 11 | Vizier, 12 | } 13 | 14 | impl SesameDatabase { 15 | /// Returns the abbreviation for the database, usable in the query URL. 16 | pub fn to_str(&self) -> &str { 17 | match self { 18 | SesameDatabase::All => "A", 19 | SesameDatabase::Simbad => "S", 20 | SesameDatabase::Ned => "N", 21 | SesameDatabase::Vizier => "V", 22 | } 23 | } 24 | } 25 | 26 | impl TryFrom for SesameDatabase { 27 | type Error = String; 28 | 29 | fn try_from(value: String) -> Result { 30 | match value.to_ascii_lowercase().as_str() { 31 | "a" | "all" => Ok(SesameDatabase::All), 32 | "s" | "simbad" => Ok(Self::Simbad), 33 | "n" | "ned" => Ok(Self::Ned), 34 | "v" | "vizier" => Ok(Self::Vizier), 35 | _ => Err(["Unknown SESAME database option: ", &value].concat()), 36 | } 37 | } 38 | } 39 | 40 | /// Configuration for SESAME queries. 41 | pub struct SesameConfig { 42 | /// The SESAME database to query. 43 | pub database: SesameDatabase, 44 | /// The URL at which the SESAME service is hosted. 45 | pub urls: Vec, 46 | } 47 | 48 | impl Default for SesameConfig { 49 | fn default() -> Self { 50 | Self { 51 | database: SesameDatabase::All, 52 | urls: vec![ 53 | String::from("http://cdsweb.u-strasbg.fr/cgi-bin/nph-sesame/"), 54 | String::from("http://vizier.cfa.harvard.edu/viz-bin/nph-sesame/"), 55 | ], 56 | } 57 | } 58 | } 59 | 60 | impl SesameConfig { 61 | /// Constructs a new SesameConfig. The associated environment variables 62 | /// (SESAME_DATABASE and SESAME_URLS) are examined first; if those 63 | /// variables cannot be parsed, a config with default values is returned. 64 | pub fn init() -> Self { 65 | Self::from_env().unwrap_or_default() 66 | } 67 | 68 | fn from_env() -> Result> { 69 | let database = SesameDatabase::try_from(env::var("SESAME_DATABASE")?)?; 70 | let urls: Vec = env::var("SESAME_URLS")? 71 | .split_ascii_whitespace() 72 | .map(|s| s.to_owned()) 73 | .collect(); 74 | if urls.is_empty() { 75 | return Err("At least one SESAME URL must be present".into()); 76 | } 77 | Ok(Self { database, urls }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/coordinates/mod.rs: -------------------------------------------------------------------------------- 1 | //! Compare, calculate, and transform spacial coordinates 2 | 3 | mod frames; 4 | mod lookup; 5 | mod lookup_config; 6 | 7 | use rust_decimal::Decimal; 8 | use uom::si::angle::radian; 9 | use uom::si::f64::{Angle, Length}; 10 | use uom::ConstZero; 11 | 12 | pub use frames::*; 13 | pub use lookup::*; 14 | pub use lookup_config::*; 15 | 16 | /// Equitorial coordinates expressed as (right ascension, declination) 17 | #[derive(Debug, Default, Copy, Clone, PartialEq)] 18 | pub struct EquatorialCoord { 19 | /// The right ascension angle 20 | pub ra: Angle, 21 | /// The declination angle 22 | pub dec: Angle, 23 | } 24 | 25 | impl EquatorialCoord { 26 | /// Constructs an EquitorialCoord. The given right ascension and declination angles will be normalized to [0.0, 2π) 27 | pub fn new(ra: Angle, dec: Angle) -> Self { 28 | Self { 29 | ra: Self::normalize(ra), 30 | dec: Self::normalize(dec), 31 | } 32 | } 33 | 34 | fn normalize(a: Angle) -> Angle { 35 | if a < Angle::ZERO { 36 | (a % Angle::FULL_TURN) + Angle::FULL_TURN 37 | } else { 38 | a % Angle::FULL_TURN 39 | } 40 | } 41 | 42 | /// Creates a new EquitorialCoord with the angle values rounded to the specified decimal place. 43 | pub fn round(&self, dp: u32) -> Self { 44 | let ra = Decimal::from_f64_retain(self.ra.value) 45 | .unwrap() 46 | .round_dp(dp) 47 | .try_into() 48 | .unwrap(); 49 | let dec = Decimal::from_f64_retain(self.dec.value) 50 | .unwrap() 51 | .round_dp(dp) 52 | .try_into() 53 | .unwrap(); 54 | Self { 55 | ra: Angle::new::(ra), 56 | dec: Angle::new::(dec), 57 | } 58 | } 59 | } 60 | 61 | /// Coordinates that represent a location on Earth. 62 | #[derive(Debug, Default, Copy, Clone, PartialEq)] 63 | pub struct EarthLocation { 64 | /// The latitude coordinate 65 | pub lat: Angle, 66 | /// The longitude coordinate 67 | pub lon: Angle, 68 | /// The height of the location 69 | pub height: Length, 70 | } 71 | -------------------------------------------------------------------------------- /src/cosmology/mod.rs: -------------------------------------------------------------------------------- 1 | //! Represent and calculate data using cosmological models. 2 | -------------------------------------------------------------------------------- /src/fits/hdu_types.rs: -------------------------------------------------------------------------------- 1 | //! Construct standard HDU types. 2 | 3 | use super::*; 4 | 5 | /// The header keyword indicating the name of a table column. 6 | pub const TTYPE_KEYWORD: [u8; 8] = *b"TTYPE "; 7 | /// The header keyword indicating the size in bytes of a table column. 8 | pub const TFORM_KEYWORD: [u8; 8] = *b"TFORM "; 9 | 10 | pub(crate) const DEFAULT_BITPIX_BYTES: [u8; 80] = 11 | *b"BITPIX = 8 "; 12 | pub(crate) const DEFAULT_NAXIS_BYTES: [u8; 80] = 13 | *b"NAXIS = 0 "; 14 | pub(crate) const DEFAULT_PCOUNT_BYTES: [u8; 80] = 15 | *b"PCOUNT = 0 "; 16 | pub(crate) const DEFAULT_GCOUNT_BYTES: [u8; 80] = 17 | *b"GCOUNT = 1 "; 18 | pub(crate) const DEFAULT_TFIELDS_BYTES: [u8; 80] = 19 | *b"TFIELDS = 0 "; 20 | pub(crate) const DEFAULT_END_BYTES: [u8; 80] = 21 | *b"END "; 22 | 23 | /// Functions related to a Primary type HDU. 24 | pub mod primary_hdu { 25 | use super::*; 26 | 27 | /// Constructs an HDU pre-populated with the required cards to be a Primary HDU. 28 | pub fn default() -> Hdu { 29 | let simple_card = FitsHeaderCard::from( 30 | *b"SIMPLE = T ", 31 | ); 32 | let bitpix_card = FitsHeaderCard::from(DEFAULT_BITPIX_BYTES); 33 | let naxis_card = FitsHeaderCard::from(DEFAULT_NAXIS_BYTES); 34 | let end_card = FitsHeaderCard::from(DEFAULT_END_BYTES); 35 | let header = FitsHeader { 36 | cards: vec![simple_card, bitpix_card, naxis_card, end_card], 37 | }; 38 | 39 | Hdu { 40 | header, 41 | ..Default::default() 42 | } 43 | } 44 | } 45 | 46 | /// Functions related to an ASCII Table type HDU. 47 | pub mod ascii_table_hdu { 48 | use super::*; 49 | 50 | /// Constructs an HDU pre-populated with the required cards to be an ASCII Table HDU. 51 | pub fn default() -> Hdu { 52 | let xtension_card = FitsHeaderCard::from( 53 | *b"XTENSION= 'TABLE ' ", 54 | ); 55 | let bitpix_card = FitsHeaderCard::from(DEFAULT_BITPIX_BYTES); 56 | let naxis_card = FitsHeaderCard::from( 57 | *b"NAXIS = 2 ", 58 | ); 59 | let naxis1_card = FitsHeaderCard::from( 60 | *b"NAXIS1 = 0 ", 61 | ); 62 | let naxis2_card = FitsHeaderCard::from( 63 | *b"NAXIS2 = 0 ", 64 | ); 65 | let pcount_card = FitsHeaderCard::from(DEFAULT_PCOUNT_BYTES); 66 | let gcount_card = FitsHeaderCard::from(DEFAULT_GCOUNT_BYTES); 67 | let tfields_card = FitsHeaderCard::from(DEFAULT_TFIELDS_BYTES); 68 | let end_card = FitsHeaderCard::from(DEFAULT_END_BYTES); 69 | let header = FitsHeader { 70 | cards: vec![ 71 | xtension_card, 72 | bitpix_card, 73 | naxis_card, 74 | naxis1_card, 75 | naxis2_card, 76 | pcount_card, 77 | gcount_card, 78 | tfields_card, 79 | end_card, 80 | ], 81 | }; 82 | 83 | Hdu { 84 | header, 85 | ..Default::default() 86 | } 87 | } 88 | } 89 | 90 | /// Functions related to an Image type HDU. 91 | pub mod image_hdu { 92 | use super::*; 93 | 94 | /// Constructs an HDU pre-populated with the required cards to be an Image HDU. 95 | pub fn default() -> Hdu { 96 | let xtension_card = FitsHeaderCard::from( 97 | *b"XTENSION= 'IMAGE ' ", 98 | ); 99 | let bitpix_card = FitsHeaderCard::from(DEFAULT_BITPIX_BYTES); 100 | let naxis_card = FitsHeaderCard::from(DEFAULT_NAXIS_BYTES); 101 | let pcount_card = FitsHeaderCard::from(DEFAULT_PCOUNT_BYTES); 102 | let gcount_card = FitsHeaderCard::from(DEFAULT_GCOUNT_BYTES); 103 | let end_card = FitsHeaderCard::from(DEFAULT_END_BYTES); 104 | let header = FitsHeader { 105 | cards: vec![ 106 | xtension_card, 107 | bitpix_card, 108 | naxis_card, 109 | pcount_card, 110 | gcount_card, 111 | end_card, 112 | ], 113 | }; 114 | 115 | Hdu { 116 | header, 117 | ..Default::default() 118 | } 119 | } 120 | } 121 | 122 | /// Functions related to a Binary Table type HDU. 123 | pub mod binary_table_hdu { 124 | use super::*; 125 | use crate::fits::header_value::TForm; 126 | 127 | /// Constructs an HDU pre-populated with the required cards to be a Binary Table HDU. 128 | pub fn default() -> Hdu { 129 | let xtension_card = FitsHeaderCard::from( 130 | *b"XTENSION= 'BINTABLE' ", 131 | ); 132 | let bitpix_card = FitsHeaderCard::from(DEFAULT_BITPIX_BYTES); 133 | let naxis_card = FitsHeaderCard::from( 134 | *b"NAXIS = 2 ", 135 | ); 136 | let naxis1_card = FitsHeaderCard::from( 137 | *b"NAXIS1 = 0 ", 138 | ); 139 | let naxis2_card = FitsHeaderCard::from( 140 | *b"NAXIS2 = 0 ", 141 | ); 142 | let pcount_card = FitsHeaderCard::from(DEFAULT_PCOUNT_BYTES); 143 | let gcount_card = FitsHeaderCard::from(DEFAULT_GCOUNT_BYTES); 144 | let tfields_card = FitsHeaderCard::from(DEFAULT_TFIELDS_BYTES); 145 | let end_card = FitsHeaderCard::from(DEFAULT_END_BYTES); 146 | let header = FitsHeader { 147 | cards: vec![ 148 | xtension_card, 149 | bitpix_card, 150 | naxis_card, 151 | naxis1_card, 152 | naxis2_card, 153 | pcount_card, 154 | gcount_card, 155 | tfields_card, 156 | end_card, 157 | ], 158 | }; 159 | 160 | Hdu { 161 | header, 162 | ..Default::default() 163 | } 164 | } 165 | 166 | /// Obtains the data in the column of the given name, or None if a column with the given name cannot be found. 167 | pub fn column_by_name(hdu: &mut Hdu, name: &str) -> Option> { 168 | let mut n = 1; 169 | let mut column_start = 0; 170 | let mut tform = None; 171 | let mut naxis_keyword = NAXIS_KEYWORD; 172 | naxis_keyword[5] = b'2'; 173 | let num_rows = *hdu 174 | .header 175 | .get_card(naxis_keyword) 176 | .and_then(|card| card.get_value::().ok()) 177 | .unwrap_or_default() as usize; 178 | naxis_keyword[5] = b'1'; 179 | let row_len = *hdu 180 | .header 181 | .get_card(naxis_keyword) 182 | .and_then(|card| card.get_value::().ok()) 183 | .unwrap_or_default() as usize; 184 | let mut tform_keyword = FitsHeaderKeyword::from(TFORM_KEYWORD); 185 | let mut ttype_keyword = FitsHeaderKeyword::from(TTYPE_KEYWORD); 186 | while n as usize <= num_rows { 187 | tform_keyword.append_number(n); 188 | if let Some(card) = hdu.header.get_card(tform_keyword) { 189 | if let Ok(tform_value) = card.get_value::() { 190 | ttype_keyword.append_number(n); 191 | if let Some(value) = hdu 192 | .header 193 | .get_card(ttype_keyword) 194 | .and_then(|card| card.get_value::().ok()) 195 | { 196 | if value.eq_ignore_ascii_case(name) { 197 | tform = Some(tform_value); 198 | break; 199 | } 200 | } 201 | 202 | column_start += tform_value.value(); 203 | } 204 | n += 1; 205 | } else { 206 | break; 207 | } 208 | } 209 | if let Some(tform) = tform { 210 | return Some(*tform.create_column(hdu.data_raw(), column_start, row_len, num_rows)); 211 | } 212 | None 213 | } 214 | 215 | /// Obtains the data in the column of the given index, or None if a column with the given index cannot be found. 216 | /// Note that column indeces start at 1. 217 | pub fn column_by_index(hdu: &mut Hdu, index: u16) -> Option> { 218 | let mut n = 1; 219 | let mut column_start = 0; 220 | let mut tform = None; 221 | let mut naxis_keyword = NAXIS_KEYWORD; 222 | naxis_keyword[5] = b'2'; 223 | let num_rows = *hdu 224 | .header 225 | .get_card(naxis_keyword) 226 | .and_then(|card| card.get_value::().ok()) 227 | .unwrap_or_default() as usize; 228 | if index as usize > num_rows { 229 | return None; 230 | } 231 | naxis_keyword[5] = b'1'; 232 | let row_len = *hdu 233 | .header 234 | .get_card(naxis_keyword) 235 | .and_then(|card| card.get_value::().ok()) 236 | .unwrap_or_default() as usize; 237 | let mut tform_keyword = FitsHeaderKeyword::from(TFORM_KEYWORD); 238 | while n <= index { 239 | tform_keyword.append_number(n); 240 | if let Some(card) = hdu.header.get_card(tform_keyword) { 241 | if let Ok(value) = card.get_value::() { 242 | if n == index { 243 | tform = Some(value); 244 | } else { 245 | column_start += value.value(); 246 | } 247 | } 248 | n += 1; 249 | } else { 250 | break; 251 | } 252 | } 253 | if let Some(tform) = tform { 254 | return Some(*tform.create_column(hdu.data_raw(), column_start, row_len, num_rows)); 255 | } 256 | None 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/fits/header.rs: -------------------------------------------------------------------------------- 1 | //! Provides tools to construct, serialize, and deserialize FITS files. 2 | 3 | use super::header_value::*; 4 | 5 | use std::fmt::Debug; 6 | use std::rc::Rc; 7 | 8 | use thiserror::Error; 9 | 10 | /// The expected keyword for the first header card of the primary HDU. 11 | pub const SIMPLE_KEYWORD: [u8; 8] = *b"SIMPLE "; 12 | /// The header keyword indicating the size of each value in the HDU data section. 13 | pub const BITPIX_KEYWORD: [u8; 8] = *b"BITPIX "; 14 | /// The header keyword indicating how many axes are present in the HDU data section. 15 | pub const NAXIS_KEYWORD: [u8; 8] = *b"NAXIS "; 16 | /// The header keyword indicating the end of the header section. 17 | pub const END_KEYWORD: [u8; 8] = *b"END "; 18 | /// The expected keyword for the first header card of each HDU following the primary. 19 | pub const XTENSION_KEYWORD: [u8; 8] = *b"XTENSION"; 20 | 21 | pub(crate) const FITS_RECORD_LEN: usize = 2880; 22 | pub(crate) const HEADER_CARD_LEN: usize = 80; 23 | pub(crate) const HEADER_KEYWORD_LEN: usize = 8; 24 | 25 | /// An enumeration of errors that could occur when processing a FITS header element. 26 | #[derive(Debug, Error)] 27 | pub enum FitsHeaderError { 28 | /// Indicates an unexpected length of bytes was encountered during processing. 29 | #[error("unexpected byte count - expected {expected} bytes for {intent}, found {found}")] 30 | InvalidLength { 31 | /// The number of bytes expected by the operation. 32 | expected: usize, 33 | /// The number of bytes found by the operation. 34 | found: usize, 35 | /// The objective of the operation. 36 | intent: String, 37 | }, 38 | /// Indicates invalid bytes were encountered during processing. 39 | #[error("expected valid string for {intent}, found {found:?}")] 40 | DeserializationError { 41 | /// The bytes that were found by the operation. 42 | found: Vec, 43 | /// The objective of the operation. 44 | intent: String, 45 | }, 46 | /// Indicates the expected type does not match the cached value type. 47 | #[error("expected type does not match cached value type")] 48 | InvalidType, 49 | } 50 | 51 | /// The header portion of an HDU. 52 | #[derive(Debug, Default, Clone)] 53 | pub struct FitsHeader { 54 | /// The card images contained in the header. 55 | pub cards: Vec, 56 | } 57 | 58 | impl FitsHeader { 59 | /// Constructs an empty header. 60 | pub fn new() -> Self { 61 | Self::default() 62 | } 63 | 64 | /// Constructs a FitsHeader from the given bytes. 65 | /// 66 | /// # Examples 67 | /// 68 | /// ``` 69 | /// use astro_rs::fits::*; 70 | /// use std::rc::Rc; 71 | /// 72 | /// // default primary HDU header bytes 73 | /// let bytes = *b"SIMPLE = T BITPIX = 8 NAXIS = 0 END "; 74 | /// let mut header = FitsHeader::from_bytes(bytes.to_vec()); 75 | /// 76 | /// assert!(*header 77 | /// .get_card(SIMPLE_KEYWORD) 78 | /// .and_then(|card| card.get_value::().ok()) 79 | /// .unwrap_or_default()); 80 | /// assert_eq!( 81 | /// header 82 | /// .get_card(BITPIX_KEYWORD) 83 | /// .and_then(|card| card.get_value::().ok()), 84 | /// Some(Rc::new(Bitpix::U8)) 85 | /// ); 86 | /// assert_eq!( 87 | /// header 88 | /// .get_card(NAXIS_KEYWORD) 89 | /// .and_then(|card| card.get_value::().ok()), 90 | /// Some(Rc::new(0)) 91 | /// ); 92 | /// assert!(header.get_card(END_KEYWORD).is_some()); 93 | /// ``` 94 | pub fn from_bytes(raw: Vec) -> FitsHeader { 95 | let raw_len = raw.len(); 96 | let num_cards = raw_len / HEADER_CARD_LEN; 97 | 98 | let mut cards = Vec::with_capacity(num_cards); 99 | for i in 0..num_cards { 100 | let index = i * HEADER_CARD_LEN; 101 | let card_slice: [u8; 80] = raw[index..index + HEADER_CARD_LEN].try_into().unwrap(); 102 | cards.push(FitsHeaderCard::from(card_slice)); 103 | } 104 | 105 | FitsHeader { cards } 106 | } 107 | 108 | /// Serializes the header into bytes. 109 | /// 110 | /// # Examples 111 | /// 112 | /// ``` 113 | /// use astro_rs::fits::*; 114 | /// 115 | /// let hdu = primary_hdu::default(); 116 | /// let mut bytes = b"SIMPLE = T BITPIX = 8 NAXIS = 0 END ".to_vec(); 117 | /// bytes.resize(2880, b' '); 118 | /// 119 | /// assert_eq!(hdu.header.to_bytes(), bytes); 120 | /// ``` 121 | pub fn to_bytes(self) -> Vec { 122 | let mut result = Vec::with_capacity(FITS_RECORD_LEN); 123 | let filled_cards = self.cards.len(); 124 | for card in self.cards { 125 | let card_raw: [u8; HEADER_CARD_LEN] = card.into(); 126 | result.extend_from_slice(&card_raw); 127 | } 128 | if filled_cards < 36 { 129 | result.resize(FITS_RECORD_LEN, b' '); 130 | } 131 | result 132 | } 133 | 134 | /// Searches the header cards for a match with the given keyword. 135 | /// 136 | /// # Examples 137 | /// 138 | /// ``` 139 | /// use astro_rs::fits::*; 140 | /// 141 | /// let mut hdu = primary_hdu::default(); 142 | /// assert!(hdu.header.get_card(SIMPLE_KEYWORD).is_some()); 143 | /// assert!(hdu.header.get_card(EXTNAME_KEYWORD).is_none()); 144 | /// ``` 145 | pub fn get_card>( 146 | &mut self, 147 | keyword: K, 148 | ) -> Option<&mut FitsHeaderCard> { 149 | self.cards.iter_mut().find(|card| keyword == card.keyword) 150 | } 151 | 152 | /// Sets the value and comment of the card with the given keyword. 153 | /// If a card already exists, the data is overwritten. 154 | /// If a card does not exist, one is created. 155 | /// 156 | /// # Examples 157 | /// 158 | /// ``` 159 | /// use astro_rs::fits::*; 160 | /// use std::rc::Rc; 161 | /// 162 | /// let mut header = FitsHeader::new(); 163 | /// header.set_card(SIMPLE_KEYWORD, true, None); 164 | /// assert!(*header 165 | /// .get_card(SIMPLE_KEYWORD) 166 | /// .and_then(|card| card.get_value::().ok()) 167 | /// .unwrap_or_default()); 168 | /// 169 | /// header.set_card(SIMPLE_KEYWORD, false, Some(String::from("FITS STANDARD"))); 170 | /// let mut card = header.get_card(SIMPLE_KEYWORD).unwrap(); 171 | /// assert!(!*card.get_value::()?); 172 | /// assert_eq!(card.get_comment()?, Rc::new(String::from("FITS STANDARD"))); 173 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 174 | /// ``` 175 | pub fn set_card< 176 | K: PartialEq + Into, 177 | T: FitsHeaderValue + 'static, 178 | >( 179 | &mut self, 180 | keyword: K, 181 | value: T, 182 | comment: Option, 183 | ) -> Result<(), FitsHeaderError> { 184 | let fits_keyword = keyword.into(); 185 | let new_card = FitsHeaderCard { 186 | keyword: fits_keyword, 187 | value: FitsHeaderValueContainer::new(value, comment)?, 188 | }; 189 | if let Some(card) = self.get_card(fits_keyword) { 190 | *card = new_card; 191 | } else { 192 | let index = if self 193 | .cards 194 | .last() 195 | .map(|card| card.keyword == END_KEYWORD) 196 | .unwrap_or_default() 197 | { 198 | self.cards.len() - 1 199 | } else { 200 | self.cards.len() 201 | }; 202 | self.cards.insert(index, new_card); 203 | } 204 | Ok(()) 205 | } 206 | 207 | /// Sets the value of the card with the given keyword. 208 | /// If a card already exists, the value is overwritten, and the comment is retained. 209 | /// If a card does not exist, one is created. 210 | /// 211 | /// # Examples 212 | /// 213 | /// ``` 214 | /// use astro_rs::fits::*; 215 | /// use std::rc::Rc; 216 | /// 217 | /// let bytes = *b"SIMPLE = T / FITS STANDARD "; 218 | /// let mut header = FitsHeader::from_bytes(bytes.to_vec()); 219 | /// header.set_value(SIMPLE_KEYWORD, false)?; 220 | /// let mut card = header.get_card(SIMPLE_KEYWORD).unwrap(); 221 | /// assert!(!*card.get_value::()?); 222 | /// assert_eq!(card.get_comment()?, Rc::new(String::from("FITS STANDARD"))); 223 | /// 224 | /// header.set_value(BITPIX_KEYWORD, Bitpix::U8)?; 225 | /// assert_eq!( 226 | /// header 227 | /// .get_card(BITPIX_KEYWORD) 228 | /// .and_then(|card| card.get_value::().ok()), 229 | /// Some(Rc::new(Bitpix::U8)) 230 | /// ); 231 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 232 | /// ``` 233 | pub fn set_value( 234 | &mut self, 235 | keyword: K, 236 | value: T, 237 | ) -> Result<(), FitsHeaderError> 238 | where 239 | K: PartialEq + Into, 240 | { 241 | let fits_keyword = keyword.into(); 242 | if let Some(card) = self.get_card(fits_keyword) { 243 | card.value.set_value(value)?; 244 | } else { 245 | let new_card = FitsHeaderCard { 246 | keyword: fits_keyword, 247 | value: FitsHeaderValueContainer::new(value, None)?, 248 | }; 249 | let index = if self 250 | .cards 251 | .last() 252 | .map(|card| card.keyword == END_KEYWORD) 253 | .unwrap_or_default() 254 | { 255 | self.cards.len() - 1 256 | } else { 257 | self.cards.len() 258 | }; 259 | self.cards.insert(index, new_card); 260 | } 261 | Ok(()) 262 | } 263 | 264 | /// Sets the comment of the card with the given keyword. 265 | /// If a card already exists, the comment is overwritten, and the value is retained. 266 | /// If a card does not exist, this function has no effect. 267 | /// 268 | /// # Examples 269 | /// 270 | /// ``` 271 | /// use astro_rs::fits::*; 272 | /// use std::rc::Rc; 273 | /// 274 | /// let mut hdu = primary_hdu::default(); 275 | /// hdu.header.set_comment(SIMPLE_KEYWORD, Some(String::from("FITS STANDARD"))); 276 | /// let mut card = hdu.header.get_card(SIMPLE_KEYWORD).unwrap(); 277 | /// assert!(*card.get_value::()?); 278 | /// assert_eq!(card.get_comment()?, Rc::new(String::from("FITS STANDARD"))); 279 | /// 280 | /// hdu.header.set_comment(EXTNAME_KEYWORD, Some(String::from("Error 404"))); 281 | /// assert!(hdu.header.get_card(EXTNAME_KEYWORD).is_none()); 282 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 283 | /// ``` 284 | pub fn set_comment>( 285 | &mut self, 286 | keyword: K, 287 | comment: Option, 288 | ) -> Result<(), FitsHeaderError> { 289 | if let Some(card) = self.get_card(keyword) { 290 | card.value.set_comment(comment)?; 291 | } 292 | Ok(()) 293 | } 294 | } 295 | 296 | /// A card within an HDU header section. 297 | /// 298 | /// # Examples 299 | /// 300 | /// ``` 301 | /// use astro_rs::fits::FitsHeaderCard; 302 | /// 303 | /// let card_raw = *b"SIMPLE = T / FITS STANDARD "; 304 | /// let mut card = FitsHeaderCard::from(card_raw); 305 | /// 306 | /// assert_eq!(*card.keyword(), "SIMPLE"); 307 | /// // deserializes value and comment, discarding padding 308 | /// assert_eq!(*card.get_value::()?, true); 309 | /// assert_eq!(*card.get_comment()?, String::from("FITS STANDARD")); 310 | /// 311 | /// // re-serialize the header card 312 | /// let comparison: [u8; 80] = card.into(); 313 | /// assert_eq!(comparison, card_raw); 314 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 315 | /// ``` 316 | #[derive(Debug, Clone)] 317 | pub struct FitsHeaderCard { 318 | keyword: FitsHeaderKeyword, 319 | value: FitsHeaderValueContainer, 320 | } 321 | 322 | impl FitsHeaderCard { 323 | /// Gets the keyword of the header card. 324 | pub fn keyword(&self) -> &FitsHeaderKeyword { 325 | &self.keyword 326 | } 327 | 328 | /// Gets the value of the header card. 329 | /// If the value has not yet been deserialized, the deserialization process is attempted. 330 | /// If the process succeeds, the deserialized value is cached. 331 | /// 332 | /// # Examples 333 | /// 334 | /// ``` 335 | /// use astro_rs::fits::*; 336 | /// 337 | /// let mut card = FitsHeaderCard::from( 338 | /// *b"SIMPLE = T ", 339 | /// ); 340 | /// assert!(card.get_value::().is_err()); 341 | /// assert!(card.get_value::().map(|value| *value).unwrap_or_default()); 342 | /// // value is now cached, deserialization is not attempted, but types differ 343 | /// assert!(card.get_value::().is_err()); 344 | /// assert!(card.get_value::().map(|value| *value).unwrap_or_default()); 345 | /// ``` 346 | pub fn get_value(&mut self) -> Result, FitsHeaderError> { 347 | self.value.get_value() 348 | } 349 | 350 | /// Gets the comment section of the header card. 351 | /// 352 | /// # Examples 353 | /// 354 | /// ``` 355 | /// use astro_rs::fits::FitsHeaderCard; 356 | /// 357 | /// let mut card = FitsHeaderCard::from(*b"SIMPLE = T / FITS STANDARD "); 358 | /// assert_eq!(*card.get_comment()?, String::from("FITS STANDARD")); 359 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 360 | /// ``` 361 | pub fn get_comment(&mut self) -> Result, FitsHeaderError> { 362 | self.value.get_comment() 363 | } 364 | } 365 | 366 | impl From<[u8; 80]> for FitsHeaderCard { 367 | fn from(raw: [u8; 80]) -> Self { 368 | let keyword_bytes: [u8; 8] = raw[0..HEADER_KEYWORD_LEN].try_into().unwrap(); 369 | let keyword = FitsHeaderKeyword::from(keyword_bytes); 370 | let value_bytes: [u8; 72] = raw[HEADER_KEYWORD_LEN..HEADER_CARD_LEN].try_into().unwrap(); 371 | let value = FitsHeaderValueContainer::from(value_bytes); 372 | FitsHeaderCard { keyword, value } 373 | } 374 | } 375 | 376 | impl From for [u8; 80] { 377 | fn from(card: FitsHeaderCard) -> Self { 378 | let mut result = [0; HEADER_CARD_LEN]; 379 | let keyword_raw: [u8; HEADER_KEYWORD_LEN] = card.keyword.into(); 380 | result[0..HEADER_KEYWORD_LEN].copy_from_slice(&keyword_raw); 381 | let value_raw: [u8; 72] = card.value.into(); 382 | result[HEADER_KEYWORD_LEN..HEADER_CARD_LEN].copy_from_slice(&value_raw); 383 | 384 | result 385 | } 386 | } 387 | 388 | /// A FITS header keyword. 389 | /// This wrapper provides functions to interact with both raw arrays and strings. 390 | /// 391 | /// # Examples 392 | /// 393 | /// ``` 394 | /// use astro_rs::fits::FitsHeaderKeyword; 395 | /// 396 | /// let simple_keyword = FitsHeaderKeyword::from(*b"SIMPLE "); 397 | /// assert!(simple_keyword == "SIMPLE"); 398 | /// assert!(simple_keyword == *b"SIMPLE "); 399 | /// 400 | /// assert!(simple_keyword != "BITPIX"); 401 | /// assert!(simple_keyword != *b"BITPIX "); 402 | /// ``` 403 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 404 | pub struct FitsHeaderKeyword { 405 | raw: [u8; 8], 406 | } 407 | 408 | impl FitsHeaderKeyword { 409 | /// Appends the given number to the keyword. 410 | /// If a number is already appended, it is replaced by the given number. 411 | /// 412 | /// # Examples 413 | /// 414 | /// ``` 415 | /// use astro_rs::fits::*; 416 | /// 417 | /// let mut naxis_keyword = FitsHeaderKeyword::from(NAXIS_KEYWORD); 418 | /// naxis_keyword.append_number(1); 419 | /// assert_eq!(naxis_keyword, "NAXIS1"); 420 | /// naxis_keyword.append_number(2); 421 | /// assert_eq!(naxis_keyword, "NAXIS2"); 422 | /// 423 | /// let mut tform_keyword = FitsHeaderKeyword::from(TFORM_KEYWORD); 424 | /// tform_keyword.append_number(100); 425 | /// assert_eq!(tform_keyword, "TFORM100"); 426 | /// tform_keyword.append_number(10); 427 | /// assert_eq!(tform_keyword, "TFORM10"); 428 | /// ``` 429 | pub fn append_number(&mut self, number: u16) { 430 | let mut i = 0; 431 | while i < 8 { 432 | let c = self.raw[i]; 433 | if c == b' ' || c.is_ascii_digit() { 434 | break; 435 | } 436 | i += 1; 437 | } 438 | if number > 99 { 439 | self.raw[i] = (number / 100 + 48) as u8; 440 | i += 1; 441 | } 442 | if number > 9 { 443 | self.raw[i] = (number % 100 / 10 + 48) as u8; 444 | i += 1; 445 | } 446 | self.raw[i] = (number % 10 + 48) as u8; 447 | i += 1; 448 | while i < 8 { 449 | self.raw[i] = b' '; 450 | i += 1; 451 | } 452 | } 453 | } 454 | 455 | impl From<[u8; 8]> for FitsHeaderKeyword { 456 | fn from(raw: [u8; 8]) -> Self { 457 | FitsHeaderKeyword { raw } 458 | } 459 | } 460 | 461 | impl From for [u8; 8] { 462 | fn from(keyword: FitsHeaderKeyword) -> Self { 463 | keyword.raw 464 | } 465 | } 466 | 467 | impl PartialEq<&str> for FitsHeaderKeyword { 468 | fn eq(&self, other: &&str) -> bool { 469 | if other.len() > HEADER_KEYWORD_LEN { 470 | return false; 471 | } 472 | let other_bytes = other.as_bytes(); 473 | for (index, b) in self.raw.iter().enumerate() { 474 | if b != other_bytes.get(index).unwrap_or(&b' ') { 475 | return false; 476 | } 477 | } 478 | 479 | true 480 | } 481 | } 482 | 483 | impl PartialEq for FitsHeaderKeyword { 484 | fn eq(&self, other: &str) -> bool { 485 | if other.len() > HEADER_KEYWORD_LEN { 486 | return false; 487 | } 488 | let other_bytes = other.as_bytes(); 489 | for (index, b) in self.raw.iter().enumerate() { 490 | if b != other_bytes.get(index).unwrap_or(&b' ') { 491 | return false; 492 | } 493 | } 494 | 495 | true 496 | } 497 | } 498 | 499 | impl PartialEq<[u8; 8]> for FitsHeaderKeyword { 500 | fn eq(&self, other: &[u8; 8]) -> bool { 501 | self.raw == *other 502 | } 503 | } 504 | 505 | impl PartialEq for [u8; 8] { 506 | fn eq(&self, other: &FitsHeaderKeyword) -> bool { 507 | *self == other.raw 508 | } 509 | } 510 | 511 | /// A representation of the combined header card value and comment. 512 | /// This wrapper ensures that the total number of bytes between the value and comment will not exceed 72. 513 | #[derive(Debug, Clone)] 514 | pub struct FitsHeaderValueContainer { 515 | raw: Vec, 516 | value: Option>, 517 | comment: Option>, 518 | } 519 | 520 | impl FitsHeaderValueContainer { 521 | /// Constructs a new FitsHeaderValueContainer with the given value and comment. 522 | pub fn new( 523 | value: T, 524 | comment: Option, 525 | ) -> Result { 526 | Self::check_comment_length(value.to_bytes(), comment.as_ref())?; 527 | Ok(FitsHeaderValueContainer { 528 | raw: Vec::new(), 529 | value: Some(Rc::new(value)), 530 | comment: comment.map(Rc::new), 531 | }) 532 | } 533 | 534 | /// Gets the value of the header card. 535 | /// If the value has not yet been deserialized, the deserialization process is attempted. 536 | /// If the process succeeds, the deserialized value is cached. 537 | /// 538 | /// # Examples 539 | /// 540 | /// ``` 541 | /// use astro_rs::fits::*; 542 | /// 543 | /// let mut card_value = FitsHeaderValueContainer::from( 544 | /// *b"= T ", 545 | /// ); 546 | /// assert!(card_value.get_value::().is_err()); 547 | /// assert!(card_value.get_value::().map(|value| *value).unwrap_or_default()); 548 | /// // value is now cached, deserialization is not attempted, but types differ 549 | /// assert!(card_value.get_value::().is_err()); 550 | /// assert!(card_value.get_value::().map(|value| *value).unwrap_or_default()); 551 | /// ``` 552 | pub fn get_value(&mut self) -> Result, FitsHeaderError> { 553 | if let Some(data) = &self.value { 554 | if !data.is::() { 555 | return Err(FitsHeaderError::InvalidType); 556 | } 557 | // safety: type is checked above 558 | unsafe { 559 | let ptr = Rc::into_raw(Rc::clone(data)); 560 | let new_ptr: *const T = ptr.cast(); 561 | Ok(Rc::from_raw(new_ptr)) 562 | } 563 | } else { 564 | let comment_start_index = self 565 | .raw 566 | .iter() 567 | .position(|b| *b == b'/') 568 | .unwrap_or(self.raw.len()); 569 | let mut value_bytes = self.raw[0..comment_start_index].to_vec(); 570 | // discard '=' prefix 571 | if value_bytes.first() == Some(&b'=') { 572 | value_bytes.remove(0); 573 | } 574 | 575 | let data = Rc::new(T::from_bytes(Self::trim_value(value_bytes))?); 576 | // only remove bytes from raw if deserialization is successful 577 | self.raw = self.raw.split_off(comment_start_index); 578 | let ret = Rc::clone(&data); 579 | self.value = Some(data); 580 | Ok(ret) 581 | } 582 | } 583 | 584 | /// Sets the value of the header card. 585 | pub fn set_value( 586 | &mut self, 587 | value: T, 588 | ) -> Result<(), FitsHeaderError> { 589 | let comment = match (self.value.as_ref(), self.comment.as_ref()) { 590 | (None, None) => { 591 | let comment = self.get_comment()?.to_string(); 592 | self.raw.clear(); 593 | comment 594 | } 595 | (None, Some(comment)) => { 596 | self.raw.clear(); 597 | comment.to_string() 598 | } 599 | (Some(_), None) => self.get_comment()?.to_string(), 600 | (Some(_), Some(comment)) => comment.to_string(), 601 | }; 602 | Self::check_comment_length(value.to_bytes(), Some(&comment))?; 603 | self.value = Some(Rc::new(value)); 604 | Ok(()) 605 | } 606 | 607 | /// Gets the comment section of the header card. 608 | /// 609 | /// # Examples 610 | /// 611 | /// ``` 612 | /// use astro_rs::fits::FitsHeaderValueContainer; 613 | /// 614 | /// let mut card_value = FitsHeaderValueContainer::from(*b"= T / FITS STANDARD "); 615 | /// assert_eq!(*card_value.get_comment()?, String::from("FITS STANDARD")); 616 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 617 | /// ``` 618 | pub fn get_comment(&mut self) -> Result, FitsHeaderError> { 619 | if let Some(data) = &self.comment { 620 | Ok(Rc::clone(data)) 621 | } else if let Some(comment_start_index) = self 622 | .raw 623 | .iter() 624 | .position(|b| *b == b'/') 625 | .or_else(|| self.raw.iter().rposition(|b| *b != b' ').map(|idx| idx + 1)) 626 | { 627 | let mut value_bytes: Vec = self.raw.drain(comment_start_index..).collect(); 628 | // discard '/' prefix 629 | value_bytes.remove(0); 630 | let value_string = String::from_utf8(Self::trim_value(value_bytes)).map_err(|er| { 631 | FitsHeaderError::DeserializationError { 632 | found: er.into_bytes(), 633 | intent: String::from("header card comment"), 634 | } 635 | })?; 636 | let value = Rc::new(value_string); 637 | let ret = Rc::clone(&value); 638 | self.comment = Some(value); 639 | 640 | Ok(ret) 641 | } else { 642 | Ok(Default::default()) 643 | } 644 | } 645 | 646 | /// Sets the comment section of the header card. 647 | pub fn set_comment(&mut self, comment: Option) -> Result<(), FitsHeaderError> { 648 | let value_raw = match (self.value.as_ref(), self.comment.as_ref()) { 649 | (Some(value), Some(_comment)) => value.to_bytes(), 650 | (Some(value), None) => { 651 | self.raw.clear(); 652 | value.to_bytes() 653 | } 654 | (None, Some(_comment)) => { 655 | let mut value_raw = [b' '; 70]; 656 | let idx_diff = if self.raw.len() > 70 { 657 | self.raw.len() - 70 658 | } else { 659 | 0 660 | }; 661 | value_raw[0..(self.raw.len() - idx_diff)].copy_from_slice(&self.raw[idx_diff..]); 662 | value_raw 663 | } 664 | (None, None) => { 665 | self.get_comment()?; 666 | let mut value_raw = [b' '; 70]; 667 | let idx_diff = if self.raw.len() > 70 { 668 | self.raw.len() - 70 669 | } else { 670 | 0 671 | }; 672 | value_raw[0..(self.raw.len() - idx_diff)].copy_from_slice(&self.raw[idx_diff..]); 673 | value_raw 674 | } 675 | }; 676 | Self::check_comment_length(value_raw, comment.as_ref())?; 677 | self.comment = comment.map(Rc::new); 678 | Ok(()) 679 | } 680 | 681 | fn check_comment_length( 682 | value_raw: [u8; 70], 683 | comment: Option<&String>, 684 | ) -> Result<(), FitsHeaderError> { 685 | if let Some(comment_str) = comment { 686 | let comment_start = value_raw 687 | .iter() 688 | .rposition(|b| *b != b' ') 689 | .unwrap_or_default(); 690 | let diff = 68_usize.checked_sub(comment_start).unwrap_or_default(); // minus an additional 2 for the delimiter 691 | if diff < comment_str.len() { 692 | return Err(FitsHeaderError::InvalidLength { 693 | expected: diff, 694 | found: comment_str.len(), 695 | intent: String::from("header card comment"), 696 | }); 697 | } 698 | } 699 | Ok(()) 700 | } 701 | 702 | fn trim_value(value: Vec) -> Vec { 703 | value 704 | .iter() 705 | .position(|b| *b != b' ') 706 | .map(|index1| { 707 | let index2 = value 708 | .iter() 709 | .rposition(|b| *b != b' ') 710 | .unwrap_or(value.len()) 711 | + 1; 712 | value[index1..index2].to_vec() 713 | }) 714 | .unwrap_or_default() 715 | } 716 | } 717 | 718 | impl From<[u8; 72]> for FitsHeaderValueContainer { 719 | fn from(raw: [u8; 72]) -> Self { 720 | FitsHeaderValueContainer { 721 | raw: raw.to_vec(), 722 | value: None, 723 | comment: None, 724 | } 725 | } 726 | } 727 | 728 | impl From for [u8; 72] { 729 | fn from(container: FitsHeaderValueContainer) -> Self { 730 | match (container.value, container.comment) { 731 | (Some(value), Some(comment)) => { 732 | let mut result = [b' '; 72]; 733 | result[0] = b'='; 734 | result[2..72].copy_from_slice(&value.to_bytes()); 735 | let mut comment_start = 736 | result.iter().rposition(|b| *b != b' ').unwrap_or_default() + 2; 737 | result[comment_start] = b'/'; 738 | comment_start += 2; 739 | let comment_raw = comment.as_bytes(); 740 | result[comment_start..comment_start + comment_raw.len()] 741 | .copy_from_slice(comment_raw); 742 | result 743 | } 744 | (Some(value), None) => { 745 | let mut result = [b' '; 72]; 746 | result[0] = b'='; 747 | result[2..72].copy_from_slice(&value.to_bytes()); 748 | let comment_start = result.iter().rposition(|b| *b != b' ').unwrap_or_default() + 2; 749 | let comment_raw = container.raw.as_slice(); 750 | result[comment_start..comment_start + comment_raw.len()] 751 | .copy_from_slice(comment_raw); 752 | result 753 | } 754 | (None, Some(comment)) => { 755 | let mut result = [b' '; 72]; 756 | let value_raw = container.raw.as_slice(); 757 | let mut comment_start = value_raw.len(); 758 | result[0..comment_start].copy_from_slice(value_raw); 759 | comment_start += 1; 760 | result[comment_start] = b'/'; 761 | comment_start += 2; 762 | let comment_raw = comment.as_bytes(); 763 | result[comment_start..comment_start + comment_raw.len()] 764 | .copy_from_slice(comment_raw); 765 | result 766 | } 767 | (None, None) => { 768 | let result: [u8; 72] = container.raw[0..72].try_into().unwrap(); 769 | result 770 | } 771 | } 772 | } 773 | } 774 | -------------------------------------------------------------------------------- /src/fits/header_value/bitpix.rs: -------------------------------------------------------------------------------- 1 | //! Defines the BITPIX header value. 2 | 3 | use crate::fits::FitsHeaderError; 4 | 5 | use super::FitsHeaderValue; 6 | 7 | /// An enumeration of valid values corresponding to the BITPIX keyword. 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 9 | pub enum Bitpix { 10 | /// Indicates each data element is an unsigned 8 bit integer value. 11 | U8, 12 | /// Indicates each data element is a signed 16 bit integer value. 13 | I16, 14 | /// Indicates each data element is a signed 32 bit integer value. 15 | I32, 16 | /// Indicates each data element is a signed 32 bit float value. 17 | F32, 18 | /// Indicates each data element is a signed 64 bit float value. 19 | F64, 20 | } 21 | 22 | impl Bitpix { 23 | /// Gets the number of bits that represent a value in the data section of the HDU. 24 | pub fn value(&self) -> usize { 25 | match self { 26 | Bitpix::U8 => 8, 27 | Bitpix::I16 => 16, 28 | Bitpix::I32 => 32, 29 | Bitpix::F32 => 32, 30 | Bitpix::F64 => 64, 31 | } 32 | } 33 | } 34 | 35 | impl FitsHeaderValue for Bitpix { 36 | fn from_bytes(raw: Vec) -> Result { 37 | match raw.as_slice() { 38 | b"8" => Ok(Bitpix::U8), 39 | b"16" => Ok(Bitpix::I16), 40 | b"32" => Ok(Bitpix::I32), 41 | b"-32" => Ok(Bitpix::F32), 42 | b"-64" => Ok(Bitpix::F64), 43 | _ => Err(FitsHeaderError::DeserializationError { 44 | found: raw, 45 | intent: String::from("header card bitpix value"), 46 | }), 47 | } 48 | } 49 | 50 | fn to_bytes(&self) -> [u8; 70] { 51 | let mut result = [b' '; 70]; 52 | match *self { 53 | Bitpix::U8 => result[19] = b'8', 54 | Bitpix::I16 => { 55 | let value_raw = b"16"; 56 | let start = 20 - value_raw.len(); 57 | for (i, b) in value_raw.iter().enumerate() { 58 | result[start + i] = *b; 59 | } 60 | } 61 | Bitpix::I32 => { 62 | let value_raw = b"32"; 63 | let start = 20 - value_raw.len(); 64 | for (i, b) in value_raw.iter().enumerate() { 65 | result[start + i] = *b; 66 | } 67 | } 68 | Bitpix::F32 => { 69 | let value_raw = b"-32"; 70 | let start = 20 - value_raw.len(); 71 | for (i, b) in value_raw.iter().enumerate() { 72 | result[start + i] = *b; 73 | } 74 | } 75 | Bitpix::F64 => { 76 | let value_raw = b"-64"; 77 | let start = 20 - value_raw.len(); 78 | for (i, b) in value_raw.iter().enumerate() { 79 | result[start + i] = *b; 80 | } 81 | } 82 | } 83 | result 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/fits/header_value/mod.rs: -------------------------------------------------------------------------------- 1 | mod bitpix; 2 | mod tform; 3 | 4 | pub use bitpix::*; 5 | pub use tform::*; 6 | 7 | use std::any::{Any, TypeId}; 8 | use std::fmt::Debug; 9 | 10 | use super::header::FitsHeaderError; 11 | 12 | /// A trait that allows data to be serialized/deserialized as a FITS header value. 13 | pub trait FitsHeaderValue: Debug + RealAny { 14 | /// Attempts to deserialize a value from the given bytes. The given bytes shall not be padded by spaces. 15 | fn from_bytes(raw: Vec) -> Result 16 | where 17 | Self: Sized; 18 | 19 | /// Serializes the value to bytes. The bytes shall include padding spaces. 20 | fn to_bytes(&self) -> [u8; 70]; 21 | } 22 | 23 | // credit to https://github.com/chris-morgan/mopa for this solution 24 | impl dyn FitsHeaderValue { 25 | /// Determines if the type of `self` is the same as `T`. 26 | pub fn is(&self) -> bool { 27 | TypeId::of::() == RealAny::real_type_id(self) 28 | } 29 | } 30 | 31 | /// A trait used to get the real type ID for implementors of `FitsHeaderValue`. 32 | pub trait RealAny { 33 | /// Gets the base type ID for `self`. 34 | fn real_type_id(&self) -> TypeId; 35 | } 36 | 37 | impl RealAny for T { 38 | fn real_type_id(&self) -> TypeId { 39 | TypeId::of::() 40 | } 41 | } 42 | 43 | /// ``` 44 | /// use astro_rs::fits::FitsHeaderError; 45 | /// use astro_rs::fits::FitsHeaderValue; 46 | /// 47 | /// // successful deserialization 48 | /// let true_value: bool = FitsHeaderValue::from_bytes(b"T".to_vec())?; 49 | /// assert!(true_value); 50 | /// let false_value: bool = FitsHeaderValue::from_bytes(b"F".to_vec())?; 51 | /// assert!(!false_value); 52 | /// 53 | /// // failed deserialization 54 | /// let result: Result = FitsHeaderValue::from_bytes(b"A".to_vec()); 55 | /// assert!(result.is_err()); 56 | /// let result: Result = FitsHeaderValue::from_bytes(b"true".to_vec()); 57 | /// assert!(result.is_err()); 58 | /// 59 | /// // serialization 60 | /// assert_eq!(true_value.to_bytes(), *b" T "); 61 | /// assert_eq!(false_value.to_bytes(), *b" F "); 62 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 63 | /// ``` 64 | impl FitsHeaderValue for bool { 65 | fn from_bytes(raw: Vec) -> Result { 66 | if raw.len() == 1 { 67 | match *raw.first().unwrap() { 68 | b'T' => Ok(true), 69 | b'F' => Ok(false), 70 | _ => Err(FitsHeaderError::DeserializationError { 71 | found: raw, 72 | intent: String::from("header card bool value"), 73 | }), 74 | } 75 | } else { 76 | Err(FitsHeaderError::InvalidLength { 77 | expected: 1, 78 | found: raw.len(), 79 | intent: String::from("header card u8 value"), 80 | }) 81 | } 82 | } 83 | 84 | fn to_bytes(&self) -> [u8; 70] { 85 | let mut result = [b' '; 70]; 86 | if *self { 87 | result[19] = b'T'; 88 | } else { 89 | result[19] = b'F'; 90 | } 91 | result 92 | } 93 | } 94 | 95 | /// ``` 96 | /// use astro_rs::fits::FitsHeaderError; 97 | /// use astro_rs::fits::FitsHeaderValue; 98 | /// 99 | /// // successful deserialization 100 | /// let max_value: u8 = FitsHeaderValue::from_bytes(b"255".to_vec())?; 101 | /// assert_eq!(max_value, 255); 102 | /// let min_value: u8 = FitsHeaderValue::from_bytes(b"0".to_vec())?; 103 | /// assert_eq!(min_value, 0); 104 | /// 105 | /// // failed deserialization 106 | /// let result: Result = FitsHeaderValue::from_bytes(b"300".to_vec()); 107 | /// assert!(result.is_err()); 108 | /// let result: Result = FitsHeaderValue::from_bytes(b"Not a number".to_vec()); 109 | /// assert!(result.is_err()); 110 | /// 111 | /// // serialization 112 | /// assert_eq!(max_value.to_bytes(), *b" 255 "); 113 | /// assert_eq!(min_value.to_bytes(), *b" 0 "); 114 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 115 | /// ``` 116 | impl FitsHeaderValue for u8 { 117 | fn from_bytes(raw: Vec) -> Result { 118 | let value_string = 119 | String::from_utf8(raw).map_err(|er| FitsHeaderError::DeserializationError { 120 | found: er.into_bytes(), 121 | intent: String::from("header card u8 value"), 122 | })?; 123 | value_string 124 | .parse() 125 | .map_err(|_| FitsHeaderError::DeserializationError { 126 | found: value_string.into_bytes(), 127 | intent: String::from("header card u8 value"), 128 | }) 129 | } 130 | 131 | fn to_bytes(&self) -> [u8; 70] { 132 | let mut result = [b' '; 70]; 133 | let value_raw = self.to_string().into_bytes(); 134 | let start = 20 - value_raw.len(); 135 | for (i, b) in value_raw.iter().enumerate() { 136 | result[start + i] = *b; 137 | } 138 | result 139 | } 140 | } 141 | 142 | /// ``` 143 | /// use astro_rs::fits::FitsHeaderError; 144 | /// use astro_rs::fits::FitsHeaderValue; 145 | /// 146 | /// // successful deserialization 147 | /// let max_value: u16 = FitsHeaderValue::from_bytes(b"65535".to_vec())?; 148 | /// assert_eq!(max_value, 65535); 149 | /// let min_value: u16 = FitsHeaderValue::from_bytes(b"0".to_vec())?; 150 | /// assert_eq!(min_value, 0); 151 | /// 152 | /// // failed deserialization 153 | /// let result: Result = FitsHeaderValue::from_bytes(b"66000".to_vec()); 154 | /// assert!(result.is_err()); 155 | /// let result: Result = FitsHeaderValue::from_bytes(b"Not a number".to_vec()); 156 | /// assert!(result.is_err()); 157 | /// 158 | /// // serialization 159 | /// assert_eq!(max_value.to_bytes(), *b" 65535 "); 160 | /// assert_eq!(min_value.to_bytes(), *b" 0 "); 161 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 162 | /// ``` 163 | impl FitsHeaderValue for u16 { 164 | fn from_bytes(raw: Vec) -> Result { 165 | let value_string = 166 | String::from_utf8(raw).map_err(|er| FitsHeaderError::DeserializationError { 167 | found: er.into_bytes(), 168 | intent: String::from("header card u16 value"), 169 | })?; 170 | value_string 171 | .parse() 172 | .map_err(|_| FitsHeaderError::DeserializationError { 173 | found: value_string.into_bytes(), 174 | intent: String::from("header card u16 value"), 175 | }) 176 | } 177 | 178 | fn to_bytes(&self) -> [u8; 70] { 179 | let mut result = [b' '; 70]; 180 | let value_raw = self.to_string().into_bytes(); 181 | let start = 20 - value_raw.len(); 182 | for (i, b) in value_raw.iter().enumerate() { 183 | result[start + i] = *b; 184 | } 185 | result 186 | } 187 | } 188 | 189 | /// ``` 190 | /// use astro_rs::fits::FitsHeaderError; 191 | /// use astro_rs::fits::FitsHeaderValue; 192 | /// 193 | /// // successful deserialization 194 | /// let max_value: u32 = FitsHeaderValue::from_bytes(b"4294967295".to_vec())?; 195 | /// assert_eq!(max_value, 4294967295); 196 | /// let min_value: u32 = FitsHeaderValue::from_bytes(b"0".to_vec())?; 197 | /// assert_eq!(min_value, 0); 198 | /// 199 | /// // failed deserialization 200 | /// let result: Result = FitsHeaderValue::from_bytes(b"4300000000".to_vec()); 201 | /// assert!(result.is_err()); 202 | /// let result: Result = FitsHeaderValue::from_bytes(b"Not a number".to_vec()); 203 | /// assert!(result.is_err()); 204 | /// 205 | /// // serialization 206 | /// assert_eq!(max_value.to_bytes(), *b" 4294967295 "); 207 | /// assert_eq!(min_value.to_bytes(), *b" 0 "); 208 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 209 | /// ``` 210 | impl FitsHeaderValue for u32 { 211 | fn from_bytes(raw: Vec) -> Result { 212 | let value_string = 213 | String::from_utf8(raw).map_err(|er| FitsHeaderError::DeserializationError { 214 | found: er.into_bytes(), 215 | intent: String::from("header card u32 value"), 216 | })?; 217 | value_string 218 | .parse() 219 | .map_err(|_| FitsHeaderError::DeserializationError { 220 | found: value_string.into_bytes(), 221 | intent: String::from("header card u32 value"), 222 | }) 223 | } 224 | 225 | fn to_bytes(&self) -> [u8; 70] { 226 | let mut result = [b' '; 70]; 227 | let value_raw = self.to_string().into_bytes(); 228 | let start = 20 - value_raw.len(); 229 | for (i, b) in value_raw.iter().enumerate() { 230 | result[start + i] = *b; 231 | } 232 | result 233 | } 234 | } 235 | 236 | /// ``` 237 | /// use astro_rs::fits::FitsHeaderError; 238 | /// use astro_rs::fits::FitsHeaderValue; 239 | /// 240 | /// // successful deserialization 241 | /// let value: String = FitsHeaderValue::from_bytes(String::from("hello world").into_bytes())?; 242 | /// assert_eq!(value, String::from("hello world")); 243 | /// let quote_value: String = FitsHeaderValue::from_bytes(String::from("'this ''includes'' quotes'").into_bytes())?; 244 | /// assert_eq!(quote_value, String::from("this 'includes' quotes")); 245 | /// 246 | /// // failed deserialization 247 | /// let result: Result = FitsHeaderValue::from_bytes(vec![0, 159, 146, 150]); 248 | /// assert!(result.is_err()); 249 | /// 250 | /// // serialization 251 | /// assert_eq!(value.to_bytes(), *b"'hello world' "); 252 | /// assert_eq!(quote_value.to_bytes(), *b"'this ''includes'' quotes' "); 253 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 254 | /// ``` 255 | impl FitsHeaderValue for String { 256 | fn from_bytes(mut raw: Vec) -> Result { 257 | let mut remove_quote = true; 258 | let mut i = 0; 259 | while i < raw.len() { 260 | if raw[i] == b'\'' { 261 | if remove_quote { 262 | raw.remove(i); 263 | } else { 264 | i += 1; 265 | } 266 | remove_quote = false; 267 | } else { 268 | remove_quote = true; 269 | i += 1; 270 | } 271 | } 272 | let value = String::from_utf8(raw).map_err(|er| FitsHeaderError::DeserializationError { 273 | found: er.into_bytes(), 274 | intent: String::from("header card String value"), 275 | })?; 276 | Ok(value.trim().to_owned()) 277 | } 278 | 279 | fn to_bytes(&self) -> [u8; 70] { 280 | let mut result = [b' '; 70]; 281 | result[0] = b'\''; 282 | let mut num_quotes = 1; 283 | let value_raw = self.as_bytes(); 284 | for (i, b) in value_raw.iter().enumerate() { 285 | if *b == b'\'' { 286 | result[i + num_quotes] = b'\''; 287 | num_quotes += 1; 288 | } 289 | result[i + num_quotes] = *b; 290 | } 291 | result[value_raw.len() + num_quotes] = b'\''; 292 | result 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/fits/header_value/tform.rs: -------------------------------------------------------------------------------- 1 | //! Defines the TFORM header value. 2 | 3 | use crate::fits::hdu_macros::return_box; 4 | use crate::fits::FitsHeaderError; 5 | 6 | use super::FitsHeaderValue; 7 | 8 | /// An enumeration of valid types corresponding to the TFORM keyword. 9 | #[allow(missing_docs)] 10 | #[derive(Debug, Clone, Copy)] 11 | pub enum TFormType { 12 | Logical, 13 | Bit, 14 | UnsignedByte, 15 | I16, 16 | I32, 17 | Character, 18 | F32, 19 | F64, 20 | C64, 21 | C128, 22 | ArrayDescriptor, 23 | } 24 | 25 | impl TryFrom for TFormType { 26 | type Error = FitsHeaderError; 27 | 28 | fn try_from(value: char) -> Result { 29 | match value { 30 | 'L' => Ok(TFormType::Logical), 31 | 'X' => Ok(TFormType::Bit), 32 | 'B' => Ok(TFormType::UnsignedByte), 33 | 'I' => Ok(TFormType::I16), 34 | 'J' => Ok(TFormType::I32), 35 | 'A' => Ok(TFormType::Character), 36 | 'E' => Ok(TFormType::F32), 37 | 'D' => Ok(TFormType::F64), 38 | 'C' => Ok(TFormType::C64), 39 | 'M' => Ok(TFormType::C128), 40 | 'P' => Ok(TFormType::ArrayDescriptor), 41 | _ => Err(FitsHeaderError::DeserializationError { 42 | found: vec![value as u8], 43 | intent: String::from("header card TFORM type value"), 44 | }), 45 | } 46 | } 47 | } 48 | 49 | /// A value corresponding to the TFORM keyword. 50 | #[derive(Debug, Clone)] 51 | pub struct TForm { 52 | /// The repeat count 53 | pub r: usize, 54 | /// The field type 55 | pub t: TFormType, 56 | /// Undefined additional characters 57 | pub a: String, 58 | } 59 | 60 | impl TForm { 61 | /// Gets the number of bytes required by the column. 62 | pub fn value(&self) -> usize { 63 | let type_bytes = match self.t { 64 | TFormType::Logical => 1, 65 | TFormType::Bit => todo!(), 66 | TFormType::UnsignedByte => 1, 67 | TFormType::I16 => 2, 68 | TFormType::I32 => 4, 69 | TFormType::Character => 1, 70 | TFormType::F32 => 4, 71 | TFormType::F64 => 8, 72 | TFormType::C64 => 8, 73 | TFormType::C128 => 16, 74 | TFormType::ArrayDescriptor => 8, 75 | }; 76 | self.r * type_bytes 77 | } 78 | 79 | /// Creates a column of values from the given data. 80 | pub fn create_column( 81 | &self, 82 | data: &[u8], 83 | column_start: usize, 84 | row_len: usize, 85 | num_rows: usize, 86 | ) -> Box> { 87 | let column_len = self.value(); 88 | unsafe { 89 | match self.t { 90 | TFormType::Logical => { 91 | let mut result = Vec::with_capacity(num_rows * self.r); 92 | for i in 0..num_rows { 93 | let start = row_len * i + column_start; 94 | let column = data[start..start + column_len].to_vec(); 95 | for value in column.iter().take(self.r) { 96 | result.push(*value != 0); 97 | } 98 | } 99 | 100 | return_box!(result) 101 | } 102 | TFormType::Bit => todo!(), 103 | TFormType::UnsignedByte => { 104 | let mut result = Vec::with_capacity(num_rows * self.r); 105 | for i in 0..num_rows { 106 | let start = row_len * i + column_start; 107 | let mut column = data[start..start + column_len].to_vec(); 108 | result.append(&mut column); 109 | } 110 | 111 | return_box!(result) 112 | } 113 | TFormType::I16 => { 114 | tform_macros::deserialize_column!( 115 | i16, 116 | num_rows, 117 | row_len, 118 | column_start, 119 | column_len, 120 | self.r, 121 | data, 122 | ) 123 | } 124 | TFormType::I32 => { 125 | tform_macros::deserialize_column!( 126 | i32, 127 | num_rows, 128 | row_len, 129 | column_start, 130 | column_len, 131 | self.r, 132 | data, 133 | ) 134 | } 135 | TFormType::Character => { 136 | unsafe fn deserialize_char(value: [u8; 4]) -> char { 137 | char::from_u32_unchecked(u32::from_be_bytes(value)) 138 | } 139 | tform_macros::deserialize_column!( 140 | char, 141 | num_rows, 142 | row_len, 143 | column_start, 144 | column_len, 145 | self.r, 146 | data, 147 | deserialize_char, 148 | ) 149 | } 150 | TFormType::F32 => { 151 | tform_macros::deserialize_column!( 152 | f32, 153 | num_rows, 154 | row_len, 155 | column_start, 156 | column_len, 157 | self.r, 158 | data, 159 | ) 160 | } 161 | TFormType::F64 => { 162 | tform_macros::deserialize_column!( 163 | f64, 164 | num_rows, 165 | row_len, 166 | column_start, 167 | column_len, 168 | self.r, 169 | data, 170 | ) 171 | } 172 | TFormType::C64 => todo!(), 173 | TFormType::C128 => todo!(), 174 | TFormType::ArrayDescriptor => todo!(), 175 | } 176 | } 177 | } 178 | } 179 | 180 | #[macro_use] 181 | mod tform_macros { 182 | /// Creates a boxed vector deserialized with the given function, or a default function if one is not given. 183 | macro_rules! deserialize_column { 184 | (@dfn $value_type: ty) => {{ 185 | <$value_type>::from_be_bytes 186 | }}; 187 | ($value_type: ty, $num_rows: expr, $row_len: expr, $column_start: expr, $column_len: expr, $repeats: expr, $data: expr,) => {{ 188 | let deserialize_fn = $crate::fits::header_value::tform::tform_macros::deserialize_column!(@dfn $value_type); 189 | $crate::fits::header_value::tform::tform_macros::deserialize_column!( 190 | $value_type, 191 | $num_rows, 192 | $row_len, 193 | $column_start, 194 | $column_len, 195 | $repeats, 196 | $data, 197 | deserialize_fn, 198 | ) 199 | }}; 200 | ($value_type: ty, $num_rows: expr, $row_len: expr, $column_start: expr, $column_len: expr, $repeats: expr, $data: expr, $deserialize_fn: tt,) => {{ 201 | let mut result = Vec::with_capacity($num_rows * $repeats); 202 | for i in 0..$num_rows { 203 | let start = $row_len * i + $column_start; 204 | let column = $data[start..start + $column_len].to_vec(); 205 | let value_size = std::mem::size_of::<$value_type>(); 206 | for repeat in 0..$repeats { 207 | let value_start = repeat * value_size; 208 | let raw_value = column[value_start..value_start + value_size] 209 | .try_into() 210 | .unwrap(); 211 | result.push($deserialize_fn(raw_value)); 212 | } 213 | } 214 | 215 | $crate::fits::hdu_macros::return_box!(result) 216 | }}; 217 | } 218 | 219 | pub(crate) use deserialize_column; 220 | } 221 | 222 | /// ``` 223 | /// use astro_rs::fits::*; 224 | /// 225 | /// // successful deserialization 226 | /// let double_value: TForm = FitsHeaderValue::from_bytes(b"'1D '".to_vec())?; 227 | /// assert_eq!(double_value.value(), 8); 228 | /// let repeat_int_value: TForm = FitsHeaderValue::from_bytes(b"'2I '".to_vec())?; 229 | /// assert_eq!(repeat_int_value.value(), 4); 230 | /// let comment_char_value: TForm = FitsHeaderValue::from_bytes(b"'1A comment'".to_vec())?; 231 | /// assert_eq!(comment_char_value.value(), 1); 232 | /// let short_complex_value: TForm = FitsHeaderValue::from_bytes(b"'M '".to_vec())?; 233 | /// assert_eq!(short_complex_value.value(), 16); 234 | /// 235 | /// // failed deserialization 236 | /// let result: Result = FitsHeaderValue::from_bytes(b"U".to_vec()); 237 | /// assert!(result.is_err()); 238 | /// let result: Result = FitsHeaderValue::from_bytes(b"nonsense".to_vec()); 239 | /// assert!(result.is_err()); 240 | /// 241 | /// // serialization 242 | /// assert_eq!(double_value.to_bytes(), *b"'1D ' "); 243 | /// assert_eq!(repeat_int_value.to_bytes(), *b"'2I ' "); 244 | /// assert_eq!(comment_char_value.to_bytes(), *b"'1A comment' "); 245 | /// assert_eq!(short_complex_value.to_bytes(), *b"'1M ' "); 246 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 247 | /// ``` 248 | impl FitsHeaderValue for TForm { 249 | fn from_bytes(raw: Vec) -> Result { 250 | let mut repeats = String::new(); 251 | let mut ttype = None; 252 | let mut i = 1; 253 | while i < raw.len() - 1 { 254 | let ch = raw[i] as char; 255 | if ch.is_ascii_digit() { 256 | repeats.push(ch); 257 | i += 1; 258 | } else { 259 | ttype = Some(TFormType::try_from(ch)?); 260 | i += 1; 261 | break; 262 | } 263 | } 264 | let r = repeats.parse::().unwrap_or(1) as usize; 265 | if let Some(t) = ttype { 266 | if let Ok(a) = String::from_utf8(raw[i..raw.len() - 1].to_vec()) { 267 | return Ok(TForm { 268 | r, 269 | t, 270 | a: a.trim_end().to_owned(), 271 | }); 272 | } 273 | } 274 | Err(FitsHeaderError::DeserializationError { 275 | found: raw, 276 | intent: String::from("header card TFORM value"), 277 | }) 278 | } 279 | 280 | fn to_bytes(&self) -> [u8; 70] { 281 | let mut result = [b' '; 70]; 282 | let mut i = 0; 283 | result[i] = b'\''; 284 | i += 1; 285 | let repeats = self.r.to_string(); 286 | for b in repeats.bytes() { 287 | result[i] = b; 288 | i += 1; 289 | } 290 | match self.t { 291 | TFormType::Logical => result[i] = b'L', 292 | TFormType::Bit => result[i] = b'X', 293 | TFormType::UnsignedByte => result[i] = b'B', 294 | TFormType::I16 => result[i] = b'I', 295 | TFormType::I32 => result[i] = b'J', 296 | TFormType::Character => result[i] = b'A', 297 | TFormType::F32 => result[i] = b'E', 298 | TFormType::F64 => result[i] = b'D', 299 | TFormType::C64 => result[i] = b'C', 300 | TFormType::C128 => result[i] = b'M', 301 | TFormType::ArrayDescriptor => result[i] = b'P', 302 | } 303 | i += 1; 304 | for b in self.a.bytes() { 305 | result[i] = b; 306 | i += 1; 307 | } 308 | if i < 9 { 309 | i = 9; 310 | } 311 | result[i] = b'\''; 312 | result 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/fits/mod.rs: -------------------------------------------------------------------------------- 1 | //! Serialize and deserialize FITS data. 2 | //! See for the FITS API. 3 | 4 | mod hdu_types; 5 | mod header; 6 | mod header_value; 7 | 8 | use std::fmt::Debug; 9 | use std::io::{BufReader, BufWriter, Cursor, Read, Write}; 10 | use std::slice::IterMut; 11 | 12 | pub use hdu_types::*; 13 | pub use header::*; 14 | pub use header_value::*; 15 | 16 | /// The expected keyword for the name of an extension. 17 | pub const EXTNAME_KEYWORD: [u8; 8] = *b"EXTNAME "; 18 | const BLANK_KEYWORD: [u8; 8] = *b" "; 19 | 20 | /// A representation of the entirety of a FITS file. 21 | #[derive(Debug)] 22 | pub struct HduList { 23 | reader: BufReader, 24 | hdus: Vec, 25 | } 26 | 27 | impl Default for HduList>> { 28 | fn default() -> Self { 29 | Self { 30 | reader: BufReader::new(Cursor::new(Vec::new())), 31 | hdus: Default::default(), 32 | } 33 | } 34 | } 35 | 36 | impl HduList { 37 | /// Constructs an empty HduList. 38 | pub fn new(reader: BufReader) -> Self { 39 | HduList { 40 | reader, 41 | hdus: Vec::new(), 42 | } 43 | } 44 | 45 | /// Retrieves the HDU at the given index, or None if an HDU doesn't exist at the index. 46 | /// 47 | /// # Examples 48 | /// 49 | /// ``` 50 | /// use astro_rs::fits::*; 51 | /// 52 | /// let mut hdu_list = HduList::default(); 53 | /// assert!(hdu_list.get_by_index(0).is_none()); 54 | /// 55 | /// hdu_list.push(primary_hdu::default()); 56 | /// assert!(hdu_list.get_by_index(0).is_some()); 57 | /// ``` 58 | pub fn get_by_index(&mut self, index: usize) -> Option<&mut Hdu> { 59 | let mut cur_hdus = self.hdus.len(); 60 | while cur_hdus <= index { 61 | let new_hdu = self.read_hdu()?; 62 | self.hdus.push(new_hdu); 63 | cur_hdus += 1; 64 | } 65 | Some(&mut self.hdus[index]) 66 | } 67 | 68 | /// Retrieves the HDU with the given value for the `EXTNAME` keyword, or None if an HDU 69 | /// with the given name doesn't exist. 70 | /// 71 | /// # Examples 72 | /// 73 | /// ``` 74 | /// use astro_rs::fits::*; 75 | /// 76 | /// let mut hdu_list = HduList::default(); 77 | /// // empty list 78 | /// assert!(hdu_list.get_by_name("hdu_name").is_none()); 79 | /// 80 | /// // name does not match 81 | /// let mut img_hdu = image_hdu::default(); 82 | /// let name_card = FitsHeaderCard::from(*b"EXTNAME = 'name_of_hdu' "); 83 | /// img_hdu.header.cards.insert(img_hdu.header.cards.len() - 1, name_card); 84 | /// hdu_list.push(img_hdu); 85 | /// assert!(hdu_list.get_by_name("hdu_name").is_none()); 86 | /// 87 | /// // name matches 88 | /// let mut img_hdu = image_hdu::default(); 89 | /// let name_card = FitsHeaderCard::from(*b"EXTNAME = 'hdu_name' "); 90 | /// img_hdu.header.cards.insert(img_hdu.header.cards.len() - 1, name_card); 91 | /// hdu_list.push(img_hdu); 92 | /// assert!(hdu_list.get_by_name("hdu_name").is_some()); 93 | /// ``` 94 | pub fn get_by_name(&mut self, name: &str) -> Option<&mut Hdu> { 95 | let index = self 96 | .hdus 97 | .iter_mut() 98 | .position(|hdu| hdu.get_name() == name) 99 | .or_else(|| { 100 | let mut index = self.hdus.len(); 101 | loop { 102 | let mut new_hdu = self.read_hdu()?; 103 | if new_hdu.get_name() == name { 104 | self.hdus.push(new_hdu); 105 | break; 106 | } 107 | 108 | self.hdus.push(new_hdu); 109 | index += 1; 110 | } 111 | Some(index) 112 | }); 113 | 114 | Some(&mut self.hdus[index?]) 115 | } 116 | 117 | /// Returns a mutable pointer to the first HDU, or `None` if the list is empty. 118 | /// 119 | /// # Examples 120 | /// 121 | /// ``` 122 | /// use astro_rs::fits::*; 123 | /// 124 | /// let mut hdu_list = HduList::default(); 125 | /// assert!(hdu_list.first_mut().is_none()); 126 | /// 127 | /// hdu_list.push(primary_hdu::default()); 128 | /// assert!(hdu_list.first_mut().is_some()); 129 | /// ``` 130 | pub fn first_mut(&mut self) -> Option<&mut Hdu> { 131 | if self.hdus.is_empty() { 132 | let new_hdu = self.read_hdu()?; 133 | self.hdus.push(new_hdu); 134 | } 135 | Some(&mut self.hdus[0]) 136 | } 137 | 138 | /// Deserializes all HDUs if necessary, then returns a mutable iterator over the HDUs. 139 | /// 140 | /// # Examples 141 | /// 142 | /// ``` 143 | /// use astro_rs::fits::*; 144 | /// 145 | /// let mut hdu_list = HduList::default(); 146 | /// hdu_list.push(primary_hdu::default()); 147 | /// 148 | /// // find the primary HDU 149 | /// assert!(hdu_list 150 | /// .iter_mut() 151 | /// .find_map(|hdu| if hdu.header.get_card(SIMPLE_KEYWORD).is_some() { 152 | /// Some(hdu) 153 | /// } else { 154 | /// None 155 | /// }) 156 | /// .is_some()) 157 | /// ``` 158 | pub fn iter_mut(&mut self) -> IterMut { 159 | while let Some(new_hdu) = self.read_hdu() { 160 | self.hdus.push(new_hdu); 161 | } 162 | self.hdus.iter_mut() 163 | } 164 | 165 | /// Deserializes all HDUs up to `index` if necessary, then inserts the given `hdu`. 166 | /// 167 | /// # Panics 168 | /// 169 | /// Panics if `index` is out of bounds. 170 | /// 171 | /// # Examples 172 | /// 173 | /// ```should_panic 174 | /// use astro_rs::fits::*; 175 | /// 176 | /// let mut hdu_list = HduList::default(); 177 | /// // panics, index is out of bounds 178 | /// hdu_list.insert(1, image_hdu::default()); 179 | /// ``` 180 | /// 181 | /// ``` 182 | /// use astro_rs::fits::*; 183 | /// 184 | /// let mut hdu_list = HduList::default(); 185 | /// hdu_list.push(primary_hdu::default()); 186 | /// hdu_list.insert(1, image_hdu::default()); 187 | /// assert_eq!(hdu_list.iter_mut().count(), 2); 188 | /// ``` 189 | pub fn insert(&mut self, index: usize, hdu: Hdu) { 190 | let mut cur_hdus = self.hdus.len(); 191 | while cur_hdus < index { 192 | if let Some(new_hdu) = self.read_hdu() { 193 | self.hdus.push(new_hdu); 194 | cur_hdus += 1; 195 | } else { 196 | panic!("{} is out of bounds (max {})", index, cur_hdus); 197 | } 198 | } 199 | self.hdus.insert(index, hdu); 200 | } 201 | 202 | /// Appends `hdu` to the end of the HDU list. 203 | /// 204 | /// # Examples 205 | /// 206 | /// ``` 207 | /// use astro_rs::fits::*; 208 | /// 209 | /// let mut hdu_list = HduList::default(); 210 | /// hdu_list.push(primary_hdu::default()); 211 | /// assert_eq!(hdu_list.iter_mut().count(), 1); 212 | /// hdu_list.push(image_hdu::default()); 213 | /// assert_eq!(hdu_list.iter_mut().count(), 2); 214 | /// ``` 215 | pub fn push(&mut self, hdu: Hdu) { 216 | while let Some(new_hdu) = self.read_hdu() { 217 | self.hdus.push(new_hdu); 218 | } 219 | self.hdus.push(hdu); 220 | } 221 | 222 | /// Writes the HDU list via the given writer. 223 | /// 224 | /// # Examples 225 | /// 226 | /// ``` 227 | /// use astro_rs::fits::*; 228 | /// use std::io::*; 229 | /// 230 | /// let in_cursor = Cursor::new(SIMPLE_KEYWORD.to_vec()); 231 | /// let mut hdu_list = HduList::new(BufReader::new(in_cursor)); 232 | /// let out_cursor = Cursor::new(Vec::new()); 233 | /// let mut out_writer = BufWriter::new(out_cursor); 234 | /// hdu_list.write(&mut out_writer)?; 235 | /// assert_eq!(out_writer.get_ref().get_ref(), &SIMPLE_KEYWORD.to_vec()); 236 | /// 237 | /// # Ok::<(), std::io::Error>(()) 238 | /// ``` 239 | pub fn write(&mut self, writer: &mut BufWriter) -> Result<(), std::io::Error> { 240 | for hdu in &self.hdus { 241 | writer.write_all(&hdu.clone().to_bytes())?; 242 | } 243 | std::io::copy(&mut self.reader, writer)?; 244 | writer.flush()?; 245 | Ok(()) 246 | } 247 | 248 | /// Validates the existence and format of the SIMPLE header card. 249 | /// 250 | /// # Examples 251 | /// 252 | /// ``` 253 | /// use astro_rs::fits::*; 254 | /// 255 | /// let mut hdu_list = HduList::default(); 256 | /// // empty header 257 | /// assert!(!hdu_list.is_header_valid()?); 258 | /// 259 | /// let mut hdu = Hdu::new(); 260 | /// 261 | /// // non-empty header missing simple card 262 | /// let bitpix_card = FitsHeaderCard::from(*b"BITPIX = -32 / FITS BITS/PIXEL "); 263 | /// hdu.header.cards.insert(0, bitpix_card); 264 | /// hdu_list.push(hdu); 265 | /// assert!(!hdu_list.is_header_valid()?); 266 | /// 267 | /// // valid header 268 | /// let simple_card = FitsHeaderCard::from(*b"SIMPLE = T / FITS STANDARD "); 269 | /// hdu_list.first_mut().unwrap().header.cards.insert(0, simple_card); 270 | /// assert!(hdu_list.is_header_valid()?); 271 | /// # Ok::<(), astro_rs::fits::FitsHeaderError>(()) 272 | /// ``` 273 | pub fn is_header_valid(&mut self) -> Result { 274 | Ok(*self 275 | .get_by_index(0) 276 | .and_then(|hdu| hdu.header.get_card(SIMPLE_KEYWORD)) 277 | .and_then(|card| card.get_value::().ok()) 278 | .unwrap_or_default()) 279 | } 280 | 281 | fn read_hdu(&mut self) -> Option { 282 | let mut header_raw = Vec::new(); 283 | let mut new_header_bytes = vec![0; FITS_RECORD_LEN]; 284 | self.reader.read_exact(&mut new_header_bytes).ok()?; 285 | header_raw.append(&mut new_header_bytes); 286 | 287 | // search for the END keyword. 288 | // this should be the last keyword in the header, so if something other than ' ' is found, stop searching 289 | loop { 290 | let mut end_found = false; 291 | for card in 1..=FITS_RECORD_LEN / HEADER_CARD_LEN { 292 | let card_index = header_raw.len() - card * HEADER_CARD_LEN; 293 | match header_raw[card_index..card_index + HEADER_KEYWORD_LEN] 294 | .try_into() 295 | .unwrap() 296 | { 297 | END_KEYWORD => { 298 | end_found = true; 299 | break; 300 | } 301 | BLANK_KEYWORD => continue, 302 | _ => { 303 | end_found = false; 304 | break; 305 | } 306 | } 307 | } 308 | if end_found { 309 | break; 310 | } 311 | new_header_bytes = vec![0; FITS_RECORD_LEN]; 312 | self.reader.read_exact(&mut new_header_bytes).ok()?; 313 | header_raw.append(&mut new_header_bytes); 314 | } 315 | 316 | let mut header = FitsHeader::from_bytes(header_raw); 317 | let mut data_raw = Vec::new(); 318 | 319 | let naxis = *header 320 | .get_card(NAXIS_KEYWORD) 321 | .and_then(|card| card.get_value::().ok()) 322 | .unwrap_or_default(); 323 | if naxis != 0 { 324 | if let Some(bitpix) = header 325 | .get_card(BITPIX_KEYWORD) 326 | .and_then(|card| card.get_value::().ok()) 327 | { 328 | let mut data_len = 1; 329 | let mut naxisx_keyword = FitsHeaderKeyword::from(NAXIS_KEYWORD); 330 | for x in 1..=naxis { 331 | naxisx_keyword.append_number(x); 332 | 333 | let naxisx = *header 334 | .get_card(naxisx_keyword) 335 | .and_then(|card| card.get_value::().ok()) 336 | .unwrap_or_default() as usize; 337 | data_len *= naxisx; 338 | } 339 | data_len *= bitpix.value() / 8; 340 | if data_len % FITS_RECORD_LEN != 0 { 341 | let num_records = (data_len / FITS_RECORD_LEN) + 1; 342 | data_len = num_records * FITS_RECORD_LEN; 343 | } 344 | data_raw = vec![0; data_len]; 345 | let _ = self.reader.read_exact(&mut data_raw); 346 | } 347 | } 348 | Some(Hdu { header, data_raw }) 349 | } 350 | } 351 | 352 | /// A Header Data Unit within a FITS file. 353 | #[derive(Debug, Default, Clone)] 354 | pub struct Hdu { 355 | /// The header section of the HDU. 356 | pub header: FitsHeader, 357 | data_raw: Vec, 358 | } 359 | 360 | impl Hdu { 361 | /// Constructs an HDU with the given header and data. 362 | pub fn new() -> Self { 363 | Self::default() 364 | } 365 | 366 | /// Serializes the contents of the HDU to bytes. 367 | pub fn to_bytes(mut self) -> Vec { 368 | let mut result = self.header.to_bytes(); 369 | result.append(&mut self.data_raw); 370 | let remainder = result.len() % FITS_RECORD_LEN; 371 | if remainder != 0 { 372 | let num_cards = (result.len() / FITS_RECORD_LEN) + 1; 373 | let new_len = num_cards * FITS_RECORD_LEN; 374 | result.resize(new_len, 0); 375 | } 376 | result 377 | } 378 | 379 | /// Gets the name of the HDU, or an empty string if the name cannot be determined. 380 | /// 381 | /// # Examples 382 | /// 383 | /// ``` 384 | /// use astro_rs::fits::*; 385 | /// 386 | /// let mut img_hdu = image_hdu::default(); 387 | /// let name_card = FitsHeaderCard::from(*b"EXTNAME = 'hdu_name' "); 388 | /// img_hdu.header.cards.insert(img_hdu.header.cards.len() - 1, name_card); 389 | /// assert_eq!(img_hdu.get_name(), String::from("hdu_name")); 390 | /// ``` 391 | pub fn get_name(&mut self) -> String { 392 | self.header 393 | .get_card(EXTNAME_KEYWORD) 394 | .and_then(|card| card.get_value::().ok()) 395 | .map(|name| name.trim().to_owned()) 396 | .unwrap_or_default() 397 | } 398 | 399 | /// Gets the data section of the HDU. 400 | pub fn data_raw(&self) -> &Vec { 401 | &self.data_raw 402 | } 403 | 404 | /// Sets the data section of the HDU. 405 | pub fn set_data_raw(&mut self, data_raw: Vec) { 406 | self.data_raw = data_raw; 407 | } 408 | 409 | /// Attempts to create a new FitsDataCollection from the data section of the HDU. 410 | pub fn get_data(&self) -> Result { 411 | T::from_bytes(&self.data_raw) 412 | } 413 | 414 | /// Sets the data section of the HDU. 415 | pub fn set_data(&mut self, data: &T) { 416 | self.data_raw = data.to_bytes(); 417 | } 418 | 419 | /// Creates a Vec containing the dimensions of the data section of the HDU as defined by the NAXIS keywords. 420 | pub fn get_dimensions(&mut self) -> Vec { 421 | let naxis = *self 422 | .header 423 | .get_card(NAXIS_KEYWORD) 424 | .and_then(|card| card.get_value::().ok()) 425 | .unwrap_or_default(); 426 | if naxis == 0 { 427 | return Vec::new(); 428 | } 429 | let mut result = Vec::with_capacity(naxis as usize); 430 | let mut naxisx_keyword = FitsHeaderKeyword::from(NAXIS_KEYWORD); 431 | for x in 1..=naxis { 432 | naxisx_keyword.append_number(x); 433 | 434 | let naxisx = *self 435 | .header 436 | .get_card(naxisx_keyword) 437 | .and_then(|card| card.get_value::().ok()) 438 | .unwrap_or_default() as usize; 439 | result.push(naxisx); 440 | } 441 | result 442 | } 443 | } 444 | 445 | /// A trait that allows data to be serialized/deserialized as the data section of an HDU. 446 | pub trait FitsDataCollection: Debug { 447 | /// Attempts to deserialize a data collection from the given bytes. 448 | fn from_bytes(raw: &[u8]) -> Result 449 | where 450 | Self: Sized; 451 | 452 | /// Serializes the data collection to bytes. 453 | fn to_bytes(&self) -> Vec; 454 | } 455 | 456 | impl FitsDataCollection for Vec { 457 | fn from_bytes(raw: &[u8]) -> Result { 458 | Ok(raw.to_owned()) 459 | } 460 | 461 | fn to_bytes(&self) -> Vec { 462 | self.to_owned() 463 | } 464 | } 465 | 466 | impl FitsDataCollection for Vec { 467 | fn from_bytes(raw: &[u8]) -> Result { 468 | let mut data = Vec::with_capacity(raw.len() / 2); 469 | for chunk in raw.chunks_exact(2) { 470 | data.push(i16::from_be_bytes(chunk.try_into().unwrap())); 471 | } 472 | Ok(data) 473 | } 474 | 475 | fn to_bytes(&self) -> Vec { 476 | let mut data = Vec::with_capacity(self.len() * 2); 477 | for chunk in self { 478 | data.extend_from_slice(&chunk.to_be_bytes()); 479 | } 480 | data 481 | } 482 | } 483 | 484 | impl FitsDataCollection for Vec { 485 | fn from_bytes(raw: &[u8]) -> Result { 486 | let mut data = Vec::with_capacity(raw.len() / 4); 487 | for chunk in raw.chunks_exact(4) { 488 | data.push(i32::from_be_bytes(chunk.try_into().unwrap())); 489 | } 490 | Ok(data) 491 | } 492 | 493 | fn to_bytes(&self) -> Vec { 494 | let mut data = Vec::with_capacity(self.len() * 4); 495 | for chunk in self { 496 | data.extend_from_slice(&chunk.to_be_bytes()); 497 | } 498 | data 499 | } 500 | } 501 | 502 | impl FitsDataCollection for Vec { 503 | fn from_bytes(raw: &[u8]) -> Result { 504 | let mut data = Vec::with_capacity(raw.len() / 4); 505 | for chunk in raw.chunks_exact(4) { 506 | data.push(f32::from_be_bytes(chunk.try_into().unwrap())); 507 | } 508 | Ok(data) 509 | } 510 | 511 | fn to_bytes(&self) -> Vec { 512 | let mut data = Vec::with_capacity(self.len() * 4); 513 | for chunk in self { 514 | data.extend_from_slice(&chunk.to_be_bytes()); 515 | } 516 | data 517 | } 518 | } 519 | 520 | impl FitsDataCollection for Vec { 521 | fn from_bytes(raw: &[u8]) -> Result { 522 | let mut data = Vec::with_capacity(raw.len() / 8); 523 | for chunk in raw.chunks_exact(8) { 524 | data.push(f64::from_be_bytes(chunk.try_into().unwrap())); 525 | } 526 | Ok(data) 527 | } 528 | 529 | fn to_bytes(&self) -> Vec { 530 | let mut data = Vec::with_capacity(self.len() * 8); 531 | for chunk in self { 532 | data.extend_from_slice(&chunk.to_be_bytes()); 533 | } 534 | data 535 | } 536 | } 537 | 538 | #[macro_use] 539 | pub(crate) mod hdu_macros { 540 | /// Creates a box of the given value and casts it to an implicit return type. 541 | macro_rules! return_box { 542 | ($result: expr) => {{ 543 | let b = Box::new($result); 544 | let ptr = Box::into_raw(b); 545 | let new_ptr = ptr.cast(); 546 | Box::from_raw(new_ptr) 547 | }}; 548 | } 549 | 550 | pub(crate) use return_box; 551 | } 552 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #![deny(clippy::all)] 3 | #![cfg_attr(docsrs, feature(doc_cfg))] 4 | #![doc = include_str!("../README.md")] 5 | 6 | #[cfg(feature = "coordinates")] 7 | #[cfg_attr(docsrs, doc(cfg(feature = "coordinates")))] 8 | pub mod coordinates; 9 | 10 | #[cfg(feature = "cosmology")] 11 | #[cfg_attr(docsrs, doc(cfg(feature = "cosmology")))] 12 | pub mod cosmology; 13 | 14 | #[cfg(feature = "fits")] 15 | #[cfg_attr(docsrs, doc(cfg(feature = "fits")))] 16 | pub mod fits; 17 | -------------------------------------------------------------------------------- /tests/coordinates.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "coordinates")] 2 | mod coordinate_tests { 3 | use std::error::Error; 4 | 5 | use astro_rs::coordinates::*; 6 | 7 | use hifitime::Epoch; 8 | use uom::si::angle::degree; 9 | use uom::si::f64::{Angle, Length}; 10 | use uom::si::length::meter; 11 | 12 | #[test] 13 | fn test_lookup_by_name() -> Result<(), Box> { 14 | let m33_eq_coords = tokio_test::block_on(async { lookup_by_name("M33").await })?; 15 | 16 | let bear_mountain = EarthLocation { 17 | lat: Angle::new::(41.3), 18 | lon: Angle::new::(-74.0), 19 | height: Length::new::(390.0), 20 | }; 21 | // 11pm EDT on 2012 July 12 22 | let date_time = Epoch::from_gregorian_utc_hms(2012, 07, 13, 03, 00, 00); 23 | 24 | let m33_horiz_coords = m33_eq_coords.to_alt_az(&date_time, &bear_mountain)?.coords; 25 | 26 | println!("{:?}", m33_horiz_coords.round(4)); 27 | 28 | Ok(()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/fits.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "fits")] 2 | mod fits_tests { 3 | use std::error::Error; 4 | use std::fs::File; 5 | use std::io::{BufReader, BufWriter, Cursor, Read}; 6 | 7 | use astro_rs::fits::*; 8 | use image::{Rgb, RgbImage}; 9 | 10 | #[test] 11 | fn test_hdu_list_from_bytes() -> Result<(), Box> { 12 | { 13 | let fits_file = File::open("assets/eagle_nebula/502nmos.fits")?; 14 | let mut fits_file_reader = BufReader::new(fits_file); 15 | let mut fits_bytes = Vec::new(); 16 | fits_file_reader.read_to_end(&mut fits_bytes)?; 17 | 18 | let in_cursor = Cursor::new(fits_bytes.clone()); 19 | let mut hdu_list = HduList::new(BufReader::new(in_cursor)); 20 | assert_eq!(hdu_list.iter_mut().count(), 2); 21 | let out_cursor = Cursor::new(Vec::new()); 22 | let mut out_writer = BufWriter::new(out_cursor); 23 | hdu_list.write(&mut out_writer)?; 24 | assert_eq!(out_writer.get_ref().get_ref(), &fits_bytes); 25 | } 26 | 27 | { 28 | let fits_file = File::open("assets/M17/502nmos.fits")?; 29 | let mut fits_file_reader = BufReader::new(fits_file); 30 | let mut fits_bytes = Vec::new(); 31 | fits_file_reader.read_to_end(&mut fits_bytes)?; 32 | 33 | let in_cursor = Cursor::new(fits_bytes.clone()); 34 | let mut hdu_list = HduList::new(BufReader::new(in_cursor)); 35 | assert_eq!(hdu_list.iter_mut().count(), 2); 36 | let out_cursor = Cursor::new(Vec::new()); 37 | let mut out_writer = BufWriter::new(out_cursor); 38 | hdu_list.write(&mut out_writer)?; 39 | assert_eq!(out_writer.get_ref().get_ref(), &fits_bytes); 40 | } 41 | 42 | Ok(()) 43 | } 44 | 45 | #[test] 46 | fn test_hdu_list_new() -> Result<(), Box> { 47 | let mut hdu_list = HduList::default(); 48 | hdu_list.push(primary_hdu::default()); 49 | hdu_list.push(image_hdu::default()); 50 | 51 | assert!(hdu_list.is_header_valid()?); 52 | 53 | Ok(()) 54 | } 55 | 56 | #[test] 57 | fn test_hdu_table_column() -> Result<(), Box> { 58 | let fits_file = 59 | File::open("assets/CDA/science/ao23/cat9/25975/primary/acisf25975N002_evt2.fits")?; 60 | let fits_file_reader = BufReader::new(fits_file); 61 | let mut hdu_list = HduList::new(fits_file_reader); 62 | let table_hdu = hdu_list.get_by_name("EVENTS").unwrap(); 63 | let energy_data = binary_table_hdu::column_by_name::(table_hdu, "energy").unwrap(); 64 | let energy_average = energy_data.iter().sum::() / energy_data.len() as f32; 65 | 66 | assert_eq!(energy_average, 9012.468); 67 | 68 | Ok(()) 69 | } 70 | 71 | #[test] 72 | fn test_hdu_image() -> Result<(), Box> { 73 | let fits_file = File::open("assets/eagle_nebula/502nmos.fits")?; 74 | let fits_file_reader = BufReader::new(fits_file); 75 | let mut hdu_list = HduList::new(fits_file_reader); 76 | let primary_hdu = hdu_list.first_mut().unwrap(); 77 | 78 | let dimensions = primary_hdu.get_dimensions(); 79 | assert_eq!(dimensions, vec![1600, 1600]); 80 | 81 | let data = primary_hdu.get_data::>()?; 82 | let mut data_min = 0.0; 83 | let mut data_max = 0.0; 84 | for value in &data { 85 | if *value > data_max { 86 | data_max = *value; 87 | } else if *value < data_min { 88 | data_min = *value; 89 | } 90 | } 91 | assert_eq!(data_max, 2925.8718); 92 | assert_eq!(data_min, -12.439324); 93 | Ok(()) 94 | } 95 | 96 | #[test] 97 | fn test_image_to_hdu() -> Result<(), Box> { 98 | let img = image::open("assets/eagle_nebula/eagle_composite.jpg")?.into_rgb8(); 99 | let (dim_x, dim_y) = img.dimensions(); 100 | let size = (dim_x * dim_y) as usize; 101 | let mut r = Vec::with_capacity(size); 102 | let mut g = Vec::with_capacity(size); 103 | let mut b = Vec::with_capacity(size); 104 | for rgb in img.pixels() { 105 | r.push(rgb.0[0] as i32); 106 | g.push(rgb.0[1] as i32); 107 | b.push(rgb.0[2] as i32); 108 | } 109 | 110 | let mut r_writer = BufWriter::new(Cursor::new(Vec::new())); 111 | let mut g_writer = BufWriter::new(Cursor::new(Vec::new())); 112 | let mut b_writer = BufWriter::new(Cursor::new(Vec::new())); 113 | { 114 | let mut r_fits = HduList::default(); 115 | let mut r_hdu = primary_hdu::default(); 116 | r_hdu.header.set_card( 117 | BITPIX_KEYWORD, 118 | Bitpix::I32, 119 | Some(String::from("array data type")), 120 | )?; 121 | let mut naxis_keyword = FitsHeaderKeyword::from(NAXIS_KEYWORD); 122 | r_hdu.header.set_card( 123 | naxis_keyword, 124 | 2u16, 125 | Some(String::from("number of array dimensions")), 126 | )?; 127 | naxis_keyword.append_number(1); 128 | r_hdu.header.set_card(naxis_keyword, dim_x, None)?; 129 | naxis_keyword.append_number(2); 130 | r_hdu.header.set_card(naxis_keyword, dim_y, None)?; 131 | r_hdu.header.set_comment( 132 | SIMPLE_KEYWORD, 133 | Some(String::from("conforms to FITS standard")), 134 | )?; 135 | r_hdu.header.set_card(*b"EXTEND ", true, None)?; 136 | 137 | r_hdu.set_data(&r); 138 | r_fits.push(r_hdu); 139 | r_fits.write(&mut r_writer)?; 140 | } 141 | 142 | { 143 | let mut g_fits = HduList::default(); 144 | let mut g_hdu = primary_hdu::default(); 145 | g_hdu.header.set_card( 146 | BITPIX_KEYWORD, 147 | Bitpix::I32, 148 | Some(String::from("array data type")), 149 | )?; 150 | let mut naxis_keyword = FitsHeaderKeyword::from(NAXIS_KEYWORD); 151 | g_hdu.header.set_card( 152 | naxis_keyword, 153 | 2u16, 154 | Some(String::from("number of array dimensions")), 155 | )?; 156 | naxis_keyword.append_number(1); 157 | g_hdu.header.set_card(naxis_keyword, dim_x, None)?; 158 | naxis_keyword.append_number(2); 159 | g_hdu.header.set_card(naxis_keyword, dim_y, None)?; 160 | g_hdu.header.set_comment( 161 | SIMPLE_KEYWORD, 162 | Some(String::from("conforms to FITS standard")), 163 | )?; 164 | g_hdu.header.set_card(*b"EXTEND ", true, None)?; 165 | 166 | g_hdu.set_data(&g); 167 | g_fits.push(g_hdu); 168 | g_fits.write(&mut g_writer)?; 169 | } 170 | 171 | { 172 | let mut b_fits = HduList::default(); 173 | let mut b_hdu = primary_hdu::default(); 174 | b_hdu.header.set_card( 175 | BITPIX_KEYWORD, 176 | Bitpix::I32, 177 | Some(String::from("array data type")), 178 | )?; 179 | let mut naxis_keyword = FitsHeaderKeyword::from(NAXIS_KEYWORD); 180 | b_hdu.header.set_card( 181 | naxis_keyword, 182 | 2u16, 183 | Some(String::from("number of array dimensions")), 184 | )?; 185 | naxis_keyword.append_number(1); 186 | b_hdu.header.set_card(naxis_keyword, dim_x, None)?; 187 | naxis_keyword.append_number(2); 188 | b_hdu.header.set_card(naxis_keyword, dim_y, None)?; 189 | b_hdu.header.set_comment( 190 | SIMPLE_KEYWORD, 191 | Some(String::from("conforms to FITS standard")), 192 | )?; 193 | b_hdu.header.set_card(*b"EXTEND ", true, None)?; 194 | 195 | b_hdu.set_data(&b); 196 | b_fits.push(b_hdu); 197 | b_fits.write(&mut b_writer)?; 198 | } 199 | 200 | let mut r_fits = HduList::new(BufReader::new(Cursor::new( 201 | r_writer.get_ref().get_ref().to_owned(), 202 | ))); 203 | let r_data = r_fits.first_mut().unwrap().get_data::>().unwrap(); 204 | let mut g_fits = HduList::new(BufReader::new(Cursor::new( 205 | g_writer.get_ref().get_ref().to_owned(), 206 | ))); 207 | let g_data = g_fits.first_mut().unwrap().get_data::>().unwrap(); 208 | let mut b_fits = HduList::new(BufReader::new(Cursor::new( 209 | b_writer.get_ref().get_ref().to_owned(), 210 | ))); 211 | let b_data = b_fits.first_mut().unwrap().get_data::>().unwrap(); 212 | let mut new_img = RgbImage::new(dim_x, dim_y); 213 | for i in 0..size { 214 | let x = i as u32 % dim_x; 215 | let y = i as u32 / dim_x; 216 | new_img.put_pixel( 217 | x, 218 | y, 219 | Rgb([r_data[i] as u8, g_data[i] as u8, b_data[i] as u8]), 220 | ); 221 | } 222 | 223 | for (orig, new) in img.pixels().zip(new_img.pixels()) { 224 | assert_eq!(orig, new); 225 | } 226 | 227 | Ok(()) 228 | } 229 | } 230 | --------------------------------------------------------------------------------