use core::f64::consts::PI;
use std::io;
use std::path::Path;
use memmap2::Mmap;
use super::areas::filename_1476;
use crate::error::{ArcsecError, Result};
const RA_SCALE: f64 = 2.0 * PI / 16_777_215.0; const DEC_SCALE: f64 = PI * 0.5 / 8_388_607.0; const HEADER_SENTINEL: u32 = 0xFF_FF_FF;
#[derive(Debug, Clone)]
pub struct CatalogStar {
pub ra: f64,
pub dec: f64,
pub mag: f64,
}
pub fn read_area_file(
file_path: &Path,
telescope_ra: f64,
telescope_dec: f64,
field_diameter: f64,
cos_telescope_dec: f64,
max_stars: usize,
) -> Result<Vec<CatalogStar>> {
let file = std::fs::File::open(file_path).map_err(ArcsecError::CatalogIo)?;
let mmap = unsafe { Mmap::map(&file).map_err(ArcsecError::CatalogIo)? };
if mmap.len() < 110 {
return Ok(vec![]);
}
let record_size = if mmap[109] == b' ' {
11
} else {
mmap[109] as usize
};
if record_size != 5 && record_size != 6 {
return Err(ArcsecError::CatalogIo(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported record_size {record_size}"),
)));
}
let data = &mmap[110..];
let half_diam = field_diameter * 0.5;
let mut dec9_storage: i32 = 0; let mut current_mag = 0.0f64;
let mut stars = Vec::new();
let mut pos = 0;
while pos + record_size <= data.len() {
let ra7 = data[pos];
let ra8 = data[pos + 1];
let ra9 = data[pos + 2];
let dec7 = data[pos + 3];
let dec8 = data[pos + 4];
pos += record_size;
let ra_raw = (ra7 as u32) | ((ra8 as u32) << 8) | ((ra9 as u32) << 16);
if ra_raw == HEADER_SENTINEL {
current_mag = (dec8 as f64 - 16.0) / 10.0;
dec9_storage = dec7 as i32 - 128; continue;
}
let ra2 = ra_raw as f64 * RA_SCALE;
let dec_raw = (dec9_storage << 16) | ((dec8 as i32) << 8) | (dec7 as i32);
let dec2 = dec_raw as f64 * DEC_SCALE;
let mut delta_ra = (ra2 - telescope_ra).abs();
if delta_ra > PI {
delta_ra = 2.0 * PI - delta_ra;
}
if delta_ra * cos_telescope_dec < half_diam && (dec2 - telescope_dec).abs() < half_diam {
stars.push(CatalogStar {
ra: ra2,
dec: dec2,
mag: current_mag,
});
if stars.len() >= max_stars {
break;
}
}
}
Ok(stars)
}
pub fn read_catalog_stars(
db_path: &Path,
db_name: &str,
telescope_ra: f64,
telescope_dec: f64,
fov: f64,
max_stars: usize,
) -> Result<Vec<CatalogStar>> {
match detect_layout(db_path, db_name) {
CatalogLayout::Areas290 => read_catalog_stars_290(
db_path,
db_name,
telescope_ra,
telescope_dec,
fov,
max_stars,
),
CatalogLayout::AllSky001 => super::format_001::read_001_file(
&db_path.join(format!("{db_name}_0101.001")),
telescope_ra,
telescope_dec,
fov * 1.05,
telescope_dec.cos().max(1e-6),
max_stars,
),
CatalogLayout::Areas1476 => read_catalog_stars_1476(
db_path,
db_name,
telescope_ra,
telescope_dec,
fov,
max_stars,
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CatalogLayout {
Areas1476,
Areas290,
AllSky001,
}
#[must_use]
pub fn catalog_present(db_path: &Path, db_name: &str) -> bool {
["1476", "290", "001"]
.iter()
.any(|ext| db_path.join(format!("{db_name}_0101.{ext}")).exists())
}
#[must_use]
pub fn detect_layout(db_path: &Path, db_name: &str) -> CatalogLayout {
if db_path.join(format!("{db_name}_0101.1476")).exists() {
CatalogLayout::Areas1476
} else if db_path.join(format!("{db_name}_0101.290")).exists() {
CatalogLayout::Areas290
} else if db_path.join(format!("{db_name}_0101.001")).exists() {
CatalogLayout::AllSky001
} else {
CatalogLayout::Areas1476
}
}
fn read_catalog_stars_290(
db_path: &Path,
db_name: &str,
telescope_ra: f64,
telescope_dec: f64,
fov: f64,
max_stars: usize,
) -> Result<Vec<CatalogStar>> {
let areas = super::areas_290::find_areas_290(telescope_ra, telescope_dec, fov);
if areas.is_empty() {
return Ok(vec![]);
}
let field_diameter = fov * 1.05;
let cos_dec = telescope_dec.cos().max(1e-6);
let per_area = (max_stars / areas.len()).max(16) * 2;
let mut all_stars: Vec<CatalogStar> = Vec::new();
for area_nr in areas {
let fname = super::areas_290::filename_290(area_nr);
let file_path = db_path.join(format!("{db_name}_{fname}"));
match read_area_file(
&file_path,
telescope_ra,
telescope_dec,
field_diameter,
cos_dec,
per_area,
) {
Ok(mut stars) => all_stars.append(&mut stars),
Err(ArcsecError::CatalogIo(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
if all_stars.len() > max_stars {
all_stars.sort_unstable_by(|a, b| a.mag.total_cmp(&b.mag));
all_stars.truncate(max_stars);
}
Ok(all_stars)
}
fn read_catalog_stars_1476(
db_path: &Path,
db_name: &str,
telescope_ra: f64,
telescope_dec: f64,
fov: f64,
max_stars: usize,
) -> Result<Vec<CatalogStar>> {
let areas = super::areas::find_areas_1476(telescope_ra, telescope_dec, fov);
if areas.is_empty() {
return Ok(vec![]);
}
let field_diameter = fov * 1.05; let cos_dec = telescope_dec.cos().max(1e-6);
let mut all_stars: Vec<CatalogStar> = Vec::new();
for (area_nr, _frac) in areas {
let fname = filename_1476(area_nr);
let file_path = db_path.join(format!("{db_name}_{fname}"));
match read_area_file(
&file_path,
telescope_ra,
telescope_dec,
field_diameter,
cos_dec,
max_stars,
) {
Ok(mut stars) => all_stars.append(&mut stars),
Err(ArcsecError::CatalogIo(ref e)) if e.kind() == io::ErrorKind::NotFound => {
continue;
}
Err(e) => return Err(e),
}
if all_stars.len() >= max_stars {
break;
}
}
all_stars.truncate(max_stars);
Ok(all_stars)
}
#[cfg(test)]
mod tests {
use super::*;
fn deg(d: f64) -> f64 {
d * PI / 180.0
}
fn make_synthetic_file(record_size: u8, stars: &[(f64, f64, f64)]) -> Vec<u8> {
let mut buf = vec![0u8; 110];
buf[109] = record_size;
for &(ra, dec, mag) in stars {
let ra_raw = (ra / (2.0 * PI) * 16_777_215.0).round() as u32;
let dec_raw = (dec / (PI * 0.5) * 8_388_607.0).round() as i32;
let dec7 = (dec_raw & 0xFF) as u8;
let dec8 = ((dec_raw >> 8) & 0xFF) as u8;
let dec9: i8 = ((dec_raw >> 16) & 0xFF) as i8;
let mag_enc = (mag * 10.0 + 16.0).round() as u8;
buf.extend_from_slice(&[0xFF, 0xFF, 0xFF]);
buf.push(dec9 as u8 + 128); buf.push(mag_enc); if record_size == 6 {
buf.push(0);
}
buf.push((ra_raw & 0xFF) as u8);
buf.push(((ra_raw >> 8) & 0xFF) as u8);
buf.push(((ra_raw >> 16) & 0xFF) as u8);
buf.push(dec7);
buf.push(dec8);
if record_size == 6 {
buf.push(0);
}
}
buf
}
#[test]
fn decode_equatorial_star() {
let bytes = make_synthetic_file(5, &[(0.0, 0.0, 1.0)]);
let path = std::env::temp_dir().join("test_decode.1476");
std::fs::write(&path, &bytes).unwrap();
let stars = read_area_file(&path, 0.0, 0.0, deg(10.0), 1.0, 100).unwrap();
assert!(!stars.is_empty(), "should decode at least one star");
let s = &stars[0];
assert!(s.ra.abs() < 1e-4, "ra={}", s.ra);
assert!(s.dec.abs() < 1e-4, "dec={}", s.dec);
assert!((s.mag - 1.0).abs() < 0.1, "mag={}", s.mag);
}
#[test]
fn header_record_not_returned_as_star() {
let mut bytes = vec![0u8; 110];
bytes[109] = 5; bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 128, 17]);
let path = std::env::temp_dir().join("test_header_only.1476");
std::fs::write(&path, &bytes).unwrap();
let stars = read_area_file(&path, 0.0, 0.0, deg(10.0), 1.0, 100).unwrap();
assert_eq!(
stars.len(),
0,
"header record must not be returned as a star"
);
}
#[test]
fn fov_filter_excludes_distant_stars() {
let bytes = make_synthetic_file(5, &[(0.0, 0.0, 1.0), (1.0, 0.0, 2.0)]);
let path = std::env::temp_dir().join("test_fov_filter.1476");
std::fs::write(&path, &bytes).unwrap();
let stars = read_area_file(&path, 0.0, 0.0, deg(1.0), 1.0, 100).unwrap();
assert_eq!(stars.len(), 1, "far star should be excluded");
}
#[test]
fn ra_roundtrip_accuracy() {
let test_ra = deg(123.456);
let bytes = make_synthetic_file(5, &[(test_ra, 0.0, 1.0)]);
let path = std::env::temp_dir().join("test_ra_roundtrip.1476");
std::fs::write(&path, &bytes).unwrap();
let stars = read_area_file(&path, test_ra, 0.0, deg(5.0), 1.0, 100).unwrap();
assert!(!stars.is_empty(), "star not found");
assert!(
(stars[0].ra - test_ra).abs() < 1e-5,
"ra error = {}",
stars[0].ra - test_ra
);
}
#[test]
fn record_size_6_works() {
let bytes = make_synthetic_file(6, &[(0.5, 0.1, 2.0)]);
let path = std::env::temp_dir().join("test_record6.1476");
std::fs::write(&path, &bytes).unwrap();
let stars = read_area_file(&path, 0.5, 0.1, deg(5.0), 0.995_f64.cos(), 100).unwrap();
assert!(!stars.is_empty(), "should decode record_size=6 star");
}
#[test]
fn missing_file_returns_empty_catalog() {
let db_path = std::env::temp_dir();
let result = read_catalog_stars(&db_path, "nonexistent_db", 0.0, 0.0, deg(2.0), 100);
if let Ok(stars) = result {
assert!(
stars.is_empty(),
"missing database yielded {} stars",
stars.len()
);
}
}
}