slow5lib/writer.rs
1use std::collections::BTreeSet;
2use std::fs::File;
3use std::io::{self, BufWriter, Write};
4use std::path::Path;
5use std::sync::{Arc, mpsc};
6
7const BUF_SIZE: usize = 512 * 1024;
8
9use crate::Result;
10use crate::error::SlowError;
11use crate::header::{Header, RecordCompression, SignalCompression};
12use crate::record::Record;
13
14/// Primary-field type header line written into every BLOW5 ASCII section.
15const TYPE_HEADER: &str = "#char*\tuint32_t\tdouble\tdouble\tdouble\tdouble\tuint64_t\tint16_t*\n";
16
17/// Primary-field column name header line written into every BLOW5 ASCII section.
18const COL_HEADER: &str = "#read_id\tread_group\tdigitisation\toffset\trange\tsampling_rate\tlen_raw_signal\traw_signal\n";
19
20/// Sequential writer for SLOW5 and BLOW5 files.
21///
22/// Records are written in the order they are passed to `write()`. The format
23/// is detected from the path extension (`.slow5` or `.blow5`).
24///
25/// For high-throughput BLOW5 writing, compress records in parallel with rayon and
26/// call `write()` in sorted order -- the writer itself must remain on one
27/// thread (it owns the file cursor).
28///
29/// ```no_run
30/// # use slow5lib::{Slow5Writer, header::{Header, RecordCompression, SignalCompression, ReadGroup}, aux::AuxMeta};
31/// # fn example() -> slow5lib::Result<()> {
32/// let header = Header {
33/// version: (0, 2, 0),
34/// num_read_groups: 1,
35/// record_compression: RecordCompression::Zstd,
36/// signal_compression: SignalCompression::SvbZd,
37/// read_groups: vec![ReadGroup::default()],
38/// aux_meta: AuxMeta::default(),
39/// };
40/// let mut writer = Slow5Writer::create("out.blow5", header)?;
41/// // writer.write(&record)?;
42/// writer.finish()?;
43/// # Ok(()) }
44/// ```
45pub struct Slow5Writer {
46 state: WriterState,
47}
48
49enum WriterState {
50 Blow5 {
51 inner: BufWriter<File>,
52 record_compression: RecordCompression,
53 signal_compression: SignalCompression,
54 aux_meta: crate::aux::AuxMeta,
55 // Reused across write() calls to avoid per-record ZSTD_CCtx alloc/free.
56 // None when record_compression is not Zstd.
57 zstd_compressor: Option<zstd::bulk::Compressor<'static>>,
58 },
59 Slow5 {
60 inner: BufWriter<File>,
61 aux_meta: crate::aux::AuxMeta,
62 },
63}
64
65impl Slow5Writer {
66 /// Create a new SLOW5 or BLOW5 file and write its header.
67 ///
68 /// The format is detected from the path extension (`.slow5` or `.blow5`).
69 /// For BLOW5 with signal compression, set `header.version` to at least `(0, 2, 0)`.
70 ///
71 /// # Examples
72 ///
73 /// ```no_run
74 /// use slow5lib::{Slow5Writer, header::{Header, RecordCompression, SignalCompression, ReadGroup}};
75 /// use slow5lib::aux::AuxMeta;
76 ///
77 /// # fn example() -> slow5lib::Result<()> {
78 /// let header = Header {
79 /// version: (0, 2, 0),
80 /// num_read_groups: 1,
81 /// record_compression: RecordCompression::Zstd,
82 /// signal_compression: SignalCompression::SvbZd,
83 /// read_groups: vec![ReadGroup::default()],
84 /// aux_meta: AuxMeta::default(),
85 /// };
86 /// let mut writer = Slow5Writer::create("out.blow5", header)?;
87 /// // writer.write(&record)?;
88 /// writer.finish()?;
89 /// # Ok(()) }
90 /// ```
91 pub fn create(path: impl AsRef<Path>, header: Header) -> Result<Self> {
92 let path = path.as_ref();
93
94 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
95 match ext {
96 "blow5" => Self::create_blow5(path, header),
97 "slow5" => Self::create_slow5(path, header),
98 other => Err(SlowError::InvalidFormat(format!(
99 "unrecognised extension '.{other}': expected '.blow5' or '.slow5'"
100 ))),
101 }
102 }
103
104 fn create_blow5(path: &Path, header: Header) -> Result<Self> {
105 let file = File::create(path)?;
106 let mut inner = BufWriter::with_capacity(BUF_SIZE, file);
107
108 let header_bytes = build_blow5_header(&header)?;
109 inner.write_all(&header_bytes)?;
110
111 let zstd_compressor = if matches!(header.record_compression, RecordCompression::Zstd) {
112 let c = zstd::bulk::Compressor::new(1)
113 .map_err(|e| SlowError::Decompression(e.to_string()))?;
114 Some(c)
115 } else {
116 None
117 };
118
119 Ok(Self {
120 state: WriterState::Blow5 {
121 inner,
122 record_compression: header.record_compression,
123 signal_compression: header.signal_compression,
124 aux_meta: header.aux_meta,
125 zstd_compressor,
126 },
127 })
128 }
129
130 fn create_slow5(path: &Path, header: Header) -> Result<Self> {
131 let file = File::create(path)?;
132 let mut inner = BufWriter::with_capacity(BUF_SIZE, file);
133
134 crate::slow5::write_header(&mut inner, &header)?;
135
136 Ok(Self {
137 state: WriterState::Slow5 {
138 inner,
139 aux_meta: header.aux_meta,
140 },
141 })
142 }
143
144 /// Compress and write a single record.
145 ///
146 /// The signal is encoded and compressed according to the compression settings
147 /// specified in the header at construction time.
148 ///
149 /// # Examples
150 ///
151 /// ```no_run
152 /// use slow5lib::{Slow5Writer, record::Record};
153 /// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
154 /// use slow5lib::aux::AuxMeta;
155 /// use std::collections::HashMap;
156 ///
157 /// # fn example() -> slow5lib::Result<()> {
158 /// let header = Header {
159 /// version: (0, 2, 0),
160 /// num_read_groups: 1,
161 /// record_compression: RecordCompression::Zstd,
162 /// signal_compression: SignalCompression::SvbZd,
163 /// read_groups: vec![ReadGroup::default()],
164 /// aux_meta: AuxMeta::default(),
165 /// };
166 /// let mut writer = Slow5Writer::create("out.blow5", header)?;
167 ///
168 /// let record = Record {
169 /// read_id: "my-read-001".to_string(),
170 /// read_group: 0,
171 /// digitisation: 2048.0,
172 /// offset: -224.0,
173 /// range: 1463.0,
174 /// sampling_rate: 4000.0,
175 /// raw_signal: vec![512i16, 515, 510, 518, 511],
176 /// aux: HashMap::new(),
177 /// };
178 /// writer.write(&record)?;
179 /// writer.finish()?;
180 /// # Ok(()) }
181 /// ```
182 pub fn write(&mut self, record: &Record) -> Result<()> {
183 match &mut self.state {
184 WriterState::Blow5 {
185 inner,
186 record_compression,
187 signal_compression,
188 aux_meta,
189 zstd_compressor,
190 } => {
191 let record_buf = encode_record(record, *signal_compression, aux_meta)?;
192
193 let (size_bytes, payload) = match record_compression {
194 RecordCompression::None => {
195 let size = record_buf.len() as u64;
196 (size.to_le_bytes(), record_buf)
197 }
198 RecordCompression::Zstd => {
199 let compressor = zstd_compressor.as_mut().expect("zstd compressor present");
200 let compressed = compressor
201 .compress(&record_buf)
202 .map_err(|e| SlowError::Decompression(e.to_string()))?;
203 let size = compressed.len() as u64;
204 (size.to_le_bytes(), compressed)
205 }
206 RecordCompression::Zlib => {
207 let compressed = crate::compression::compress_record_zlib(&record_buf)?;
208 let size = compressed.len() as u64;
209 (size.to_le_bytes(), compressed)
210 }
211 };
212
213 inner.write_all(&size_bytes)?;
214 inner.write_all(&payload)?;
215 Ok(())
216 }
217 WriterState::Slow5 { inner, aux_meta } => {
218 crate::slow5::write_record(inner, record, aux_meta)
219 }
220 }
221 }
222
223 /// Compress and write a batch of records in parallel, then flush sequentially.
224 ///
225 /// All records are encoded and compressed concurrently across the rayon thread pool.
226 /// The compressed payloads are then written to disk in order on the calling thread.
227 /// Compression and I/O are not overlapped -- for that, see the manual `records_raw` pattern.
228 ///
229 /// For SLOW5 text files, this falls back to sequential `write()` calls since there
230 /// is no signal compression to parallelize.
231 ///
232 /// Requires the `rayon` feature.
233 ///
234 /// # Examples
235 ///
236 /// ```no_run
237 /// use slow5lib::{Slow5Writer, record::Record};
238 /// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
239 /// use slow5lib::aux::AuxMeta;
240 /// use std::collections::HashMap;
241 ///
242 /// # fn example() -> slow5lib::Result<()> {
243 /// let header = Header {
244 /// version: (0, 2, 0),
245 /// num_read_groups: 1,
246 /// record_compression: RecordCompression::Zstd,
247 /// signal_compression: SignalCompression::SvbZd,
248 /// read_groups: vec![ReadGroup::default()],
249 /// aux_meta: AuxMeta::default(),
250 /// };
251 /// let mut writer = Slow5Writer::create("out.blow5", header)?;
252 ///
253 /// let records: Vec<Record> = Vec::new(); // normally populated from a reader
254 /// writer.write_all_par(&records)?;
255 /// writer.finish()?;
256 /// # Ok(()) }
257 /// ```
258 #[cfg(feature = "rayon")]
259 pub fn write_all_par(&mut self, records: &[Record]) -> Result<()> {
260 use rayon::prelude::*;
261
262 // SLOW5 text -- no record compression, fall back to sequential
263 if matches!(self.state, WriterState::Slow5 { .. }) {
264 for record in records {
265 self.write(record)?;
266 }
267 return Ok(());
268 }
269
270 // Extract parameters; releases the immutable borrow before the mutable write below
271 let (rc, sc, am) = match &self.state {
272 WriterState::Blow5 {
273 record_compression,
274 signal_compression,
275 aux_meta,
276 ..
277 } => (*record_compression, *signal_compression, aux_meta.clone()),
278 WriterState::Slow5 { .. } => unreachable!(),
279 };
280
281 // Parallel: encode + compress
282 let chunks: Vec<Result<(u64, Vec<u8>)>> = records
283 .par_iter()
284 .map(|record| {
285 let record_buf = encode_record(record, sc, &am)?;
286 compress_chunk(&record_buf, rc)
287 })
288 .collect();
289
290 // Sequential: write to file in order
291 let WriterState::Blow5 { inner, .. } = &mut self.state else {
292 unreachable!()
293 };
294 for chunk in chunks {
295 let (size, payload) = chunk?;
296 inner.write_all(&size.to_le_bytes())?;
297 inner.write_all(&payload)?;
298 }
299 Ok(())
300 }
301
302 /// Write the EOF marker (BLOW5) or flush (SLOW5) and close the file.
303 ///
304 /// Consumes `self` so the writer cannot be used after finishing.
305 ///
306 /// # Examples
307 ///
308 /// ```no_run
309 /// use slow5lib::{Slow5Writer, header::{Header, RecordCompression, SignalCompression, ReadGroup}};
310 /// use slow5lib::aux::AuxMeta;
311 ///
312 /// # fn example() -> slow5lib::Result<()> {
313 /// let header = Header {
314 /// version: (0, 2, 0),
315 /// num_read_groups: 1,
316 /// record_compression: RecordCompression::None,
317 /// signal_compression: SignalCompression::None,
318 /// read_groups: vec![ReadGroup::default()],
319 /// aux_meta: AuxMeta::default(),
320 /// };
321 /// let writer = Slow5Writer::create("out.blow5", header)?;
322 /// writer.finish()?; // flushes and writes EOF marker
323 /// # Ok(()) }
324 /// ```
325 pub fn finish(self) -> Result<()> {
326 match self.state {
327 WriterState::Blow5 { mut inner, .. } => {
328 // aux_meta consumed via `..`
329 inner.write_all(crate::blow5::EOF_MARKER)?;
330 inner.flush().map_err(SlowError::Io)
331 }
332 WriterState::Slow5 { mut inner, .. } => inner.flush().map_err(SlowError::Io),
333 }
334 }
335}
336
337// ── ParallelSlow5Writer ───────────────────────────────────────────────────────
338
339/// Cloneable write handle for [`ParallelSlow5Writer`].
340///
341/// Obtain one from [`ParallelSlow5Writer::write_handle`] and clone it for each
342/// rayon worker (or any thread). Calling `write()` compresses on the calling thread
343/// and enqueues the payload for the background I/O thread. Blocks under backpressure.
344///
345/// Drop all handles before calling [`ParallelSlow5Writer::finish`].
346///
347/// # Examples
348///
349/// ```no_run
350/// use slow5lib::{Slow5WriteHandle, writer::ParallelSlow5Writer};
351/// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
352/// use slow5lib::aux::AuxMeta;
353///
354/// # fn example() -> slow5lib::Result<()> {
355/// let header = Header {
356/// version: (0, 2, 0),
357/// num_read_groups: 1,
358/// record_compression: RecordCompression::Zstd,
359/// signal_compression: SignalCompression::SvbZd,
360/// read_groups: vec![ReadGroup::default()],
361/// aux_meta: AuxMeta::default(),
362/// };
363/// let par_writer = ParallelSlow5Writer::create("out.blow5", header, 32)?;
364/// let handle = par_writer.write_handle();
365/// // handle.write(&rec)?;
366/// drop(handle);
367/// par_writer.finish()?;
368/// # Ok(()) }
369/// ```
370#[derive(Clone)]
371pub struct Slow5WriteHandle {
372 tx: mpsc::SyncSender<(u64, Vec<u8>)>,
373 record_compression: RecordCompression,
374 signal_compression: SignalCompression,
375 aux_meta: Arc<crate::aux::AuxMeta>,
376}
377
378impl Slow5WriteHandle {
379 /// Encode and compress `record` on the calling thread, then enqueue for writing.
380 ///
381 /// Blocks when the internal channel is full (backpressure from the I/O thread).
382 /// Returns an error if the background writer thread has exited unexpectedly.
383 pub fn write(&self, record: &crate::record::Record) -> Result<()> {
384 let buf = encode_record(record, self.signal_compression, &self.aux_meta)?;
385 let (size, payload) = compress_chunk(&buf, self.record_compression)?;
386 self.tx.send((size, payload)).map_err(|_| {
387 SlowError::Io(io::Error::new(
388 io::ErrorKind::BrokenPipe,
389 "writer thread has exited",
390 ))
391 })
392 }
393}
394
395/// BLOW5 writer backed by a dedicated background I/O thread.
396///
397/// Rayon workers (or any threads) call `write()` on a cloned [`Slow5WriteHandle`] --
398/// compression happens on the calling thread in parallel. The background thread does
399/// all sequential file I/O, overlapping with compression work.
400///
401/// Records are written in the order they arrive at the background thread, which is
402/// non-deterministic under parallel use. The BLOW5 format is order-independent; the
403/// sidecar index provides random access regardless of record order.
404///
405/// # Usage pattern
406///
407/// ```no_run
408/// use slow5lib::writer::ParallelSlow5Writer;
409/// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
410/// use slow5lib::aux::AuxMeta;
411///
412/// # fn example() -> slow5lib::Result<()> {
413/// let header = Header {
414/// version: (0, 2, 0),
415/// num_read_groups: 1,
416/// record_compression: RecordCompression::Zstd,
417/// signal_compression: SignalCompression::SvbZd,
418/// read_groups: vec![ReadGroup::default()],
419/// aux_meta: AuxMeta::default(),
420/// };
421/// let n_threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
422/// let par_writer = ParallelSlow5Writer::create("out.blow5", header, n_threads * 4)?;
423/// let handle = par_writer.write_handle();
424///
425/// // rayon::prelude::*;
426/// // ids.par_iter().for_each(|id| {
427/// // handle.write(&reader.get(id).unwrap()).unwrap();
428/// // });
429///
430/// drop(handle); // signal EOF -- all senders must be dropped before finish()
431/// par_writer.finish() // join background thread, propagate any I/O error
432/// # }
433/// ```
434pub struct ParallelSlow5Writer {
435 tx: Option<mpsc::SyncSender<(u64, Vec<u8>)>>,
436 handle: Option<std::thread::JoinHandle<Result<()>>>,
437 record_compression: RecordCompression,
438 signal_compression: SignalCompression,
439 aux_meta: Arc<crate::aux::AuxMeta>,
440}
441
442impl ParallelSlow5Writer {
443 /// Create a BLOW5 file and spawn the background writer thread.
444 ///
445 /// Only `.blow5` files are supported; returns an error for `.slow5` paths.
446 ///
447 /// `channel_cap` is the maximum number of compressed payloads buffered between
448 /// compression threads and the writer thread. A value of `num_threads * 4` is a
449 /// reasonable default. Larger values consume more memory; smaller values add
450 /// backpressure earlier.
451 pub fn create(path: impl AsRef<Path>, header: Header, channel_cap: usize) -> Result<Self> {
452 let path = path.as_ref();
453 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
454 if ext != "blow5" {
455 return Err(SlowError::InvalidFormat(
456 "ParallelSlow5Writer only supports .blow5 files".into(),
457 ));
458 }
459
460 let file = File::create(path)?;
461 let mut inner = BufWriter::with_capacity(BUF_SIZE, file);
462 let header_bytes = build_blow5_header(&header)?;
463 inner.write_all(&header_bytes)?;
464
465 let record_compression = header.record_compression;
466 let signal_compression = header.signal_compression;
467 let aux_meta = Arc::new(header.aux_meta);
468
469 let (tx, rx) = mpsc::sync_channel::<(u64, Vec<u8>)>(channel_cap);
470
471 let handle = std::thread::spawn(move || -> Result<()> {
472 for (size, payload) in rx {
473 inner
474 .write_all(&size.to_le_bytes())
475 .map_err(SlowError::Io)?;
476 inner.write_all(&payload).map_err(SlowError::Io)?;
477 }
478 inner
479 .write_all(crate::blow5::EOF_MARKER)
480 .map_err(SlowError::Io)?;
481 inner.flush().map_err(SlowError::Io)
482 });
483
484 Ok(Self {
485 tx: Some(tx),
486 handle: Some(handle),
487 record_compression,
488 signal_compression,
489 aux_meta,
490 })
491 }
492
493 /// Return a cloneable handle for submitting records from worker threads.
494 ///
495 /// Clone the handle once per rayon worker (or thread). All clones must be
496 /// dropped before calling `finish()`.
497 pub fn write_handle(&self) -> Slow5WriteHandle {
498 Slow5WriteHandle {
499 tx: self.tx.as_ref().expect("writer already finished").clone(),
500 record_compression: self.record_compression,
501 signal_compression: self.signal_compression,
502 aux_meta: Arc::clone(&self.aux_meta),
503 }
504 }
505
506 /// Close the channel and join the background thread, returning any I/O error.
507 ///
508 /// **All `Slow5WriteHandle` clones must be dropped before calling this.**
509 /// If any handle is still live, the background thread will keep waiting for
510 /// records and this call will block indefinitely.
511 pub fn finish(mut self) -> Result<()> {
512 drop(self.tx.take());
513 let h = self.handle.take().expect("writer already finished");
514 match h.join() {
515 Ok(result) => result,
516 Err(_) => Err(SlowError::Io(io::Error::other("writer thread panicked"))),
517 }
518 }
519}
520
521impl Drop for ParallelSlow5Writer {
522 fn drop(&mut self) {
523 drop(self.tx.take());
524 if let Some(h) = self.handle.take() {
525 let _ = h.join();
526 }
527 }
528}
529
530/// Compress one encoded record buffer and return `(compressed_size, payload)`.
531fn compress_chunk(buf: &[u8], rc: RecordCompression) -> Result<(u64, Vec<u8>)> {
532 match rc {
533 RecordCompression::None => Ok((buf.len() as u64, buf.to_vec())),
534 RecordCompression::Zstd => {
535 let c = crate::compression::compress_record_zstd_tl(buf)?;
536 Ok((c.len() as u64, c))
537 }
538 RecordCompression::Zlib => {
539 let c = crate::compression::compress_record_zlib(buf)?;
540 Ok((c.len() as u64, c))
541 }
542 }
543}
544
545/// Build the complete BLOW5 binary header as a byte buffer.
546///
547/// Binary wire layout:
548/// ```text
549/// [magic: 6][major: 1][minor: 1][patch: 1][rec_comp: 1][num_read_groups: 4]
550/// [sig_comp: 1 if version >= 0.2.0][padding: zeros to offset 64]
551/// [header_size: u32 le][ASCII section: header_size bytes]
552/// ```
553///
554/// `header_size` is the byte count of the ASCII section (not including its own
555/// 4-byte field). It is computed after building the ASCII section and patched
556/// back into the buffer at offset 64.
557fn build_blow5_header(header: &Header) -> Result<Vec<u8>> {
558 let mut buf: Vec<u8> = Vec::with_capacity(256);
559
560 // Fixed binary prefix
561 buf.extend_from_slice(crate::blow5::MAGIC);
562 buf.push(header.version.0);
563 buf.push(header.version.1);
564 buf.push(header.version.2);
565 buf.push(header.record_compression.to_byte());
566 buf.extend_from_slice(&header.num_read_groups.to_le_bytes());
567
568 // Signal compression byte present only for version >= 0.2.0
569 if (header.version.0, header.version.1) >= (0, 2) {
570 buf.push(header.signal_compression.to_byte());
571 }
572
573 // Pad with zeros to reach offset 64
574 while buf.len() < 64 {
575 buf.push(0);
576 }
577
578 // Placeholder for header_size (u32 le) at byte 64
579 let header_size_pos = buf.len(); // == 64
580 buf.extend_from_slice(&0u32.to_le_bytes());
581
582 // ASCII section begins at byte 68
583 let ascii_start = buf.len(); // == 68
584
585 // @attribute lines -- collect all unique attribute names sorted for determinism
586 let all_keys: BTreeSet<&str> = header
587 .read_groups
588 .iter()
589 .flat_map(|rg| rg.attributes.keys().map(String::as_str))
590 .collect();
591
592 for key in &all_keys {
593 buf.push(b'@');
594 buf.extend_from_slice(key.as_bytes());
595 for rg in &header.read_groups {
596 buf.push(b'\t');
597 let val = rg.attributes.get(*key).map(String::as_str).unwrap_or("");
598 if val.is_empty() {
599 buf.push(b'.');
600 } else {
601 buf.extend_from_slice(val.as_bytes());
602 }
603 }
604 buf.push(b'\n');
605 }
606
607 // Type header line: primary fields + aux fields
608 buf.extend_from_slice(TYPE_HEADER.trim_end_matches('\n').as_bytes());
609 for typ in &header.aux_meta.types {
610 buf.push(b'\t');
611 buf.extend_from_slice(crate::aux::aux_type_str(typ).as_bytes());
612 }
613 buf.push(b'\n');
614
615 // Column name header line: primary fields + aux fields
616 buf.extend_from_slice(COL_HEADER.trim_end_matches('\n').as_bytes());
617 for name in &header.aux_meta.names {
618 buf.push(b'\t');
619 buf.extend_from_slice(name.as_bytes());
620 }
621 buf.push(b'\n');
622
623 // Patch header_size with the actual ASCII byte count
624 let ascii_len = (buf.len() - ascii_start) as u32;
625 buf[header_size_pos..header_size_pos + 4].copy_from_slice(&ascii_len.to_le_bytes());
626
627 Ok(buf)
628}
629
630/// Encode a `Record` into the uncompressed binary record buffer.
631///
632/// Wire layout (before record compression):
633/// ```text
634/// [read_id_len: u16 le][read_id: N bytes]
635/// [read_group: u32 le]
636/// [digitisation: f64 le][offset: f64 le][range: f64 le][sampling_rate: f64 le]
637/// [len_raw_signal: u64 le][signal bytes]
638/// ```
639///
640/// For `SignalCompression::None`: `len_raw_signal` = sample count; signal bytes = i16 LE samples.
641/// For `SignalCompression::SvbZd`: `len_raw_signal` = byte count of SVB-ZD data; signal bytes = compressed.
642/// For `SignalCompression::ExZd`: `len_raw_signal` = byte count of the ex-zd frame; signal bytes = compressed.
643fn encode_record(
644 record: &Record,
645 signal_compression: SignalCompression,
646 aux_meta: &crate::aux::AuxMeta,
647) -> Result<Vec<u8>> {
648 let read_id_bytes = record.read_id.as_bytes();
649 let read_id_len = u16::try_from(read_id_bytes.len()).map_err(|_| {
650 SlowError::InvalidFormat(format!(
651 "read_id '{}' is too long ({} bytes, max 65535)",
652 record.read_id,
653 read_id_bytes.len()
654 ))
655 })?;
656
657 let mut buf =
658 Vec::with_capacity(2 + read_id_bytes.len() + 4 + 8 * 5 + 2 * record.raw_signal.len());
659
660 buf.extend_from_slice(&read_id_len.to_le_bytes());
661 buf.extend_from_slice(read_id_bytes);
662 buf.extend_from_slice(&record.read_group.to_le_bytes());
663 buf.extend_from_slice(&record.digitisation.to_le_bytes());
664 buf.extend_from_slice(&record.offset.to_le_bytes());
665 buf.extend_from_slice(&record.range.to_le_bytes());
666 buf.extend_from_slice(&record.sampling_rate.to_le_bytes());
667
668 match signal_compression {
669 SignalCompression::None => {
670 let n_samples = record.raw_signal.len() as u64;
671 buf.extend_from_slice(&n_samples.to_le_bytes());
672 for &s in &record.raw_signal {
673 buf.extend_from_slice(&s.to_le_bytes());
674 }
675 }
676 SignalCompression::SvbZd => {
677 let compressed = crate::compression::compress_signal_svb_zd(&record.raw_signal)?;
678 let byte_count = compressed.len() as u64;
679 buf.extend_from_slice(&byte_count.to_le_bytes());
680 buf.extend_from_slice(&compressed);
681 }
682 SignalCompression::ExZd => {
683 let compressed = crate::compression::compress_signal_ex_zd(&record.raw_signal)?;
684 let byte_count = compressed.len() as u64;
685 buf.extend_from_slice(&byte_count.to_le_bytes());
686 buf.extend_from_slice(&compressed);
687 }
688 }
689
690 // Auxiliary fields in schema order
691 for (name, typ) in aux_meta.names.iter().zip(aux_meta.types.iter()) {
692 let val = record
693 .aux
694 .get(name)
695 .unwrap_or(&crate::aux::AuxValue::Missing);
696 crate::aux::encode_binary(val, typ, &mut buf);
697 }
698
699 Ok(buf)
700}