bioforge 2.3.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bioforge-2.3.0.data/purelib/bioforge/__init__.py +74 -0
- bioforge-2.3.0.data/purelib/bioforge/aligner.py +953 -0
- bioforge-2.3.0.data/purelib/bioforge/analyze.py +385 -0
- bioforge-2.3.0.data/purelib/bioforge/bgzf.py +119 -0
- bioforge-2.3.0.data/purelib/bioforge/biocore.py +2092 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/__init__.py +1 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/_loader.py +543 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/build.py +231 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/engine.c +1538 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/engine.dll +0 -0
- bioforge-2.3.0.data/purelib/bioforge/qcreport.py +298 -0
- bioforge-2.3.0.data/purelib/bioforge/smart_translator.py +601 -0
- bioforge-2.3.0.dist-info/METADATA +736 -0
- bioforge-2.3.0.dist-info/RECORD +18 -0
- bioforge-2.3.0.dist-info/WHEEL +5 -0
- bioforge-2.3.0.dist-info/entry_points.txt +4 -0
- bioforge-2.3.0.dist-info/licenses/LICENSE +280 -0
- bioforge-2.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2092 @@
|
|
|
1
|
+
"""
|
|
2
|
+
biocore.py
|
|
3
|
+
══════════════════════════════════════════════════════════════════════
|
|
4
|
+
BioForge — high-performance bioinformatics engine built on a unified
|
|
5
|
+
5-bit biological alphabet.
|
|
6
|
+
|
|
7
|
+
Design principles
|
|
8
|
+
─────────────────
|
|
9
|
+
• No biological sequence is ever stored as a Python ``str`` in memory.
|
|
10
|
+
• All sequences live as compact, bit-packed NumPy uint8 arrays.
|
|
11
|
+
• All encode/decode operations are fully vectorised — zero Python loops.
|
|
12
|
+
• Sequences are read-only (numpy write-lock) after construction.
|
|
13
|
+
|
|
14
|
+
Unified 5-bit encoding map (32 possible states)
|
|
15
|
+
─────────────────────────────────────────────────
|
|
16
|
+
State │ Binary │ Symbol
|
|
17
|
+
───────┼─────────┼──────────────────────────────────────────────────
|
|
18
|
+
0 │ 00000 │ Adenine ─ nucleotide A
|
|
19
|
+
1 │ 00001 │ Cytosine ─ nucleotide C
|
|
20
|
+
2 │ 00010 │ Guanine ─ nucleotide G
|
|
21
|
+
3 │ 00011 │ Thymine / Uracil ─ nucleotide T / U (shared)
|
|
22
|
+
4 │ 00100 │ Alanine ─ amino acid A
|
|
23
|
+
5 │ 00101 │ Cysteine ─ amino acid C
|
|
24
|
+
6 │ 00110 │ Aspartic acid ─ amino acid D
|
|
25
|
+
7 │ 00111 │ Glutamic acid ─ amino acid E
|
|
26
|
+
8 │ 01000 │ Phenylalanine ─ amino acid F
|
|
27
|
+
9 │ 01001 │ Glycine ─ amino acid G
|
|
28
|
+
10 │ 01010 │ Histidine ─ amino acid H
|
|
29
|
+
11 │ 01011 │ Isoleucine ─ amino acid I
|
|
30
|
+
12 │ 01100 │ Lysine ─ amino acid K
|
|
31
|
+
13 │ 01101 │ Leucine ─ amino acid L
|
|
32
|
+
14 │ 01110 │ Methionine ─ amino acid M
|
|
33
|
+
15 │ 01111 │ Asparagine ─ amino acid N
|
|
34
|
+
16 │ 10000 │ Proline ─ amino acid P
|
|
35
|
+
17 │ 10001 │ Glutamine ─ amino acid Q
|
|
36
|
+
18 │ 10010 │ Arginine ─ amino acid R
|
|
37
|
+
19 │ 10011 │ Serine ─ amino acid S
|
|
38
|
+
20 │ 10100 │ Threonine ─ amino acid T
|
|
39
|
+
21 │ 10101 │ Valine ─ amino acid V
|
|
40
|
+
22 │ 10110 │ Tryptophan ─ amino acid W
|
|
41
|
+
23 │ 10111 │ Tyrosine ─ amino acid Y
|
|
42
|
+
24 │ 11000 │ STOP codon / chain terminator *
|
|
43
|
+
25 │ 11001 │ Alignment gap ─ -
|
|
44
|
+
26–30 │ … │ Reserved for future extension
|
|
45
|
+
31 │ 11111 │ Unknown / ambiguous (N in DNA, X in protein)
|
|
46
|
+
───────┴─────────┴──────────────────────────────────────────────────
|
|
47
|
+
Memory savings over plain ASCII: 5 bits/symbol → 37.5 % reduction.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import ctypes
|
|
53
|
+
import mmap
|
|
54
|
+
import os
|
|
55
|
+
from dataclasses import dataclass
|
|
56
|
+
from enum import IntEnum
|
|
57
|
+
from typing import Iterator, Optional
|
|
58
|
+
|
|
59
|
+
import numpy as np
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
from .engine._loader import C_AVAILABLE as _C_AVAILABLE
|
|
63
|
+
from .engine._loader import C_BATCH_AVAILABLE as _C_BATCH_AVAILABLE
|
|
64
|
+
from .engine._loader import C_LIBDEFLATE_AVAILABLE as _C_LIBDEFLATE_AVAILABLE
|
|
65
|
+
from .engine._loader import C_PARALLEL_AVAILABLE as _C_PARALLEL_AVAILABLE
|
|
66
|
+
from .engine._loader import C_PARSER_AVAILABLE as _C_PARSER_AVAILABLE
|
|
67
|
+
from .engine._loader import c_bgzf_decompress_parallel as _c_bgzf_decompress_parallel
|
|
68
|
+
from .engine._loader import c_bgzf_usize as _c_bgzf_usize
|
|
69
|
+
from .engine._loader import c_getitem5 as _c_getitem5
|
|
70
|
+
from .engine._loader import c_gzip_decompress as _c_gzip_decompress
|
|
71
|
+
from .engine._loader import c_is_bgzf as _c_is_bgzf
|
|
72
|
+
from .engine._loader import c_pack5 as _c_pack5
|
|
73
|
+
from .engine._loader import c_parse_mem_parallel as _c_parse_mem_parallel
|
|
74
|
+
from .engine._loader import c_parser_close as _c_parser_close
|
|
75
|
+
from .engine._loader import c_parser_next as _c_parser_next
|
|
76
|
+
from .engine._loader import c_parser_next_batch as _c_parser_next_batch
|
|
77
|
+
from .engine._loader import c_parser_next_fastq as _c_parser_next_fastq
|
|
78
|
+
from .engine._loader import c_parser_open as _c_parser_open
|
|
79
|
+
from .engine._loader import c_unpack5 as _c_unpack5
|
|
80
|
+
except ImportError:
|
|
81
|
+
_C_AVAILABLE = False
|
|
82
|
+
_C_PARSER_AVAILABLE = False
|
|
83
|
+
_C_BATCH_AVAILABLE = False
|
|
84
|
+
_C_PARALLEL_AVAILABLE = False
|
|
85
|
+
_C_LIBDEFLATE_AVAILABLE = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__: list[str] = [
|
|
89
|
+
# Excepciones — importar para capturar errores del motor
|
|
90
|
+
"BioForgeError",
|
|
91
|
+
"SequenceTypeError",
|
|
92
|
+
"SequenceValueError",
|
|
93
|
+
"TranslationError",
|
|
94
|
+
"AlignmentError",
|
|
95
|
+
"BioForgeIOError",
|
|
96
|
+
"EngineError",
|
|
97
|
+
# Núcleo
|
|
98
|
+
"BioCode",
|
|
99
|
+
"SeqType",
|
|
100
|
+
"NUC_LUT",
|
|
101
|
+
"AA_LUT",
|
|
102
|
+
"BitPacker",
|
|
103
|
+
"PackedSequence",
|
|
104
|
+
"FastqRecord",
|
|
105
|
+
"SmartImporter",
|
|
106
|
+
"SequenceStats",
|
|
107
|
+
"compute_stats",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
112
|
+
# §0 EXCEPTIONS — jerarquía de errores de BioForge
|
|
113
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
114
|
+
|
|
115
|
+
class BioForgeError(Exception):
|
|
116
|
+
"""Base para todos los errores propios de BioForge.
|
|
117
|
+
|
|
118
|
+
Úsala en bloques ``except`` para capturar cualquier error del motor
|
|
119
|
+
sin interferir con el resto de Python::
|
|
120
|
+
|
|
121
|
+
from bioforge import BioForgeError
|
|
122
|
+
try:
|
|
123
|
+
prot = SmartTranslator.translate(seq)
|
|
124
|
+
except BioForgeError as e:
|
|
125
|
+
print(f"Error de BioForge: {e}")
|
|
126
|
+
|
|
127
|
+
Las subclases también heredan de ``TypeError`` o ``ValueError`` según
|
|
128
|
+
corresponda, por lo que el código existente que ya atrapa esos tipos
|
|
129
|
+
estándar sigue funcionando sin cambios.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SequenceTypeError(BioForgeError, TypeError):
|
|
134
|
+
"""Tipo incorrecto al llamar a una función del motor.
|
|
135
|
+
|
|
136
|
+
Se lanza cuando:
|
|
137
|
+
|
|
138
|
+
- Se pasa un ``str``, ``list`` u otro objeto donde se esperaba
|
|
139
|
+
``PackedSequence``.
|
|
140
|
+
- Se mezclan tipos biológicos incompatibles (NUCLEOTIDE con PROTEIN).
|
|
141
|
+
- El ``seq_type`` de un ``PackedSequence`` no es un valor ``SeqType``.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SequenceValueError(BioForgeError, ValueError):
|
|
146
|
+
"""Valor inválido en una secuencia o en sus metadatos.
|
|
147
|
+
|
|
148
|
+
Se lanza cuando:
|
|
149
|
+
|
|
150
|
+
- ``n_symbols`` es negativo.
|
|
151
|
+
- El buffer ``packed`` es demasiado pequeño para ``n`` símbolos.
|
|
152
|
+
- La secuencia está vacía donde se requiere contenido.
|
|
153
|
+
- ``codes`` no es un array 1-D.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TranslationError(BioForgeError, ValueError):
|
|
158
|
+
"""Error durante la traducción ADN→Proteína.
|
|
159
|
+
|
|
160
|
+
Se lanza cuando:
|
|
161
|
+
|
|
162
|
+
- La secuencia no contiene ningún codón ATG/AUG.
|
|
163
|
+
- El ORF no tiene ningún codón completo tras el ATG.
|
|
164
|
+
- La secuencia es demasiado corta para contener un codón.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AlignmentError(BioForgeError, ValueError):
|
|
169
|
+
"""Error durante el alineamiento o en sus parámetros.
|
|
170
|
+
|
|
171
|
+
Se lanza cuando:
|
|
172
|
+
|
|
173
|
+
- El modo no es ``'global'`` ni ``'semi-global'``.
|
|
174
|
+
- ``width`` es ≤ 0 en ``format_alignment``.
|
|
175
|
+
- Las cadenas alineadas tienen longitudes incongruentes.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class BioForgeIOError(BioForgeError, OSError):
|
|
180
|
+
"""No se pudo abrir o leer un archivo de secuencias.
|
|
181
|
+
|
|
182
|
+
Hereda de ``OSError`` (= ``IOError``), por lo que el código que ya atrapa
|
|
183
|
+
errores de E/S sigue funcionando, y además se captura con ``BioForgeError``.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class EngineError(BioForgeError, RuntimeError):
|
|
188
|
+
"""Fallo del motor de ingesta: parser, descompresión o conversión BGZF.
|
|
189
|
+
|
|
190
|
+
Se lanza cuando:
|
|
191
|
+
|
|
192
|
+
- El parser por lotes o paralelo devuelve un código de error (buffer
|
|
193
|
+
desbordado, ventana demasiado densa, registro gigante).
|
|
194
|
+
- La (des)compresión BGZF/libdeflate falla.
|
|
195
|
+
- Se pide la vía rápida ``.gz`` sin libdeflate compilado.
|
|
196
|
+
|
|
197
|
+
Hereda de ``RuntimeError`` y se captura con ``BioForgeError``.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
202
|
+
# §1 BIOLOGICAL ALPHABET — 5-bit codes (values 0 … 31)
|
|
203
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
204
|
+
|
|
205
|
+
class BioCode(IntEnum):
|
|
206
|
+
"""
|
|
207
|
+
Unified 5-bit code for every biological symbol supported by the library.
|
|
208
|
+
|
|
209
|
+
Guaranteed range [0, 31]. Stored as ``np.uint8`` in packed arrays,
|
|
210
|
+
where the upper 3 bits are always zero after unpacking.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
# ── Nucleotides (states 0–3) ──────────────────────────────────────────────
|
|
214
|
+
NUC_A = 0 # Adenine
|
|
215
|
+
NUC_C = 1 # Cytosine
|
|
216
|
+
NUC_G = 2 # Guanine
|
|
217
|
+
NUC_TU = 3 # Thymine (DNA) / Uracil (RNA) — unified slot
|
|
218
|
+
|
|
219
|
+
# ── Amino acids (states 4–23), alphabetical IUPAC 1-letter order ──────────
|
|
220
|
+
AA_A = 4 # Alanine (A)
|
|
221
|
+
AA_C = 5 # Cysteine (C)
|
|
222
|
+
AA_D = 6 # Aspartic acid (D)
|
|
223
|
+
AA_E = 7 # Glutamic acid (E)
|
|
224
|
+
AA_F = 8 # Phenylalanine (F)
|
|
225
|
+
AA_G = 9 # Glycine (G)
|
|
226
|
+
AA_H = 10 # Histidine (H)
|
|
227
|
+
AA_I = 11 # Isoleucine (I)
|
|
228
|
+
AA_K = 12 # Lysine (K)
|
|
229
|
+
AA_L = 13 # Leucine (L)
|
|
230
|
+
AA_M = 14 # Methionine (M)
|
|
231
|
+
AA_N = 15 # Asparagine (N)
|
|
232
|
+
AA_P = 16 # Proline (P)
|
|
233
|
+
AA_Q = 17 # Glutamine (Q)
|
|
234
|
+
AA_R = 18 # Arginine (R)
|
|
235
|
+
AA_S = 19 # Serine (S)
|
|
236
|
+
AA_T = 20 # Threonine (T)
|
|
237
|
+
AA_V = 21 # Valine (V)
|
|
238
|
+
AA_W = 22 # Tryptophan (W)
|
|
239
|
+
AA_Y = 23 # Tyrosine (Y)
|
|
240
|
+
|
|
241
|
+
# ── Special / terminal states (24–31) ────────────────────────────────────
|
|
242
|
+
STOP = 24 # Stop codon / chain terminator *
|
|
243
|
+
GAP = 25 # Alignment gap -
|
|
244
|
+
# 26–30 reserved (e.g. modified bases, selenocysteine, pyrrolysine)
|
|
245
|
+
UNK = 31 # Unknown / ambiguous (N in DNA, X in protein)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SeqType(IntEnum):
|
|
249
|
+
"""Biological sequence alphabet family."""
|
|
250
|
+
NUCLEOTIDE = 0 # DNA or RNA
|
|
251
|
+
PROTEIN = 1 # amino-acid chain
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
255
|
+
# §2 LOOKUP TABLES — constant-time ASCII ↔ BioCode translation
|
|
256
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
257
|
+
|
|
258
|
+
def _build_encode_lut(
|
|
259
|
+
char_map: dict[str, int],
|
|
260
|
+
default: int = BioCode.UNK,
|
|
261
|
+
) -> np.ndarray:
|
|
262
|
+
"""
|
|
263
|
+
Build a 256-element uint8 lookup array indexed by ASCII ordinal.
|
|
264
|
+
|
|
265
|
+
A single ``lut[raw_uint8_array]`` call translates an entire sequence
|
|
266
|
+
in one vectorised numpy step — no Python-level loop required.
|
|
267
|
+
|
|
268
|
+
Parameters
|
|
269
|
+
----------
|
|
270
|
+
char_map : dict[str, int]
|
|
271
|
+
Character → BioCode mapping; applied to both upper and lower case.
|
|
272
|
+
default : int
|
|
273
|
+
BioCode assigned to any character absent from *char_map*.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
np.ndarray, dtype=uint8, shape=(256,)
|
|
278
|
+
"""
|
|
279
|
+
lut = np.full(256, default, dtype=np.uint8)
|
|
280
|
+
for ch, code in char_map.items():
|
|
281
|
+
lut[ord(ch.upper())] = code
|
|
282
|
+
lut[ord(ch.lower())] = code
|
|
283
|
+
return lut
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── Nucleotide encode table ────────────────────────────────────────────────────
|
|
287
|
+
NUC_LUT: np.ndarray = _build_encode_lut({
|
|
288
|
+
"A": BioCode.NUC_A,
|
|
289
|
+
"C": BioCode.NUC_C,
|
|
290
|
+
"G": BioCode.NUC_G,
|
|
291
|
+
"T": BioCode.NUC_TU, # DNA thymine
|
|
292
|
+
"U": BioCode.NUC_TU, # RNA uracil → same slot as T
|
|
293
|
+
"N": BioCode.UNK, # IUPAC ambiguous nucleotide
|
|
294
|
+
"-": BioCode.GAP,
|
|
295
|
+
".": BioCode.GAP,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
# ── Amino-acid encode table ────────────────────────────────────────────────────
|
|
299
|
+
AA_LUT: np.ndarray = _build_encode_lut({
|
|
300
|
+
"A": BioCode.AA_A, "C": BioCode.AA_C, "D": BioCode.AA_D,
|
|
301
|
+
"E": BioCode.AA_E, "F": BioCode.AA_F, "G": BioCode.AA_G,
|
|
302
|
+
"H": BioCode.AA_H, "I": BioCode.AA_I, "K": BioCode.AA_K,
|
|
303
|
+
"L": BioCode.AA_L, "M": BioCode.AA_M, "N": BioCode.AA_N,
|
|
304
|
+
"P": BioCode.AA_P, "Q": BioCode.AA_Q, "R": BioCode.AA_R,
|
|
305
|
+
"S": BioCode.AA_S, "T": BioCode.AA_T, "V": BioCode.AA_V,
|
|
306
|
+
"W": BioCode.AA_W, "Y": BioCode.AA_Y,
|
|
307
|
+
"*": BioCode.STOP,
|
|
308
|
+
"-": BioCode.GAP,
|
|
309
|
+
"X": BioCode.UNK,
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
# ── Decode maps (BioCode int → canonical single-letter character) ─────────────
|
|
313
|
+
_NUC_DECODE: dict[int, str] = {
|
|
314
|
+
BioCode.NUC_A: "A", BioCode.NUC_C: "C",
|
|
315
|
+
BioCode.NUC_G: "G", BioCode.NUC_TU: "T",
|
|
316
|
+
BioCode.GAP: "-", BioCode.UNK: "N",
|
|
317
|
+
}
|
|
318
|
+
_AA_DECODE: dict[int, str] = {
|
|
319
|
+
BioCode.AA_A: "A", BioCode.AA_C: "C", BioCode.AA_D: "D",
|
|
320
|
+
BioCode.AA_E: "E", BioCode.AA_F: "F", BioCode.AA_G: "G",
|
|
321
|
+
BioCode.AA_H: "H", BioCode.AA_I: "I", BioCode.AA_K: "K",
|
|
322
|
+
BioCode.AA_L: "L", BioCode.AA_M: "M", BioCode.AA_N: "N",
|
|
323
|
+
BioCode.AA_P: "P", BioCode.AA_Q: "Q", BioCode.AA_R: "R",
|
|
324
|
+
BioCode.AA_S: "S", BioCode.AA_T: "T", BioCode.AA_V: "V",
|
|
325
|
+
BioCode.AA_W: "W", BioCode.AA_Y: "Y", BioCode.STOP: "*",
|
|
326
|
+
BioCode.GAP: "-", BioCode.UNK: "X",
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# ── Protein-exclusive character LUT (for vectorised type auto-detection) ───────
|
|
330
|
+
# Characters that appear in protein FASTA but NOT in the standard IUPAC
|
|
331
|
+
# nucleotide alphabet {A C G T U R Y S W K M B D H V N - .}.
|
|
332
|
+
# Conservative set to minimise false positives on degenerate-nucleotide files.
|
|
333
|
+
_IS_PROTEIN_CHAR: np.ndarray = np.zeros(256, dtype=np.bool_)
|
|
334
|
+
for _ch in "EFILPQefilpq*":
|
|
335
|
+
_IS_PROTEIN_CHAR[ord(_ch)] = True
|
|
336
|
+
|
|
337
|
+
# ── Vectorised decode LUTs (BioCode index → ASCII byte) ────────────────────────
|
|
338
|
+
# Pre-built once at module load. to_string() indexes into these arrays with a
|
|
339
|
+
# single fancy-index op, then calls .tobytes().decode('ascii') — no Python loop.
|
|
340
|
+
_NUC_DECODE_ARR: np.ndarray = np.full(32, ord('?'), dtype=np.uint8)
|
|
341
|
+
for _code, _char in _NUC_DECODE.items():
|
|
342
|
+
_NUC_DECODE_ARR[int(_code)] = ord(_char)
|
|
343
|
+
|
|
344
|
+
_AA_DECODE_ARR: np.ndarray = np.full(32, ord('?'), dtype=np.uint8)
|
|
345
|
+
for _code, _char in _AA_DECODE.items():
|
|
346
|
+
_AA_DECODE_ARR[int(_code)] = ord(_char)
|
|
347
|
+
|
|
348
|
+
# ── Watson-Crick complement LUT (BioCode index → complement BioCode) ───────────
|
|
349
|
+
# A(0)↔T/U(3), C(1)↔G(2). Gaps and UNK map to themselves. All other codes
|
|
350
|
+
# (amino acids, reserved) map to UNK — reverse complement of protein is undefined.
|
|
351
|
+
_NUC_COMPLEMENT: np.ndarray = np.full(32, BioCode.UNK, dtype=np.uint8)
|
|
352
|
+
_NUC_COMPLEMENT[int(BioCode.NUC_A )] = np.uint8(BioCode.NUC_TU)
|
|
353
|
+
_NUC_COMPLEMENT[int(BioCode.NUC_C )] = np.uint8(BioCode.NUC_G)
|
|
354
|
+
_NUC_COMPLEMENT[int(BioCode.NUC_G )] = np.uint8(BioCode.NUC_C)
|
|
355
|
+
_NUC_COMPLEMENT[int(BioCode.NUC_TU)] = np.uint8(BioCode.NUC_A)
|
|
356
|
+
_NUC_COMPLEMENT[int(BioCode.UNK )] = np.uint8(BioCode.UNK)
|
|
357
|
+
_NUC_COMPLEMENT[int(BioCode.GAP )] = np.uint8(BioCode.GAP)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
361
|
+
# §3 BIT PACKER — compact 5-bit ↔ uint8 array conversion
|
|
362
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
363
|
+
|
|
364
|
+
class BitPacker:
|
|
365
|
+
"""
|
|
366
|
+
Stateless utility for lossless 5-bit dense packing.
|
|
367
|
+
|
|
368
|
+
**Bit layout** — MSB-first big-endian bit stream::
|
|
369
|
+
|
|
370
|
+
sym₀[b₄b₃b₂b₁b₀] sym₁[b₄b₃b₂b₁b₀] sym₂ …
|
|
371
|
+
──────────────────────────────────────────────────
|
|
372
|
+
byte₀[b₇b₆b₅b₄b₃b₂b₁b₀] byte₁ …
|
|
373
|
+
|
|
374
|
+
For 8 symbols (= 40 bits = 5 bytes) the stream is byte-aligned with
|
|
375
|
+
zero padding overhead. Any other length gets ≤ 7 zero-padding bits
|
|
376
|
+
appended in the final byte, transparent to the caller.
|
|
377
|
+
|
|
378
|
+
Both ``pack`` and ``unpack`` are fully vectorised — no Python loops.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
# Pre-computed constants — allocated once at class definition time.
|
|
382
|
+
_SHIFTS: np.ndarray = np.array([4, 3, 2, 1, 0], dtype=np.uint8) # MSB→LSB
|
|
383
|
+
_WEIGHTS: np.ndarray = np.array([16, 8, 4, 2, 1], dtype=np.uint8) # reconstruction
|
|
384
|
+
|
|
385
|
+
# ── Pack ──────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def pack(codes: np.ndarray) -> np.ndarray:
|
|
389
|
+
"""
|
|
390
|
+
Pack 5-bit BioCode values into a compact uint8 byte array.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
codes : np.ndarray, dtype uint8, values in [0, 31]
|
|
395
|
+
Sequence of BioCode integers to compress.
|
|
396
|
+
|
|
397
|
+
Returns
|
|
398
|
+
-------
|
|
399
|
+
np.ndarray, dtype uint8
|
|
400
|
+
Packed byte array. ``len`` = ⌈``len(codes)`` × 5 / 8⌉.
|
|
401
|
+
Trailing bits in the last byte are zero-padded.
|
|
402
|
+
|
|
403
|
+
Complexity
|
|
404
|
+
──────────
|
|
405
|
+
Time : O(n) — two vectorised numpy ops.
|
|
406
|
+
Memory: O(n) — peak ≈ 5n bits for the intermediate bit matrix.
|
|
407
|
+
"""
|
|
408
|
+
codes = np.asarray(codes, dtype=np.uint8)
|
|
409
|
+
if codes.ndim != 1:
|
|
410
|
+
raise SequenceValueError(
|
|
411
|
+
f"codes debe ser un array 1-D, se recibió shape {codes.shape}. "
|
|
412
|
+
"Pasa un array plano, p.ej. np.array([0, 1, 2], dtype=np.uint8)."
|
|
413
|
+
)
|
|
414
|
+
if _C_AVAILABLE:
|
|
415
|
+
return _c_pack5(codes)
|
|
416
|
+
|
|
417
|
+
# ① Expand each 5-bit code → one row of a (n, 5) bit matrix, MSB first.
|
|
418
|
+
bits: np.ndarray = (
|
|
419
|
+
(codes[:, np.newaxis] >> BitPacker._SHIFTS) & np.uint8(1)
|
|
420
|
+
) # shape: (n, 5), dtype uint8
|
|
421
|
+
|
|
422
|
+
# ② Flatten to a 1-D bit stream; numpy packs every 8 bits → 1 byte.
|
|
423
|
+
return np.packbits(bits.ravel())
|
|
424
|
+
|
|
425
|
+
# ── Unpack ────────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def unpack(packed: np.ndarray, n: int) -> np.ndarray:
|
|
429
|
+
"""
|
|
430
|
+
Unpack a 5-bit packed byte array back to BioCode values.
|
|
431
|
+
|
|
432
|
+
Parameters
|
|
433
|
+
----------
|
|
434
|
+
packed : np.ndarray, dtype uint8
|
|
435
|
+
Byte array as returned by :meth:`pack`.
|
|
436
|
+
n : int
|
|
437
|
+
Original symbol count (required to trim padding bits).
|
|
438
|
+
|
|
439
|
+
Returns
|
|
440
|
+
-------
|
|
441
|
+
np.ndarray, dtype uint8, shape (n,), values in [0, 31]
|
|
442
|
+
"""
|
|
443
|
+
if not isinstance(n, (int, np.integer)) or int(n) < 0:
|
|
444
|
+
raise SequenceValueError(
|
|
445
|
+
f"n debe ser un entero no negativo, se recibió {n!r}."
|
|
446
|
+
)
|
|
447
|
+
n = int(n)
|
|
448
|
+
min_bytes = BitPacker.packed_size(n)
|
|
449
|
+
if len(packed) < min_bytes:
|
|
450
|
+
raise SequenceValueError(
|
|
451
|
+
f"packed tiene {len(packed)} byte(s) pero se necesitan "
|
|
452
|
+
f"al menos {min_bytes} para desempaquetar {n} símbolo(s). "
|
|
453
|
+
"¿El buffer procede de BitPacker.pack() con los mismos datos?"
|
|
454
|
+
)
|
|
455
|
+
if _C_AVAILABLE:
|
|
456
|
+
return _c_unpack5(packed, n)
|
|
457
|
+
|
|
458
|
+
# ① Expand all bytes → individual bits; keep exactly n×5 of them.
|
|
459
|
+
bits: np.ndarray = np.unpackbits(packed)[: n * 5].reshape(n, 5)
|
|
460
|
+
|
|
461
|
+
# ② Dot each row with [16, 8, 4, 2, 1] to restore the 5-bit integer.
|
|
462
|
+
return (bits * BitPacker._WEIGHTS).sum(axis=1, dtype=np.uint8)
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def packed_size(n_symbols: int) -> int:
|
|
466
|
+
"""Minimum byte count needed to store ``n_symbols`` 5-bit codes."""
|
|
467
|
+
return (n_symbols * 5 + 7) // 8
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
471
|
+
# §4 PACKED SEQUENCE — immutable, memory-efficient sequence container
|
|
472
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
473
|
+
|
|
474
|
+
@dataclass(eq=False)
|
|
475
|
+
class PackedSequence:
|
|
476
|
+
"""
|
|
477
|
+
Immutable container for a single 5-bit packed biological sequence.
|
|
478
|
+
|
|
479
|
+
Biological data lives **only** inside ``data`` — a read-only uint8
|
|
480
|
+
numpy array write-locked after construction. The ``header`` field
|
|
481
|
+
stores FASTA metadata only and is never derived from sequence data.
|
|
482
|
+
|
|
483
|
+
Attributes
|
|
484
|
+
──────────
|
|
485
|
+
header : FASTA description line (``>`` prefix stripped).
|
|
486
|
+
seq_type : NUCLEOTIDE or PROTEIN.
|
|
487
|
+
n_symbols : Original sequence length in biological symbols.
|
|
488
|
+
data : Read-only uint8 numpy array of 5-bit packed codes.
|
|
489
|
+
``len(data)`` == ⌈``n_symbols`` × 5 / 8⌉ bytes.
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
header: str
|
|
493
|
+
seq_type: SeqType
|
|
494
|
+
n_symbols: int
|
|
495
|
+
data: np.ndarray # uint8, write-locked, 5-bit packed
|
|
496
|
+
|
|
497
|
+
# ── Construction & validation ──────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
def __post_init__(self) -> None:
|
|
500
|
+
"""Normalise *data* to a write-locked uint8 array and validate length."""
|
|
501
|
+
if not isinstance(self.n_symbols, (int, np.integer)) or int(self.n_symbols) < 0:
|
|
502
|
+
raise SequenceValueError(
|
|
503
|
+
f"n_symbols debe ser un entero no negativo, se recibió {self.n_symbols!r}."
|
|
504
|
+
)
|
|
505
|
+
if not isinstance(self.seq_type, SeqType):
|
|
506
|
+
raise SequenceTypeError(
|
|
507
|
+
f"seq_type debe ser SeqType.NUCLEOTIDE o SeqType.PROTEIN, "
|
|
508
|
+
f"se recibió {type(self.seq_type).__name__!r}."
|
|
509
|
+
)
|
|
510
|
+
arr = np.asarray(self.data, dtype=np.uint8)
|
|
511
|
+
arr.flags.writeable = False # seal the buffer against mutations
|
|
512
|
+
self.data = arr
|
|
513
|
+
|
|
514
|
+
min_bytes = BitPacker.packed_size(self.n_symbols)
|
|
515
|
+
if len(arr) < min_bytes:
|
|
516
|
+
raise SequenceValueError(
|
|
517
|
+
f"data tiene {len(arr)} byte(s) pero se necesitan "
|
|
518
|
+
f"≥ {min_bytes} para almacenar {self.n_symbols} símbolo(s). "
|
|
519
|
+
"Usa BitPacker.pack(codes) para generar el buffer correcto."
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# ── Equality & hashing ────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
def __eq__(self, other: object) -> bool:
|
|
525
|
+
if not isinstance(other, PackedSequence):
|
|
526
|
+
return NotImplemented
|
|
527
|
+
return (
|
|
528
|
+
self.seq_type == other.seq_type
|
|
529
|
+
and self.n_symbols == other.n_symbols
|
|
530
|
+
and self.header == other.header
|
|
531
|
+
and np.array_equal(self.data, other.data)
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def __hash__(self) -> int:
|
|
535
|
+
# data.tobytes() is O(n) but guarantees content-correct hashing.
|
|
536
|
+
return hash((self.seq_type, self.n_symbols,
|
|
537
|
+
self.header, self.data.tobytes()))
|
|
538
|
+
|
|
539
|
+
# ── Representation ────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
def __repr__(self) -> str:
|
|
542
|
+
tag = "NUC" if self.seq_type == SeqType.NUCLEOTIDE else "PRO"
|
|
543
|
+
return (
|
|
544
|
+
f"PackedSequence(type={tag}, n={self.n_symbols:,}, "
|
|
545
|
+
f"packed={self.packed_bytes:,} B, "
|
|
546
|
+
f"saved={100 * (1 - self.memory_ratio):.1f}%, "
|
|
547
|
+
f"header={self.header[:40]!r})"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# ── Sequence protocol ─────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
def __len__(self) -> int:
|
|
553
|
+
"""Number of biological symbols in the sequence."""
|
|
554
|
+
return self.n_symbols
|
|
555
|
+
|
|
556
|
+
def __getitem__(self, key: int | slice) -> int | PackedSequence:
|
|
557
|
+
"""
|
|
558
|
+
Access one symbol (→ ``int`` BioCode) or a sub-sequence
|
|
559
|
+
(→ new ``PackedSequence``).
|
|
560
|
+
|
|
561
|
+
Single-index access is **O(1)** and reads only the 1–2 bytes
|
|
562
|
+
containing the target 5-bit window without unpacking the whole array.
|
|
563
|
+
Slice access decodes the required range and repacks.
|
|
564
|
+
"""
|
|
565
|
+
if isinstance(key, int):
|
|
566
|
+
idx = key + self.n_symbols if key < 0 else key
|
|
567
|
+
if not 0 <= idx < self.n_symbols:
|
|
568
|
+
raise IndexError(
|
|
569
|
+
f"index {key} out of range for sequence of length {self.n_symbols}"
|
|
570
|
+
)
|
|
571
|
+
return self._code_at(idx)
|
|
572
|
+
|
|
573
|
+
if isinstance(key, slice):
|
|
574
|
+
sub_codes = self.decode()[key]
|
|
575
|
+
return PackedSequence(
|
|
576
|
+
header = self.header,
|
|
577
|
+
seq_type = self.seq_type,
|
|
578
|
+
n_symbols = len(sub_codes),
|
|
579
|
+
data = BitPacker.pack(sub_codes),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
raise TypeError(f"index must be int or slice, not {type(key).__name__}")
|
|
583
|
+
|
|
584
|
+
def _code_at(self, idx: int) -> int:
|
|
585
|
+
"""Return the 5-bit BioCode at position *idx* sin desempaquetar todo."""
|
|
586
|
+
if _C_AVAILABLE:
|
|
587
|
+
return _c_getitem5(self.data, idx)
|
|
588
|
+
|
|
589
|
+
bit_pos = idx * 5
|
|
590
|
+
byte_i = bit_pos >> 3
|
|
591
|
+
bit_off = bit_pos & 7
|
|
592
|
+
|
|
593
|
+
buf = np.zeros(2, dtype=np.uint8)
|
|
594
|
+
buf[0] = self.data[byte_i]
|
|
595
|
+
if byte_i + 1 < len(self.data):
|
|
596
|
+
buf[1] = self.data[byte_i + 1]
|
|
597
|
+
|
|
598
|
+
bits = np.unpackbits(buf)[bit_off: bit_off + 5]
|
|
599
|
+
return int((bits * BitPacker._WEIGHTS).sum())
|
|
600
|
+
|
|
601
|
+
# ── Storage properties ────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
@property
|
|
604
|
+
def packed_bytes(self) -> int:
|
|
605
|
+
"""Byte count consumed by the ``data`` array."""
|
|
606
|
+
return int(self.data.nbytes)
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def memory_ratio(self) -> float:
|
|
610
|
+
"""
|
|
611
|
+
Bytes used per symbol relative to naive 8-bit (ASCII) storage.
|
|
612
|
+
|
|
613
|
+
Ideal value for 5-bit packing: **0.625** (= 5 ÷ 8 = 37.5 % reduction).
|
|
614
|
+
Marginally above 0.625 only for sequences shorter than 8 symbols
|
|
615
|
+
due to byte-alignment padding.
|
|
616
|
+
"""
|
|
617
|
+
return self.packed_bytes / self.n_symbols if self.n_symbols else 1.0
|
|
618
|
+
|
|
619
|
+
# ── Decode / output ───────────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
def decode(self) -> np.ndarray:
|
|
622
|
+
"""
|
|
623
|
+
Unpack to a uint8 array of :class:`BioCode` values.
|
|
624
|
+
|
|
625
|
+
Returns
|
|
626
|
+
-------
|
|
627
|
+
np.ndarray, dtype uint8, shape (n_symbols,), values in [0, 31]
|
|
628
|
+
"""
|
|
629
|
+
return BitPacker.unpack(self.data, self.n_symbols)
|
|
630
|
+
|
|
631
|
+
def to_string(self) -> str:
|
|
632
|
+
"""
|
|
633
|
+
Decode to a human-readable single-letter string.
|
|
634
|
+
|
|
635
|
+
**For output / FASTA export only.**
|
|
636
|
+
Do *not* store the result as biological data — storing it defeats
|
|
637
|
+
the library's memory-efficiency design.
|
|
638
|
+
|
|
639
|
+
Returns
|
|
640
|
+
-------
|
|
641
|
+
str — canonical IUPAC single-letter sequence.
|
|
642
|
+
"""
|
|
643
|
+
lut = (
|
|
644
|
+
_NUC_DECODE_ARR
|
|
645
|
+
if self.seq_type == SeqType.NUCLEOTIDE
|
|
646
|
+
else _AA_DECODE_ARR
|
|
647
|
+
)
|
|
648
|
+
return lut[self.decode()].tobytes().decode("ascii")
|
|
649
|
+
|
|
650
|
+
def reverse_complement(self) -> PackedSequence:
|
|
651
|
+
"""
|
|
652
|
+
Compute the reverse complement (5'→3' antisense strand).
|
|
653
|
+
|
|
654
|
+
Watson-Crick pairing: A↔T/U, C↔G. Unknown bases (N) and gaps (-)
|
|
655
|
+
map to themselves. Two fully vectorised NumPy operations: flip + LUT.
|
|
656
|
+
|
|
657
|
+
Returns
|
|
658
|
+
-------
|
|
659
|
+
PackedSequence (NUCLEOTIDE) with header prefixed ``[RC]``.
|
|
660
|
+
|
|
661
|
+
Raises
|
|
662
|
+
------
|
|
663
|
+
SequenceTypeError
|
|
664
|
+
If the sequence is not ``SeqType.NUCLEOTIDE``.
|
|
665
|
+
"""
|
|
666
|
+
if self.seq_type != SeqType.NUCLEOTIDE:
|
|
667
|
+
raise SequenceTypeError(
|
|
668
|
+
f"reverse_complement() requiere SeqType.NUCLEOTIDE, "
|
|
669
|
+
f"se recibió {self.seq_type.name}. "
|
|
670
|
+
"Las proteínas no tienen complemento de Watson-Crick."
|
|
671
|
+
)
|
|
672
|
+
rc = _NUC_COMPLEMENT[self.decode()[::-1]] # flip + complement, two vectorised ops
|
|
673
|
+
return PackedSequence(
|
|
674
|
+
header = f"[RC] {self.header}",
|
|
675
|
+
seq_type = SeqType.NUCLEOTIDE,
|
|
676
|
+
n_symbols = self.n_symbols,
|
|
677
|
+
data = BitPacker.pack(rc),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
682
|
+
# §5 FASTQ RECORD — secuencia 5-bit + calidades Phred
|
|
683
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
684
|
+
|
|
685
|
+
@dataclass
|
|
686
|
+
class FastqRecord:
|
|
687
|
+
"""
|
|
688
|
+
Un registro FASTQ: secuencia nucleotídica empaquetada + calidades Phred.
|
|
689
|
+
|
|
690
|
+
Attributes
|
|
691
|
+
──────────
|
|
692
|
+
sequence : PackedSequence (SeqType.NUCLEOTIDE, 5-bit packed)
|
|
693
|
+
quality : np.ndarray uint8, valores Phred 0–93 (ASCII-33 ya restado).
|
|
694
|
+
Longitud idéntica a ``sequence.n_symbols``.
|
|
695
|
+
|
|
696
|
+
Quick start
|
|
697
|
+
───────────
|
|
698
|
+
>>> for rec in SmartImporter.stream_fastq("reads.fastq"):
|
|
699
|
+
... if rec.passes_quality(20):
|
|
700
|
+
... process(rec.sequence)
|
|
701
|
+
"""
|
|
702
|
+
|
|
703
|
+
sequence: PackedSequence
|
|
704
|
+
quality: np.ndarray # uint8, Phred 0–93
|
|
705
|
+
|
|
706
|
+
@property
|
|
707
|
+
def mean_quality(self) -> float:
|
|
708
|
+
"""Calidad Phred media de la lectura."""
|
|
709
|
+
return float(self.quality.mean()) if len(self.quality) > 0 else 0.0
|
|
710
|
+
|
|
711
|
+
def passes_quality(self, min_q: int) -> bool:
|
|
712
|
+
"""True si la calidad Phred media es ≥ min_q."""
|
|
713
|
+
return self.mean_quality >= min_q
|
|
714
|
+
|
|
715
|
+
def __repr__(self) -> str:
|
|
716
|
+
return (
|
|
717
|
+
f"FastqRecord(n={self.sequence.n_symbols:,}, "
|
|
718
|
+
f"q_mean={self.mean_quality:.1f}, "
|
|
719
|
+
f"header={self.sequence.header[:40]!r})"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
724
|
+
# §5b COLUMNAR BATCHES — miles de registros como matrices, sin objeto/registro
|
|
725
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
726
|
+
#
|
|
727
|
+
# La ruta rápida de v2.0. En vez de fabricar un objeto Python por registro
|
|
728
|
+
# (≈5 µs cada uno = ~2 s para 200 000 lecturas), se conservan las matrices
|
|
729
|
+
# contiguas que el motor C ya produce y los análisis se hacen como operaciones
|
|
730
|
+
# NumPy sobre columnas enteras. Los objetos individuales (PackedSequence /
|
|
731
|
+
# FastqRecord) se materializan **solo** cuando se piden con indexación.
|
|
732
|
+
#
|
|
733
|
+
# Caso ideal (Illumina): todas las lecturas miden lo mismo → las calidades son
|
|
734
|
+
# una matriz 2-D limpia (m × L) y filtrar es indexación booleana pura.
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _gather_headers(
|
|
738
|
+
hdr_raw: bytes, hdr_off: np.ndarray, idx: np.ndarray
|
|
739
|
+
) -> "tuple[bytes, np.ndarray]":
|
|
740
|
+
"""Reconstruye el blob de cabeceras para los registros seleccionados."""
|
|
741
|
+
parts: list[bytes] = []
|
|
742
|
+
new_off = np.empty(len(idx) + 1, dtype=np.int32)
|
|
743
|
+
new_off[0] = 0
|
|
744
|
+
cur = 0
|
|
745
|
+
for k, j in enumerate(idx):
|
|
746
|
+
seg = hdr_raw[int(hdr_off[j]): int(hdr_off[j + 1])] # incluye '\0'
|
|
747
|
+
parts.append(seg)
|
|
748
|
+
cur += len(seg)
|
|
749
|
+
new_off[k + 1] = cur
|
|
750
|
+
return b"".join(parts), new_off
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# ── Cortes de ventana para el parser paralelo ────────────────────────────────
|
|
754
|
+
# Devuelven el offset del INICIO del último registro del bloque; los registros
|
|
755
|
+
# anteriores están completos y se parsean, el resto se arrastra a la siguiente
|
|
756
|
+
# ventana. Asumen que el bloque empieza en un límite de registro.
|
|
757
|
+
|
|
758
|
+
def _cut_fasta(data: bytes) -> int:
|
|
759
|
+
i = data.rfind(b"\n>")
|
|
760
|
+
return i + 1 if i != -1 else 0
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _cut_fastq(data: bytes) -> int:
|
|
764
|
+
pos = len(data)
|
|
765
|
+
while True:
|
|
766
|
+
i = data.rfind(b"\n@", 0, pos)
|
|
767
|
+
if i == -1:
|
|
768
|
+
return 0
|
|
769
|
+
rec = i + 1
|
|
770
|
+
nl1 = data.find(b"\n", rec)
|
|
771
|
+
if nl1 == -1:
|
|
772
|
+
pos = i; continue
|
|
773
|
+
nl2 = data.find(b"\n", nl1 + 1)
|
|
774
|
+
if nl2 == -1:
|
|
775
|
+
pos = i; continue
|
|
776
|
+
if data[nl2 + 1: nl2 + 2] == b"+": # verificado: registro real
|
|
777
|
+
return rec
|
|
778
|
+
pos = i
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _columnar_batch(m, pack_buf, pack_off, n_syms, types,
|
|
782
|
+
hdr_buf, hdr_off, qual_buf, qual_off, fastq):
|
|
783
|
+
"""Construye un SequenceBatch/ReadBatch desde los buffers de salida de C.
|
|
784
|
+
|
|
785
|
+
Compartido por el camino columnar secuencial y el paralelo. Una copia por
|
|
786
|
+
lote desacopla de los buffers reutilizados; string_at copia solo lo usado.
|
|
787
|
+
"""
|
|
788
|
+
pack_used = int(pack_off[m])
|
|
789
|
+
hdr_used = int(hdr_off[m])
|
|
790
|
+
packed = pack_buf[:pack_used].copy()
|
|
791
|
+
poff = pack_off[: m + 1].copy()
|
|
792
|
+
nsy = n_syms[:m].copy()
|
|
793
|
+
tps = types[:m].copy()
|
|
794
|
+
hraw = ctypes.string_at(ctypes.addressof(hdr_buf), hdr_used)
|
|
795
|
+
hoff = hdr_off[: m + 1].copy()
|
|
796
|
+
if not fastq:
|
|
797
|
+
return SequenceBatch(packed, poff, nsy, tps, hraw, hoff)
|
|
798
|
+
qual_used = int(qual_off[m])
|
|
799
|
+
fixed = (int(nsy[0]) if (m > 0 and nsy[0] > 0
|
|
800
|
+
and bool(np.all(nsy == nsy[0]))) else 0)
|
|
801
|
+
if fixed and qual_used == m * fixed:
|
|
802
|
+
qual = qual_buf[:qual_used].copy().reshape(m, fixed)
|
|
803
|
+
qoff = None
|
|
804
|
+
else:
|
|
805
|
+
fixed = 0
|
|
806
|
+
qual = qual_buf[:qual_used].copy()
|
|
807
|
+
qoff = qual_off[: m + 1].copy()
|
|
808
|
+
return ReadBatch(packed, poff, nsy, tps, hraw, hoff, qual, qoff, fixed)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ── Núcleo vectorizado de GC y k-meros (compartido por ambos lotes) ──────────
|
|
812
|
+
|
|
813
|
+
_GC_W = np.array([16, 8, 4, 2, 1], dtype=np.uint8) # pesos de bit MSB→LSB
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _decode_fixed_2d(packed: np.ndarray, m: int, L: int) -> np.ndarray:
|
|
817
|
+
"""Decodifica m registros de longitud fija L → matriz (m, L) de BioCode.
|
|
818
|
+
|
|
819
|
+
Totalmente vectorizado: una sola ``unpackbits`` sobre toda la matriz de
|
|
820
|
+
bytes empaquetados. Aprovecha que cada registro ocupa exactamente
|
|
821
|
+
``plen`` bytes cuando todas las longitudes coinciden.
|
|
822
|
+
"""
|
|
823
|
+
plen = (L * 5 + 7) // 8
|
|
824
|
+
packed2d = packed[: m * plen].reshape(m, plen)
|
|
825
|
+
bits = np.unpackbits(packed2d, axis=1)[:, : L * 5].reshape(m, L, 5)
|
|
826
|
+
return (bits * _GC_W).sum(axis=2, dtype=np.uint8)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _decode_cached(obj) -> "Optional[np.ndarray]":
|
|
830
|
+
"""Decodifica el lote a una matriz (m, L) UNA vez y la cachea en ``obj``.
|
|
831
|
+
|
|
832
|
+
Devuelve ``None`` si las longitudes no son fijas (ruta irregular). Compartido
|
|
833
|
+
por ``gc_content()`` y ``kmer_spectrum()`` para no desempaquetar dos veces.
|
|
834
|
+
"""
|
|
835
|
+
if getattr(obj, "_dec_done", False):
|
|
836
|
+
return obj._dec_cache
|
|
837
|
+
n = obj._n_syms
|
|
838
|
+
out = (_decode_fixed_2d(obj._packed, len(obj), int(n[0]))
|
|
839
|
+
if (len(obj) and int(n[0]) > 0 and bool(np.all(n == n[0]))) else None)
|
|
840
|
+
obj._dec_cache = out
|
|
841
|
+
obj._dec_done = True
|
|
842
|
+
return out
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _batch_gc(packed, pack_off, n_syms, codes2d) -> np.ndarray:
|
|
846
|
+
"""Fracción GC (0..1) de cada registro. ``codes2d`` = matriz cacheada o None."""
|
|
847
|
+
m = int(n_syms.shape[0])
|
|
848
|
+
if m == 0:
|
|
849
|
+
return np.empty(0, dtype=np.float64)
|
|
850
|
+
if codes2d is not None:
|
|
851
|
+
gc = ((codes2d == 1) | (codes2d == 2)).sum(axis=1)
|
|
852
|
+
return gc / codes2d.shape[1]
|
|
853
|
+
# Longitud irregular: bucle por registro (cada uno se decodifica vectorizado).
|
|
854
|
+
out = np.empty(m, dtype=np.float64)
|
|
855
|
+
for i in range(m):
|
|
856
|
+
n = int(n_syms[i])
|
|
857
|
+
if n == 0:
|
|
858
|
+
out[i] = 0.0
|
|
859
|
+
continue
|
|
860
|
+
c = BitPacker.unpack(packed[int(pack_off[i]): int(pack_off[i + 1])], n)
|
|
861
|
+
out[i] = float(((c == 1) | (c == 2)).sum()) / n
|
|
862
|
+
return out
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _batch_kmer_spectrum(packed, pack_off, n_syms, k: int, codes2d) -> np.ndarray:
|
|
866
|
+
"""Espectro de k-meros del lote → array int64 de longitud 4**k.
|
|
867
|
+
|
|
868
|
+
Cuenta todos los k-meros de todas las secuencias; los que tienen bases
|
|
869
|
+
ambiguas (código > 3) se descartan. ``codes2d`` = matriz cacheada o None.
|
|
870
|
+
"""
|
|
871
|
+
if k < 1:
|
|
872
|
+
raise SequenceValueError(f"k debe ser >= 1, se recibió {k}.")
|
|
873
|
+
n_kmers = 4 ** k
|
|
874
|
+
out = np.zeros(n_kmers, dtype=np.int64)
|
|
875
|
+
m = int(n_syms.shape[0])
|
|
876
|
+
if m == 0:
|
|
877
|
+
return out
|
|
878
|
+
powers = (4 ** np.arange(k - 1, -1, -1)).astype(np.int64)
|
|
879
|
+
sw = np.lib.stride_tricks.sliding_window_view
|
|
880
|
+
|
|
881
|
+
if codes2d is not None:
|
|
882
|
+
if codes2d.shape[1] < k:
|
|
883
|
+
return out # ninguna ventana válida
|
|
884
|
+
win = sw(codes2d, k, axis=1) # (m, L-k+1, k)
|
|
885
|
+
valid = (win <= 3).all(axis=2) # (m, L-k+1)
|
|
886
|
+
ids = (win.astype(np.int64) * powers).sum(axis=2) # (m, L-k+1)
|
|
887
|
+
return np.bincount(ids[valid].ravel(), minlength=n_kmers)[:n_kmers]
|
|
888
|
+
|
|
889
|
+
for i in range(m):
|
|
890
|
+
n = int(n_syms[i])
|
|
891
|
+
if n < k:
|
|
892
|
+
continue
|
|
893
|
+
c = BitPacker.unpack(
|
|
894
|
+
packed[int(pack_off[i]): int(pack_off[i + 1])], n).astype(np.int64)
|
|
895
|
+
win = sw(c, k) # (n-k+1, k)
|
|
896
|
+
valid = (win <= 3).all(axis=1)
|
|
897
|
+
ids = (win * powers).sum(axis=1)[valid]
|
|
898
|
+
out += np.bincount(ids, minlength=n_kmers)[:n_kmers]
|
|
899
|
+
return out
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@dataclass
|
|
903
|
+
class SequenceBatch:
|
|
904
|
+
"""
|
|
905
|
+
Lote columnar de secuencias FASTA.
|
|
906
|
+
|
|
907
|
+
Guarda todas las secuencias de un lote como matrices contiguas. El acceso
|
|
908
|
+
a un registro concreto (``batch[i]``) materializa un :class:`PackedSequence`
|
|
909
|
+
en ese momento; las operaciones de conjunto se hacen vectorizadas.
|
|
910
|
+
|
|
911
|
+
No instancies esto a mano — lo produce :meth:`SmartImporter.stream_batches`.
|
|
912
|
+
"""
|
|
913
|
+
|
|
914
|
+
_packed: np.ndarray # uint8, secuencias 5-bit concatenadas (byte-alineadas)
|
|
915
|
+
_pack_off: np.ndarray # int32, offsets de byte, len = m+1
|
|
916
|
+
_n_syms: np.ndarray # int32, longitud de cada registro, len = m
|
|
917
|
+
_types: np.ndarray # int32, tipo (0=nuc,1=prot), len = m
|
|
918
|
+
_hdr_raw: bytes # blob de cabeceras null-terminadas
|
|
919
|
+
_hdr_off: np.ndarray # int32, offsets de cabecera, len = m+1
|
|
920
|
+
|
|
921
|
+
def __len__(self) -> int:
|
|
922
|
+
return int(self._n_syms.shape[0])
|
|
923
|
+
|
|
924
|
+
@property
|
|
925
|
+
def n_symbols(self) -> np.ndarray:
|
|
926
|
+
"""Longitud de cada registro del lote (array int32)."""
|
|
927
|
+
return self._n_syms
|
|
928
|
+
|
|
929
|
+
def header(self, i: int) -> str:
|
|
930
|
+
"""Cabecera del registro ``i`` (decodificada bajo demanda)."""
|
|
931
|
+
i = int(i)
|
|
932
|
+
return self._hdr_raw[
|
|
933
|
+
int(self._hdr_off[i]): int(self._hdr_off[i + 1]) - 1
|
|
934
|
+
].decode("ascii", errors="replace")
|
|
935
|
+
|
|
936
|
+
def __getitem__(self, i: int) -> PackedSequence:
|
|
937
|
+
n = len(self)
|
|
938
|
+
i = int(i)
|
|
939
|
+
if i < 0:
|
|
940
|
+
i += n
|
|
941
|
+
if not 0 <= i < n:
|
|
942
|
+
raise IndexError(f"índice {i} fuera de rango (lote de {n})")
|
|
943
|
+
return PackedSequence(
|
|
944
|
+
header = self.header(i),
|
|
945
|
+
seq_type = SeqType(int(self._types[i])),
|
|
946
|
+
n_symbols = int(self._n_syms[i]),
|
|
947
|
+
data = self._packed[
|
|
948
|
+
int(self._pack_off[i]): int(self._pack_off[i + 1])
|
|
949
|
+
].copy(),
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
def __iter__(self) -> "Iterator[PackedSequence]":
|
|
953
|
+
for i in range(len(self)):
|
|
954
|
+
yield self[i]
|
|
955
|
+
|
|
956
|
+
# ── Análisis vectorizado de composición (solo nucleótidos) ──────────────
|
|
957
|
+
def _require_nucleotide(self, op: str) -> None:
|
|
958
|
+
if len(self) and bool((self._types == 1).any()):
|
|
959
|
+
raise SequenceTypeError(
|
|
960
|
+
f"{op} solo aplica a secuencias nucleotídicas; el lote contiene "
|
|
961
|
+
"proteínas. Filtra por tipo antes de llamarlo."
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
def gc_content(self) -> np.ndarray:
|
|
965
|
+
"""Fracción GC (0..1) de cada secuencia del lote (vectorizado)."""
|
|
966
|
+
self._require_nucleotide("gc_content()")
|
|
967
|
+
return _batch_gc(self._packed, self._pack_off, self._n_syms,
|
|
968
|
+
_decode_cached(self))
|
|
969
|
+
|
|
970
|
+
def kmer_spectrum(self, k: int) -> np.ndarray:
|
|
971
|
+
"""Espectro de k-meros del lote → array int64 de longitud ``4**k``."""
|
|
972
|
+
self._require_nucleotide("kmer_spectrum()")
|
|
973
|
+
return _batch_kmer_spectrum(self._packed, self._pack_off, self._n_syms,
|
|
974
|
+
k, _decode_cached(self))
|
|
975
|
+
|
|
976
|
+
def __repr__(self) -> str:
|
|
977
|
+
return (f"SequenceBatch(m={len(self)}, "
|
|
978
|
+
f"bases={int(self._n_syms.sum()):,})")
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@dataclass
|
|
982
|
+
class ReadBatch:
|
|
983
|
+
"""
|
|
984
|
+
Lote columnar de lecturas FASTQ — la vía rápida para control de calidad.
|
|
985
|
+
|
|
986
|
+
Las calidades de todo el lote viven en una sola matriz. Filtrar por calidad
|
|
987
|
+
media es una operación NumPy sobre las ``m`` lecturas a la vez, sin fabricar
|
|
988
|
+
un objeto por lectura. ``batch[i]`` materializa un :class:`FastqRecord`
|
|
989
|
+
solo cuando lo pides.
|
|
990
|
+
|
|
991
|
+
Caso de longitud fija (Illumina): ``_fixed_len > 0`` y las calidades son una
|
|
992
|
+
matriz 2-D ``(m, L)``. Caso irregular (Nanopore): calidades concatenadas en
|
|
993
|
+
1-D con ``_qual_off``.
|
|
994
|
+
|
|
995
|
+
No instancies esto a mano — lo produce :meth:`SmartImporter.stream_fastq_batches`.
|
|
996
|
+
"""
|
|
997
|
+
|
|
998
|
+
_packed: np.ndarray
|
|
999
|
+
_pack_off: np.ndarray
|
|
1000
|
+
_n_syms: np.ndarray
|
|
1001
|
+
_types: np.ndarray
|
|
1002
|
+
_hdr_raw: bytes
|
|
1003
|
+
_hdr_off: np.ndarray
|
|
1004
|
+
_qual: np.ndarray # 2-D (m,L) si fijo; 1-D concatenado si no
|
|
1005
|
+
_qual_off: "Optional[np.ndarray]" # None si longitud fija
|
|
1006
|
+
_fixed_len: int # >0 = longitud fija; 0 = irregular
|
|
1007
|
+
|
|
1008
|
+
def __len__(self) -> int:
|
|
1009
|
+
return int(self._n_syms.shape[0])
|
|
1010
|
+
|
|
1011
|
+
@property
|
|
1012
|
+
def n_symbols(self) -> np.ndarray:
|
|
1013
|
+
return self._n_syms
|
|
1014
|
+
|
|
1015
|
+
# ── Operaciones vectorizadas sobre TODO el lote ─────────────────────────
|
|
1016
|
+
def mean_quality(self) -> np.ndarray:
|
|
1017
|
+
"""Calidad Phred media de cada lectura (array float, una op NumPy)."""
|
|
1018
|
+
if len(self) == 0:
|
|
1019
|
+
return np.empty(0, dtype=np.float64)
|
|
1020
|
+
if self._fixed_len:
|
|
1021
|
+
return self._qual.mean(axis=1)
|
|
1022
|
+
# Irregular: suma por segmentos con reduceat (vectorizado).
|
|
1023
|
+
starts = self._qual_off[:-1]
|
|
1024
|
+
sums = np.add.reduceat(self._qual.astype(np.int64), starts)
|
|
1025
|
+
n = self._n_syms.astype(np.int64)
|
|
1026
|
+
return np.where(n > 0, sums / np.maximum(n, 1), 0.0)
|
|
1027
|
+
|
|
1028
|
+
def passes(self, min_q: float) -> np.ndarray:
|
|
1029
|
+
"""Máscara booleana: True donde la calidad media ≥ ``min_q``."""
|
|
1030
|
+
return self.mean_quality() >= min_q
|
|
1031
|
+
|
|
1032
|
+
def decoded_2d(self) -> "Optional[np.ndarray]":
|
|
1033
|
+
"""Códigos BioCode como matriz ``(m, L)`` si todas las lecturas miden
|
|
1034
|
+
igual; ``None`` si la longitud es irregular. Decodifica una sola vez
|
|
1035
|
+
(cacheado), compartido con ``gc_content``/``kmer_spectrum``."""
|
|
1036
|
+
return _decode_cached(self)
|
|
1037
|
+
|
|
1038
|
+
def quality_matrix(self) -> "Optional[np.ndarray]":
|
|
1039
|
+
"""Calidades Phred como matriz ``(m, L)`` si la longitud es fija;
|
|
1040
|
+
``None`` si es irregular (usa ``quality_of(i)`` por lectura)."""
|
|
1041
|
+
return self._qual if self._fixed_len else None
|
|
1042
|
+
|
|
1043
|
+
def gc_content(self) -> np.ndarray:
|
|
1044
|
+
"""Fracción GC (0..1) de cada lectura del lote (vectorizado)."""
|
|
1045
|
+
return _batch_gc(self._packed, self._pack_off, self._n_syms,
|
|
1046
|
+
_decode_cached(self))
|
|
1047
|
+
|
|
1048
|
+
def kmer_spectrum(self, k: int) -> np.ndarray:
|
|
1049
|
+
"""Espectro de k-meros del lote → array int64 de longitud ``4**k``.
|
|
1050
|
+
|
|
1051
|
+
Cuenta todos los k-meros de todas las lecturas (los que tienen bases
|
|
1052
|
+
ambiguas se descartan). Útil para perfiles de k-meros, corrección de
|
|
1053
|
+
errores o estimación de cobertura — sin crear objetos por lectura.
|
|
1054
|
+
"""
|
|
1055
|
+
return _batch_kmer_spectrum(self._packed, self._pack_off,
|
|
1056
|
+
self._n_syms, k, _decode_cached(self))
|
|
1057
|
+
|
|
1058
|
+
def filter(self, mask: np.ndarray) -> "ReadBatch":
|
|
1059
|
+
"""Devuelve un nuevo ReadBatch con solo las lecturas de ``mask``."""
|
|
1060
|
+
mask = np.asarray(mask, dtype=bool)
|
|
1061
|
+
if mask.shape[0] != len(self):
|
|
1062
|
+
raise SequenceValueError(
|
|
1063
|
+
f"la máscara tiene {mask.shape[0]} elementos pero el lote "
|
|
1064
|
+
f"tiene {len(self)} lecturas."
|
|
1065
|
+
)
|
|
1066
|
+
idx = np.flatnonzero(mask)
|
|
1067
|
+
new_n = self._n_syms[idx].copy()
|
|
1068
|
+
new_t = self._types[idx].copy()
|
|
1069
|
+
new_hdr, new_hoff = _gather_headers(self._hdr_raw, self._hdr_off, idx)
|
|
1070
|
+
|
|
1071
|
+
if self._fixed_len:
|
|
1072
|
+
# Todo es 2-D regular → indexación pura, sin bucles.
|
|
1073
|
+
L = self._fixed_len
|
|
1074
|
+
plen = (L * 5 + 7) // 8
|
|
1075
|
+
m = len(self)
|
|
1076
|
+
packed2d = self._packed[: m * plen].reshape(m, plen)
|
|
1077
|
+
new_packed = packed2d[idx].reshape(-1).copy()
|
|
1078
|
+
new_poff = (np.arange(len(idx) + 1, dtype=np.int32) * plen)
|
|
1079
|
+
new_qual = self._qual[idx].copy()
|
|
1080
|
+
return ReadBatch(new_packed, new_poff, new_n, new_t,
|
|
1081
|
+
new_hdr, new_hoff, new_qual, None, L)
|
|
1082
|
+
|
|
1083
|
+
# Irregular: reunir slices de los supervivientes (bucle por registro).
|
|
1084
|
+
pack_parts, qual_parts = [], []
|
|
1085
|
+
new_poff = np.empty(len(idx) + 1, dtype=np.int32)
|
|
1086
|
+
new_qoff = np.empty(len(idx) + 1, dtype=np.int32)
|
|
1087
|
+
new_poff[0] = new_qoff[0] = 0
|
|
1088
|
+
pcur = qcur = 0
|
|
1089
|
+
for k, j in enumerate(idx):
|
|
1090
|
+
ps = self._packed[int(self._pack_off[j]): int(self._pack_off[j + 1])]
|
|
1091
|
+
qs = self._qual[int(self._qual_off[j]): int(self._qual_off[j + 1])]
|
|
1092
|
+
pack_parts.append(ps); qual_parts.append(qs)
|
|
1093
|
+
pcur += ps.shape[0]; qcur += qs.shape[0]
|
|
1094
|
+
new_poff[k + 1] = pcur; new_qoff[k + 1] = qcur
|
|
1095
|
+
new_packed = (np.concatenate(pack_parts) if pack_parts
|
|
1096
|
+
else np.empty(0, dtype=np.uint8))
|
|
1097
|
+
new_qual = (np.concatenate(qual_parts) if qual_parts
|
|
1098
|
+
else np.empty(0, dtype=np.uint8))
|
|
1099
|
+
return ReadBatch(new_packed, new_poff, new_n, new_t,
|
|
1100
|
+
new_hdr, new_hoff, new_qual, new_qoff, 0)
|
|
1101
|
+
|
|
1102
|
+
# ── Acceso por registro (materializa el objeto solo aquí) ───────────────
|
|
1103
|
+
def header(self, i: int) -> str:
|
|
1104
|
+
i = int(i)
|
|
1105
|
+
return self._hdr_raw[
|
|
1106
|
+
int(self._hdr_off[i]): int(self._hdr_off[i + 1]) - 1
|
|
1107
|
+
].decode("ascii", errors="replace")
|
|
1108
|
+
|
|
1109
|
+
def quality_of(self, i: int) -> np.ndarray:
|
|
1110
|
+
"""Calidades Phred de la lectura ``i`` (copia uint8)."""
|
|
1111
|
+
i = int(i)
|
|
1112
|
+
if self._fixed_len:
|
|
1113
|
+
return self._qual[i].copy()
|
|
1114
|
+
return self._qual[
|
|
1115
|
+
int(self._qual_off[i]): int(self._qual_off[i + 1])
|
|
1116
|
+
].copy()
|
|
1117
|
+
|
|
1118
|
+
def __getitem__(self, i: int) -> FastqRecord:
|
|
1119
|
+
n = len(self)
|
|
1120
|
+
i = int(i)
|
|
1121
|
+
if i < 0:
|
|
1122
|
+
i += n
|
|
1123
|
+
if not 0 <= i < n:
|
|
1124
|
+
raise IndexError(f"índice {i} fuera de rango (lote de {n})")
|
|
1125
|
+
seq = PackedSequence(
|
|
1126
|
+
header = self.header(i),
|
|
1127
|
+
seq_type = SeqType(int(self._types[i])),
|
|
1128
|
+
n_symbols = int(self._n_syms[i]),
|
|
1129
|
+
data = self._packed[
|
|
1130
|
+
int(self._pack_off[i]): int(self._pack_off[i + 1])
|
|
1131
|
+
].copy(),
|
|
1132
|
+
)
|
|
1133
|
+
return FastqRecord(sequence=seq, quality=self.quality_of(i))
|
|
1134
|
+
|
|
1135
|
+
def __iter__(self) -> "Iterator[FastqRecord]":
|
|
1136
|
+
for i in range(len(self)):
|
|
1137
|
+
yield self[i]
|
|
1138
|
+
|
|
1139
|
+
def __repr__(self) -> str:
|
|
1140
|
+
kind = f"fixed L={self._fixed_len}" if self._fixed_len else "ragged"
|
|
1141
|
+
return (f"ReadBatch(m={len(self)}, "
|
|
1142
|
+
f"bases={int(self._n_syms.sum()):,}, {kind})")
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1146
|
+
# §6 SMART IMPORTER — FASTA/FASTQ parser and 5-bit encoder
|
|
1147
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1148
|
+
|
|
1149
|
+
class SmartImporter:
|
|
1150
|
+
"""
|
|
1151
|
+
Parse FASTA text and immediately encode sequences into 5-bit packed
|
|
1152
|
+
:class:`PackedSequence` objects.
|
|
1153
|
+
|
|
1154
|
+
The raw sequence ``str`` exists **only** inside :meth:`_encode` as a
|
|
1155
|
+
local variable released on return. No biological sequence data
|
|
1156
|
+
persists as a Python ``str`` after that call.
|
|
1157
|
+
|
|
1158
|
+
Sequence-type auto-detection
|
|
1159
|
+
────────────────────────────
|
|
1160
|
+
The importer scans raw ASCII byte values for characters that are
|
|
1161
|
+
exclusive to protein sequences and absent from the IUPAC nucleotide
|
|
1162
|
+
alphabet ``{A C G T U R Y S W K M B D H V N - .}``::
|
|
1163
|
+
|
|
1164
|
+
E F I L P Q * (and their lowercase equivalents)
|
|
1165
|
+
|
|
1166
|
+
Any match → ``PROTEIN``; no match → ``NUCLEOTIDE``.
|
|
1167
|
+
Override per-call with the ``force_type`` parameter.
|
|
1168
|
+
|
|
1169
|
+
Quick start
|
|
1170
|
+
───────────
|
|
1171
|
+
>>> records = SmartImporter.from_string(fasta_text)
|
|
1172
|
+
>>> records = SmartImporter.from_file("genome.fa")
|
|
1173
|
+
>>> for rec in SmartImporter.from_file_chunked("large.fa"):
|
|
1174
|
+
... process(rec)
|
|
1175
|
+
"""
|
|
1176
|
+
|
|
1177
|
+
@classmethod
|
|
1178
|
+
def from_string(
|
|
1179
|
+
cls,
|
|
1180
|
+
fasta: str,
|
|
1181
|
+
force_type: Optional[SeqType] = None,
|
|
1182
|
+
) -> list[PackedSequence]:
|
|
1183
|
+
"""
|
|
1184
|
+
Parse a complete FASTA string.
|
|
1185
|
+
|
|
1186
|
+
Parameters
|
|
1187
|
+
----------
|
|
1188
|
+
fasta : str
|
|
1189
|
+
FASTA-formatted text (one or more ``>``-delimited records).
|
|
1190
|
+
force_type : SeqType, optional
|
|
1191
|
+
Skip auto-detection and force this type for all records.
|
|
1192
|
+
|
|
1193
|
+
Returns
|
|
1194
|
+
-------
|
|
1195
|
+
list[PackedSequence]
|
|
1196
|
+
One object per FASTA record, in input order.
|
|
1197
|
+
"""
|
|
1198
|
+
return list(cls._iter_records(fasta, force_type))
|
|
1199
|
+
|
|
1200
|
+
@classmethod
|
|
1201
|
+
def from_file(
|
|
1202
|
+
cls,
|
|
1203
|
+
path: str,
|
|
1204
|
+
force_type: Optional[SeqType] = None,
|
|
1205
|
+
) -> list[PackedSequence]:
|
|
1206
|
+
"""
|
|
1207
|
+
Load and parse an entire FASTA file into memory.
|
|
1208
|
+
|
|
1209
|
+
Parameters
|
|
1210
|
+
----------
|
|
1211
|
+
path : str
|
|
1212
|
+
Path to the FASTA file (ASCII or UTF-8 with ASCII sequences).
|
|
1213
|
+
force_type : SeqType, optional
|
|
1214
|
+
Override auto-detection for all records.
|
|
1215
|
+
|
|
1216
|
+
Returns
|
|
1217
|
+
-------
|
|
1218
|
+
list[PackedSequence]
|
|
1219
|
+
"""
|
|
1220
|
+
with open(path, "r", encoding="ascii", errors="replace") as fh:
|
|
1221
|
+
return cls.from_string(fh.read(), force_type)
|
|
1222
|
+
|
|
1223
|
+
@classmethod
|
|
1224
|
+
def from_file_chunked(
|
|
1225
|
+
cls,
|
|
1226
|
+
path: str,
|
|
1227
|
+
force_type: Optional[SeqType] = None,
|
|
1228
|
+
) -> Iterator[PackedSequence]:
|
|
1229
|
+
"""
|
|
1230
|
+
Lazy generator for genome-scale files that do not fit in RAM.
|
|
1231
|
+
|
|
1232
|
+
Only one record's raw lines are held in memory at a time.
|
|
1233
|
+
Each record is encoded and yielded before the next is read.
|
|
1234
|
+
|
|
1235
|
+
Parameters
|
|
1236
|
+
----------
|
|
1237
|
+
path : str
|
|
1238
|
+
Path to the FASTA file.
|
|
1239
|
+
force_type : SeqType, optional
|
|
1240
|
+
Override auto-detection.
|
|
1241
|
+
|
|
1242
|
+
Yields
|
|
1243
|
+
------
|
|
1244
|
+
PackedSequence — one per FASTA record, in file order.
|
|
1245
|
+
"""
|
|
1246
|
+
header: Optional[str] = None
|
|
1247
|
+
chunks: list[str] = []
|
|
1248
|
+
|
|
1249
|
+
with open(path, "r", encoding="ascii", errors="replace") as fh:
|
|
1250
|
+
for raw_line in fh:
|
|
1251
|
+
line = raw_line.strip()
|
|
1252
|
+
if line.startswith(">"):
|
|
1253
|
+
if header is not None and chunks:
|
|
1254
|
+
yield cls._encode("".join(chunks), header, force_type)
|
|
1255
|
+
header = line[1:]
|
|
1256
|
+
chunks = []
|
|
1257
|
+
elif line and not line.startswith(";"): # skip FASTA comments
|
|
1258
|
+
chunks.append(line)
|
|
1259
|
+
|
|
1260
|
+
if header is not None and chunks:
|
|
1261
|
+
yield cls._encode("".join(chunks), header, force_type)
|
|
1262
|
+
|
|
1263
|
+
# ── Streaming API (O(1) RAM, motor C con buffer 64 KB) ───────────────────
|
|
1264
|
+
|
|
1265
|
+
_STREAM_HDR = 4096 # bytes máximos para una cabecera FASTA/FASTQ
|
|
1266
|
+
_STREAM_SEQ = 16 * 1024 * 1024 # 16 MB — cubre lecturas Nanopore largas
|
|
1267
|
+
|
|
1268
|
+
# ── Modo por lotes (batch) — el camino rápido de v2.0 ────────────────────
|
|
1269
|
+
# Una sola llamada a C parsea miles de registros y los empaqueta a 5-bit
|
|
1270
|
+
# dentro de C. Elimina el peaje ctypes y el pack NumPy por registro.
|
|
1271
|
+
_BATCH_RECORDS = 8192 # registros por llamada
|
|
1272
|
+
_BATCH_HDR = 2 * 1024 * 1024 # 2 MB para cabeceras concatenadas
|
|
1273
|
+
_BATCH_PACK = 16 * 1024 * 1024 # 16 MB de secuencias 5-bit (≤25 Mbp/lote)
|
|
1274
|
+
|
|
1275
|
+
# ── Parser paralelo (OpenMP) — la vía de máximo rendimiento (FASTQ plano) ──
|
|
1276
|
+
_PWINDOW = 32 * 1024 * 1024 # ventana (32 MB): amortiza copias y OpenMP
|
|
1277
|
+
_PBUF = 48 * 1024 * 1024 # buffers de salida (1.5× ventana, seguro)
|
|
1278
|
+
_PMAXREC = 4_000_000 # registros máx. por ventana
|
|
1279
|
+
# La vía rápida .gz descomprime el archivo entero en RAM. Por encima de este
|
|
1280
|
+
# tamaño COMPRIMIDO se cae a la ruta secuencial (zlib, RAM constante) para no
|
|
1281
|
+
# agotar memoria con archivos enormes.
|
|
1282
|
+
_PGZ_MAX_COMPRESSED = 512 * 1024 * 1024 # 512 MB
|
|
1283
|
+
|
|
1284
|
+
@classmethod
|
|
1285
|
+
def stream(
|
|
1286
|
+
cls,
|
|
1287
|
+
path: str,
|
|
1288
|
+
force_type: Optional[SeqType] = None,
|
|
1289
|
+
) -> Iterator[PackedSequence]:
|
|
1290
|
+
"""
|
|
1291
|
+
Generador de bajo consumo de RAM para archivos FASTA de cualquier tamaño.
|
|
1292
|
+
|
|
1293
|
+
Cuando el motor C está disponible (compilado) usa el parser C con
|
|
1294
|
+
buffer de 64 KB y encoding 5-bit directo en C — sin strings Python
|
|
1295
|
+
en la ruta crítica. Fallback automático a :meth:`from_file_chunked`
|
|
1296
|
+
si el motor C no está presente.
|
|
1297
|
+
|
|
1298
|
+
Parameters
|
|
1299
|
+
----------
|
|
1300
|
+
path : str
|
|
1301
|
+
Ruta al archivo FASTA.
|
|
1302
|
+
force_type : SeqType, optional
|
|
1303
|
+
Fuerza el tipo para todos los registros (omite auto-detección).
|
|
1304
|
+
|
|
1305
|
+
Yields
|
|
1306
|
+
------
|
|
1307
|
+
PackedSequence — un objeto por registro, en orden de fichero.
|
|
1308
|
+
|
|
1309
|
+
Example
|
|
1310
|
+
-------
|
|
1311
|
+
>>> for seq in SmartImporter.stream("genome.fa"):
|
|
1312
|
+
... print(seq.n_symbols)
|
|
1313
|
+
"""
|
|
1314
|
+
ft = -1
|
|
1315
|
+
if force_type == SeqType.NUCLEOTIDE: ft = 0
|
|
1316
|
+
elif force_type == SeqType.PROTEIN: ft = 1
|
|
1317
|
+
|
|
1318
|
+
if _C_BATCH_AVAILABLE:
|
|
1319
|
+
yield from cls._stream_batch(path, ft, fastq=False)
|
|
1320
|
+
return
|
|
1321
|
+
|
|
1322
|
+
if not _C_PARSER_AVAILABLE:
|
|
1323
|
+
yield from cls.from_file_chunked(path, force_type)
|
|
1324
|
+
return
|
|
1325
|
+
|
|
1326
|
+
hdr_buf = ctypes.create_string_buffer(cls._STREAM_HDR)
|
|
1327
|
+
codes_buf = np.empty(cls._STREAM_SEQ, dtype=np.uint8)
|
|
1328
|
+
|
|
1329
|
+
handle = _c_parser_open(path)
|
|
1330
|
+
if not handle:
|
|
1331
|
+
raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
|
|
1332
|
+
|
|
1333
|
+
try:
|
|
1334
|
+
while True:
|
|
1335
|
+
ret, n, stype = _c_parser_next(handle, hdr_buf, codes_buf, ft)
|
|
1336
|
+
if ret <= 0:
|
|
1337
|
+
break
|
|
1338
|
+
packed = BitPacker.pack(codes_buf[:n])
|
|
1339
|
+
header = hdr_buf.value.decode("ascii", errors="replace")
|
|
1340
|
+
yield PackedSequence(
|
|
1341
|
+
header = header,
|
|
1342
|
+
seq_type = SeqType(stype),
|
|
1343
|
+
n_symbols = n,
|
|
1344
|
+
data = packed,
|
|
1345
|
+
)
|
|
1346
|
+
finally:
|
|
1347
|
+
_c_parser_close(handle)
|
|
1348
|
+
|
|
1349
|
+
@classmethod
|
|
1350
|
+
def _stream_batch(cls, path: str, force_type: int, fastq: bool):
|
|
1351
|
+
"""Núcleo del modo por lotes — compartido por stream() y stream_fastq().
|
|
1352
|
+
|
|
1353
|
+
C parsea hasta ``_BATCH_RECORDS`` registros por llamada y empaqueta cada
|
|
1354
|
+
secuencia a 5-bit. Aquí solo cruzamos la frontera ~N/8192 veces y
|
|
1355
|
+
creamos los objetos Python a partir de buffers ya empaquetados.
|
|
1356
|
+
|
|
1357
|
+
Yields PackedSequence (FASTA) o FastqRecord (FASTQ).
|
|
1358
|
+
"""
|
|
1359
|
+
BR = cls._BATCH_RECORDS
|
|
1360
|
+
hdr_buf = ctypes.create_string_buffer(cls._BATCH_HDR)
|
|
1361
|
+
pack_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
|
|
1362
|
+
hdr_off = np.empty(BR + 1, dtype=np.int32)
|
|
1363
|
+
pack_off = np.empty(BR + 1, dtype=np.int32)
|
|
1364
|
+
n_syms = np.empty(BR, dtype=np.int32)
|
|
1365
|
+
types = np.empty(BR, dtype=np.int32)
|
|
1366
|
+
if fastq:
|
|
1367
|
+
qual_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
|
|
1368
|
+
qual_off = np.empty(BR + 1, dtype=np.int32)
|
|
1369
|
+
else:
|
|
1370
|
+
qual_buf = qual_off = None
|
|
1371
|
+
|
|
1372
|
+
handle = _c_parser_open(path)
|
|
1373
|
+
if not handle:
|
|
1374
|
+
raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
|
|
1375
|
+
|
|
1376
|
+
ps = PackedSequence # alias local — menos lookups en el bucle
|
|
1377
|
+
try:
|
|
1378
|
+
while True:
|
|
1379
|
+
m = _c_parser_next_batch(
|
|
1380
|
+
handle, BR, force_type,
|
|
1381
|
+
hdr_buf, hdr_off, pack_buf, pack_off,
|
|
1382
|
+
n_syms, types, qual_buf, qual_off,
|
|
1383
|
+
)
|
|
1384
|
+
if m == 0:
|
|
1385
|
+
break
|
|
1386
|
+
if m < 0:
|
|
1387
|
+
raise EngineError(
|
|
1388
|
+
f"Error del parser por lotes (código {m}) en {path!r}. "
|
|
1389
|
+
"Código -2 = un registro supera el buffer de 16 MB; "
|
|
1390
|
+
"usa una herramienta de lecturas ultra-largas."
|
|
1391
|
+
)
|
|
1392
|
+
# Snapshot de los buffers como bytes/listas: una copia por lote,
|
|
1393
|
+
# no por registro. string_at copia solo los bytes usados, no los
|
|
1394
|
+
# 2 MB completos del buffer de cabeceras.
|
|
1395
|
+
hdr_used = int(hdr_off[m])
|
|
1396
|
+
hraw = ctypes.string_at(ctypes.addressof(hdr_buf), hdr_used)
|
|
1397
|
+
hoff = hdr_off[: m + 1].tolist()
|
|
1398
|
+
poff = pack_off[: m + 1].tolist()
|
|
1399
|
+
nlst = n_syms[:m].tolist()
|
|
1400
|
+
tlst = types[:m].tolist()
|
|
1401
|
+
qoff = qual_off[: m + 1].tolist() if fastq else None
|
|
1402
|
+
|
|
1403
|
+
for i in range(m):
|
|
1404
|
+
header = hraw[hoff[i]: hoff[i + 1] - 1].decode(
|
|
1405
|
+
"ascii", errors="replace")
|
|
1406
|
+
seq = ps(
|
|
1407
|
+
header = header,
|
|
1408
|
+
seq_type = SeqType(tlst[i]),
|
|
1409
|
+
n_symbols = nlst[i],
|
|
1410
|
+
data = pack_buf[poff[i]: poff[i + 1]].copy(),
|
|
1411
|
+
)
|
|
1412
|
+
if fastq:
|
|
1413
|
+
yield FastqRecord(
|
|
1414
|
+
sequence = seq,
|
|
1415
|
+
quality = qual_buf[qoff[i]: qoff[i + 1]].copy(),
|
|
1416
|
+
)
|
|
1417
|
+
else:
|
|
1418
|
+
yield seq
|
|
1419
|
+
finally:
|
|
1420
|
+
_c_parser_close(handle)
|
|
1421
|
+
|
|
1422
|
+
# ── Núcleo columnar (la vía rápida de v2.0) ──────────────────────────────
|
|
1423
|
+
@classmethod
|
|
1424
|
+
def _stream_columnar(cls, path: str, force_type: int, fastq: bool):
|
|
1425
|
+
"""Entrega lotes como matrices contiguas (SequenceBatch / ReadBatch).
|
|
1426
|
+
|
|
1427
|
+
Cero objetos por registro: se conservan las matrices que C ya produce.
|
|
1428
|
+
Una copia por lote (no por registro) las desacopla de los buffers
|
|
1429
|
+
reutilizados. Yields SequenceBatch (FASTA) o ReadBatch (FASTQ).
|
|
1430
|
+
"""
|
|
1431
|
+
if not _C_BATCH_AVAILABLE:
|
|
1432
|
+
yield from cls._columnar_fallback(path, force_type, fastq)
|
|
1433
|
+
return
|
|
1434
|
+
|
|
1435
|
+
BR = cls._BATCH_RECORDS
|
|
1436
|
+
hdr_buf = ctypes.create_string_buffer(cls._BATCH_HDR)
|
|
1437
|
+
pack_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
|
|
1438
|
+
hdr_off = np.empty(BR + 1, dtype=np.int32)
|
|
1439
|
+
pack_off = np.empty(BR + 1, dtype=np.int32)
|
|
1440
|
+
n_syms = np.empty(BR, dtype=np.int32)
|
|
1441
|
+
types = np.empty(BR, dtype=np.int32)
|
|
1442
|
+
if fastq:
|
|
1443
|
+
qual_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
|
|
1444
|
+
qual_off = np.empty(BR + 1, dtype=np.int32)
|
|
1445
|
+
else:
|
|
1446
|
+
qual_buf = qual_off = None
|
|
1447
|
+
|
|
1448
|
+
handle = _c_parser_open(path)
|
|
1449
|
+
if not handle:
|
|
1450
|
+
raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
|
|
1451
|
+
|
|
1452
|
+
try:
|
|
1453
|
+
while True:
|
|
1454
|
+
m = _c_parser_next_batch(
|
|
1455
|
+
handle, BR, force_type,
|
|
1456
|
+
hdr_buf, hdr_off, pack_buf, pack_off,
|
|
1457
|
+
n_syms, types, qual_buf, qual_off,
|
|
1458
|
+
)
|
|
1459
|
+
if m == 0:
|
|
1460
|
+
break
|
|
1461
|
+
if m < 0:
|
|
1462
|
+
raise EngineError(
|
|
1463
|
+
f"Error del parser por lotes (código {m}) en {path!r}. "
|
|
1464
|
+
"Código -2 = un registro supera el buffer de 16 MB."
|
|
1465
|
+
)
|
|
1466
|
+
yield _columnar_batch(m, pack_buf, pack_off, n_syms, types,
|
|
1467
|
+
hdr_buf, hdr_off, qual_buf, qual_off, fastq)
|
|
1468
|
+
finally:
|
|
1469
|
+
_c_parser_close(handle)
|
|
1470
|
+
|
|
1471
|
+
# ── Camino paralelo (OpenMP) — máximo rendimiento en FASTA/FASTQ plano ───
|
|
1472
|
+
@staticmethod
|
|
1473
|
+
def _is_plain(path: str) -> bool:
|
|
1474
|
+
"""True si el archivo NO está comprimido en gzip (mira el magic)."""
|
|
1475
|
+
try:
|
|
1476
|
+
with open(path, "rb") as f:
|
|
1477
|
+
return f.read(2) != b"\x1f\x8b"
|
|
1478
|
+
except OSError:
|
|
1479
|
+
return False
|
|
1480
|
+
|
|
1481
|
+
@staticmethod
|
|
1482
|
+
def _resolve_threads(n_threads: int) -> int:
|
|
1483
|
+
if n_threads <= 0:
|
|
1484
|
+
return os.cpu_count() or 1
|
|
1485
|
+
return n_threads
|
|
1486
|
+
|
|
1487
|
+
@classmethod
|
|
1488
|
+
def _use_parallel(cls, path: str, n_threads: int) -> bool:
|
|
1489
|
+
return (_C_PARALLEL_AVAILABLE and n_threads != 1
|
|
1490
|
+
and cls._is_plain(path))
|
|
1491
|
+
|
|
1492
|
+
@classmethod
|
|
1493
|
+
def _use_gz_fast(cls, path: str, n_threads: int) -> bool:
|
|
1494
|
+
return (_C_LIBDEFLATE_AVAILABLE and _C_PARALLEL_AVAILABLE
|
|
1495
|
+
and n_threads != 1 and not cls._is_plain(path))
|
|
1496
|
+
|
|
1497
|
+
@staticmethod
|
|
1498
|
+
def _boundary_before(mm, fastq: bool, lo: int, hi: int) -> int:
|
|
1499
|
+
"""Mayor inicio de registro en (lo, hi]; ``lo`` si no hay ninguno.
|
|
1500
|
+
|
|
1501
|
+
Opera sobre el mmap sin copiar (rfind/find in situ). Para FASTQ verifica
|
|
1502
|
+
la 3ª línea '+' para no confundir un '@' de calidad.
|
|
1503
|
+
"""
|
|
1504
|
+
if not fastq:
|
|
1505
|
+
i = mm.rfind(b"\n>", lo, hi)
|
|
1506
|
+
return i + 1 if i != -1 else lo
|
|
1507
|
+
sp = hi
|
|
1508
|
+
while True:
|
|
1509
|
+
i = mm.rfind(b"\n@", lo, sp)
|
|
1510
|
+
if i == -1:
|
|
1511
|
+
return lo
|
|
1512
|
+
rec = i + 1
|
|
1513
|
+
nl1 = mm.find(b"\n", rec, hi)
|
|
1514
|
+
if nl1 == -1:
|
|
1515
|
+
sp = i; continue
|
|
1516
|
+
nl2 = mm.find(b"\n", nl1 + 1, hi)
|
|
1517
|
+
if nl2 == -1:
|
|
1518
|
+
sp = i; continue
|
|
1519
|
+
if mm[nl2 + 1: nl2 + 2] == b"+":
|
|
1520
|
+
return rec
|
|
1521
|
+
sp = i
|
|
1522
|
+
|
|
1523
|
+
@classmethod
|
|
1524
|
+
def _parse_buffer_windows(cls, buf, arr, size: int,
|
|
1525
|
+
force_type: int, fastq: bool, nt: int):
|
|
1526
|
+
"""Núcleo del parseo paralelo: trocea ``arr`` (vista de ``buf``) en
|
|
1527
|
+
ventanas alineadas a registro y reparte cada una entre ``nt`` hilos.
|
|
1528
|
+
|
|
1529
|
+
``buf`` soporta rfind/find/slicing (mmap o bytearray); ``arr`` es su
|
|
1530
|
+
vista NumPy uint8. Yields SequenceBatch / ReadBatch.
|
|
1531
|
+
"""
|
|
1532
|
+
if size == 0:
|
|
1533
|
+
return
|
|
1534
|
+
fmt = 2 if fastq else 1
|
|
1535
|
+
WIN = cls._PWINDOW
|
|
1536
|
+
MR = cls._PMAXREC
|
|
1537
|
+
start_char = ord("@") if fastq else ord(">")
|
|
1538
|
+
hdr_buf = ctypes.create_string_buffer(cls._PBUF)
|
|
1539
|
+
pack_buf = np.empty(cls._PBUF, dtype=np.uint8)
|
|
1540
|
+
hdr_off = np.empty(MR + 1, dtype=np.int32)
|
|
1541
|
+
pack_off = np.empty(MR + 1, dtype=np.int32)
|
|
1542
|
+
n_syms = np.empty(MR, dtype=np.int32)
|
|
1543
|
+
types = np.empty(MR, dtype=np.int32)
|
|
1544
|
+
if fastq:
|
|
1545
|
+
qual_buf = np.empty(cls._PBUF, dtype=np.uint8)
|
|
1546
|
+
qual_off = np.empty(MR + 1, dtype=np.int32)
|
|
1547
|
+
else:
|
|
1548
|
+
qual_buf = qual_off = None
|
|
1549
|
+
|
|
1550
|
+
pos = 0 if arr[0] == start_char else \
|
|
1551
|
+
cls._boundary_before(buf, fastq, 0, size)
|
|
1552
|
+
while pos < size:
|
|
1553
|
+
end = pos + WIN
|
|
1554
|
+
if end >= size:
|
|
1555
|
+
block_end = size
|
|
1556
|
+
else:
|
|
1557
|
+
block_end = cls._boundary_before(buf, fastq, pos, end)
|
|
1558
|
+
if block_end <= pos: # ventana < 1 registro: crecer
|
|
1559
|
+
grow = end
|
|
1560
|
+
while block_end <= pos and grow < size:
|
|
1561
|
+
grow = min(grow * 2, size)
|
|
1562
|
+
block_end = cls._boundary_before(buf, fastq, pos, grow)
|
|
1563
|
+
if block_end <= pos:
|
|
1564
|
+
block_end = size
|
|
1565
|
+
block = arr[pos:block_end] # vista sin copia
|
|
1566
|
+
m = _c_parse_mem_parallel(
|
|
1567
|
+
block, fmt, nt, force_type,
|
|
1568
|
+
hdr_buf, hdr_off, pack_buf, pack_off,
|
|
1569
|
+
n_syms, types, qual_buf, qual_off, MR)
|
|
1570
|
+
if m < 0:
|
|
1571
|
+
raise EngineError(
|
|
1572
|
+
f"Parser paralelo: código {m} (ventana demasiado densa o "
|
|
1573
|
+
"registro gigante; usa n_threads=1).")
|
|
1574
|
+
if m > 0:
|
|
1575
|
+
yield _columnar_batch(
|
|
1576
|
+
m, pack_buf, pack_off, n_syms, types,
|
|
1577
|
+
hdr_buf, hdr_off, qual_buf, qual_off, fastq)
|
|
1578
|
+
block = None # soltar la vista
|
|
1579
|
+
pos = block_end
|
|
1580
|
+
|
|
1581
|
+
@classmethod
|
|
1582
|
+
def _stream_parallel(cls, path: str, force_type: int, fastq: bool,
|
|
1583
|
+
n_threads: int):
|
|
1584
|
+
"""FASTA/FASTQ plano: mmap + parseo paralelo por ventanas (sin copia)."""
|
|
1585
|
+
nt = cls._resolve_threads(n_threads)
|
|
1586
|
+
size = os.path.getsize(path)
|
|
1587
|
+
if size == 0:
|
|
1588
|
+
return
|
|
1589
|
+
with open(path, "rb") as fh:
|
|
1590
|
+
mm = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ)
|
|
1591
|
+
arr = None
|
|
1592
|
+
try:
|
|
1593
|
+
arr = np.frombuffer(mm, dtype=np.uint8)
|
|
1594
|
+
yield from cls._parse_buffer_windows(
|
|
1595
|
+
mm, arr, size, force_type, fastq, nt)
|
|
1596
|
+
finally:
|
|
1597
|
+
arr = None # liberar el export del mmap antes de cerrar
|
|
1598
|
+
mm.close()
|
|
1599
|
+
|
|
1600
|
+
@classmethod
|
|
1601
|
+
def _stream_gz_parallel(cls, path: str, force_type: int, fastq: bool,
|
|
1602
|
+
n_threads: int):
|
|
1603
|
+
""".gz: descomprime entero con libdeflate (~2× zlib) y parsea en paralelo.
|
|
1604
|
+
|
|
1605
|
+
Si el tamaño es inesperado (gzip multi-miembro, etc.) cae a la ruta
|
|
1606
|
+
secuencial con zlib (RAM constante) sin fallar.
|
|
1607
|
+
"""
|
|
1608
|
+
import struct
|
|
1609
|
+
nt = cls._resolve_threads(n_threads)
|
|
1610
|
+
# Archivos comprimidos enormes → ruta secuencial (RAM constante), para no
|
|
1611
|
+
# cargar todo el archivo descomprimido en memoria.
|
|
1612
|
+
if os.path.getsize(path) > cls._PGZ_MAX_COMPRESSED:
|
|
1613
|
+
yield from cls._stream_columnar(path, force_type, fastq)
|
|
1614
|
+
return
|
|
1615
|
+
with open(path, "rb") as fh:
|
|
1616
|
+
comp = fh.read()
|
|
1617
|
+
if len(comp) < 18: # gzip mínimo
|
|
1618
|
+
yield from cls._stream_columnar(path, force_type, fastq)
|
|
1619
|
+
return
|
|
1620
|
+
cbuf = np.frombuffer(comp, dtype=np.uint8)
|
|
1621
|
+
|
|
1622
|
+
# ── Palanca 3: BGZF (gzip por bloques) → descompresión PARALELA ──────
|
|
1623
|
+
if _c_is_bgzf(cbuf):
|
|
1624
|
+
usize = _c_bgzf_usize(cbuf)
|
|
1625
|
+
if usize > 0:
|
|
1626
|
+
out = bytearray(usize)
|
|
1627
|
+
oarr = np.frombuffer(out, dtype=np.uint8)
|
|
1628
|
+
n = _c_bgzf_decompress_parallel(cbuf, oarr, nt)
|
|
1629
|
+
if n >= 0:
|
|
1630
|
+
yield from cls._parse_buffer_windows(
|
|
1631
|
+
out, oarr, n, force_type, fastq, nt)
|
|
1632
|
+
return
|
|
1633
|
+
# si algo falla, sigue al camino gzip normal
|
|
1634
|
+
|
|
1635
|
+
# ── Palanca 2: gzip normal → libdeflate (1 hilo) ────────────────────
|
|
1636
|
+
isize = struct.unpack("<I", comp[-4:])[0] # tamaño descomprimido (mod 2^32)
|
|
1637
|
+
if isize == 0:
|
|
1638
|
+
yield from cls._stream_columnar(path, force_type, fastq)
|
|
1639
|
+
return
|
|
1640
|
+
out = bytearray(isize)
|
|
1641
|
+
oarr = np.frombuffer(out, dtype=np.uint8)
|
|
1642
|
+
n = _c_gzip_decompress(cbuf, oarr)
|
|
1643
|
+
if n < 0: # tamaño/forma inesperada → fallback
|
|
1644
|
+
yield from cls._stream_columnar(path, force_type, fastq)
|
|
1645
|
+
return
|
|
1646
|
+
yield from cls._parse_buffer_windows(out, oarr, n, force_type, fastq, nt)
|
|
1647
|
+
|
|
1648
|
+
@classmethod
|
|
1649
|
+
def _columnar_fallback(cls, path: str, force_type: int, fastq: bool):
|
|
1650
|
+
"""Construye lotes columnares desde el generador por registro.
|
|
1651
|
+
|
|
1652
|
+
Solo se usa si el motor C por lotes no está disponible (DLL antiguo o
|
|
1653
|
+
sin compilar). Más lento, pero produce idénticos SequenceBatch/ReadBatch.
|
|
1654
|
+
"""
|
|
1655
|
+
BR = cls._BATCH_RECORDS
|
|
1656
|
+
ft = (SeqType.NUCLEOTIDE if force_type == 0 else
|
|
1657
|
+
SeqType.PROTEIN if force_type == 1 else None)
|
|
1658
|
+
gen = (cls.stream_fastq(path) if fastq else cls.stream(path, ft))
|
|
1659
|
+
buf: list = []
|
|
1660
|
+
for item in gen:
|
|
1661
|
+
buf.append(item)
|
|
1662
|
+
if len(buf) >= BR:
|
|
1663
|
+
yield cls._assemble_batch(buf, fastq)
|
|
1664
|
+
buf = []
|
|
1665
|
+
if buf:
|
|
1666
|
+
yield cls._assemble_batch(buf, fastq)
|
|
1667
|
+
|
|
1668
|
+
@staticmethod
|
|
1669
|
+
def _assemble_batch(items: list, fastq: bool):
|
|
1670
|
+
"""Ensambla una lista de PackedSequence/FastqRecord en un lote columnar."""
|
|
1671
|
+
m = len(items)
|
|
1672
|
+
seqs = [(it.sequence if fastq else it) for it in items]
|
|
1673
|
+
nsy = np.array([s.n_symbols for s in seqs], dtype=np.int32)
|
|
1674
|
+
tps = np.array([int(s.seq_type) for s in seqs], dtype=np.int32)
|
|
1675
|
+
|
|
1676
|
+
pack_parts = [np.asarray(s.data, dtype=np.uint8) for s in seqs]
|
|
1677
|
+
poff = np.empty(m + 1, dtype=np.int32)
|
|
1678
|
+
poff[0] = 0
|
|
1679
|
+
acc = 0
|
|
1680
|
+
for k, p in enumerate(pack_parts):
|
|
1681
|
+
acc += p.shape[0]; poff[k + 1] = acc
|
|
1682
|
+
packed = (np.concatenate(pack_parts) if pack_parts
|
|
1683
|
+
else np.empty(0, dtype=np.uint8))
|
|
1684
|
+
|
|
1685
|
+
hdr_parts = [s.header.encode("ascii", "replace") + b"\0" for s in seqs]
|
|
1686
|
+
hoff = np.empty(m + 1, dtype=np.int32)
|
|
1687
|
+
hoff[0] = 0
|
|
1688
|
+
acc = 0
|
|
1689
|
+
for k, h in enumerate(hdr_parts):
|
|
1690
|
+
acc += len(h); hoff[k + 1] = acc
|
|
1691
|
+
hraw = b"".join(hdr_parts)
|
|
1692
|
+
|
|
1693
|
+
if not fastq:
|
|
1694
|
+
return SequenceBatch(packed, poff, nsy, tps, hraw, hoff)
|
|
1695
|
+
|
|
1696
|
+
quals = [np.asarray(it.quality, dtype=np.uint8) for it in items]
|
|
1697
|
+
fixed = (int(nsy[0]) if (m > 0 and nsy[0] > 0
|
|
1698
|
+
and bool(np.all(nsy == nsy[0]))) else 0)
|
|
1699
|
+
if fixed:
|
|
1700
|
+
qual = (np.stack(quals) if quals
|
|
1701
|
+
else np.empty((0, fixed), dtype=np.uint8))
|
|
1702
|
+
qoff = None
|
|
1703
|
+
else:
|
|
1704
|
+
qual = (np.concatenate(quals) if quals
|
|
1705
|
+
else np.empty(0, dtype=np.uint8))
|
|
1706
|
+
qoff = np.empty(m + 1, dtype=np.int32)
|
|
1707
|
+
qoff[0] = 0
|
|
1708
|
+
acc = 0
|
|
1709
|
+
for k, q in enumerate(quals):
|
|
1710
|
+
acc += q.shape[0]; qoff[k + 1] = acc
|
|
1711
|
+
return ReadBatch(packed, poff, nsy, tps, hraw, hoff, qual, qoff, fixed)
|
|
1712
|
+
|
|
1713
|
+
@classmethod
|
|
1714
|
+
def stream_batches(
|
|
1715
|
+
cls,
|
|
1716
|
+
path: str,
|
|
1717
|
+
force_type: Optional[SeqType] = None,
|
|
1718
|
+
n_threads: int = 1,
|
|
1719
|
+
) -> "Iterator[SequenceBatch]":
|
|
1720
|
+
"""
|
|
1721
|
+
Lee un FASTA como lotes columnares :class:`SequenceBatch` (vía rápida).
|
|
1722
|
+
|
|
1723
|
+
Cada lote agrupa registros como matrices contiguas, sin crear un objeto
|
|
1724
|
+
por registro. Para acceder a un registro concreto usa ``batch[i]``.
|
|
1725
|
+
|
|
1726
|
+
Parameters
|
|
1727
|
+
----------
|
|
1728
|
+
n_threads : int, default 1
|
|
1729
|
+
1 = secuencial. >1 = nº de hilos. 0 = todos los núcleos (auto).
|
|
1730
|
+
El parseo paralelo (OpenMP) solo aplica a archivos **planos**
|
|
1731
|
+
(no `.gz`) cuando el motor C lo soporta; si no, cae a secuencial.
|
|
1732
|
+
|
|
1733
|
+
Example
|
|
1734
|
+
-------
|
|
1735
|
+
>>> for batch in SmartImporter.stream_batches("genome.fa", n_threads=0):
|
|
1736
|
+
... print(len(batch), "secuencias,", int(batch.n_symbols.sum()), "bases")
|
|
1737
|
+
"""
|
|
1738
|
+
ft = -1
|
|
1739
|
+
if force_type == SeqType.NUCLEOTIDE: ft = 0
|
|
1740
|
+
elif force_type == SeqType.PROTEIN: ft = 1
|
|
1741
|
+
if cls._use_parallel(path, n_threads):
|
|
1742
|
+
yield from cls._stream_parallel(path, ft, False, n_threads)
|
|
1743
|
+
elif cls._use_gz_fast(path, n_threads):
|
|
1744
|
+
yield from cls._stream_gz_parallel(path, ft, False, n_threads)
|
|
1745
|
+
else:
|
|
1746
|
+
yield from cls._stream_columnar(path, ft, fastq=False)
|
|
1747
|
+
|
|
1748
|
+
@classmethod
|
|
1749
|
+
def stream_fastq_batches(
|
|
1750
|
+
cls, path: str, n_threads: int = 1,
|
|
1751
|
+
) -> "Iterator[ReadBatch]":
|
|
1752
|
+
"""
|
|
1753
|
+
Lee un FASTQ como lotes columnares :class:`ReadBatch` — la vía rápida
|
|
1754
|
+
para control de calidad.
|
|
1755
|
+
|
|
1756
|
+
Filtrar por calidad media se vuelve una operación NumPy sobre todo el
|
|
1757
|
+
lote, sin fabricar un objeto por lectura.
|
|
1758
|
+
|
|
1759
|
+
Parameters
|
|
1760
|
+
----------
|
|
1761
|
+
n_threads : int, default 1
|
|
1762
|
+
1 = secuencial. >1 = nº de hilos. 0 = todos los núcleos (auto).
|
|
1763
|
+
El parseo paralelo solo aplica a FASTQ **plano** (no `.gz`).
|
|
1764
|
+
|
|
1765
|
+
Example
|
|
1766
|
+
-------
|
|
1767
|
+
>>> total = buenas = 0
|
|
1768
|
+
>>> for batch in SmartImporter.stream_fastq_batches("reads.fastq", n_threads=0):
|
|
1769
|
+
... mask = batch.passes(20) # 1 op NumPy para miles de lecturas
|
|
1770
|
+
... total += len(batch)
|
|
1771
|
+
... buenas += int(mask.sum())
|
|
1772
|
+
>>> print(f"{buenas}/{total} lecturas con calidad media ≥ 20")
|
|
1773
|
+
"""
|
|
1774
|
+
if cls._use_parallel(path, n_threads):
|
|
1775
|
+
yield from cls._stream_parallel(path, 0, True, n_threads)
|
|
1776
|
+
elif cls._use_gz_fast(path, n_threads):
|
|
1777
|
+
yield from cls._stream_gz_parallel(path, 0, True, n_threads)
|
|
1778
|
+
else:
|
|
1779
|
+
yield from cls._stream_columnar(path, 0, fastq=True)
|
|
1780
|
+
|
|
1781
|
+
@classmethod
|
|
1782
|
+
def stream_fastq(cls, path: str) -> Iterator[FastqRecord]:
|
|
1783
|
+
"""
|
|
1784
|
+
Generador de bajo consumo de RAM para archivos FASTQ.
|
|
1785
|
+
|
|
1786
|
+
Cada ``FastqRecord`` contiene la secuencia 5-bit empaquetada y las
|
|
1787
|
+
calidades Phred (valor entero 0–93, ya restado el offset ASCII de 33).
|
|
1788
|
+
|
|
1789
|
+
Parameters
|
|
1790
|
+
----------
|
|
1791
|
+
path : str
|
|
1792
|
+
Ruta al archivo FASTQ (no comprimido).
|
|
1793
|
+
|
|
1794
|
+
Yields
|
|
1795
|
+
------
|
|
1796
|
+
FastqRecord — uno por lectura, en orden de fichero.
|
|
1797
|
+
|
|
1798
|
+
Example
|
|
1799
|
+
-------
|
|
1800
|
+
>>> for rec in SmartImporter.stream_fastq("reads.fastq"):
|
|
1801
|
+
... if rec.passes_quality(20):
|
|
1802
|
+
... prot = SmartTranslator.translate(rec.sequence)
|
|
1803
|
+
"""
|
|
1804
|
+
if _C_BATCH_AVAILABLE:
|
|
1805
|
+
yield from cls._stream_batch(path, 0, fastq=True)
|
|
1806
|
+
return
|
|
1807
|
+
|
|
1808
|
+
if not _C_PARSER_AVAILABLE:
|
|
1809
|
+
yield from cls._stream_fastq_python(path)
|
|
1810
|
+
return
|
|
1811
|
+
|
|
1812
|
+
hdr_buf = ctypes.create_string_buffer(cls._STREAM_HDR)
|
|
1813
|
+
codes_buf = np.empty(cls._STREAM_SEQ, dtype=np.uint8)
|
|
1814
|
+
qual_buf = np.empty(cls._STREAM_SEQ, dtype=np.uint8)
|
|
1815
|
+
|
|
1816
|
+
handle = _c_parser_open(path)
|
|
1817
|
+
if not handle:
|
|
1818
|
+
raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
|
|
1819
|
+
|
|
1820
|
+
try:
|
|
1821
|
+
while True:
|
|
1822
|
+
ret, n, q = _c_parser_next_fastq(
|
|
1823
|
+
handle, hdr_buf, codes_buf, qual_buf
|
|
1824
|
+
)
|
|
1825
|
+
if ret <= 0:
|
|
1826
|
+
break
|
|
1827
|
+
packed = BitPacker.pack(codes_buf[:n])
|
|
1828
|
+
header = hdr_buf.value.decode("ascii", errors="replace")
|
|
1829
|
+
seq = PackedSequence(
|
|
1830
|
+
header = header,
|
|
1831
|
+
seq_type = SeqType.NUCLEOTIDE,
|
|
1832
|
+
n_symbols = n,
|
|
1833
|
+
data = packed,
|
|
1834
|
+
)
|
|
1835
|
+
yield FastqRecord(
|
|
1836
|
+
sequence = seq,
|
|
1837
|
+
quality = qual_buf[:q].copy(),
|
|
1838
|
+
)
|
|
1839
|
+
finally:
|
|
1840
|
+
_c_parser_close(handle)
|
|
1841
|
+
|
|
1842
|
+
@classmethod
|
|
1843
|
+
def _stream_fastq_python(cls, path: str) -> Iterator[FastqRecord]:
|
|
1844
|
+
"""Fallback Python puro para FASTQ (sin motor C)."""
|
|
1845
|
+
with open(path, "r", encoding="ascii", errors="replace") as fh:
|
|
1846
|
+
while True:
|
|
1847
|
+
line1 = fh.readline()
|
|
1848
|
+
if not line1:
|
|
1849
|
+
break
|
|
1850
|
+
line1 = line1.strip()
|
|
1851
|
+
if not line1.startswith("@"):
|
|
1852
|
+
continue
|
|
1853
|
+
header = line1[1:]
|
|
1854
|
+
seq_raw = fh.readline().strip()
|
|
1855
|
+
fh.readline() # línea '+comment'
|
|
1856
|
+
qual_raw = fh.readline().strip()
|
|
1857
|
+
if not seq_raw:
|
|
1858
|
+
continue
|
|
1859
|
+
seq = cls._encode(seq_raw, header, None)
|
|
1860
|
+
q = np.frombuffer(qual_raw.encode("ascii"), dtype=np.uint8)
|
|
1861
|
+
q = np.clip(q.astype(np.int16) - 33, 0, 93).astype(np.uint8)
|
|
1862
|
+
yield FastqRecord(sequence=seq, quality=q)
|
|
1863
|
+
|
|
1864
|
+
# ── Private helpers ───────────────────────────────────────────────────────
|
|
1865
|
+
|
|
1866
|
+
@classmethod
|
|
1867
|
+
def _iter_records(
|
|
1868
|
+
cls,
|
|
1869
|
+
fasta: str,
|
|
1870
|
+
force_type: Optional[SeqType],
|
|
1871
|
+
) -> Iterator[PackedSequence]:
|
|
1872
|
+
"""Yield one ``PackedSequence`` per record from a FASTA string."""
|
|
1873
|
+
header: Optional[str] = None
|
|
1874
|
+
chunks: list[str] = []
|
|
1875
|
+
|
|
1876
|
+
for line in fasta.splitlines():
|
|
1877
|
+
if line.startswith(">"):
|
|
1878
|
+
if header is not None and chunks:
|
|
1879
|
+
yield cls._encode("".join(chunks), header, force_type)
|
|
1880
|
+
header = line[1:]
|
|
1881
|
+
chunks = []
|
|
1882
|
+
elif line and not line.startswith(";"):
|
|
1883
|
+
chunks.append(line.strip())
|
|
1884
|
+
|
|
1885
|
+
if header is not None and chunks:
|
|
1886
|
+
yield cls._encode("".join(chunks), header, force_type)
|
|
1887
|
+
|
|
1888
|
+
@staticmethod
|
|
1889
|
+
def _detect_type(ascii_bytes: np.ndarray) -> SeqType:
|
|
1890
|
+
"""
|
|
1891
|
+
Classify nucleotide vs protein from raw ASCII byte values.
|
|
1892
|
+
|
|
1893
|
+
``_IS_PROTEIN_CHAR[ascii_bytes].any()`` performs the entire
|
|
1894
|
+
classification in one vectorised numpy call, with short-circuit
|
|
1895
|
+
semantics on the first protein-exclusive byte found.
|
|
1896
|
+
|
|
1897
|
+
Parameters
|
|
1898
|
+
----------
|
|
1899
|
+
ascii_bytes : np.ndarray, dtype uint8
|
|
1900
|
+
ASCII ordinals of the (uppercased) raw sequence.
|
|
1901
|
+
|
|
1902
|
+
Returns
|
|
1903
|
+
-------
|
|
1904
|
+
SeqType
|
|
1905
|
+
"""
|
|
1906
|
+
return (
|
|
1907
|
+
SeqType.PROTEIN
|
|
1908
|
+
if _IS_PROTEIN_CHAR[ascii_bytes].any()
|
|
1909
|
+
else SeqType.NUCLEOTIDE
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
@staticmethod
|
|
1913
|
+
def _encode(
|
|
1914
|
+
raw_seq: str,
|
|
1915
|
+
header: str,
|
|
1916
|
+
force_type: Optional[SeqType] = None,
|
|
1917
|
+
) -> PackedSequence:
|
|
1918
|
+
"""
|
|
1919
|
+
Core encoding pipeline — **the only function that ever holds a
|
|
1920
|
+
biological sequence as a ``str``.** Four vectorised steps:
|
|
1921
|
+
|
|
1922
|
+
① ``str`` → raw uint8 ASCII array (``np.frombuffer``, near-zero copy)
|
|
1923
|
+
② ASCII → 5-bit BioCode array (single LUT fancy-index)
|
|
1924
|
+
③ BioCode → packed uint8 byte array (``np.packbits``-based)
|
|
1925
|
+
④ Wrap in :class:`PackedSequence` and return.
|
|
1926
|
+
|
|
1927
|
+
*raw_seq* and all intermediate arrays are local and released on return.
|
|
1928
|
+
|
|
1929
|
+
Parameters
|
|
1930
|
+
----------
|
|
1931
|
+
raw_seq : Raw sequence text (mixed case / gaps accepted).
|
|
1932
|
+
header : FASTA description line without the ``>`` prefix.
|
|
1933
|
+
force_type : Override auto-detection.
|
|
1934
|
+
|
|
1935
|
+
Returns
|
|
1936
|
+
-------
|
|
1937
|
+
PackedSequence
|
|
1938
|
+
"""
|
|
1939
|
+
# ① String → ASCII ordinal array (frombuffer avoids a full copy).
|
|
1940
|
+
# NUC_LUT and AA_LUT already map both upper and lower case, so
|
|
1941
|
+
# .upper() would waste one full string copy — skip it.
|
|
1942
|
+
ascii_bytes: np.ndarray = np.frombuffer(
|
|
1943
|
+
raw_seq.encode("ascii", errors="replace"),
|
|
1944
|
+
dtype=np.uint8,
|
|
1945
|
+
)
|
|
1946
|
+
|
|
1947
|
+
# ② Detect or apply sequence type
|
|
1948
|
+
seq_type: SeqType = (
|
|
1949
|
+
force_type
|
|
1950
|
+
if force_type is not None
|
|
1951
|
+
else SmartImporter._detect_type(ascii_bytes)
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
# ③ Translate ASCII ordinals → 5-bit BioCode (single LUT index op)
|
|
1955
|
+
lut: np.ndarray = NUC_LUT if seq_type == SeqType.NUCLEOTIDE else AA_LUT
|
|
1956
|
+
codes: np.ndarray = lut[ascii_bytes] # shape (n,), dtype uint8
|
|
1957
|
+
|
|
1958
|
+
# ④ Compress 5-bit codes → packed byte array
|
|
1959
|
+
packed: np.ndarray = BitPacker.pack(codes)
|
|
1960
|
+
|
|
1961
|
+
return PackedSequence( # raw_seq released here ✓
|
|
1962
|
+
header = header,
|
|
1963
|
+
seq_type = seq_type,
|
|
1964
|
+
n_symbols = int(len(codes)),
|
|
1965
|
+
data = packed,
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1970
|
+
# §6 UTILITIES
|
|
1971
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1972
|
+
|
|
1973
|
+
@dataclass
|
|
1974
|
+
class SequenceStats:
|
|
1975
|
+
"""Composition and storage statistics for a :class:`PackedSequence`."""
|
|
1976
|
+
n_symbols: int
|
|
1977
|
+
n_packed_bytes: int
|
|
1978
|
+
compression_pct: float # percent saved vs naive 8-bit ASCII
|
|
1979
|
+
composition: dict[str, int] # canonical IUPAC letter → symbol count
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def compute_stats(seq: PackedSequence) -> SequenceStats:
|
|
1983
|
+
"""
|
|
1984
|
+
Compute composition and storage statistics for a ``PackedSequence``.
|
|
1985
|
+
|
|
1986
|
+
All counting is done via ``np.bincount`` — fully vectorised.
|
|
1987
|
+
|
|
1988
|
+
Parameters
|
|
1989
|
+
----------
|
|
1990
|
+
seq : PackedSequence
|
|
1991
|
+
|
|
1992
|
+
Returns
|
|
1993
|
+
-------
|
|
1994
|
+
SequenceStats
|
|
1995
|
+
"""
|
|
1996
|
+
codes = seq.decode() # uint8, shape (n,)
|
|
1997
|
+
dec_map = _NUC_DECODE if seq.seq_type == SeqType.NUCLEOTIDE else _AA_DECODE
|
|
1998
|
+
raw_counts = np.bincount(codes, minlength=32) # counts per BioCode
|
|
1999
|
+
|
|
2000
|
+
composition: dict[str, int] = {
|
|
2001
|
+
dec_map[code]: int(cnt)
|
|
2002
|
+
for code, cnt in enumerate(raw_counts)
|
|
2003
|
+
if cnt > 0 and code in dec_map
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
return SequenceStats(
|
|
2007
|
+
n_symbols = seq.n_symbols,
|
|
2008
|
+
n_packed_bytes = seq.packed_bytes,
|
|
2009
|
+
compression_pct = (1.0 - seq.memory_ratio) * 100.0,
|
|
2010
|
+
composition = composition,
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2015
|
+
# §7 DEMO / SELF-TEST (run with: python biocore.py)
|
|
2016
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2017
|
+
|
|
2018
|
+
if __name__ == "__main__":
|
|
2019
|
+
import time
|
|
2020
|
+
|
|
2021
|
+
_DEMO_FASTA = """\
|
|
2022
|
+
>NC_000913.3 E. coli K-12 MG1655 — genomic fragment (DNA)
|
|
2023
|
+
ATGAAACGCATTAGCACCACCATTACCACCACCATCACCATTACCACAGGTAACGGTGCGGGCTGA
|
|
2024
|
+
CGCGTACAGGAAACAGCCAGCGATAAGTCCTGAATCAGCAAAAGCTTTTGCCCATCAGTTCAGTCA
|
|
2025
|
+
>sp|P68871|HBB_HUMAN Hemoglobin subunit beta OS=Homo sapiens
|
|
2026
|
+
MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGK
|
|
2027
|
+
KVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQ
|
|
2028
|
+
KVVAGVANALAHKYH*
|
|
2029
|
+
>sp|P69905|HBA_HUMAN Hemoglobin subunit alpha OS=Homo sapiens
|
|
2030
|
+
MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADAT
|
|
2031
|
+
LNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVS
|
|
2032
|
+
TVLTSKYR*
|
|
2033
|
+
"""
|
|
2034
|
+
|
|
2035
|
+
W = 65
|
|
2036
|
+
print("═" * W)
|
|
2037
|
+
print(" BioForge — biocore.py — Unified 5-bit bioinformatics engine demo")
|
|
2038
|
+
print("═" * W)
|
|
2039
|
+
|
|
2040
|
+
records = SmartImporter.from_string(_DEMO_FASTA)
|
|
2041
|
+
|
|
2042
|
+
for rec in records:
|
|
2043
|
+
stats = compute_stats(rec)
|
|
2044
|
+
tag = rec.seq_type.name
|
|
2045
|
+
print(f"\n ── {tag} {'─' * (W - 6 - len(tag))}")
|
|
2046
|
+
print(f" Header : {rec.header[:55]}")
|
|
2047
|
+
print(f" Symbols : {stats.n_symbols:>10,}")
|
|
2048
|
+
print(f" Packed : {stats.n_packed_bytes:>10,} B")
|
|
2049
|
+
print(f" Naive (ASCII): {stats.n_symbols:>10,} B")
|
|
2050
|
+
print(f" Saved : {stats.compression_pct:>9.1f} %")
|
|
2051
|
+
print(f" Preview : {rec.to_string()[:45]!r}")
|
|
2052
|
+
comp = " ".join(f"{k}:{v}" for k, v in sorted(stats.composition.items()))
|
|
2053
|
+
print(f" Composition : {comp}")
|
|
2054
|
+
|
|
2055
|
+
# ── Round-trip integrity check ─────────────────────────────────────
|
|
2056
|
+
codes = rec.decode()
|
|
2057
|
+
repacked = BitPacker.pack(codes)
|
|
2058
|
+
assert np.array_equal(repacked, rec.data), "❌ Round-trip parity failure!"
|
|
2059
|
+
print(f" Round-trip : ✅ ({rec.n_symbols} sym → {rec.packed_bytes} B → OK)")
|
|
2060
|
+
|
|
2061
|
+
# ── Per-symbol O(1) access check ───────────────────────────────────
|
|
2062
|
+
n_check = min(10, rec.n_symbols)
|
|
2063
|
+
for i in range(n_check):
|
|
2064
|
+
assert rec[i] == int(codes[i]), f"❌ rec[{i}] mismatch"
|
|
2065
|
+
print(f" __getitem__ : ✅ (first {n_check} symbols checked, each O(1))")
|
|
2066
|
+
|
|
2067
|
+
# ── Micro-benchmark ────────────────────────────────────────────────────────
|
|
2068
|
+
print(f"\n{'═' * W}")
|
|
2069
|
+
print(" Micro-benchmark — 10 million random nucleotides")
|
|
2070
|
+
print(f"{'═' * W}")
|
|
2071
|
+
|
|
2072
|
+
N = 10_000_000
|
|
2073
|
+
rng = np.random.default_rng(seed=42)
|
|
2074
|
+
bench_codes = rng.integers(0, 4, size=N, dtype=np.uint8)
|
|
2075
|
+
|
|
2076
|
+
t0 = time.perf_counter()
|
|
2077
|
+
bench_packed = BitPacker.pack(bench_codes)
|
|
2078
|
+
t1 = time.perf_counter()
|
|
2079
|
+
bench_unpacked = BitPacker.unpack(bench_packed, N)
|
|
2080
|
+
t2 = time.perf_counter()
|
|
2081
|
+
|
|
2082
|
+
assert np.array_equal(bench_unpacked, bench_codes), "❌ Benchmark round-trip failed"
|
|
2083
|
+
|
|
2084
|
+
pack_ms = (t1 - t0) * 1e3
|
|
2085
|
+
unpack_ms = (t2 - t1) * 1e3
|
|
2086
|
+
|
|
2087
|
+
print(f" Symbols : {N:>12,}")
|
|
2088
|
+
print(f" Pack time : {pack_ms:>10.2f} ms ({N / pack_ms / 1e3:>7.0f} M sym/s)")
|
|
2089
|
+
print(f" Unpack time : {unpack_ms:>10.2f} ms ({N / unpack_ms / 1e3:>7.0f} M sym/s)")
|
|
2090
|
+
print(f" Packed size : {len(bench_packed):>12,} B (naive: {N:,} B)")
|
|
2091
|
+
print(f" Memory ratio : {len(bench_packed) / N:.4f} (ideal 5-bit: 0.6250)")
|
|
2092
|
+
print()
|