1use std::collections::HashMap;
41use std::fs::File;
42use std::io::{BufReader, Read};
43use std::path::{Path, PathBuf};
44
45const MAGIC: &[u8; 4] = b"BC5D";
47
48const SUPPORTED_VERSION: u32 = 2;
50
51#[derive(Debug)]
53pub struct Bc5dTable {
54 caliber: f32,
56 data: Vec<f32>,
58 weight_bins: Vec<f32>,
60 bc_bins: Vec<f32>,
62 muzzle_vel_bins: Vec<f32>,
64 current_vel_bins: Vec<f32>,
66 num_drag_types: usize,
68 version: u32,
70 api_version: String,
72 timestamp: u64,
74}
75
76#[cfg(any(test, target_arch = "wasm32"))]
78pub(crate) struct Bc5dSegmentSchedule {
79 pub(crate) segments: Vec<crate::BCSegmentData>,
80 pub(crate) fallback_bc: f64,
81}
82
83#[derive(Debug, Default)]
85pub struct Bc5dTableManager {
86 table_dir: Option<PathBuf>,
88 tables: HashMap<i32, Bc5dTable>,
90}
91
92#[derive(Debug)]
94pub enum Bc5dError {
95 IoError(std::io::Error),
96 InvalidMagic,
97 UnsupportedVersion(u32),
98 ChecksumMismatch { expected: u32, actual: u32 },
99 InvalidDimensions,
100 TableNotFound(f64),
101 NoTableDirectory,
102}
103
104impl std::fmt::Display for Bc5dError {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 match self {
107 Bc5dError::IoError(e) => write!(f, "IO error: {}", e),
108 Bc5dError::InvalidMagic => write!(f, "Invalid file magic (expected 'BC5D')"),
109 Bc5dError::UnsupportedVersion(v) => write!(f, "Unsupported table version: {}", v),
110 Bc5dError::ChecksumMismatch { expected, actual } => {
111 write!(f, "Checksum mismatch: expected {:08x}, got {:08x}", expected, actual)
112 }
113 Bc5dError::InvalidDimensions => write!(f, "Invalid table dimensions"),
114 Bc5dError::TableNotFound(cal) => write!(f, "No BC5D table found for caliber {:.3}", cal),
115 Bc5dError::NoTableDirectory => write!(f, "No BC table directory configured"),
116 }
117 }
118}
119
120impl std::error::Error for Bc5dError {}
121
122impl From<std::io::Error> for Bc5dError {
123 fn from(e: std::io::Error) -> Self {
124 Bc5dError::IoError(e)
125 }
126}
127
128impl Bc5dTable {
129 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Bc5dError> {
131 let file = File::open(&path)?;
132 Self::from_reader(BufReader::new(file))
133 }
134
135 pub fn from_bytes(bytes: &[u8]) -> Result<Self, Bc5dError> {
142 Self::from_reader(std::io::Cursor::new(bytes))
143 }
144
145 fn from_reader<R: Read>(mut reader: R) -> Result<Self, Bc5dError> {
146 let mut magic = [0u8; 4];
148 reader.read_exact(&mut magic)?;
149 if &magic != MAGIC {
150 return Err(Bc5dError::InvalidMagic);
151 }
152
153 let version = read_u32(&mut reader)?;
155 if version != SUPPORTED_VERSION {
156 return Err(Bc5dError::UnsupportedVersion(version));
157 }
158
159 let caliber = read_f32(&mut reader)?;
160 let _flags = read_u32(&mut reader)?;
161 let _padding = read_u32(&mut reader)?;
162
163 let dim_weight = read_u32(&mut reader)? as usize;
164 let dim_bc = read_u32(&mut reader)? as usize;
165 let dim_muzzle_vel = read_u32(&mut reader)? as usize;
166 let dim_current_vel = read_u32(&mut reader)? as usize;
167 let dim_drag_types = read_u32(&mut reader)? as usize;
168
169 let timestamp = read_u64(&mut reader)?;
170 let stored_checksum = read_u32(&mut reader)?;
171
172 let mut api_version_bytes = [0u8; 16];
174 reader.read_exact(&mut api_version_bytes)?;
175 let api_version = String::from_utf8_lossy(&api_version_bytes)
176 .trim_end_matches('\0')
177 .to_string();
178
179 let mut reserved = [0u8; 12];
181 reader.read_exact(&mut reserved)?;
182
183 if dim_weight == 0 || dim_bc == 0 || dim_muzzle_vel == 0 || dim_current_vel == 0 || dim_drag_types == 0 {
185 return Err(Bc5dError::InvalidDimensions);
186 }
187
188 let weight_bins = read_f32_array(&mut reader, dim_weight)?;
190 let bc_bins = read_f32_array(&mut reader, dim_bc)?;
191 let muzzle_vel_bins = read_f32_array(&mut reader, dim_muzzle_vel)?;
192 let current_vel_bins = read_f32_array(&mut reader, dim_current_vel)?;
193
194 const MAX_TOTAL_CELLS: usize = 64_000_000; let total_cells = dim_drag_types
199 .checked_mul(dim_weight)
200 .and_then(|x| x.checked_mul(dim_bc))
201 .and_then(|x| x.checked_mul(dim_muzzle_vel))
202 .and_then(|x| x.checked_mul(dim_current_vel))
203 .filter(|&n| n <= MAX_TOTAL_CELLS)
204 .ok_or(Bc5dError::InvalidDimensions)?;
205 let data = read_f32_array(&mut reader, total_cells)?;
206
207 let mut checksum_data = Vec::new();
209 for &v in &weight_bins {
210 checksum_data.extend_from_slice(&v.to_le_bytes());
211 }
212 for &v in &bc_bins {
213 checksum_data.extend_from_slice(&v.to_le_bytes());
214 }
215 for &v in &muzzle_vel_bins {
216 checksum_data.extend_from_slice(&v.to_le_bytes());
217 }
218 for &v in ¤t_vel_bins {
219 checksum_data.extend_from_slice(&v.to_le_bytes());
220 }
221 for &v in &data {
222 checksum_data.extend_from_slice(&v.to_le_bytes());
223 }
224
225 let calculated_checksum = crc32_ieee(&checksum_data);
226 if calculated_checksum != stored_checksum {
227 return Err(Bc5dError::ChecksumMismatch {
228 expected: stored_checksum,
229 actual: calculated_checksum,
230 });
231 }
232
233 Ok(Bc5dTable {
234 caliber,
235 data,
236 weight_bins,
237 bc_bins,
238 muzzle_vel_bins,
239 current_vel_bins,
240 num_drag_types: dim_drag_types,
241 version,
242 api_version,
243 timestamp,
244 })
245 }
246
247 pub fn lookup(
260 &self,
261 weight_grains: f64,
262 base_bc: f64,
263 muzzle_velocity: f64,
264 current_velocity: f64,
265 drag_type: &str,
266 ) -> f64 {
267 let drag_idx = if drag_type.eq_ignore_ascii_case("G7") { 1 } else { 0 };
269
270 let drag_idx = drag_idx.min(self.num_drag_types - 1);
272
273 let (weight_idx, weight_w) = self.interp_idx(weight_grains as f32, &self.weight_bins);
275 let (bc_idx, bc_w) = self.interp_idx(base_bc as f32, &self.bc_bins);
276 let (muzzle_idx, muzzle_w) = self.interp_idx(muzzle_velocity as f32, &self.muzzle_vel_bins);
277 let (current_idx, current_w) = self.interp_idx(current_velocity as f32, &self.current_vel_bins);
278
279 let mut result = 0.0f64;
281
282 for dw in 0..2 {
283 for db in 0..2 {
284 for dm in 0..2 {
285 for dc in 0..2 {
286 let weight = (if dw == 0 { 1.0 - weight_w } else { weight_w })
288 * (if db == 0 { 1.0 - bc_w } else { bc_w })
289 * (if dm == 0 { 1.0 - muzzle_w } else { muzzle_w })
290 * (if dc == 0 { 1.0 - current_w } else { current_w });
291
292 let wi = (weight_idx + dw).min(self.weight_bins.len() - 1);
294 let bi = (bc_idx + db).min(self.bc_bins.len() - 1);
295 let mi = (muzzle_idx + dm).min(self.muzzle_vel_bins.len() - 1);
296 let ci = (current_idx + dc).min(self.current_vel_bins.len() - 1);
297
298 let idx = self.flat_index(drag_idx, wi, bi, mi, ci);
300 result += weight * self.data[idx] as f64;
301 }
302 }
303 }
304 }
305
306 if !result.is_finite() {
309 return 1.0;
310 }
311
312 result.clamp(0.5, 1.5)
313 }
314
315 pub fn get_effective_bc(
319 &self,
320 weight_grains: f64,
321 base_bc: f64,
322 muzzle_velocity: f64,
323 current_velocity: f64,
324 drag_type: &str,
325 ) -> f64 {
326 let correction = self.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type);
327 base_bc * correction
328 }
329
330 pub fn generate_segments(
345 &self,
346 base_bc: f64,
347 drag_type: &str,
348 weight_grains: f64,
349 muzzle_velocity_fps: Option<f64>,
350 ) -> Option<Vec<crate::BCSegmentData>> {
351 let mut breakpoints: Vec<f64> = vec![
352 4000.0, 3500.0, 3000.0, 2700.0, 2500.0, 2300.0, 2100.0, 2000.0, 1900.0, 1800.0, 1700.0,
353 1600.0, 1500.0, 1400.0, 1350.0, 1300.0, 1250.0, 1200.0, 1150.0, 1100.0, 1050.0, 1000.0,
354 950.0, 900.0, 850.0, 800.0, 700.0, 600.0, 500.0,
355 ];
356 if let Some(mv) = muzzle_velocity_fps {
357 breakpoints.push(mv);
358 }
359
360 let mut velocities: Vec<f64> = breakpoints
361 .into_iter()
362 .filter(|&v| v >= 500.0 && muzzle_velocity_fps.map_or(true, |mv| v <= mv))
363 .collect();
364 velocities.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
365 velocities.dedup();
366
367 let reference_mv = velocities.first().copied().unwrap_or(3000.0);
370
371 let mut segments: Vec<crate::BCSegmentData> = Vec::new();
372 let mut any_correction = false;
373 for i in 0..velocities.len().saturating_sub(1) {
374 let vel_max = velocities[i];
375 let vel_min = velocities[i + 1];
376 let vel_mid = (vel_max + vel_min) / 2.0;
377
378 let correction =
379 self.lookup(weight_grains, base_bc, reference_mv, vel_mid, drag_type);
380 if (correction - 1.0).abs() > 1e-6 {
381 any_correction = true;
382 }
383 segments.push(crate::BCSegmentData {
384 velocity_min: vel_min,
385 velocity_max: vel_max,
386 bc_value: base_bc * correction,
387 });
388 }
389
390 if any_correction && !segments.is_empty() {
391 Some(segments)
392 } else {
393 None
394 }
395 }
396
397 #[cfg(any(test, target_arch = "wasm32"))]
399 pub(crate) fn generate_segment_schedule(
400 &self,
401 base_bc: f64,
402 drag_type: &str,
403 weight_grains: f64,
404 muzzle_velocity_fps: f64,
405 ) -> Option<Bc5dSegmentSchedule> {
406 let segments =
407 self.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_velocity_fps))?;
408 let fallback_bc = self.get_effective_bc(
409 weight_grains,
410 base_bc,
411 muzzle_velocity_fps,
412 muzzle_velocity_fps,
413 drag_type,
414 );
415
416 Some(Bc5dSegmentSchedule {
417 segments,
418 fallback_bc,
419 })
420 }
421
422 fn interp_idx(&self, value: f32, bins: &[f32]) -> (usize, f64) {
424 if bins.len() < 2 || value.is_nan() {
425 return (0, 0.0);
426 }
427
428 if value <= bins[0] {
430 return (0, 0.0);
431 }
432 if value >= bins[bins.len() - 1] {
433 return (bins.len().saturating_sub(2), 1.0);
434 }
435
436 let last_interval = bins.len().saturating_sub(2);
438 let idx = match bins.binary_search_by(|probe| {
439 probe
440 .partial_cmp(&value)
441 .unwrap_or(std::cmp::Ordering::Equal)
442 }) {
443 Ok(i) => i.saturating_sub(1).min(last_interval),
444 Err(i) => i.saturating_sub(1).min(last_interval),
445 };
446
447 let low = bins[idx];
449 let high = bins[idx + 1];
450 let weight = if high > low {
451 ((value - low) / (high - low)) as f64
452 } else {
453 0.0
454 };
455
456 (idx, weight)
457 }
458
459 fn flat_index(&self, drag_idx: usize, weight_idx: usize, bc_idx: usize, muzzle_idx: usize, current_idx: usize) -> usize {
461 let n_weight = self.weight_bins.len();
462 let n_bc = self.bc_bins.len();
463 let n_muzzle = self.muzzle_vel_bins.len();
464 let n_current = self.current_vel_bins.len();
465
466 drag_idx * (n_weight * n_bc * n_muzzle * n_current)
467 + weight_idx * (n_bc * n_muzzle * n_current)
468 + bc_idx * (n_muzzle * n_current)
469 + muzzle_idx * n_current
470 + current_idx
471 }
472
473 pub fn caliber(&self) -> f32 {
475 self.caliber
476 }
477
478 pub fn version(&self) -> u32 {
480 self.version
481 }
482
483 pub fn api_version(&self) -> &str {
485 &self.api_version
486 }
487
488 pub fn timestamp(&self) -> u64 {
490 self.timestamp
491 }
492
493 pub fn total_cells(&self) -> usize {
495 self.data.len()
496 }
497
498 pub fn dimensions_str(&self) -> String {
500 format!(
501 "{}x{}x{}x{}x{} (weight x bc x muzzle_vel x current_vel x drag_types)",
502 self.weight_bins.len(),
503 self.bc_bins.len(),
504 self.muzzle_vel_bins.len(),
505 self.current_vel_bins.len(),
506 self.num_drag_types
507 )
508 }
509
510 pub fn weight_range(&self) -> (f32, f32) {
512 (*self.weight_bins.first().unwrap_or(&0.0), *self.weight_bins.last().unwrap_or(&0.0))
513 }
514
515 pub fn velocity_range(&self) -> (f32, f32) {
517 (*self.current_vel_bins.first().unwrap_or(&0.0), *self.current_vel_bins.last().unwrap_or(&0.0))
518 }
519}
520
521impl Bc5dTableManager {
522 pub fn new<P: AsRef<Path>>(table_dir: P) -> Self {
524 Bc5dTableManager {
525 table_dir: Some(table_dir.as_ref().to_path_buf()),
526 tables: HashMap::new(),
527 }
528 }
529
530 pub fn empty() -> Self {
532 Bc5dTableManager {
533 table_dir: None,
534 tables: HashMap::new(),
535 }
536 }
537
538 pub fn get_table(&mut self, caliber: f64) -> Result<&Bc5dTable, Bc5dError> {
542 let caliber_key = caliber_to_key(caliber);
543
544 if self.tables.contains_key(&caliber_key) {
546 return Ok(self.tables.get(&caliber_key).unwrap());
547 }
548
549 let table_dir = self.table_dir.as_ref().ok_or(Bc5dError::NoTableDirectory)?;
551 let table_path = find_table_file(table_dir, caliber)?;
552 let table = Bc5dTable::load(&table_path)?;
553 self.tables.insert(caliber_key, table);
554 Ok(self.tables.get(&caliber_key).unwrap())
555 }
556
557 pub fn lookup(
559 &mut self,
560 caliber: f64,
561 weight_grains: f64,
562 base_bc: f64,
563 muzzle_velocity: f64,
564 current_velocity: f64,
565 drag_type: &str,
566 ) -> Result<f64, Bc5dError> {
567 let table = self.get_table(caliber)?;
568 Ok(table.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
569 }
570
571 pub fn get_effective_bc(
573 &mut self,
574 caliber: f64,
575 weight_grains: f64,
576 base_bc: f64,
577 muzzle_velocity: f64,
578 current_velocity: f64,
579 drag_type: &str,
580 ) -> Result<f64, Bc5dError> {
581 let table = self.get_table(caliber)?;
582 Ok(table.get_effective_bc(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
583 }
584
585 pub fn has_table(&self, caliber: f64) -> bool {
587 if let Some(ref table_dir) = self.table_dir {
588 find_table_file(table_dir, caliber).is_ok()
589 } else {
590 false
591 }
592 }
593
594 pub fn available_calibers(&self) -> Vec<f64> {
596 let mut calibers = Vec::new();
597 if let Some(ref table_dir) = self.table_dir {
598 if let Ok(entries) = std::fs::read_dir(table_dir) {
599 for entry in entries.flatten() {
600 let path = entry.path();
601 if let Some(ext) = path.extension() {
602 if ext == "bin" {
603 if let Some(stem) = path.file_stem() {
604 let name = stem.to_string_lossy();
605 if name.starts_with("bc5d_") {
606 if let Ok(cal_int) = name[5..].parse::<i32>() {
608 calibers.push(cal_int as f64 / 1000.0);
609 }
610 }
611 }
612 }
613 }
614 }
615 }
616 }
617 calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
618 calibers
619 }
620}
621
622fn caliber_to_key(caliber: f64) -> i32 {
624 (caliber * 1000.0).round() as i32
625}
626
627fn find_table_file(table_dir: &Path, caliber: f64) -> Result<PathBuf, Bc5dError> {
629 let caliber_int = (caliber * 1000.0).round() as i32;
630 let filename = format!("bc5d_{}.bin", caliber_int);
631 let path = table_dir.join(&filename);
632
633 if path.exists() {
634 return Ok(path);
635 }
636
637 let variations = [
639 format!("bc5d_{:03}.bin", caliber_int),
640 format!("bc5d_0{}.bin", caliber_int),
641 ];
642
643 for var in &variations {
644 let var_path = table_dir.join(var);
645 if var_path.exists() {
646 return Ok(var_path);
647 }
648 }
649
650 Err(Bc5dError::TableNotFound(caliber))
651}
652
653fn read_u32<R: Read>(reader: &mut R) -> Result<u32, std::io::Error> {
656 let mut buf = [0u8; 4];
657 reader.read_exact(&mut buf)?;
658 Ok(u32::from_le_bytes(buf))
659}
660
661fn read_u64<R: Read>(reader: &mut R) -> Result<u64, std::io::Error> {
662 let mut buf = [0u8; 8];
663 reader.read_exact(&mut buf)?;
664 Ok(u64::from_le_bytes(buf))
665}
666
667fn read_f32<R: Read>(reader: &mut R) -> Result<f32, std::io::Error> {
668 let mut buf = [0u8; 4];
669 reader.read_exact(&mut buf)?;
670 Ok(f32::from_le_bytes(buf))
671}
672
673fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
674 const MAX_ELEMS: usize = 64_000_000; if count > MAX_ELEMS {
678 return Err(std::io::Error::new(
679 std::io::ErrorKind::InvalidData,
680 "f32 array length too large",
681 ));
682 }
683 let byte_len = count.checked_mul(4).ok_or_else(|| {
684 std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
685 })?;
686 let mut data = vec![0f32; count];
687 let mut buf = vec![0u8; byte_len];
688 reader.read_exact(&mut buf)?;
689
690 for (i, chunk) in buf.chunks_exact(4).enumerate() {
691 data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
692 }
693
694 Ok(data)
695}
696
697pub(crate) fn crc32_ieee(data: &[u8]) -> u32 {
699 const TABLE: [u32; 256] = make_crc32_table();
700 let mut crc = 0xFFFFFFFFu32;
701 for &byte in data {
702 let idx = ((crc ^ byte as u32) & 0xFF) as usize;
703 crc = (crc >> 8) ^ TABLE[idx];
704 }
705 !crc
706}
707
708const fn make_crc32_table() -> [u32; 256] {
709 const POLY: u32 = 0xEDB88320;
710 let mut table = [0u32; 256];
711 let mut i = 0;
712 while i < 256 {
713 let mut crc = i as u32;
714 let mut j = 0;
715 while j < 8 {
716 if crc & 1 != 0 {
717 crc = (crc >> 1) ^ POLY;
718 } else {
719 crc >>= 1;
720 }
721 j += 1;
722 }
723 table[i] = crc;
724 i += 1;
725 }
726 table
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 fn create_test_table() -> Bc5dTable {
734 let weight_bins = vec![100.0, 150.0, 200.0];
736 let bc_bins = vec![0.3, 0.4, 0.5];
737 let muzzle_vel_bins = vec![2500.0, 3000.0];
738 let current_vel_bins = vec![1000.0, 2000.0, 3000.0];
739 let num_drag_types = 2;
740
741 let total = num_drag_types * weight_bins.len() * bc_bins.len() * muzzle_vel_bins.len() * current_vel_bins.len();
743 let mut data = vec![1.0f32; total];
744
745 data[0] = 0.95; data[total - 1] = 1.05; Bc5dTable {
752 caliber: 0.308,
753 data,
754 weight_bins,
755 bc_bins,
756 muzzle_vel_bins,
757 current_vel_bins,
758 num_drag_types,
759 version: 2,
760 api_version: "test".to_string(),
761 timestamp: 0,
762 }
763 }
764
765 fn create_single_cell_test_table() -> Bc5dTable {
766 Bc5dTable {
767 caliber: 0.308,
768 data: vec![0.875],
769 weight_bins: vec![168.0],
770 bc_bins: vec![0.4],
771 muzzle_vel_bins: vec![2500.0],
772 current_vel_bins: vec![2000.0],
773 num_drag_types: 1,
774 version: 2,
775 api_version: "test".to_string(),
776 timestamp: 0,
777 }
778 }
779
780 fn serialize_test_table(t: &Bc5dTable) -> Vec<u8> {
783 let mut out = Vec::new();
784 out.extend_from_slice(MAGIC);
785 out.extend_from_slice(&t.version.to_le_bytes());
786 out.extend_from_slice(&t.caliber.to_le_bytes());
787 out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&(t.weight_bins.len() as u32).to_le_bytes());
790 out.extend_from_slice(&(t.bc_bins.len() as u32).to_le_bytes());
791 out.extend_from_slice(&(t.muzzle_vel_bins.len() as u32).to_le_bytes());
792 out.extend_from_slice(&(t.current_vel_bins.len() as u32).to_le_bytes());
793 out.extend_from_slice(&(t.num_drag_types as u32).to_le_bytes());
794 out.extend_from_slice(&t.timestamp.to_le_bytes());
795
796 let mut checksum_data = Vec::new();
798 for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
799 .chain(&t.current_vel_bins).chain(&t.data) {
800 checksum_data.extend_from_slice(&v.to_le_bytes());
801 }
802 out.extend_from_slice(&crc32_ieee(&checksum_data).to_le_bytes());
803
804 let mut api = [0u8; 16];
805 let bytes = t.api_version.as_bytes();
806 api[..bytes.len().min(16)].copy_from_slice(&bytes[..bytes.len().min(16)]);
807 out.extend_from_slice(&api);
808 out.extend_from_slice(&[0u8; 12]); for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
811 .chain(&t.current_vel_bins).chain(&t.data) {
812 out.extend_from_slice(&v.to_le_bytes());
813 }
814 out
815 }
816
817 #[test]
818 fn test_from_bytes_roundtrip() {
819 let original = create_test_table();
820 let bytes = serialize_test_table(&original);
821 let parsed = Bc5dTable::from_bytes(&bytes).expect("from_bytes should parse");
822
823 assert_eq!(parsed.caliber, original.caliber);
824 assert_eq!(parsed.num_drag_types, original.num_drag_types);
825 assert_eq!(parsed.weight_bins, original.weight_bins);
826 assert_eq!(parsed.current_vel_bins, original.current_vel_bins);
827 assert_eq!(parsed.data, original.data);
828 assert_eq!(parsed.api_version, original.api_version);
829
830 let mut bad = bytes.clone();
832 *bad.last_mut().unwrap() ^= 0xFF;
833 assert!(Bc5dTable::from_bytes(&bad).is_err());
834 }
835
836 #[test]
837 fn test_generate_segments() {
838 let mut uniform = create_test_table();
841 uniform.data.iter_mut().for_each(|v| *v = 1.0);
842 assert!(uniform
843 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
844 .is_none());
845
846 let mut corrected = create_test_table();
849 corrected.data.iter_mut().for_each(|v| *v = 0.9);
850 let segments = corrected
851 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
852 .expect("segments expected for a table with corrections");
853 assert!(!segments.is_empty());
854 for w in segments.windows(2) {
855 assert!((segments[0].velocity_max - w[0].velocity_max).abs() >= 0.0);
857 assert!(w[0].velocity_min >= w[1].velocity_max - 1e-6);
858 }
859 for s in &segments {
860 assert!((s.bc_value - 0.4 * 0.9).abs() < 1e-6); assert!(s.velocity_max > s.velocity_min);
862 }
863 }
864
865 #[test]
866 fn segment_schedule_carries_muzzle_corrected_fallback_bc() {
867 let table = create_single_cell_test_table();
868 let base_bc = 0.4;
869 let schedule = table
870 .generate_segment_schedule(base_bc, "G1", 168.0, 2500.0)
871 .expect("uniform non-neutral correction should produce a schedule");
872 let expected_fallback = table.get_effective_bc(168.0, base_bc, 2500.0, 2500.0, "G1");
873
874 assert!(!schedule.segments.is_empty());
875 assert_eq!(expected_fallback.to_bits(), (base_bc * 0.875).to_bits());
876 assert_eq!(schedule.fallback_bc.to_bits(), expected_fallback.to_bits());
877 }
878
879 #[test]
880 fn test_interp_idx_in_range() {
881 let table = create_test_table();
882
883 let (idx, weight) = table.interp_idx(125.0, &table.weight_bins);
885 assert_eq!(idx, 0);
886 assert!((weight - 0.5).abs() < 0.01);
887
888 let (idx, weight) = table.interp_idx(150.0, &table.weight_bins);
890 assert_eq!(idx, 0);
891 assert!((weight - 1.0).abs() < 0.01);
892 }
893
894 #[test]
895 fn test_interp_idx_out_of_range() {
896 let table = create_test_table();
897
898 let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
900 assert_eq!(idx, 0);
901 assert_eq!(weight, 0.0);
902
903 let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
905 assert_eq!(idx, 1); assert_eq!(weight, 1.0);
907 }
908
909 #[test]
910 fn test_interp_idx_nan_defaults_to_first_bin() {
911 let table = create_test_table();
912
913 assert_eq!(table.interp_idx(f32::NAN, &table.weight_bins), (0, 0.0));
914 assert_eq!(
915 table.interp_idx(f32::NEG_INFINITY, &table.weight_bins),
916 (0, 0.0)
917 );
918 assert_eq!(
919 table.interp_idx(f32::INFINITY, &table.weight_bins),
920 (1, 1.0)
921 );
922 }
923
924 #[test]
925 fn test_lookup_nan_with_single_bin_axes_uses_only_cell() {
926 let table = create_single_cell_test_table();
927
928 assert_eq!(table.lookup(f64::NAN, 0.4, 2500.0, 2000.0, "G1"), 0.875);
929 }
930
931 #[test]
932 fn test_lookup_non_finite_table_cells_are_neutral() {
933 for cell in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
934 let mut source = create_test_table();
935 source.data.fill(cell);
936 let bytes = serialize_test_table(&source);
937 let table = Bc5dTable::from_bytes(&bytes).expect("CRC-valid table should load");
938
939 assert_eq!(
940 table.lookup(125.0, 0.35, 2750.0, 1500.0, "G1"),
941 1.0,
942 "non-finite cell {cell:?} must produce a neutral correction"
943 );
944 assert_eq!(
945 table.get_effective_bc(125.0, 0.35, 2750.0, 1500.0, "G1"),
946 0.35,
947 "neutral correction must preserve base BC for {cell:?}"
948 );
949 }
950 }
951
952 #[test]
953 fn test_lookup_returns_valid_range() {
954 let table = create_test_table();
955
956 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G1");
957 assert!(correction >= 0.5 && correction <= 1.5);
958
959 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G7");
960 assert!(correction >= 0.5 && correction <= 1.5);
961 }
962
963 #[test]
964 fn test_effective_bc() {
965 let table = create_test_table();
966
967 let base_bc = 0.4;
968 let effective = table.get_effective_bc(150.0, base_bc, 2750.0, 2000.0, "G1");
969
970 assert!(effective >= base_bc * 0.5 && effective <= base_bc * 1.5);
972 }
973
974 #[test]
975 fn test_caliber_to_key() {
976 assert_eq!(caliber_to_key(0.308), 308);
977 assert_eq!(caliber_to_key(0.224), 224);
978 assert_eq!(caliber_to_key(0.338), 338);
979 }
980
981 #[test]
982 fn test_table_metadata() {
983 let table = create_test_table();
984 assert!((table.caliber() - 0.308).abs() < 0.001);
985 assert_eq!(table.version(), 2);
986 assert_eq!(table.api_version(), "test");
987 }
988
989 #[test]
990 fn test_crc32() {
991 let data = b"123456789";
993 let crc = crc32_ieee(data);
994 assert_eq!(crc, 0xCBF43926);
995 }
996}