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()
);
}
}
use crate::test_support::{
Rng, SkySpec, SkyStar, TempDir, area_file_bytes, random_sky, separation, write_001_db,
write_290_db, write_1476_db,
};
fn write_area(dir: &TempDir, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let path = dir.path().join(name);
std::fs::write(&path, bytes).unwrap();
path
}
fn all_sky_sample() -> Vec<SkyStar> {
let mut out = Vec::new();
for (k, dec_deg) in [-89.99, -60.0, -30.5, -0.001, 0.0, 0.001, 29.0, 61.3, 89.99]
.iter()
.enumerate()
{
for (j, ra_deg) in [0.0, 0.0001, 123.456, 359.9999].iter().enumerate() {
out.push(SkyStar {
ra: deg(*ra_deg),
dec: deg(*dec_deg),
mag: -1.5 + 0.3 * (4 * k + j) as f64,
});
}
}
out
}
#[test]
fn every_declination_and_magnitude_round_trips() {
let dir = TempDir::new("f1476-rt");
for record_size in [5usize, 6] {
let stars = all_sky_sample();
let path = write_area(&dir, "all.1476", &area_file_bytes(&stars, record_size));
let got = read_area_file(&path, PI, 0.0, 4.0 * PI, 1.0, usize::MAX).unwrap();
assert_eq!(got.len(), stars.len(), "record size {record_size}");
for want in &stars {
let found = got
.iter()
.find(|g| (g.mag - want.mag).abs() < 0.051)
.unwrap_or_else(|| panic!("lost {want:?}"));
assert!((found.dec - want.dec).abs() < 2e-7, "{found:?} vs {want:?}");
let dra = (found.ra - want.ra).rem_euclid(2.0 * PI);
assert!(dra.min(2.0 * PI - dra) < 4e-7, "{found:?} vs {want:?}");
assert!((0.0..2.0 * PI).contains(&found.ra));
}
}
}
#[test]
fn stars_come_back_brightest_first_and_max_stars_truncates() {
let dir = TempDir::new("f1476-max");
let mut rng = Rng::new(5);
let stars: Vec<SkyStar> = (0..50)
.map(|_| SkyStar {
ra: rng.range(1.0, 1.01),
dec: rng.range(0.2, 0.21),
mag: rng.range(5.0, 15.0),
})
.collect();
let path = write_area(&dir, "a.1476", &area_file_bytes(&stars, 5));
let got = read_area_file(&path, 1.005, 0.205, deg(5.0), 0.205f64.cos(), 10).unwrap();
assert_eq!(got.len(), 10);
assert!(got.windows(2).all(|w| w[0].mag <= w[1].mag));
let mut mags: Vec<f64> = stars.iter().map(|s| s.mag).collect();
mags.sort_by(f64::total_cmp);
assert!((got[9].mag - mags[9]).abs() < 0.051);
}
#[test]
fn the_square_window_wraps_at_ra_zero_and_scales_with_declination() {
let dir = TempDir::new("f1476-win");
let stars = [
SkyStar {
ra: deg(359.8),
dec: deg(60.0),
mag: 5.0,
},
SkyStar {
ra: deg(0.3),
dec: deg(60.0),
mag: 5.0,
},
SkyStar {
ra: deg(2.5),
dec: deg(60.0),
mag: 5.0,
}, SkyStar {
ra: deg(0.0),
dec: deg(61.1),
mag: 5.0,
}, ];
let path = write_area(&dir, "w.1476", &area_file_bytes(&stars, 5));
let got = read_area_file(&path, 0.0, deg(60.0), deg(2.0), deg(60.0).cos(), 100).unwrap();
assert_eq!(got.len(), 2, "{got:?}");
}
#[test]
fn short_and_malformed_area_files() {
let dir = TempDir::new("f1476-bad");
match read_area_file(&dir.path().join("x.1476"), 0.0, 0.0, 1.0, 1.0, 10) {
Err(ArcsecError::CatalogIo(e)) => assert_eq!(e.kind(), io::ErrorKind::NotFound),
other => panic!("{other:?}"),
}
let short = write_area(&dir, "short.1476", &[b' '; 60]);
assert!(
read_area_file(&short, 0.0, 0.0, 1.0, 1.0, 10)
.unwrap()
.is_empty()
);
for marker in [b' ', 7, 0] {
let mut bytes = vec![b' '; 110];
bytes[109] = marker;
bytes.extend_from_slice(&[0; 22]);
let path = write_area(&dir, "odd.1476", &bytes);
match read_area_file(&path, 0.0, 0.0, 1.0, 1.0, 10) {
Err(ArcsecError::CatalogIo(e)) => {
assert_eq!(e.kind(), io::ErrorKind::InvalidData);
}
other => panic!("marker {marker}: {other:?}"),
}
}
let mut bytes = area_file_bytes(
&[SkyStar {
ra: 0.1,
dec: 0.1,
mag: 3.0,
}],
5,
);
bytes.extend_from_slice(&[1, 2, 3]);
let path = write_area(&dir, "trunc.1476", &bytes);
assert_eq!(
read_area_file(&path, 0.1, 0.1, 0.1, 1.0, 10).unwrap().len(),
1
);
}
fn field(seed: u64, ra: f64, dec: f64, side_deg: f64, n: usize) -> Vec<SkyStar> {
random_sky(
&mut Rng::new(seed),
&SkySpec {
ra0: deg(ra),
dec0: deg(dec),
side_deg,
n,
min_sep_deg: 0.0,
mag_lo: 6.0,
mag_hi: 16.0,
},
)
}
fn in_window(ra0: f64, dec0: f64, side: f64, ra: f64, dec: f64) -> bool {
let half = side * 0.5;
let mut dra = (ra - ra0).abs();
if dra > PI {
dra = 2.0 * PI - dra;
}
dra * dec0.cos() < half && (dec - dec0).abs() < half
}
fn assert_field_read(all: &[SkyStar], got: &[CatalogStar], ra: f64, dec: f64, fov: f64) {
for s in all.iter().filter(|s| in_window(ra, dec, fov, s.ra, s.dec)) {
let n = got
.iter()
.filter(|g| separation(g.ra, g.dec, s.ra, s.dec) < 1e-6)
.count();
assert_eq!(n, 1, "{s:?} returned {n} times");
}
for g in got {
assert!(in_window(ra, dec, fov * 1.05, g.ra, g.dec), "{g:?} outside");
}
}
#[test]
fn layout_detection_and_presence() {
let dir = TempDir::new("layout");
assert!(!catalog_present(dir.path(), "d50"));
assert_eq!(detect_layout(dir.path(), "d50"), CatalogLayout::Areas1476);
write_290_db(dir.path(), "g05", &[]);
write_001_db(dir.path(), "w08", &[]);
write_1476_db(dir.path(), "d50", &[]);
assert_eq!(detect_layout(dir.path(), "g05"), CatalogLayout::Areas290);
assert_eq!(detect_layout(dir.path(), "w08"), CatalogLayout::AllSky001);
assert_eq!(detect_layout(dir.path(), "d50"), CatalogLayout::Areas1476);
for name in ["g05", "w08", "d50"] {
assert!(catalog_present(dir.path(), name), "{name}");
}
assert!(!catalog_present(dir.path(), "d80"));
}
#[test]
fn a_1476_database_returns_the_whole_field_across_tile_boundaries() {
let (ra, dec, fov) = (0.0, deg(5.2), deg(2.0));
let all = field(1, 0.0, 5.2, 4.0, 3000);
let dir = TempDir::new("db1476");
write_1476_db(dir.path(), "d50", &all);
let got = read_catalog_stars(dir.path(), "d50", ra, dec, fov, usize::MAX).unwrap();
assert_field_read(&all, &got, ra, dec, fov);
let capped = read_catalog_stars(dir.path(), "d50", ra, dec, fov, 25).unwrap();
assert_eq!(capped.len(), 25);
}
fn g05_field() -> (TempDir, Vec<CatalogStar>, (f64, f64, f64)) {
let (ra, dec, fov) = (deg(200.0), deg(-30.0), deg(12.0));
let all = field(2, 200.0, -30.0, 20.0, 4000);
let dir = TempDir::new("db290");
write_290_db(dir.path(), "g05", &all);
let full = read_catalog_stars(dir.path(), "g05", ra, dec, fov, usize::MAX).unwrap();
assert_field_read(&all, &full, ra, dec, fov);
(dir, full, (ra, dec, fov))
}
#[test]
fn a_290_database_reads_every_tile_and_keeps_to_its_budget() {
let (dir, _, (ra, dec, fov)) = g05_field();
let got = read_catalog_stars(dir.path(), "g05", ra, dec, fov, 100).unwrap();
assert_eq!(got.len(), 100);
assert!(got.windows(2).all(|w| w[0].mag <= w[1].mag), "sorted");
assert!(got.iter().any(|s| s.dec < dec - deg(3.0)));
assert!(got.iter().any(|s| s.dec > dec + deg(3.0)));
}
#[test]
#[ignore = "bug: the .290 reader's per-tile budget under-samples a tile that covers most of the field"]
fn a_290_database_returns_the_brightest_stars_of_the_field() {
let (dir, full, (ra, dec, fov)) = g05_field();
let got = read_catalog_stars(dir.path(), "g05", ra, dec, fov, 100).unwrap();
let mut mags: Vec<f64> = full.iter().map(|s| s.mag).collect();
mags.sort_by(f64::total_cmp);
let faintest = got.iter().map(|s| s.mag).fold(f64::MIN, f64::max);
assert!(
faintest <= mags[99] + 0.05,
"cut at mag {faintest}, but the field's 100th brightest is {}",
mags[99]
);
}
#[test]
fn an_all_sky_001_database_is_read_through_the_same_entry_point() {
let (ra, dec, fov) = (deg(90.0), deg(-50.0), deg(30.0));
let all = field(3, 90.0, -50.0, 60.0, 2000);
let dir = TempDir::new("db001");
write_001_db(dir.path(), "w08", &all);
let got = read_catalog_stars(dir.path(), "w08", ra, dec, fov, usize::MAX).unwrap();
assert_field_read(&all, &got, ra, dec, fov);
}
#[test]
fn a_corrupt_tile_is_an_error_but_a_missing_one_is_not() {
let (ra, dec, fov) = (deg(40.0), deg(20.0), deg(1.0));
let all = field(4, 40.0, 20.0, 2.0, 500);
for layout in ["1476", "290"] {
let dir = TempDir::new("corrupt");
if layout == "1476" {
write_1476_db(dir.path(), "db", &all);
} else {
write_290_db(dir.path(), "db", &all);
}
assert!(
!read_catalog_stars(dir.path(), "db", ra, dec, fov, 1000)
.unwrap()
.is_empty()
);
let tiles: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| !p.to_string_lossy().contains("_0101."))
.collect();
for t in &tiles {
let mut b = std::fs::read(t).unwrap();
b[109] = 9;
std::fs::write(t, b).unwrap();
}
assert!(
matches!(
read_catalog_stars(dir.path(), "db", ra, dec, fov, 1000),
Err(ArcsecError::CatalogIo(_))
),
"{layout}"
);
for t in &tiles {
std::fs::remove_file(t).unwrap();
}
assert!(
read_catalog_stars(dir.path(), "db", ra, dec, fov, 1000)
.unwrap()
.is_empty()
);
}
}
}