Skip to main content

ballistics_engine/
bc_table_5d.rs

1// BC5D - 5-Dimensional BC Correction Table with Caliber-Specific Files
2//
3// This module provides offline BC corrections by loading precomputed tables
4// of correction factors derived from ML model predictions. The tables are
5// caliber-specific and indexed by:
6//   - Weight (grains) - caliber-specific ranges
7//   - Base BC (0.05-1.2)
8//   - Muzzle Velocity (2000-4000 fps)
9//   - Current Velocity (500-4000 fps, dense in transonic)
10//   - Drag Model (G1, G7)
11//
12// Binary file format (BC5D v2):
13//   Header (80 bytes):
14//     - Magic: 4 bytes ('BC5D')
15//     - Version: 4 bytes (uint32)
16//     - Caliber: 4 bytes (float32)
17//     - Flags: 4 bytes (uint32)
18//     - Padding: 4 bytes
19//     - dim_weight: 4 bytes (uint32)
20//     - dim_bc: 4 bytes (uint32)
21//     - dim_muzzle_vel: 4 bytes (uint32)
22//     - dim_current_vel: 4 bytes (uint32)
23//     - dim_drag_types: 4 bytes (uint32)
24//     - timestamp: 8 bytes (uint64)
25//     - checksum: 4 bytes (uint32, CRC32 of data section)
26//     - api_version: 16 bytes (null-padded string)
27//     - reserved: 12 bytes
28//   Bin definitions:
29//     - Weight bins: dim_weight * 4 bytes (float32)
30//     - BC bins: dim_bc * 4 bytes (float32)
31//     - Muzzle velocity bins: dim_muzzle_vel * 4 bytes (float32)
32//     - Current velocity bins: dim_current_vel * 4 bytes (float32)
33//   Data section:
34//     - Correction factors: total_cells * 4 bytes (float32)
35//     - Layout: [drag_type][weight][bc][muzzle_vel][current_vel]
36//
37// Correction factors are ratios: predicted_bc / base_bc
38// Range: 0.5 to 1.5 (clipped during generation)
39
40use std::collections::HashMap;
41use std::fs::File;
42use std::io::{BufReader, Read};
43use std::path::{Path, PathBuf};
44
45/// Magic bytes for BC5D format
46const MAGIC: &[u8; 4] = b"BC5D";
47
48/// Supported format version
49const SUPPORTED_VERSION: u32 = 2;
50
51/// Header size in bytes
52const HEADER_SIZE: usize = 80;
53
54/// BC5D table with 4D interpolation (drag type is discrete)
55#[derive(Debug)]
56pub struct Bc5dTable {
57    /// Caliber this table is for
58    caliber: f32,
59    /// Correction data: [drag_type][weight][bc][muzzle_vel][current_vel]
60    data: Vec<f32>,
61    /// Weight bin values (grains)
62    weight_bins: Vec<f32>,
63    /// BC bin values
64    bc_bins: Vec<f32>,
65    /// Muzzle velocity bin values (fps)
66    muzzle_vel_bins: Vec<f32>,
67    /// Current velocity bin values (fps)
68    current_vel_bins: Vec<f32>,
69    /// Number of drag types (typically 2: G1=0, G7=1)
70    num_drag_types: usize,
71    /// Table version
72    version: u32,
73    /// API version used to generate the table
74    api_version: String,
75    /// Generation timestamp
76    timestamp: u64,
77}
78
79/// Manager for loading caliber-specific BC5D tables
80#[derive(Debug, Default)]
81pub struct Bc5dTableManager {
82    /// Directory containing BC5D table files
83    table_dir: Option<PathBuf>,
84    /// Loaded tables by caliber (rounded to 3 decimal places)
85    tables: HashMap<i32, Bc5dTable>,
86}
87
88/// Error type for BC5D table operations
89#[derive(Debug)]
90pub enum Bc5dError {
91    IoError(std::io::Error),
92    InvalidMagic,
93    UnsupportedVersion(u32),
94    ChecksumMismatch { expected: u32, actual: u32 },
95    InvalidDimensions,
96    TableNotFound(f64),
97    NoTableDirectory,
98}
99
100impl std::fmt::Display for Bc5dError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Bc5dError::IoError(e) => write!(f, "IO error: {}", e),
104            Bc5dError::InvalidMagic => write!(f, "Invalid file magic (expected 'BC5D')"),
105            Bc5dError::UnsupportedVersion(v) => write!(f, "Unsupported table version: {}", v),
106            Bc5dError::ChecksumMismatch { expected, actual } => {
107                write!(f, "Checksum mismatch: expected {:08x}, got {:08x}", expected, actual)
108            }
109            Bc5dError::InvalidDimensions => write!(f, "Invalid table dimensions"),
110            Bc5dError::TableNotFound(cal) => write!(f, "No BC5D table found for caliber {:.3}", cal),
111            Bc5dError::NoTableDirectory => write!(f, "No BC table directory configured"),
112        }
113    }
114}
115
116impl std::error::Error for Bc5dError {}
117
118impl From<std::io::Error> for Bc5dError {
119    fn from(e: std::io::Error) -> Self {
120        Bc5dError::IoError(e)
121    }
122}
123
124impl Bc5dTable {
125    /// Load a BC5D table from a binary file
126    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Bc5dError> {
127        let file = File::open(&path)?;
128        let mut reader = BufReader::new(file);
129
130        // Read and validate magic
131        let mut magic = [0u8; 4];
132        reader.read_exact(&mut magic)?;
133        if &magic != MAGIC {
134            return Err(Bc5dError::InvalidMagic);
135        }
136
137        // Read header fields
138        let version = read_u32(&mut reader)?;
139        if version != SUPPORTED_VERSION {
140            return Err(Bc5dError::UnsupportedVersion(version));
141        }
142
143        let caliber = read_f32(&mut reader)?;
144        let _flags = read_u32(&mut reader)?;
145        let _padding = read_u32(&mut reader)?;
146
147        let dim_weight = read_u32(&mut reader)? as usize;
148        let dim_bc = read_u32(&mut reader)? as usize;
149        let dim_muzzle_vel = read_u32(&mut reader)? as usize;
150        let dim_current_vel = read_u32(&mut reader)? as usize;
151        let dim_drag_types = read_u32(&mut reader)? as usize;
152
153        let timestamp = read_u64(&mut reader)?;
154        let stored_checksum = read_u32(&mut reader)?;
155
156        // Read API version (16 bytes, null-terminated)
157        let mut api_version_bytes = [0u8; 16];
158        reader.read_exact(&mut api_version_bytes)?;
159        let api_version = String::from_utf8_lossy(&api_version_bytes)
160            .trim_end_matches('\0')
161            .to_string();
162
163        // Skip reserved bytes
164        let mut reserved = [0u8; 12];
165        reader.read_exact(&mut reserved)?;
166
167        // Validate dimensions
168        if dim_weight == 0 || dim_bc == 0 || dim_muzzle_vel == 0 || dim_current_vel == 0 || dim_drag_types == 0 {
169            return Err(Bc5dError::InvalidDimensions);
170        }
171
172        // Read bin definitions
173        let weight_bins = read_f32_array(&mut reader, dim_weight)?;
174        let bc_bins = read_f32_array(&mut reader, dim_bc)?;
175        let muzzle_vel_bins = read_f32_array(&mut reader, dim_muzzle_vel)?;
176        let current_vel_bins = read_f32_array(&mut reader, dim_current_vel)?;
177
178        // Read data section. Bound the product with checked arithmetic so a corrupt or
179        // hostile file cannot overflow (debug panic / release wrap) or trigger a huge OOM
180        // allocation before the trailing CRC check can reject it.
181        const MAX_TOTAL_CELLS: usize = 64_000_000; // ~256 MB of f32; far above any real table
182        let total_cells = dim_drag_types
183            .checked_mul(dim_weight)
184            .and_then(|x| x.checked_mul(dim_bc))
185            .and_then(|x| x.checked_mul(dim_muzzle_vel))
186            .and_then(|x| x.checked_mul(dim_current_vel))
187            .filter(|&n| n <= MAX_TOTAL_CELLS)
188            .ok_or(Bc5dError::InvalidDimensions)?;
189        let data = read_f32_array(&mut reader, total_cells)?;
190
191        // Verify checksum (CRC32 of bins + data)
192        let mut checksum_data = Vec::new();
193        for &v in &weight_bins {
194            checksum_data.extend_from_slice(&v.to_le_bytes());
195        }
196        for &v in &bc_bins {
197            checksum_data.extend_from_slice(&v.to_le_bytes());
198        }
199        for &v in &muzzle_vel_bins {
200            checksum_data.extend_from_slice(&v.to_le_bytes());
201        }
202        for &v in &current_vel_bins {
203            checksum_data.extend_from_slice(&v.to_le_bytes());
204        }
205        for &v in &data {
206            checksum_data.extend_from_slice(&v.to_le_bytes());
207        }
208
209        let calculated_checksum = crc32_ieee(&checksum_data);
210        if calculated_checksum != stored_checksum {
211            return Err(Bc5dError::ChecksumMismatch {
212                expected: stored_checksum,
213                actual: calculated_checksum,
214            });
215        }
216
217        Ok(Bc5dTable {
218            caliber,
219            data,
220            weight_bins,
221            bc_bins,
222            muzzle_vel_bins,
223            current_vel_bins,
224            num_drag_types: dim_drag_types,
225            version,
226            api_version,
227            timestamp,
228        })
229    }
230
231    /// Look up a BC correction factor with 4D linear interpolation
232    /// (drag type is discrete, not interpolated)
233    ///
234    /// # Arguments
235    /// * `weight_grains` - Bullet weight in grains
236    /// * `base_bc` - Published BC value
237    /// * `muzzle_velocity` - Initial muzzle velocity in fps
238    /// * `current_velocity` - Current bullet velocity in fps
239    /// * `drag_type` - "G1" or "G7"
240    ///
241    /// # Returns
242    /// Correction factor (multiply published BC by this value)
243    pub fn lookup(
244        &self,
245        weight_grains: f64,
246        base_bc: f64,
247        muzzle_velocity: f64,
248        current_velocity: f64,
249        drag_type: &str,
250    ) -> f64 {
251        // Get drag type index (0 = G1, 1 = G7)
252        let drag_idx = if drag_type.eq_ignore_ascii_case("G7") { 1 } else { 0 };
253
254        // Clamp drag_idx to valid range
255        let drag_idx = drag_idx.min(self.num_drag_types - 1);
256
257        // Find interpolation indices and weights for each continuous dimension
258        let (weight_idx, weight_w) = self.interp_idx(weight_grains as f32, &self.weight_bins);
259        let (bc_idx, bc_w) = self.interp_idx(base_bc as f32, &self.bc_bins);
260        let (muzzle_idx, muzzle_w) = self.interp_idx(muzzle_velocity as f32, &self.muzzle_vel_bins);
261        let (current_idx, current_w) = self.interp_idx(current_velocity as f32, &self.current_vel_bins);
262
263        // 4D linear interpolation (16 corners of a hypercube)
264        let mut result = 0.0f64;
265
266        for dw in 0..2 {
267            for db in 0..2 {
268                for dm in 0..2 {
269                    for dc in 0..2 {
270                        // Calculate weight for this corner
271                        let weight = (if dw == 0 { 1.0 - weight_w } else { weight_w })
272                            * (if db == 0 { 1.0 - bc_w } else { bc_w })
273                            * (if dm == 0 { 1.0 - muzzle_w } else { muzzle_w })
274                            * (if dc == 0 { 1.0 - current_w } else { current_w });
275
276                        // Get clamped indices
277                        let wi = (weight_idx + dw).min(self.weight_bins.len() - 1);
278                        let bi = (bc_idx + db).min(self.bc_bins.len() - 1);
279                        let mi = (muzzle_idx + dm).min(self.muzzle_vel_bins.len() - 1);
280                        let ci = (current_idx + dc).min(self.current_vel_bins.len() - 1);
281
282                        // Calculate flat index
283                        let idx = self.flat_index(drag_idx, wi, bi, mi, ci);
284                        result += weight * self.data[idx] as f64;
285                    }
286                }
287            }
288        }
289
290        // Clamp result to valid range
291        result.max(0.5).min(1.5)
292    }
293
294    /// Get the effective BC at a given velocity
295    ///
296    /// This multiplies the base BC by the correction factor from the table.
297    pub fn get_effective_bc(
298        &self,
299        weight_grains: f64,
300        base_bc: f64,
301        muzzle_velocity: f64,
302        current_velocity: f64,
303        drag_type: &str,
304    ) -> f64 {
305        let correction = self.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type);
306        base_bc * correction
307    }
308
309    /// Find interpolation index and weight for a value in bins
310    fn interp_idx(&self, value: f32, bins: &[f32]) -> (usize, f64) {
311        if bins.is_empty() {
312            return (0, 0.0);
313        }
314
315        // Handle out of range (clamp to edges)
316        if value <= bins[0] {
317            return (0, 0.0);
318        }
319        if value >= bins[bins.len() - 1] {
320            return (bins.len().saturating_sub(2), 1.0);
321        }
322
323        // Binary search for interval containing value
324        let idx = match bins.binary_search_by(|probe| {
325            probe.partial_cmp(&value).unwrap_or(std::cmp::Ordering::Equal)
326        }) {
327            Ok(i) => i.saturating_sub(1).min(bins.len() - 2),
328            Err(i) => i.saturating_sub(1).min(bins.len() - 2),
329        };
330
331        // Calculate interpolation weight
332        let low = bins[idx];
333        let high = bins[idx + 1];
334        let weight = if high > low {
335            ((value - low) / (high - low)) as f64
336        } else {
337            0.0
338        };
339
340        (idx, weight)
341    }
342
343    /// Calculate flat array index from 5D indices
344    fn flat_index(&self, drag_idx: usize, weight_idx: usize, bc_idx: usize, muzzle_idx: usize, current_idx: usize) -> usize {
345        let n_weight = self.weight_bins.len();
346        let n_bc = self.bc_bins.len();
347        let n_muzzle = self.muzzle_vel_bins.len();
348        let n_current = self.current_vel_bins.len();
349
350        drag_idx * (n_weight * n_bc * n_muzzle * n_current)
351            + weight_idx * (n_bc * n_muzzle * n_current)
352            + bc_idx * (n_muzzle * n_current)
353            + muzzle_idx * n_current
354            + current_idx
355    }
356
357    /// Get caliber this table is for
358    pub fn caliber(&self) -> f32 {
359        self.caliber
360    }
361
362    /// Get table version
363    pub fn version(&self) -> u32 {
364        self.version
365    }
366
367    /// Get API version used to generate the table
368    pub fn api_version(&self) -> &str {
369        &self.api_version
370    }
371
372    /// Get generation timestamp
373    pub fn timestamp(&self) -> u64 {
374        self.timestamp
375    }
376
377    /// Get total number of cells in the table
378    pub fn total_cells(&self) -> usize {
379        self.data.len()
380    }
381
382    /// Get table dimensions as a string
383    pub fn dimensions_str(&self) -> String {
384        format!(
385            "{}x{}x{}x{}x{} (weight x bc x muzzle_vel x current_vel x drag_types)",
386            self.weight_bins.len(),
387            self.bc_bins.len(),
388            self.muzzle_vel_bins.len(),
389            self.current_vel_bins.len(),
390            self.num_drag_types
391        )
392    }
393
394    /// Get weight range
395    pub fn weight_range(&self) -> (f32, f32) {
396        (*self.weight_bins.first().unwrap_or(&0.0), *self.weight_bins.last().unwrap_or(&0.0))
397    }
398
399    /// Get velocity range
400    pub fn velocity_range(&self) -> (f32, f32) {
401        (*self.current_vel_bins.first().unwrap_or(&0.0), *self.current_vel_bins.last().unwrap_or(&0.0))
402    }
403}
404
405impl Bc5dTableManager {
406    /// Create a new table manager with a directory path
407    pub fn new<P: AsRef<Path>>(table_dir: P) -> Self {
408        Bc5dTableManager {
409            table_dir: Some(table_dir.as_ref().to_path_buf()),
410            tables: HashMap::new(),
411        }
412    }
413
414    /// Create an empty manager (no table directory)
415    pub fn empty() -> Self {
416        Bc5dTableManager {
417            table_dir: None,
418            tables: HashMap::new(),
419        }
420    }
421
422    /// Get or load the table for a caliber
423    ///
424    /// Tables are cached after first load.
425    pub fn get_table(&mut self, caliber: f64) -> Result<&Bc5dTable, Bc5dError> {
426        let caliber_key = caliber_to_key(caliber);
427
428        // Check if already loaded
429        if self.tables.contains_key(&caliber_key) {
430            return Ok(self.tables.get(&caliber_key).unwrap());
431        }
432
433        // Need to load
434        let table_dir = self.table_dir.as_ref().ok_or(Bc5dError::NoTableDirectory)?;
435        let table_path = find_table_file(table_dir, caliber)?;
436        let table = Bc5dTable::load(&table_path)?;
437        self.tables.insert(caliber_key, table);
438        Ok(self.tables.get(&caliber_key).unwrap())
439    }
440
441    /// Look up BC correction for a bullet
442    pub fn lookup(
443        &mut self,
444        caliber: f64,
445        weight_grains: f64,
446        base_bc: f64,
447        muzzle_velocity: f64,
448        current_velocity: f64,
449        drag_type: &str,
450    ) -> Result<f64, Bc5dError> {
451        let table = self.get_table(caliber)?;
452        Ok(table.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
453    }
454
455    /// Get effective BC with correction applied
456    pub fn get_effective_bc(
457        &mut self,
458        caliber: f64,
459        weight_grains: f64,
460        base_bc: f64,
461        muzzle_velocity: f64,
462        current_velocity: f64,
463        drag_type: &str,
464    ) -> Result<f64, Bc5dError> {
465        let table = self.get_table(caliber)?;
466        Ok(table.get_effective_bc(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
467    }
468
469    /// Check if a table is available for a caliber
470    pub fn has_table(&self, caliber: f64) -> bool {
471        if let Some(ref table_dir) = self.table_dir {
472            find_table_file(table_dir, caliber).is_ok()
473        } else {
474            false
475        }
476    }
477
478    /// List available calibers in the table directory
479    pub fn available_calibers(&self) -> Vec<f64> {
480        let mut calibers = Vec::new();
481        if let Some(ref table_dir) = self.table_dir {
482            if let Ok(entries) = std::fs::read_dir(table_dir) {
483                for entry in entries.flatten() {
484                    let path = entry.path();
485                    if let Some(ext) = path.extension() {
486                        if ext == "bin" {
487                            if let Some(stem) = path.file_stem() {
488                                let name = stem.to_string_lossy();
489                                if name.starts_with("bc5d_") {
490                                    // Parse caliber from filename (e.g., bc5d_308.bin -> 0.308)
491                                    if let Ok(cal_int) = name[5..].parse::<i32>() {
492                                        calibers.push(cal_int as f64 / 1000.0);
493                                    }
494                                }
495                            }
496                        }
497                    }
498                }
499            }
500        }
501        calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
502        calibers
503    }
504}
505
506/// Convert caliber to integer key (multiply by 1000)
507fn caliber_to_key(caliber: f64) -> i32 {
508    (caliber * 1000.0).round() as i32
509}
510
511/// Find the table file for a caliber
512fn find_table_file(table_dir: &Path, caliber: f64) -> Result<PathBuf, Bc5dError> {
513    let caliber_int = (caliber * 1000.0).round() as i32;
514    let filename = format!("bc5d_{}.bin", caliber_int);
515    let path = table_dir.join(&filename);
516
517    if path.exists() {
518        return Ok(path);
519    }
520
521    // Try common variations
522    let variations = [
523        format!("bc5d_{:03}.bin", caliber_int),
524        format!("bc5d_0{}.bin", caliber_int),
525    ];
526
527    for var in &variations {
528        let var_path = table_dir.join(var);
529        if var_path.exists() {
530            return Ok(var_path);
531        }
532    }
533
534    Err(Bc5dError::TableNotFound(caliber))
535}
536
537// Helper functions for reading binary data
538
539fn read_u32<R: Read>(reader: &mut R) -> Result<u32, std::io::Error> {
540    let mut buf = [0u8; 4];
541    reader.read_exact(&mut buf)?;
542    Ok(u32::from_le_bytes(buf))
543}
544
545fn read_u64<R: Read>(reader: &mut R) -> Result<u64, std::io::Error> {
546    let mut buf = [0u8; 8];
547    reader.read_exact(&mut buf)?;
548    Ok(u64::from_le_bytes(buf))
549}
550
551fn read_f32<R: Read>(reader: &mut R) -> Result<f32, std::io::Error> {
552    let mut buf = [0u8; 4];
553    reader.read_exact(&mut buf)?;
554    Ok(f32::from_le_bytes(buf))
555}
556
557fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
558    // Defensive bounds: reject absurd lengths from corrupt/hostile files before
559    // allocating, and guard the byte-count multiply against overflow.
560    const MAX_ELEMS: usize = 64_000_000; // 256 MB of f32
561    if count > MAX_ELEMS {
562        return Err(std::io::Error::new(
563            std::io::ErrorKind::InvalidData,
564            "f32 array length too large",
565        ));
566    }
567    let byte_len = count.checked_mul(4).ok_or_else(|| {
568        std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
569    })?;
570    let mut data = vec![0f32; count];
571    let mut buf = vec![0u8; byte_len];
572    reader.read_exact(&mut buf)?;
573
574    for (i, chunk) in buf.chunks_exact(4).enumerate() {
575        data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
576    }
577
578    Ok(data)
579}
580
581/// Simple CRC32 (IEEE polynomial) implementation
582pub(crate) fn crc32_ieee(data: &[u8]) -> u32 {
583    const TABLE: [u32; 256] = make_crc32_table();
584    let mut crc = 0xFFFFFFFFu32;
585    for &byte in data {
586        let idx = ((crc ^ byte as u32) & 0xFF) as usize;
587        crc = (crc >> 8) ^ TABLE[idx];
588    }
589    !crc
590}
591
592const fn make_crc32_table() -> [u32; 256] {
593    const POLY: u32 = 0xEDB88320;
594    let mut table = [0u32; 256];
595    let mut i = 0;
596    while i < 256 {
597        let mut crc = i as u32;
598        let mut j = 0;
599        while j < 8 {
600            if crc & 1 != 0 {
601                crc = (crc >> 1) ^ POLY;
602            } else {
603                crc >>= 1;
604            }
605            j += 1;
606        }
607        table[i] = crc;
608        i += 1;
609    }
610    table
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    fn create_test_table() -> Bc5dTable {
618        // Create a small test table with known values
619        let weight_bins = vec![100.0, 150.0, 200.0];
620        let bc_bins = vec![0.3, 0.4, 0.5];
621        let muzzle_vel_bins = vec![2500.0, 3000.0];
622        let current_vel_bins = vec![1000.0, 2000.0, 3000.0];
623        let num_drag_types = 2;
624
625        // Total cells: 2 * 3 * 3 * 2 * 3 = 108
626        let total = num_drag_types * weight_bins.len() * bc_bins.len() * muzzle_vel_bins.len() * current_vel_bins.len();
627        let mut data = vec![1.0f32; total];
628
629        // Set some non-uniform values for testing interpolation
630        // At weight=150, bc=0.4, muzzle=2750 (interpolated), current=2000, G1
631        // We'll set corners to test 4D interpolation
632        data[0] = 0.95; // First corner
633        data[total - 1] = 1.05; // Last corner
634
635        Bc5dTable {
636            caliber: 0.308,
637            data,
638            weight_bins,
639            bc_bins,
640            muzzle_vel_bins,
641            current_vel_bins,
642            num_drag_types,
643            version: 2,
644            api_version: "test".to_string(),
645            timestamp: 0,
646        }
647    }
648
649    #[test]
650    fn test_interp_idx_in_range() {
651        let table = create_test_table();
652
653        // Test middle of range
654        let (idx, weight) = table.interp_idx(125.0, &table.weight_bins);
655        assert_eq!(idx, 0);
656        assert!((weight - 0.5).abs() < 0.01);
657
658        // Test at bin boundary
659        let (idx, weight) = table.interp_idx(150.0, &table.weight_bins);
660        assert_eq!(idx, 0);
661        assert!((weight - 1.0).abs() < 0.01);
662    }
663
664    #[test]
665    fn test_interp_idx_out_of_range() {
666        let table = create_test_table();
667
668        // Test below range
669        let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
670        assert_eq!(idx, 0);
671        assert_eq!(weight, 0.0);
672
673        // Test above range
674        let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
675        assert_eq!(idx, 1); // len - 2
676        assert_eq!(weight, 1.0);
677    }
678
679    #[test]
680    fn test_lookup_returns_valid_range() {
681        let table = create_test_table();
682
683        let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G1");
684        assert!(correction >= 0.5 && correction <= 1.5);
685
686        let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G7");
687        assert!(correction >= 0.5 && correction <= 1.5);
688    }
689
690    #[test]
691    fn test_effective_bc() {
692        let table = create_test_table();
693
694        let base_bc = 0.4;
695        let effective = table.get_effective_bc(150.0, base_bc, 2750.0, 2000.0, "G1");
696
697        // Effective BC should be base_bc * correction
698        assert!(effective >= base_bc * 0.5 && effective <= base_bc * 1.5);
699    }
700
701    #[test]
702    fn test_caliber_to_key() {
703        assert_eq!(caliber_to_key(0.308), 308);
704        assert_eq!(caliber_to_key(0.224), 224);
705        assert_eq!(caliber_to_key(0.338), 338);
706    }
707
708    #[test]
709    fn test_table_metadata() {
710        let table = create_test_table();
711        assert!((table.caliber() - 0.308).abs() < 0.001);
712        assert_eq!(table.version(), 2);
713        assert_eq!(table.api_version(), "test");
714    }
715
716    #[test]
717    fn test_crc32() {
718        // Test with known CRC32 value
719        let data = b"123456789";
720        let crc = crc32_ieee(data);
721        assert_eq!(crc, 0xCBF43926);
722    }
723}