Skip to main content

flow_fcs_compress/codec/
adc_bitpack.rs

1//! Mode B: ADC-bit lossless codec.
2//!
3//! For raw spectral data, an FCS file's `$PnB` / `$PnR` keywords describe a
4//! finite-resolution ADC (typically 14–22 bits on modern instruments). The
5//! float values stored in the data segment are exact integer-valued multiples
6//! of `scale = $PnR / 2^bits`. Storing them as full f32 wastes ~10 bits per
7//! value on noise; quantizing back to the ADC integer is lossless w.r.t. the
8//! physical signal.
9//!
10//! ## Per-chunk encoding
11//!
12//! 1. Determine `bits` (caller-supplied via [`ChannelParams::adc_bits`], else
13//!    `ceil(log2(range))`).
14//! 2. Compute `scale = range as f64 / (1 << bits) as f64` (kept in `f64` for
15//!    precision when packing back into an `f32`).
16//! 3. For every value `x`: `q = round(x / scale) as i64`, accumulate `min` and
17//!    `max`.
18//! 4. Choose `bits_used = max(1, ceil(log2(max - min + 1)))`. Bias each `q` by
19//!    `min` to get a non-negative `u` that fits in `bits_used`.
20//! 5. Pack the `u`-stream little-endian-bit-first with width `bits_used`.
21//!
22//! ## Per-chunk header
23//!
24//! ```text
25//! [scale_f32: f32  4B] — the per-channel decode scale (lossy stash of f64 → f32)
26//! [offset:    i64  8B] — the bias subtracted at encode time
27//! [n_values:  u32  4B] — number of f32 samples encoded
28//! [bits_used: u8   1B] — packing width in bits (0..=32; 0 = constant chunk)
29//! [reserved:  u8   1B]
30//! [packed bytes...]    — present only when bits_used > 0
31//! ```
32//!
33//! ## Negatives
34//!
35//! Post-compensation channels routinely take negative values. The `offset`
36//! field is `i64` so we can carry an arbitrary positive or negative bias
37//! without changing the wire layout.
38
39use byteorder::{ByteOrder, LittleEndian};
40
41use crate::codec::{ChannelParams, CodecId, ColumnCodec, EncodeStats};
42use crate::error::{Error, Result};
43
44const HEADER_BYTES: usize = 4 + 8 + 4 + 1 + 1; // 18
45
46/// Mode B codec.
47#[derive(Debug, Clone, Default)]
48pub struct AdcBitpack;
49
50impl ColumnCodec for AdcBitpack {
51    fn id(&self) -> CodecId {
52        CodecId::AdcBitpack
53    }
54
55    fn encode_chunk(
56        &self,
57        input: &[f32],
58        params: &ChannelParams,
59        out: &mut Vec<u8>,
60    ) -> Result<EncodeStats> {
61        if input.is_empty() {
62            return Err(Error::InvalidParams("AdcBitpack: empty chunk"));
63        }
64        if params.range == 0 {
65            return Err(Error::InvalidParams(
66                "AdcBitpack: $PnR must be > 0 (use Mode A for unbounded data)",
67            ));
68        }
69
70        let adc_bits = effective_adc_bits(params)?;
71        let scale = scale_from(params.range, adc_bits);
72        if !scale.is_finite() || scale == 0.0 {
73            return Err(Error::InvalidParams(
74                "AdcBitpack: derived scale is non-finite or zero",
75            ));
76        }
77
78        // Pass 1: quantize, track min/max.
79        let mut quantized: Vec<i64> = Vec::with_capacity(input.len());
80        let mut q_min = i64::MAX;
81        let mut q_max = i64::MIN;
82        for &x in input {
83            if !x.is_finite() {
84                return Err(Error::InvalidParams(
85                    "AdcBitpack: encountered NaN or infinite value",
86                ));
87            }
88            let q = (x as f64 / scale).round() as i64;
89            q_min = q_min.min(q);
90            q_max = q_max.max(q);
91            quantized.push(q);
92        }
93
94        // Pass 2: pack with bits_used wide enough for the range.
95        let span = (q_max - q_min) as u128;
96        let bits_used = if span == 0 {
97            0u8
98        } else {
99            // ceil(log2(span + 1))
100            ((128 - (span).leading_zeros()) as u8).clamp(1, 32)
101        };
102
103        let header_start = out.len();
104        out.resize(header_start + HEADER_BYTES, 0);
105        {
106            let h = &mut out[header_start..header_start + HEADER_BYTES];
107            LittleEndian::write_f32(&mut h[0..4], scale as f32);
108            LittleEndian::write_i64(&mut h[4..12], q_min);
109            LittleEndian::write_u32(&mut h[12..16], input.len() as u32);
110            h[16] = bits_used;
111            h[17] = 0; // reserved
112        }
113
114        if bits_used > 0 {
115            let mask = if bits_used == 32 {
116                u32::MAX
117            } else {
118                (1u32 << bits_used) - 1
119            };
120            // Stage biased u32 values, then bulk-pack via the bit-reservoir
121            // packer. Reusing the buffer would matter at the edges (millions
122            // of chunks/sec); not the M5 bottleneck.
123            let mut staged: Vec<u32> = Vec::with_capacity(quantized.len());
124            for q in &quantized {
125                staged.push((*q - q_min) as u32 & mask);
126            }
127            pack_bits_fast(&staged, bits_used, out);
128        }
129
130        let written = out.len() - header_start;
131        Ok(EncodeStats {
132            input_events: input.len() as u32,
133            input_bytes: (input.len() * 4) as u64,
134            output_bytes: written as u64,
135        })
136    }
137
138    fn decode_chunk(
139        &self,
140        payload: &[u8],
141        _params: &ChannelParams,
142        out: &mut [f32],
143    ) -> Result<()> {
144        if payload.len() < HEADER_BYTES {
145            return Err(Error::Truncated {
146                needed: HEADER_BYTES,
147                have: payload.len(),
148            });
149        }
150        let scale = LittleEndian::read_f32(&payload[0..4]) as f64;
151        let offset = LittleEndian::read_i64(&payload[4..12]);
152        let n_values = LittleEndian::read_u32(&payload[12..16]) as usize;
153        let bits_used = payload[16];
154
155        if out.len() != n_values {
156            return Err(Error::LengthMismatch {
157                expected: n_values,
158                actual: out.len(),
159            });
160        }
161
162        if bits_used == 0 {
163            // Constant chunk: every value decodes to offset * scale.
164            let v = (offset as f64 * scale) as f32;
165            for slot in out.iter_mut() {
166                *slot = v;
167            }
168            return Ok(());
169        }
170        if bits_used > 32 {
171            return Err(Error::InvalidParams(
172                "AdcBitpack: bits_used > 32 is invalid",
173            ));
174        }
175
176        let total_bits = n_values * bits_used as usize;
177        let needed = HEADER_BYTES + total_bits.div_ceil(8);
178        if payload.len() < needed {
179            return Err(Error::Truncated {
180                needed,
181                have: payload.len(),
182            });
183        }
184        let packed = &payload[HEADER_BYTES..];
185        // Two-pass: bulk bit-unpack into a u32 staging buffer, then dequantize.
186        // Splitting the loops gives the compiler a clean shot at vectorizing the
187        // dequant pass (purely arithmetic, no data-dependent branches).
188        let mut staging: Vec<u32> = vec![0; n_values];
189        unpack_bits_fast(packed, bits_used, n_values, &mut staging);
190        for (slot, q_biased) in out.iter_mut().zip(staging.iter()) {
191            let q = *q_biased as i64 + offset;
192            *slot = (q as f64 * scale) as f32;
193        }
194        Ok(())
195    }
196}
197
198fn effective_adc_bits(params: &ChannelParams) -> Result<u8> {
199    if let Some(b) = params.adc_bits {
200        if !(1..=32).contains(&b) {
201            return Err(Error::InvalidParams(
202                "AdcBitpack: adc_bits must be in 1..=32",
203            ));
204        }
205        return Ok(b);
206    }
207    // Fallback: derive from $PnR. range = max value + 1, so bits = ceil(log2(range)).
208    let range = params.range as u64;
209    if range <= 1 {
210        return Ok(1);
211    }
212    let bits = 64 - (range - 1).leading_zeros();
213    Ok(bits.clamp(1, 32) as u8)
214}
215
216fn scale_from(range: u32, adc_bits: u8) -> f64 {
217    // FCS convention: $PnR = max + 1, ADC integer in [0, 2^bits).
218    // scale = range / 2^bits.
219    let denom = if adc_bits >= 64 {
220        f64::INFINITY
221    } else {
222        (1u64 << adc_bits) as f64
223    };
224    range as f64 / denom
225}
226
227/// Pack `values` into a contiguous LE bit-stream of `width`-bit fields.
228///
229/// Bit-reservoir form: a `u64` accumulator gathers up to 64 bits, then flushes
230/// 8 bytes at a time using an unaligned LE store. For typical widths (16..24)
231/// this is roughly an order of magnitude faster than the per-bit loop because
232/// the inner loop body has no data-dependent branches and amortizes bookkeeping
233/// across 4–8 values per iteration.
234fn pack_bits_fast(values: &[u32], width: u8, dst: &mut Vec<u8>) {
235    if width == 0 {
236        return;
237    }
238    let mask = if width >= 32 { u32::MAX } else { (1u32 << width) - 1 };
239    let mut buf: u64 = 0;
240    let mut buf_bits: u32 = 0;
241    for &v in values {
242        let masked = (v & mask) as u64;
243        buf |= masked << buf_bits;
244        buf_bits += width as u32;
245        if buf_bits >= 32 {
246            // Flush 4 bytes (we know we have ≥ 32 bits buffered).
247            let four = (buf & 0xFFFF_FFFF) as u32;
248            dst.extend_from_slice(&four.to_le_bytes());
249            buf >>= 32;
250            buf_bits -= 32;
251        }
252    }
253    while buf_bits >= 8 {
254        dst.push((buf & 0xFF) as u8);
255        buf >>= 8;
256        buf_bits -= 8;
257    }
258    if buf_bits > 0 {
259        dst.push((buf & 0xFF) as u8);
260    }
261}
262
263/// Inverse of [`pack_bits_fast`]. Reads `n` values of `width` bits each and
264/// writes them into `out`. `src` may be padded with arbitrary garbage past the
265/// last meaningful byte.
266#[inline]
267fn unpack_bits_fast(src: &[u8], width: u8, n: usize, out: &mut [u32]) {
268    if width == 0 {
269        for slot in out.iter_mut().take(n) {
270            *slot = 0;
271        }
272        return;
273    }
274    let mask = if width >= 32 {
275        u32::MAX as u64
276    } else {
277        (1u64 << width) - 1
278    };
279    let mut buf: u64 = 0;
280    let mut buf_bits: u32 = 0;
281    let mut src_pos = 0usize;
282    let bytes_avail = src.len();
283    for slot in out.iter_mut().take(n) {
284        // Refill: pull as much as we can in one u64 read.
285        while buf_bits < width as u32 {
286            // Try a 4-byte refill first; fall back to byte-at-a-time near the
287            // tail. The tail loop matters because the encoder only emits whole
288            // bytes once the reservoir has ≥ 8 bits remaining.
289            if src_pos + 4 <= bytes_avail && buf_bits + 32 <= 64 {
290                let four = u32::from_le_bytes([
291                    src[src_pos],
292                    src[src_pos + 1],
293                    src[src_pos + 2],
294                    src[src_pos + 3],
295                ]);
296                buf |= (four as u64) << buf_bits;
297                buf_bits += 32;
298                src_pos += 4;
299            } else if src_pos < bytes_avail {
300                buf |= (src[src_pos] as u64) << buf_bits;
301                buf_bits += 8;
302                src_pos += 1;
303            } else {
304                break;
305            }
306        }
307        *slot = (buf & mask) as u32;
308        buf >>= width;
309        buf_bits = buf_bits.saturating_sub(width as u32);
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn integer_channel(range: u32, adc_bits: u8, n: usize, seed: u64) -> Vec<f32> {
318        // Generate exact integer-valued floats in [0, range) at ADC granularity.
319        let scale = scale_from(range, adc_bits);
320        let mut v = Vec::with_capacity(n);
321        let mut s = seed;
322        for _ in 0..n {
323            s = s
324                .wrapping_mul(6364136223846793005)
325                .wrapping_add(1442695040888963407);
326            let q = (s as u64) % (1u64 << adc_bits);
327            v.push((q as f64 * scale) as f32);
328        }
329        v
330    }
331
332    fn signed_integer_channel(range: u32, adc_bits: u8, n: usize, seed: u64) -> Vec<f32> {
333        // Like integer_channel but biased to include negatives (post-comp).
334        let scale = scale_from(range, adc_bits);
335        let mut v = Vec::with_capacity(n);
336        let mut s = seed;
337        for _ in 0..n {
338            s = s
339                .wrapping_mul(6364136223846793005)
340                .wrapping_add(1442695040888963407);
341            let q = ((s as i64) % (1i64 << adc_bits)) - (1i64 << (adc_bits - 1));
342            v.push((q as f64 * scale) as f32);
343        }
344        v
345    }
346
347    fn params(range: u32, adc_bits: u8, signed: bool) -> ChannelParams {
348        ChannelParams {
349            name: "test".into(),
350            stored_bits: 32,
351            range,
352            log_decades: (0.0, 0.0),
353            adc_bits: Some(adc_bits),
354            signed,
355        }
356    }
357
358    #[test]
359    fn round_trips_22_bit_unsigned() {
360        let p = params(1 << 22, 22, false);
361        let input = integer_channel(p.range, 22, 4096, 42);
362        let codec = AdcBitpack::default();
363
364        let mut payload = Vec::new();
365        let stats = codec.encode_chunk(&input, &p, &mut payload).unwrap();
366        assert_eq!(stats.input_events, 4096);
367
368        let mut out = vec![0.0f32; input.len()];
369        codec.decode_chunk(&payload, &p, &mut out).unwrap();
370        // Lossless w.r.t. ADC: round-trip should be bit-exact for integer values.
371        for (a, b) in input.iter().zip(out.iter()) {
372            assert_eq!(a.to_bits(), b.to_bits(), "lossless ADC round-trip violated");
373        }
374    }
375
376    #[test]
377    fn round_trips_18_bit_signed_with_negatives() {
378        let p = params(1 << 18, 18, true);
379        let input = signed_integer_channel(p.range, 18, 2048, 7);
380        // Sanity: at least one negative present.
381        assert!(input.iter().any(|x| *x < 0.0));
382
383        let codec = AdcBitpack::default();
384        let mut payload = Vec::new();
385        codec.encode_chunk(&input, &p, &mut payload).unwrap();
386        let mut out = vec![0.0f32; input.len()];
387        codec.decode_chunk(&payload, &p, &mut out).unwrap();
388        for (a, b) in input.iter().zip(out.iter()) {
389            assert_eq!(a.to_bits(), b.to_bits());
390        }
391    }
392
393    #[test]
394    fn constant_chunk_uses_zero_bits() {
395        let p = params(262_144, 18, false);
396        let input = vec![1024.0f32; 1024];
397
398        let codec = AdcBitpack::default();
399        let mut payload = Vec::new();
400        codec.encode_chunk(&input, &p, &mut payload).unwrap();
401        // Header only: bits_used = 0.
402        assert_eq!(payload.len(), HEADER_BYTES);
403        assert_eq!(payload[16], 0);
404
405        let mut out = vec![0.0f32; input.len()];
406        codec.decode_chunk(&payload, &p, &mut out).unwrap();
407        for (a, b) in input.iter().zip(out.iter()) {
408            assert_eq!(a.to_bits(), b.to_bits());
409        }
410    }
411
412    #[test]
413    fn ratio_beats_raw_on_22_bit_data() {
414        let p = params(1 << 22, 22, false);
415        let input = integer_channel(p.range, 22, 65536, 99);
416
417        let codec = AdcBitpack::default();
418        let mut adc_payload = Vec::new();
419        codec.encode_chunk(&input, &p, &mut adc_payload).unwrap();
420
421        // Mode B should beat raw f32 (4 bytes/event) by close to 22/32 = 31%.
422        let raw_bytes = input.len() * 4;
423        assert!(
424            adc_payload.len() < raw_bytes,
425            "Mode B ({}) failed to beat raw f32 ({})",
426            adc_payload.len(),
427            raw_bytes
428        );
429        // Loose lower bound: should be less than ~75% of raw.
430        assert!(
431            adc_payload.len() * 4 < raw_bytes * 3,
432            "Mode B compression unexpectedly poor: {} vs raw {}",
433            adc_payload.len(),
434            raw_bytes
435        );
436    }
437
438    #[test]
439    fn rejects_nan() {
440        let p = params(1 << 22, 22, false);
441        let mut input = integer_channel(p.range, 22, 16, 1);
442        input[3] = f32::NAN;
443        let codec = AdcBitpack::default();
444        let mut out = Vec::new();
445        let err = codec.encode_chunk(&input, &p, &mut out).unwrap_err();
446        assert!(matches!(err, Error::InvalidParams(_)));
447    }
448
449    #[test]
450    fn truncated_payload_detected() {
451        let p = params(1 << 22, 22, false);
452        let input = integer_channel(p.range, 22, 256, 5);
453        let codec = AdcBitpack::default();
454        let mut payload = Vec::new();
455        codec.encode_chunk(&input, &p, &mut payload).unwrap();
456        payload.truncate(HEADER_BYTES + 1);
457        let mut out = vec![0.0f32; input.len()];
458        let err = codec.decode_chunk(&payload, &p, &mut out).unwrap_err();
459        assert!(matches!(err, Error::Truncated { .. }));
460    }
461}