use crate::coordinates::Equatorial;
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::Path;
use std::time::{Duration, Instant};
use super::{StarCatalog, StarData, StarPosition};
use crate::StarfieldError;
pub const MAGIC_BYTES: &[u8; 6] = b"BINCAT";
pub const FORMAT_VERSION: u8 = 3;
pub const DESCRIPTION_LENGTH: usize = 128;
const HEADER_SIZE: u64 = 6 + 1 + 8 + DESCRIPTION_LENGTH as u64;
#[derive(Debug, Clone, Copy)]
pub struct ProgressUpdate {
pub bytes_read: u64,
pub total_bytes: Option<u64>,
pub stars_loaded: usize,
pub stars_total: usize,
pub elapsed: Duration,
}
const PROGRESS_STAR_INTERVAL: usize = 1_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct MinimalStar {
pub id: u64,
pub position: Equatorial,
pub magnitude: f64,
}
impl MinimalStar {
#[inline]
pub fn new(id: u64, ra_deg: f64, dec_deg: f64, magnitude: f64) -> Self {
Self {
id,
position: Equatorial::from_degrees(ra_deg, dec_deg),
magnitude,
}
}
pub fn with_position(id: u64, position: Equatorial, magnitude: f64) -> Self {
Self {
id,
position,
magnitude,
}
}
pub const fn size_bytes() -> usize {
32
}
#[inline]
pub fn write_binary<W: Write>(&self, writer: &mut W) -> io::Result<()> {
writer.write_u64::<LittleEndian>(self.id)?;
writer.write_f64::<LittleEndian>(self.position.ra_degrees())?;
writer.write_f64::<LittleEndian>(self.position.dec_degrees())?;
writer.write_f64::<LittleEndian>(self.magnitude)?;
Ok(())
}
#[inline]
pub fn read_binary<R: Read>(reader: &mut R) -> io::Result<Self> {
let id = reader.read_u64::<LittleEndian>()?;
let ra_deg = reader.read_f64::<LittleEndian>()?;
let dec_deg = reader.read_f64::<LittleEndian>()?;
let magnitude = reader.read_f64::<LittleEndian>()?;
Ok(MinimalStar {
id,
position: Equatorial::from_degrees(ra_deg, dec_deg),
magnitude,
})
}
}
impl StarPosition for MinimalStar {
fn ra(&self) -> f64 {
self.position.ra_degrees()
}
fn dec(&self) -> f64 {
self.position.dec_degrees()
}
}
#[derive(Debug, Clone)]
pub struct MinimalCatalog {
stars: Vec<MinimalStar>,
description: String,
}
impl MinimalCatalog {
pub fn new() -> Self {
Self {
stars: Vec::new(),
description: String::new(),
}
}
pub fn with_description(description: &str) -> Self {
Self {
stars: Vec::new(),
description: description.to_string(),
}
}
pub fn from_stars(stars: Vec<MinimalStar>, description: &str) -> Self {
Self {
stars,
description: description.to_string(),
}
}
pub fn description(&self) -> &str {
&self.description
}
pub fn len(&self) -> usize {
self.stars.len()
}
pub fn is_empty(&self) -> bool {
self.stars.is_empty()
}
pub fn max_magnitude(&self) -> f64 {
self.stars
.iter()
.map(|star| star.magnitude)
.fold(f64::MIN, f64::max)
}
pub fn stars(&self) -> &[MinimalStar] {
&self.stars
}
pub fn stars_mut(&mut self) -> &mut Vec<MinimalStar> {
&mut self.stars
}
pub fn add_star(self, star: MinimalStar) -> Self {
let mut new_stars = self.stars;
new_stars.push(star);
Self {
stars: new_stars,
description: self.description,
}
}
pub fn brighter_than(&self, magnitude: f64) -> Vec<&MinimalStar> {
self.stars
.iter()
.filter(|star| star.magnitude <= magnitude)
.collect()
}
pub fn filter<F>(&self, predicate: F) -> Vec<&MinimalStar>
where
F: Fn(&MinimalStar) -> bool,
{
self.stars.iter().filter(|star| predicate(star)).collect()
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), StarfieldError> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
writer.write_all(MAGIC_BYTES)?;
writer.write_u8(FORMAT_VERSION)?;
writer.write_u64::<LittleEndian>(self.stars.len() as u64)?;
let mut description_bytes = [0u8; DESCRIPTION_LENGTH];
let desc_bytes = self.description.as_bytes();
let copy_len = desc_bytes.len().min(DESCRIPTION_LENGTH);
description_bytes[..copy_len].copy_from_slice(&desc_bytes[..copy_len]);
writer.write_all(&description_bytes)?;
for star in &self.stars {
star.write_binary(&mut writer)?;
}
writer.flush()?;
Ok(())
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, StarfieldError> {
Self::load_with_progress(path, |_| {})
}
pub fn load_with_progress<P, F>(path: P, mut on_progress: F) -> Result<Self, StarfieldError>
where
P: AsRef<Path>,
F: FnMut(ProgressUpdate),
{
let start = Instant::now();
let file = File::open(&path)?;
let total_bytes = file.metadata().ok().map(|m| m.len());
let mut reader = BufReader::new(file);
let mut magic = [0u8; 6];
reader.read_exact(&mut magic)?;
if &magic != MAGIC_BYTES {
return Err(StarfieldError::DataError(
"Invalid minimal catalog format: incorrect magic bytes".to_string(),
));
}
let version = reader.read_u8()?;
if version != FORMAT_VERSION {
return Err(StarfieldError::DataError(format!(
"Unsupported minimal catalog version: {}. Expected version {}",
version, FORMAT_VERSION
)));
}
let star_count = reader.read_u64::<LittleEndian>()?;
let mut description_bytes = [0u8; DESCRIPTION_LENGTH];
reader.read_exact(&mut description_bytes)?;
let null_pos = description_bytes
.iter()
.position(|&b| b == 0)
.unwrap_or(DESCRIPTION_LENGTH);
let description = String::from_utf8_lossy(&description_bytes[..null_pos]).to_string();
let star_count_usize = star_count as usize;
let star_size = MinimalStar::size_bytes() as u64;
let make_update = |stars_loaded: usize, elapsed: Duration| ProgressUpdate {
bytes_read: HEADER_SIZE + (stars_loaded as u64) * star_size,
total_bytes,
stars_loaded,
stars_total: star_count_usize,
elapsed,
};
let mut stars = Vec::with_capacity(star_count_usize);
let mut next_callback_at = PROGRESS_STAR_INTERVAL;
for _ in 0..star_count {
match MinimalStar::read_binary(&mut reader) {
Ok(star) => stars.push(star),
Err(e) => {
if e.kind() == io::ErrorKind::UnexpectedEof {
return Err(StarfieldError::DataError(
"Truncated minimal catalog file".to_string(),
));
} else {
return Err(StarfieldError::IoError(e));
}
}
}
if stars.len() == next_callback_at {
on_progress(make_update(stars.len(), start.elapsed()));
next_callback_at += PROGRESS_STAR_INTERVAL;
}
}
if stars.len() != star_count_usize {
return Err(StarfieldError::DataError(format!(
"Expected {} stars but read {}",
star_count,
stars.len()
)));
}
on_progress(make_update(stars.len(), start.elapsed()));
Ok(Self { stars, description })
}
}
impl MinimalCatalog {
pub fn write_from_star_data<P, I>(
path: P,
stars: I,
description: &str,
star_count: Option<u64>,
) -> Result<u64, StarfieldError>
where
P: AsRef<Path>,
I: Iterator<Item = StarData>,
{
let file = File::create(&path)?;
let mut writer = BufWriter::new(file);
writer.write_all(MAGIC_BYTES)?;
writer.write_u8(FORMAT_VERSION)?;
let count_position = writer.stream_position()?;
writer.write_u64::<LittleEndian>(star_count.unwrap_or(0))?;
let mut description_bytes = [0u8; DESCRIPTION_LENGTH];
let desc_bytes = description.as_bytes();
let copy_len = desc_bytes.len().min(DESCRIPTION_LENGTH);
description_bytes[..copy_len].copy_from_slice(&desc_bytes[..copy_len]);
writer.write_all(&description_bytes)?;
let mut actual_count: u64 = 0;
for star in stars {
let minimal_star = MinimalStar::with_position(star.id, star.position, star.magnitude);
minimal_star.write_binary(&mut writer)?;
actual_count += 1;
}
if star_count.is_none() {
let current_position = writer.stream_position()?;
writer.seek(SeekFrom::Start(count_position))?;
writer.write_u64::<LittleEndian>(actual_count)?;
writer.seek(SeekFrom::Start(current_position))?;
}
writer.flush()?;
Ok(actual_count)
}
}
impl Default for MinimalCatalog {
fn default() -> Self {
Self::new()
}
}
impl StarCatalog for MinimalCatalog {
type Star = MinimalStar;
fn get_star(&self, id: usize) -> Option<&Self::Star> {
self.stars.iter().find(|star| star.id == id as u64)
}
fn stars(&self) -> impl Iterator<Item = &Self::Star> {
self.stars.iter()
}
fn len(&self) -> usize {
self.stars.len()
}
fn filter<F>(&self, predicate: F) -> Vec<&Self::Star>
where
F: Fn(&Self::Star) -> bool,
{
self.stars.iter().filter(|star| predicate(star)).collect()
}
fn star_data(&self) -> impl Iterator<Item = StarData> + '_ {
self.stars.iter().map(|star| {
StarData::with_position(
star.id,
star.position,
star.magnitude,
None, )
})
}
fn filter_star_data<F>(&self, predicate: F) -> Vec<StarData>
where
F: Fn(&StarData) -> bool,
{
self.star_data()
.filter(|star_data| predicate(star_data))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use tempfile::tempdir;
fn create_test_catalog() -> MinimalCatalog {
let stars = vec![
MinimalStar::new(1, 100.0, 10.0, -1.5), MinimalStar::new(2, 50.0, -20.0, 0.5), MinimalStar::new(3, 150.0, 30.0, 1.2), MinimalStar::new(4, 200.0, -45.0, 3.7), MinimalStar::new(5, 250.0, 60.0, 5.9), ];
MinimalCatalog::from_stars(stars, "Test star catalog with bright stars")
}
#[test]
fn test_minimal_star_binary_roundtrip() {
let star = MinimalStar::new(42, 123.456, -45.678, 3.21);
let mut buffer = Vec::new();
star.write_binary(&mut buffer).unwrap();
let mut cursor = Cursor::new(buffer);
let read_star = MinimalStar::read_binary(&mut cursor).unwrap();
assert_eq!(star.id, read_star.id);
assert_eq!(star.ra(), read_star.ra());
assert_eq!(star.dec(), read_star.dec());
assert_eq!(star.magnitude, read_star.magnitude);
}
#[test]
fn test_catalog_save_load_roundtrip() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test_catalog.bin");
let catalog = create_test_catalog();
catalog.save(&file_path).unwrap();
let loaded_catalog = MinimalCatalog::load(&file_path).unwrap();
assert_eq!(catalog.len(), loaded_catalog.len());
assert_eq!(catalog.max_magnitude(), loaded_catalog.max_magnitude());
for (orig, loaded) in catalog.stars().iter().zip(loaded_catalog.stars().iter()) {
assert_eq!(orig.id, loaded.id);
assert_eq!(orig.ra(), loaded.ra());
assert_eq!(orig.dec(), loaded.dec());
assert_eq!(orig.magnitude, loaded.magnitude);
}
}
#[test]
fn test_brighter_than_filter() {
let catalog = create_test_catalog();
let bright_stars = catalog.brighter_than(1.0);
assert_eq!(bright_stars.len(), 2);
let visible_stars = catalog.brighter_than(6.0);
assert_eq!(visible_stars.len(), 5);
let very_bright = catalog.brighter_than(-1.0);
assert_eq!(very_bright.len(), 1); }
#[test]
fn test_custom_filter() {
let catalog = create_test_catalog();
let northern_stars = catalog.filter(|star| star.dec() > 0.0);
assert_eq!(northern_stars.len(), 3);
for star in northern_stars {
assert!(
star.dec() > 0.0,
"Expected star to have positive declination"
);
}
let ra_range_stars = catalog.filter(|star| star.ra() >= 100.0 && star.ra() <= 200.0);
let expected_count = catalog
.stars()
.iter()
.filter(|star| star.ra() >= 100.0 && star.ra() <= 200.0)
.count();
assert_eq!(ra_range_stars.len(), expected_count);
}
#[test]
fn test_invalid_magic_bytes() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("invalid_catalog.bin");
let file = File::create(&file_path).unwrap();
let mut writer = BufWriter::new(file);
writer.write_all(b"BADCAT").unwrap();
writer.write_u8(FORMAT_VERSION).unwrap();
writer.write_u64::<LittleEndian>(0).unwrap();
let empty_desc = [0u8; DESCRIPTION_LENGTH];
writer.write_all(&empty_desc).unwrap();
writer.flush().unwrap();
let result = MinimalCatalog::load(&file_path);
assert!(result.is_err());
if let Err(StarfieldError::DataError(msg)) = result {
assert!(msg.contains("incorrect magic bytes"));
} else {
panic!("Expected DataError with 'incorrect magic bytes' message");
}
}
#[test]
fn test_invalid_version() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("invalid_version.bin");
let file = File::create(&file_path).unwrap();
let mut writer = BufWriter::new(file);
writer.write_all(MAGIC_BYTES).unwrap();
writer.write_u8(2).unwrap(); writer.write_u64::<LittleEndian>(0).unwrap();
let empty_desc = [0u8; DESCRIPTION_LENGTH];
writer.write_all(&empty_desc).unwrap();
writer.flush().unwrap();
let result = MinimalCatalog::load(&file_path);
assert!(result.is_err());
if let Err(StarfieldError::DataError(msg)) = result {
assert!(msg.contains("Unsupported minimal catalog version"));
} else {
panic!("Expected DataError with 'Unsupported minimal catalog version' message");
}
}
#[test]
fn test_truncated_file() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("truncated_catalog.bin");
let file = File::create(&file_path).unwrap();
let mut writer = BufWriter::new(file);
writer.write_all(MAGIC_BYTES).unwrap();
writer.write_u8(FORMAT_VERSION).unwrap();
writer.write_u64::<LittleEndian>(5).unwrap();
let empty_desc = [0u8; DESCRIPTION_LENGTH];
writer.write_all(&empty_desc).unwrap();
MinimalStar::new(1, 100.0, 10.0, 1.5)
.write_binary(&mut writer)
.unwrap();
MinimalStar::new(2, 50.0, -20.0, 2.5)
.write_binary(&mut writer)
.unwrap();
writer.flush().unwrap();
let result = MinimalCatalog::load(&file_path);
assert!(result.is_err());
if let Err(StarfieldError::DataError(msg)) = result {
assert!(msg.contains("Truncated minimal catalog") || msg.contains("Expected"));
} else {
panic!("Expected DataError with truncated file message");
}
}
#[test]
fn test_load_with_progress_callback() {
use std::cell::RefCell;
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("progress_catalog.bin");
let catalog = create_test_catalog();
catalog.save(&file_path).unwrap();
let updates = RefCell::new(Vec::new());
let loaded = MinimalCatalog::load_with_progress(&file_path, |u| {
updates.borrow_mut().push(u);
})
.unwrap();
assert_eq!(loaded.len(), catalog.len());
let updates = updates.into_inner();
assert_eq!(updates.len(), 1);
let last = updates.last().unwrap();
assert_eq!(last.stars_loaded, catalog.len());
assert_eq!(last.stars_total, catalog.len());
let expected_bytes =
HEADER_SIZE + (catalog.len() as u64) * MinimalStar::size_bytes() as u64;
assert_eq!(last.bytes_read, expected_bytes);
assert_eq!(last.total_bytes, Some(expected_bytes));
}
#[test]
fn test_write_from_star_data() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("streamed_catalog.bin");
let star_data = vec![
StarData::new(1, 100.0, 10.0, -1.5, Some(-0.4)),
StarData::new(2, 50.0, -20.0, 0.5, Some(0.1)),
StarData::new(3, 150.0, 30.0, 1.2, Some(0.5)),
StarData::new(4, 200.0, -45.0, 3.7, Some(1.1)),
StarData::new(5, 250.0, 60.0, 5.9, Some(1.5)),
];
let count = MinimalCatalog::write_from_star_data(
&file_path,
star_data.iter().copied(),
"Test streaming catalog",
Some(star_data.len() as u64),
)
.unwrap();
assert_eq!(count, 5);
let loaded_catalog = MinimalCatalog::load(&file_path).unwrap();
assert_eq!(loaded_catalog.len(), 5);
assert_eq!(loaded_catalog.description(), "Test streaming catalog");
let file_path2 = temp_dir.path().join("streamed_catalog2.bin");
let count2 = MinimalCatalog::write_from_star_data(
&file_path2,
star_data.iter().copied(),
"Test streaming catalog 2",
None,
)
.unwrap();
assert_eq!(count2, 5);
let loaded_catalog2 = MinimalCatalog::load(&file_path2).unwrap();
assert_eq!(loaded_catalog2.len(), 5);
for (i, star) in loaded_catalog.stars().iter().enumerate() {
let original = &star_data[i];
assert_eq!(star.id, original.id);
assert_eq!(star.ra(), original.ra());
assert_eq!(star.dec(), original.dec());
assert_eq!(star.magnitude, original.magnitude);
}
}
}