use anyhow::{Context, Result};
use chrono::{DateTime, NaiveDateTime, Utc};
use fitsio::FitsFile;
use log::warn;
use std::collections::HashMap;
use std::path::Path;
use super::types::{
AstroMetadata, Detector, Environment, Equipment, Exposure, Filter, Mount, WcsData,
};
pub fn extract_metadata_from_path(path: &Path) -> Result<AstroMetadata> {
let mut fits_file = FitsFile::open(path).context("Failed to open FITS file")?;
extract_metadata(&mut fits_file)
}
pub fn extract_metadata(fits_file: &mut FitsFile) -> Result<AstroMetadata> {
let hdu = fits_file.primary_hdu()?;
let mut metadata = AstroMetadata::default();
let mut raw_headers = HashMap::new();
let keywords = [
"TELESCOP", "FOCALLEN", "APERTURE", "INSTRUME", "CAMERA", "PIXSIZE", "XPIXSZ", "NAXIS1",
"NAXIS2", "XBINNING", "YBINNING", "GAIN", "EGAIN", "RDNOISE", "CCD-TEMP", "CCDTEMP",
"SET-TEMP", "FILTER", "OBJECT", "RA", "OBJCTRA", "DEC", "OBJCTDEC", "DATE-OBS", "EXPTIME",
"EXPOSURE", "IMAGETYP", "FRAME",
];
for keyword in &keywords {
if let Ok(value) = hdu.read_key::<String>(fits_file, keyword) {
raw_headers.insert(keyword.to_string(), value);
}
}
parse_equipment(&mut metadata.equipment, &raw_headers);
parse_detector(&mut metadata.detector, &raw_headers, &hdu.info);
parse_filter(&mut metadata.filter, &raw_headers);
parse_exposure(&mut metadata.exposure, &raw_headers);
metadata.mount = parse_mount(&raw_headers);
metadata.environment = parse_environment(&raw_headers);
metadata.wcs = parse_wcs(&raw_headers);
metadata.raw_headers = raw_headers;
metadata.calculate_session_date();
Ok(metadata)
}
fn parse_equipment(equipment: &mut Equipment, headers: &HashMap<String, String>) {
equipment.telescope_name = get_string_header(headers, &["TELESCOP"]);
equipment.focal_length = get_float_header(headers, &["FOCALLEN"]);
equipment.aperture = get_float_header(headers, &["APERTURE"]);
if equipment.focal_ratio.is_none() {
if let (Some(focal_length), Some(aperture)) = (equipment.focal_length, equipment.aperture) {
if aperture > 0.0 {
equipment.focal_ratio = Some(focal_length / aperture);
}
}
}
if let Some(instrume) = get_string_header(headers, &["INSTRUME"]) {
if instrume.contains("reducer") || instrume.contains("flattener") {
equipment.reducer_flattener = Some(instrume);
}
}
equipment.mount_model = get_string_header(headers, &["MOUNT"]);
equipment.focuser_position = get_int_header(headers, &["FOCPOS", "FOCUSPOS"]);
equipment.focuser_temperature = get_float_header(headers, &["FOCTEMP", "FOCUSTEMP"]);
}
fn parse_detector(
detector: &mut Detector,
headers: &HashMap<String, String>,
hdu_info: &fitsio::hdu::HduInfo,
) {
detector.camera_name = get_string_header(headers, &["INSTRUME", "CAMERA"]);
detector.pixel_size = get_float_header(headers, &["PIXSIZE", "XPIXSZ"]);
if let Some(naxis1) = get_int_header(headers, &["NAXIS1"]) {
detector.width = naxis1 as usize;
}
if let Some(naxis2) = get_int_header(headers, &["NAXIS2"]) {
detector.height = naxis2 as usize;
}
if detector.width == 0 || detector.height == 0 {
if let fitsio::hdu::HduInfo::ImageInfo { shape, .. } = hdu_info {
if shape.len() >= 2 {
detector.height = shape[0];
detector.width = shape[1];
}
}
}
detector.binning_x = get_int_header(headers, &["XBINNING"]).unwrap_or(1) as usize;
detector.binning_y = get_int_header(headers, &["YBINNING"]).unwrap_or(1) as usize;
detector.gain = get_float_header(headers, &["GAIN", "EGAIN"]);
detector.offset = get_int_header(headers, &["OFFSET", "CCDOFFST"]);
detector.readout_mode = get_string_header(headers, &["READOUT", "READOUTM"]);
detector.usb_limit = get_string_header(headers, &["USBLIMIT", "USBTRFC"]);
detector.read_noise = get_float_header(headers, &["RDNOISE"]);
detector.temperature = get_float_header(headers, &["CCD-TEMP", "CCDTEMP"]);
detector.temp_setpoint = get_float_header(headers, &["CCD-TEMP-SETPOINT", "SET-TEMP"]);
detector.cooler_power = get_float_header(headers, &["COOL-PWR", "COOLPWR"]);
detector.cooler_status = get_string_header(headers, &["COOL-STAT", "COOLSTAT"]);
detector.rotator_angle = get_float_header(headers, &["ROTANG", "ROTPA", "ROTATANG"]);
}
fn parse_filter(filter: &mut Filter, headers: &HashMap<String, String>) {
filter.name = get_string_header(headers, &["FILTER"]);
if let Some(pos_str) = get_string_header(headers, &["FILTERID", "FLTPOS"]) {
if let Ok(pos) = pos_str.parse::<usize>() {
filter.position = Some(pos);
}
}
filter.wavelength = get_float_header(headers, &["WAVELENG", "WAVELEN"]);
}
fn parse_exposure(exposure: &mut Exposure, headers: &HashMap<String, String>) {
exposure.object_name = get_string_header(headers, &["OBJECT"]);
exposure.ra = get_float_header(headers, &["RA", "OBJCTRA"]).map(|ra| ra as f64 * 15.0); exposure.dec = get_float_header(headers, &["DEC", "OBJCTDEC"]).map(|dec| dec as f64);
if let Some(date_str) = get_string_header(headers, &["DATE-OBS"]) {
exposure.date_obs = parse_date_time(&date_str);
}
exposure.exposure_time = get_float_header(headers, &["EXPTIME", "EXPOSURE"]);
exposure.frame_type = get_string_header(headers, &["IMAGETYP", "FRAME"]);
exposure.sequence_id = get_string_header(headers, &["SEQID", "SEQFILE"]);
if let Some(frame_num_str) = get_string_header(headers, &["FRAMENUM", "SEQNUM"]) {
if let Ok(frame_num) = frame_num_str.parse::<usize>() {
exposure.frame_number = Some(frame_num);
}
}
exposure.dither_offset_x = get_float_header(headers, &["DX", "DITHX"]);
exposure.dither_offset_y = get_float_header(headers, &["DY", "DITHY"]);
exposure.project_name = get_string_header(headers, &["PROJECT", "PROJNAME"]);
exposure.session_id = get_string_header(headers, &["SESSIONID", "SESSID"]);
}
fn parse_mount(headers: &HashMap<String, String>) -> Option<Mount> {
if !headers.contains_key("PIERSIDE")
&& !headers.contains_key("MFLIP")
&& !headers.contains_key("GUIDERMS")
&& !headers.contains_key("SITELAT")
&& !headers.contains_key("OBSLAT")
{
return None;
}
let mut mount = Mount {
pier_side: get_string_header(headers, &["PIERSIDE"]),
latitude: get_float_header(headers, &["SITELAT", "OBSLAT"]).map(|v| v as f64),
longitude: get_float_header(headers, &["SITELONG", "OBSLONG"]).map(|v| v as f64),
height: get_float_header(headers, &["SITEELEV", "OBSELEV"]).map(|v| v as f64),
guide_camera: get_string_header(headers, &["GUIDECAM"]),
guide_rms: get_float_header(headers, &["GUIDERMS"]),
guide_scale: get_float_header(headers, &["GUIDESCALE"]),
peak_ra_error: get_float_header(headers, &["PEAKRA", "PEAKRAER"]),
peak_dec_error: get_float_header(headers, &["PEAKDEC", "PEAKDCER"]),
..Default::default()
};
if let Some(mflip_str) = get_string_header(headers, &["MFLIP", "MFOC"]) {
mount.meridian_flip = Some(mflip_str.to_lowercase() == "true" || mflip_str == "1");
}
if let Some(dither_str) = get_string_header(headers, &["DITHER"]) {
mount.dither_enabled = Some(dither_str.to_lowercase() == "true" || dither_str == "1");
}
Some(mount)
}
fn parse_environment(headers: &HashMap<String, String>) -> Option<Environment> {
if !headers.contains_key("AMB_TEMP")
&& !headers.contains_key("HUMIDITY")
&& !headers.contains_key("NINA-VERSION")
&& !headers.contains_key("EKOS-VERSION")
&& !headers.contains_key("SQM")
{
return None;
}
let mut env = Environment {
ambient_temp: get_float_header(headers, &["AMB_TEMP", "AMBTEMP"]),
humidity: get_float_header(headers, &["HUMIDITY"]),
dew_heater_power: get_float_header(headers, &["DEWPOWER", "DEWPWR"]),
voltage: get_float_header(headers, &["VOLTAGE", "SYSVOLT"]),
current: get_float_header(headers, &["CURRENT", "SYSCURR"]),
sqm: get_float_header(headers, &["SQM", "SQMMAG", "SKYQUAL"]),
..Default::default()
};
if let Some(nina_ver) = get_string_header(headers, &["NINA-VERSION"]) {
env.software_version = Some(format!("NINA {}", nina_ver));
} else if let Some(ekos_ver) = get_string_header(headers, &["EKOS-VERSION"]) {
env.software_version = Some(format!("EKOS {}", ekos_ver));
} else if let Some(software) = get_string_header(headers, &["SWCREATE", "SOFTWARE"]) {
env.software_version = Some(software);
}
Some(env)
}
fn parse_wcs(headers: &HashMap<String, String>) -> Option<WcsData> {
if !headers.contains_key("CRPIX1")
&& !headers.contains_key("CRPIX2")
&& !headers.contains_key("CRVAL1")
&& !headers.contains_key("CRVAL2")
{
return None;
}
let wcs = WcsData {
crpix1: get_float_header(headers, &["CRPIX1"]).map(|v| v as f64),
crpix2: get_float_header(headers, &["CRPIX2"]).map(|v| v as f64),
crval1: get_float_header(headers, &["CRVAL1"]).map(|v| v as f64),
crval2: get_float_header(headers, &["CRVAL2"]).map(|v| v as f64),
cd1_1: get_float_header(headers, &["CD1_1"]).map(|v| v as f64),
cd1_2: get_float_header(headers, &["CD1_2"]).map(|v| v as f64),
cd2_1: get_float_header(headers, &["CD2_1"]).map(|v| v as f64),
cd2_2: get_float_header(headers, &["CD2_2"]).map(|v| v as f64),
ctype1: get_string_header(headers, &["CTYPE1"]),
ctype2: get_string_header(headers, &["CTYPE2"]),
..Default::default()
};
Some(wcs)
}
fn get_string_header(headers: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
for key in keys {
if let Some(value) = headers.get(*key) {
if !value.is_empty() {
return Some(value.clone());
}
}
}
None
}
fn get_float_header(headers: &HashMap<String, String>, keys: &[&str]) -> Option<f32> {
for key in keys {
if let Some(value) = headers.get(*key) {
if let Ok(float_val) = value.parse::<f32>() {
return Some(float_val);
}
}
}
None
}
fn get_int_header(headers: &HashMap<String, String>, keys: &[&str]) -> Option<i32> {
for key in keys {
if let Some(value) = headers.get(*key) {
if let Ok(int_val) = value.parse::<i32>() {
return Some(int_val);
}
}
}
None
}
pub fn parse_sexagesimal(value: &str) -> Option<f64> {
let parts: Vec<&str> = value.split_whitespace().collect();
if parts.len() >= 3 {
if let (Ok(h), Ok(m), Ok(s)) = (
parts[0].parse::<f64>(),
parts[1].parse::<f64>(),
parts[2].parse::<f64>(),
) {
let sign = if h < 0.0 || value.starts_with('-') {
-1.0
} else {
1.0
};
return Some(sign * (h.abs() + m / 60.0 + s / 3600.0));
}
}
None
}
fn parse_date_time(date_str: &str) -> Option<DateTime<Utc>> {
let formats = [
"%Y-%m-%dT%H:%M:%S%.fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%d %H:%M:%S", ];
for format in &formats {
if let Ok(dt) = NaiveDateTime::parse_from_str(date_str, format) {
return Some(DateTime::from_naive_utc_and_offset(dt, Utc));
}
}
warn!("Failed to parse date string: {}", date_str);
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_sexagesimal() {
assert_eq!(parse_sexagesimal("12 30 45"), Some(12.5125));
assert_eq!(parse_sexagesimal("-45 30 15"), Some(-45.50416666666667));
assert_eq!(parse_sexagesimal("0 0 0"), Some(0.0));
assert_eq!(parse_sexagesimal("not a coordinate"), None);
assert_eq!(parse_sexagesimal("12 30"), None); }
}