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 = record_size(&mmap)?;
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,
),
}
}
pub fn for_each_star_in_dec_band(
db_path: &Path,
db_name: &str,
dec_lo: f64,
dec_hi: f64,
mag_limit: f64,
mut f: impl FnMut(&CatalogStar),
) -> Result<()> {
let paths: Vec<std::path::PathBuf> = match detect_layout(db_path, db_name) {
CatalogLayout::AllSky001 => {
let all = super::format_001::read_001_file(
&db_path.join(format!("{db_name}_0101.001")),
0.0,
0.0,
4.0 * PI,
1.0,
usize::MAX,
)?;
for s in all.iter().filter(|s| s.dec >= dec_lo && s.dec <= dec_hi) {
if s.mag > mag_limit {
break;
}
f(s);
}
return Ok(());
}
CatalogLayout::Areas290 => super::areas_290::areas_in_dec_band_290(dec_lo, dec_hi)
.into_iter()
.map(|a| db_path.join(format!("{db_name}_{}", super::areas_290::filename_290(a))))
.collect(),
CatalogLayout::Areas1476 => super::areas::areas_in_dec_band_1476(dec_lo, dec_hi)
.into_iter()
.map(|a| db_path.join(format!("{db_name}_{}", filename_1476(a))))
.collect(),
};
for path in paths {
let cursor = match AreaCursor::open(&path) {
Ok(Some(c)) => c,
Ok(None) => continue,
Err(ArcsecError::CatalogIo(ref e)) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
let data = &cursor.mmap[..];
let rs = cursor.record_size;
let mut pos = cursor.pos;
let mut dec9 = 0i32;
let mut mag = 0.0f64;
while pos + rs <= data.len() {
let r = &data[pos..pos + 5];
pos += rs;
let ra_raw = (r[0] as u32) | ((r[1] as u32) << 8) | ((r[2] as u32) << 16);
if ra_raw == HEADER_SENTINEL {
mag = header_mag(r[4]);
if mag > mag_limit {
break;
}
dec9 = r[3] as i32 - 128;
continue;
}
let dec_raw = (dec9 << 16) | ((r[4] as i32) << 8) | (r[3] as i32);
let dec = dec_raw as f64 * DEC_SCALE;
if dec >= dec_lo && dec <= dec_hi {
f(&CatalogStar {
ra: ra_raw as f64 * RA_SCALE,
dec,
mag,
});
}
}
}
Ok(())
}
#[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);
read_brightest(
areas.into_iter().map(|area_nr| {
let fname = super::areas_290::filename_290(area_nr);
db_path.join(format!("{db_name}_{fname}"))
}),
telescope_ra,
telescope_dec,
fov,
max_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);
read_brightest(
areas.into_iter().map(|(area_nr, _frac)| {
let fname = filename_1476(area_nr);
db_path.join(format!("{db_name}_{fname}"))
}),
telescope_ra,
telescope_dec,
fov,
max_stars,
)
}
fn read_brightest(
paths: impl Iterator<Item = std::path::PathBuf>,
telescope_ra: f64,
telescope_dec: f64,
fov: f64,
max_stars: usize,
) -> Result<Vec<CatalogStar>> {
let window = Window {
ra: telescope_ra,
dec: telescope_dec,
half: fov * 1.05 * 0.5,
cos_dec: telescope_dec.cos().max(1e-6),
};
let mut cursors = Vec::new();
for path in paths {
match AreaCursor::open(&path) {
Ok(Some(c)) => cursors.push(c),
Ok(None) => {}
Err(ArcsecError::CatalogIo(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
let mut stars: Vec<CatalogStar> = Vec::new();
while stars.len() < max_stars {
let Some(group) = cursors
.iter()
.filter_map(AreaCursor::next_mag)
.min_by(f64::total_cmp)
else {
break; };
for c in &mut cursors {
c.read_through(group, &window, &mut stars);
}
}
stars.sort_by(|a, b| a.mag.total_cmp(&b.mag));
stars.truncate(max_stars);
Ok(stars)
}
struct Window {
ra: f64,
dec: f64,
half: f64,
cos_dec: f64,
}
impl Window {
fn contains(&self, ra: f64, dec: f64) -> bool {
let mut delta_ra = (ra - self.ra).abs();
if delta_ra > PI {
delta_ra = 2.0 * PI - delta_ra;
}
delta_ra * self.cos_dec < self.half && (dec - self.dec).abs() < self.half
}
}
struct AreaCursor {
mmap: Mmap,
record_size: usize,
pos: usize,
dec9: i32,
mag: f64,
}
impl AreaCursor {
fn open(path: &Path) -> Result<Option<Self>> {
let file = std::fs::File::open(path).map_err(ArcsecError::CatalogIo)?;
let mmap = unsafe { Mmap::map(&file).map_err(ArcsecError::CatalogIo)? };
if mmap.len() < 110 {
return Ok(None);
}
let record_size = record_size(&mmap)?;
Ok(Some(Self {
mmap,
record_size,
pos: 110,
dec9: 0,
mag: 0.0,
}))
}
fn next_mag(&self) -> Option<f64> {
let r = self.mmap.get(self.pos..self.pos + self.record_size)?;
Some(if r[..3] == [0xFF; 3] {
header_mag(r[4])
} else {
self.mag
})
}
fn read_through(&mut self, limit: f64, window: &Window, out: &mut Vec<CatalogStar>) {
let data = &self.mmap[..];
let rs = self.record_size;
while self.pos + rs <= data.len() {
let r = &data[self.pos..self.pos + 5];
let ra_raw = (r[0] as u32) | ((r[1] as u32) << 8) | ((r[2] as u32) << 16);
if ra_raw == HEADER_SENTINEL {
let mag = header_mag(r[4]);
if mag > limit {
return;
}
self.mag = mag;
self.dec9 = r[3] as i32 - 128; } else {
let ra = ra_raw as f64 * RA_SCALE;
let dec_raw = (self.dec9 << 16) | ((r[4] as i32) << 8) | (r[3] as i32);
let dec = dec_raw as f64 * DEC_SCALE;
if window.contains(ra, dec) {
out.push(CatalogStar {
ra,
dec,
mag: self.mag,
});
}
}
self.pos += rs;
}
}
}
fn header_mag(byte: u8) -> f64 {
(byte as f64 - 16.0) / 10.0
}
fn record_size(mmap: &[u8]) -> Result<usize> {
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}"),
)));
}
Ok(record_size)
}
#[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]
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]
);
assert_brightest(&full, &got, 100);
}
fn assert_brightest(full: &[CatalogStar], got: &[CatalogStar], n: usize) {
assert_eq!(got.len(), n);
assert!(
got.windows(2).all(|w| w[0].mag <= w[1].mag),
"brightest first"
);
let cut = got[n - 1].mag;
for s in full.iter().filter(|s| s.mag < cut) {
assert!(
got.iter()
.any(|g| separation(g.ra, g.dec, s.ra, s.dec) < 1e-9),
"{s:?} is brighter than the cut at {cut} but was not returned"
);
}
for g in got {
assert!(
full.iter()
.any(|s| separation(g.ra, g.dec, s.ra, s.dec) < 1e-9),
"{g:?} is not in the field"
);
}
}
#[test]
fn a_1476_budget_is_shared_across_every_tile_of_the_field() {
let (ra, dec, fov) = (deg(0.3), deg(5.0), deg(2.0));
assert_eq!(super::super::areas::find_areas_1476(ra, dec, fov).len(), 4);
let all = field(5, 0.3, 5.0, 4.0, 4000);
let dir = TempDir::new("db1476-budget");
write_1476_db(dir.path(), "d50", &all);
let full = read_catalog_stars(dir.path(), "d50", ra, dec, fov, usize::MAX).unwrap();
assert_field_read(&all, &full, ra, dec, fov);
for n in [25, 200, 600] {
let got = read_catalog_stars(dir.path(), "d50", ra, dec, fov, n).unwrap();
assert_brightest(&full, &got, n);
}
let got = read_catalog_stars(dir.path(), "d50", ra, dec, fov, 400).unwrap();
let ring = deg(5.142_857);
for (east, north) in [(false, false), (false, true), (true, false), (true, true)] {
let n = got
.iter()
.filter(|s| (s.ra < PI) == east && (s.dec > ring) == north)
.count();
assert!(n > 20, "quarter east={east} north={north} has {n} of 400");
}
}
#[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()
);
}
}
}