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.
@@ -0,0 +1,601 @@
1
+ """
2
+ smart_translator.py
3
+ ═══════════════════════════════════════════════════════════════════════
4
+ Standard Genetic Code translator for the biocore 5-bit engine.
5
+
6
+ Integrates with biocore.py (A=0, C=1, G=2, T/U=3, AA codes 4–23,
7
+ STOP=24) without any Python-level loops in the hot path.
8
+
9
+ Pipeline (fully vectorised — zero Python loops at call time)
10
+ ─────────────────────────────────────────────────────────────
11
+ ① Decode PackedSequence(NUC) → uint8 codes [0–3]
12
+ ② Locate first ATG/AUG via sliding_window_view (Fail-Fast)
13
+ ③ Extract ORF from ATG; reshape into (N, 3) codon matrix
14
+ ④ Compute LUT index: idx = n₁×16 + n₂×4 + n₃ (base-4, vectorised)
15
+ ⑤ CODON_LUT[indices] → amino-acid BioCode array (single fancy-index)
16
+ ⑥ Truncate at first STOP codon (BioCode.STOP = 24)
17
+ ⑦ Raise UserWarning if result < 50 aa (possible ncRNA / broken fragment)
18
+ ⑧ BitPacker.pack(aa_codes) → new PackedSequence(PROTEIN)
19
+
20
+ CODON_LUT (64 entries, Standard Genetic Code NCBI table #1)
21
+ ─────────────────────────────────────────────────────────────
22
+ Index = n₁×16 + n₂×4 + n₃ │ A=0 C=1 G=2 T/U=3
23
+
24
+ Idx Codon AA │ Idx Codon AA │ Idx Codon AA │ Idx Codon AA
25
+ ──────────────── │ ──────────────── │ ──────────────── │ ────────────────
26
+ 0 AAA K │ 16 CAA Q │ 32 GAA E │ 48 UAA STP
27
+ 1 AAC N │ 17 CAC H │ 33 GAC D │ 49 UAC Y
28
+ 2 AAG K │ 18 CAG Q │ 34 GAG E │ 50 UAG STP
29
+ 3 AAU N │ 19 CAU H │ 35 GAU D │ 51 UAU Y
30
+ 4 ACA T │ 20 CCA P │ 36 GCA A │ 52 UCA S
31
+ 5 ACC T │ 21 CCC P │ 37 GCC A │ 53 UCC S
32
+ 6 ACG T │ 22 CCG P │ 38 GCG A │ 54 UCG S
33
+ 7 ACU T │ 23 CCU P │ 39 GCU A │ 55 UCU S
34
+ 8 AGA R │ 24 CGA R │ 40 GGA G │ 56 UGA STP
35
+ 9 AGC S │ 25 CGC R │ 41 GGC G │ 57 UGC C
36
+ 10 AGG R │ 26 CGG R │ 42 GGG G │ 58 UGG W
37
+ 11 AGU S │ 27 CGU R │ 43 GGU G │ 59 UGU C
38
+ 12 AUA I │ 28 CUA L │ 44 GUA V │ 60 UUA L
39
+ 13 AUC I │ 29 CUC L │ 45 GUC V │ 61 UUC F
40
+ 14 AUG M │ 30 CUG L │ 46 GUG V │ 62 UUG L
41
+ 15 AUU I │ 31 CUU L │ 47 GUU V │ 63 UUU F
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import warnings
47
+
48
+ import numpy as np
49
+ from numpy.lib.stride_tricks import sliding_window_view
50
+
51
+ # ── biocore integration ────────────────────────────────────────────────────────
52
+ from .biocore import (
53
+ BioCode,
54
+ BitPacker,
55
+ PackedSequence,
56
+ SeqType,
57
+ SequenceTypeError,
58
+ SequenceValueError,
59
+ TranslationError,
60
+ )
61
+
62
+ # ── motor C (opcional) ────────────────────────────────────────────────────────
63
+ try:
64
+ from .engine._loader import C_AVAILABLE as _C_AVAILABLE
65
+ from .engine._loader import c_find_atg as _c_find_atg
66
+ from .engine._loader import c_translate as _c_translate
67
+ except ImportError:
68
+ _C_AVAILABLE = False
69
+
70
+ __all__: list[str] = ["SmartTranslator"]
71
+
72
+
73
+ # ══════════════════════════════════════════════════════════════════════════════
74
+ # §1 CODON LUT — Standard Genetic Code, built once at module load
75
+ # ══════════════════════════════════════════════════════════════════════════════
76
+
77
+ def _build_codon_lut() -> np.ndarray:
78
+ """
79
+ Build the 64-entry Standard Genetic Code lookup array.
80
+
81
+ Encoding: A=0, C=1, G=2, U=3 (T and U share the same slot as in biocore).
82
+ LUT index = n₁×16 + n₂×4 + n₃ → [0, 63].
83
+ Stored values: BioCode.AA_* (4–23), BioCode.STOP (24), BioCode.UNK (31).
84
+
85
+ This function runs **once** at module import time and is never called again.
86
+
87
+ Returns
88
+ -------
89
+ np.ndarray, dtype=uint8, shape=(64,)
90
+ """
91
+ # RNA base → biocore nucleotide code
92
+ _B: dict[str, int] = {"A": 0, "C": 1, "G": 2, "U": 3}
93
+
94
+ # Standard Genetic Code (NCBI table #1)
95
+ # RNA codon string → BioCode integer value
96
+ _TABLE: dict[str, int] = {
97
+ # ── Phenylalanine F = 8 ─────────────────────────────────────────────
98
+ "UUU": BioCode.AA_F, "UUC": BioCode.AA_F,
99
+ # ── Leucine L = 13 ──────────────────────────────────────────────────
100
+ "UUA": BioCode.AA_L, "UUG": BioCode.AA_L,
101
+ "CUU": BioCode.AA_L, "CUC": BioCode.AA_L,
102
+ "CUA": BioCode.AA_L, "CUG": BioCode.AA_L,
103
+ # ── Isoleucine I = 11 ───────────────────────────────────────────────
104
+ "AUU": BioCode.AA_I, "AUC": BioCode.AA_I, "AUA": BioCode.AA_I,
105
+ # ── Methionine / START M = 14 ───────────────────────────────────────
106
+ "AUG": BioCode.AA_M,
107
+ # ── Valine V = 21 ───────────────────────────────────────────────────
108
+ "GUU": BioCode.AA_V, "GUC": BioCode.AA_V,
109
+ "GUA": BioCode.AA_V, "GUG": BioCode.AA_V,
110
+ # ── Serine S = 19 ───────────────────────────────────────────────────
111
+ "UCU": BioCode.AA_S, "UCC": BioCode.AA_S,
112
+ "UCA": BioCode.AA_S, "UCG": BioCode.AA_S,
113
+ "AGU": BioCode.AA_S, "AGC": BioCode.AA_S,
114
+ # ── Proline P = 16 ──────────────────────────────────────────────────
115
+ "CCU": BioCode.AA_P, "CCC": BioCode.AA_P,
116
+ "CCA": BioCode.AA_P, "CCG": BioCode.AA_P,
117
+ # ── Threonine T = 20 ────────────────────────────────────────────────
118
+ "ACU": BioCode.AA_T, "ACC": BioCode.AA_T,
119
+ "ACA": BioCode.AA_T, "ACG": BioCode.AA_T,
120
+ # ── Alanine A = 4 ───────────────────────────────────────────────────
121
+ "GCU": BioCode.AA_A, "GCC": BioCode.AA_A,
122
+ "GCA": BioCode.AA_A, "GCG": BioCode.AA_A,
123
+ # ── Tyrosine Y = 23 ─────────────────────────────────────────────────
124
+ "UAU": BioCode.AA_Y, "UAC": BioCode.AA_Y,
125
+ # ── STOP codons (24) ────────────────────────────────────────────────
126
+ "UAA": BioCode.STOP, "UAG": BioCode.STOP, "UGA": BioCode.STOP,
127
+ # ── Histidine H = 10 ────────────────────────────────────────────────
128
+ "CAU": BioCode.AA_H, "CAC": BioCode.AA_H,
129
+ # ── Glutamine Q = 17 ────────────────────────────────────────────────
130
+ "CAA": BioCode.AA_Q, "CAG": BioCode.AA_Q,
131
+ # ── Asparagine N = 15 ───────────────────────────────────────────────
132
+ "AAU": BioCode.AA_N, "AAC": BioCode.AA_N,
133
+ # ── Lysine K = 12 ───────────────────────────────────────────────────
134
+ "AAA": BioCode.AA_K, "AAG": BioCode.AA_K,
135
+ # ── Aspartic acid D = 6 ─────────────────────────────────────────────
136
+ "GAU": BioCode.AA_D, "GAC": BioCode.AA_D,
137
+ # ── Glutamic acid E = 7 ─────────────────────────────────────────────
138
+ "GAA": BioCode.AA_E, "GAG": BioCode.AA_E,
139
+ # ── Cysteine C = 5 ──────────────────────────────────────────────────
140
+ "UGU": BioCode.AA_C, "UGC": BioCode.AA_C,
141
+ # ── Tryptophan W = 22 ───────────────────────────────────────────────
142
+ "UGG": BioCode.AA_W,
143
+ # ── Arginine R = 18 ─────────────────────────────────────────────────
144
+ "CGU": BioCode.AA_R, "CGC": BioCode.AA_R,
145
+ "CGA": BioCode.AA_R, "CGG": BioCode.AA_R,
146
+ "AGA": BioCode.AA_R, "AGG": BioCode.AA_R,
147
+ # ── Glycine G = 9 ───────────────────────────────────────────────────
148
+ "GGU": BioCode.AA_G, "GGC": BioCode.AA_G,
149
+ "GGA": BioCode.AA_G, "GGG": BioCode.AA_G,
150
+ }
151
+
152
+ assert len(_TABLE) == 64, f"Genetic code table must have 64 entries, got {len(_TABLE)}"
153
+
154
+ lut = np.full(64, BioCode.UNK, dtype=np.uint8)
155
+ for codon, aa_code in _TABLE.items():
156
+ n1, n2, n3 = _B[codon[0]], _B[codon[1]], _B[codon[2]]
157
+ lut[n1 * 16 + n2 * 4 + n3] = np.uint8(aa_code)
158
+
159
+ return lut
160
+
161
+
162
+ # ══════════════════════════════════════════════════════════════════════════════
163
+ # §2 SMART TRANSLATOR
164
+ # ══════════════════════════════════════════════════════════════════════════════
165
+
166
+ class SmartTranslator:
167
+ """
168
+ Vectorised Standard Genetic Code translator for biocore PackedSequences.
169
+
170
+ Accepts a ``PackedSequence(NUCLEOTIDE)``, locates the first ATG/AUG
171
+ codon via a zero-copy sliding-window view, translates the ORF using
172
+ a single numpy fancy-index lookup, truncates at the first STOP codon,
173
+ and returns a ``PackedSequence(PROTEIN)`` — all without a single
174
+ Python-level loop in the call path.
175
+
176
+ Class constants
177
+ ───────────────
178
+ CODON_LUT : np.ndarray, shape (64,) — Standard Genetic Code LUT.
179
+ _START_CODON : np.ndarray [0, 3, 2] — ATG/AUG in 5-bit encoding.
180
+ _CODON_WEIGHTS: np.ndarray [16, 4, 1] — base-4 index weights (uint16).
181
+ _MIN_AA_LEN : int = 50 — biological plausibility floor.
182
+
183
+ Quick start
184
+ ───────────
185
+ >>> from bioforge import SmartImporter, SeqType
186
+ >>> nuc_seq = SmartImporter.from_string(fasta)[0]
187
+ >>> prot_seq = SmartTranslator.translate(nuc_seq)
188
+ >>> print(prot_seq.to_string())
189
+ """
190
+
191
+ # ── Class-level constants (allocated once at class definition) ─────────────
192
+
193
+ # Standard Genetic Code LUT — 64 entries, computed at class load time.
194
+ # Index formula: n₁×16 + n₂×4 + n₃ (A=0 C=1 G=2 T/U=3)
195
+ # Values: BioCode.AA_* (4–23), BioCode.STOP (24), BioCode.UNK (31)
196
+ CODON_LUT: np.ndarray = _build_codon_lut()
197
+
198
+ # ATG / AUG start codon in biocore 5-bit encoding (A=0, T/U=3, G=2)
199
+ _START_CODON: np.ndarray = np.array([0, 3, 2], dtype=np.uint8)
200
+
201
+ # Base-4 weights for codon index arithmetic.
202
+ # uint16 prevents overflow when ambiguous/gap codes (values > 3) appear:
203
+ # worst case: UNK=31 → 31×16 + 31×4 + 31 = 651 → out of [0,63] → masked
204
+ _CODON_WEIGHTS: np.ndarray = np.array([16, 4, 1], dtype=np.uint16)
205
+
206
+ # Proteins shorter than this trigger a UserWarning
207
+ _MIN_AA_LEN: int = 50
208
+
209
+ # ── Public API ─────────────────────────────────────────────────────────────
210
+
211
+ @classmethod
212
+ def translate(
213
+ cls,
214
+ seq: PackedSequence,
215
+ warn_short: bool = True,
216
+ ) -> PackedSequence:
217
+ """
218
+ Translate a nucleotide ``PackedSequence`` into a protein ``PackedSequence``.
219
+
220
+ Translation starts at the first ATG/AUG codon and stops at the
221
+ first in-frame STOP codon. The STOP codon itself is excluded from
222
+ the result.
223
+
224
+ Parameters
225
+ ----------
226
+ seq : PackedSequence
227
+ A nucleotide-type packed sequence (``SeqType.NUCLEOTIDE``).
228
+ warn_short : bool, default True
229
+ If ``True``, emit a ``UserWarning`` when the translated protein
230
+ is shorter than 50 amino acids.
231
+
232
+ Returns
233
+ -------
234
+ PackedSequence
235
+ A protein-type packed sequence (``SeqType.PROTEIN``).
236
+ Header is prefixed with ``[PROT | ORF@<start>]``.
237
+
238
+ Raises
239
+ ------
240
+ TypeError
241
+ If *seq* is not a ``NUCLEOTIDE`` sequence.
242
+ ValueError
243
+ If the sequence is too short, or no ATG/AUG codon is found,
244
+ or the ORF yields no complete codon after the start.
245
+ """
246
+
247
+ # ── Guard clauses ─────────────────────────────────────────────────────
248
+ if not isinstance(seq, PackedSequence):
249
+ raise SequenceTypeError(
250
+ f"seq debe ser PackedSequence, se recibió {type(seq).__name__!r}. "
251
+ "Crea la secuencia con SmartImporter.from_string() o SmartImporter.from_file()."
252
+ )
253
+ if seq.seq_type != SeqType.NUCLEOTIDE:
254
+ raise SequenceTypeError(
255
+ f"Se esperaba SeqType.NUCLEOTIDE, se recibió {seq.seq_type.name}. "
256
+ "SmartTranslator solo traduce ADN/ARN. "
257
+ "Para alinear proteínas usa SequenceAligner directamente."
258
+ )
259
+ if seq.n_symbols < 3:
260
+ raise SequenceValueError(
261
+ f"Secuencia demasiado corta ({seq.n_symbols} nt): "
262
+ "se necesitan al menos 3 nucleótidos para un codón."
263
+ )
264
+
265
+ # ① Unpack to flat uint8 nucleotide code array
266
+ nuc_codes: np.ndarray = seq.decode() # shape (n,)
267
+
268
+ # ② Vectorised ORF search — locate first ATG/AUG (Fail-Fast)
269
+ orf_start: int = cls._find_orf_start(nuc_codes)
270
+
271
+ # ③ Extract ORF from start codon; discard any incomplete trailing codon
272
+ orf: np.ndarray = nuc_codes[orf_start:]
273
+ n_codons: int = len(orf) // 3
274
+ if n_codons == 0:
275
+ raise TranslationError(
276
+ f"El ORF en la posición {orf_start} no tiene ningún codón "
277
+ "completo tras el ATG de inicio. "
278
+ "La secuencia termina justo en el ATG sin residuos posteriores."
279
+ )
280
+
281
+ # ④+⑤ Traducir codones → AAs (C si disponible, NumPy si no)
282
+ if _C_AVAILABLE:
283
+ aa_codes: np.ndarray = _c_translate(cls.CODON_LUT, orf, n_codons)
284
+ else:
285
+ codons: np.ndarray = orf[: n_codons * 3].reshape(n_codons, 3)
286
+ indices_u16: np.ndarray = (
287
+ codons.astype(np.uint16) * cls._CODON_WEIGHTS
288
+ ).sum(axis=1)
289
+ valid_codon: np.ndarray = indices_u16 < np.uint16(64)
290
+ safe_idx: np.ndarray = np.where(
291
+ valid_codon, indices_u16, np.uint16(0)
292
+ ).astype(np.uint8)
293
+ aa_codes = np.where(
294
+ valid_codon,
295
+ cls.CODON_LUT[safe_idx],
296
+ np.uint8(BioCode.UNK),
297
+ ).astype(np.uint8)
298
+
299
+ # ⑥ Truncate at first in-frame STOP codon (BioCode.STOP = 24).
300
+ stop_mask: np.ndarray = aa_codes == np.uint8(BioCode.STOP)
301
+ if stop_mask.any():
302
+ aa_codes = aa_codes[: int(np.argmax(stop_mask))] # exclude STOP itself
303
+
304
+ # ⑦ Biological plausibility check
305
+ n_aa: int = int(len(aa_codes))
306
+ if warn_short and n_aa < cls._MIN_AA_LEN:
307
+ warnings.warn(
308
+ f"Secuencia sospechosamente corta ({n_aa} aa < {cls._MIN_AA_LEN} aa). "
309
+ "Podría ser un ARNt, micro-ARN o un fragmento roto.",
310
+ UserWarning,
311
+ stacklevel=2,
312
+ )
313
+
314
+ # ⑧ Pack and return as a new PROTEIN PackedSequence
315
+ return PackedSequence(
316
+ header = f"[PROT | ORF@{orf_start}] {seq.header}",
317
+ seq_type = SeqType.PROTEIN,
318
+ n_symbols = n_aa,
319
+ data = BitPacker.pack(aa_codes),
320
+ )
321
+
322
+ @classmethod
323
+ def translate_all_frames(
324
+ cls,
325
+ seq: PackedSequence,
326
+ warn_short: bool = False,
327
+ ) -> list[PackedSequence]:
328
+ """
329
+ Translate all 6 reading frames of a nucleotide sequence.
330
+
331
+ The 6 frames are:
332
+ +1, +2, +3 — forward strand, offsets 0, 1, 2.
333
+ -1, -2, -3 — reverse complement strand, offsets 0, 1, 2.
334
+
335
+ Only frames that contain at least one ATG codon are included.
336
+ Frames with no ATG are silently skipped, so the result may have
337
+ fewer than 6 entries (or be empty for non-coding sequences).
338
+
339
+ Parameters
340
+ ----------
341
+ seq : PackedSequence
342
+ A nucleotide-type packed sequence (``SeqType.NUCLEOTIDE``).
343
+ warn_short : bool, default False
344
+ If ``True``, emit a ``UserWarning`` for proteins shorter than
345
+ 50 amino acids. Off by default because short ORFs are common
346
+ in non-primary frames.
347
+
348
+ Returns
349
+ -------
350
+ list[PackedSequence]
351
+ Up to 6 protein PackedSequences, one per frame with an ORF.
352
+ Each header: ``[PROT | frame +/-N | ORF@<pos>] <original>``.
353
+ Empty list if no ORF is found in any frame.
354
+
355
+ Raises
356
+ ------
357
+ SequenceTypeError
358
+ If *seq* is not a ``NUCLEOTIDE`` sequence.
359
+ SequenceValueError
360
+ If the sequence is shorter than 3 nucleotides.
361
+ """
362
+ if not isinstance(seq, PackedSequence):
363
+ raise SequenceTypeError(
364
+ f"seq debe ser PackedSequence, se recibió {type(seq).__name__!r}. "
365
+ "Crea la secuencia con SmartImporter.from_string() o SmartImporter.from_file()."
366
+ )
367
+ if seq.seq_type != SeqType.NUCLEOTIDE:
368
+ raise SequenceTypeError(
369
+ f"Se esperaba SeqType.NUCLEOTIDE, se recibió {seq.seq_type.name}. "
370
+ "translate_all_frames() solo traduce ADN/ARN."
371
+ )
372
+ if seq.n_symbols < 3:
373
+ raise SequenceValueError(
374
+ f"Secuencia demasiado corta ({seq.n_symbols} nt): "
375
+ "se necesitan al menos 3 nucleótidos para un codón."
376
+ )
377
+
378
+ rc_seq = seq.reverse_complement()
379
+ results: list[PackedSequence] = []
380
+
381
+ for strand_codes, strand_sign in [
382
+ (seq.decode(), '+'),
383
+ (rc_seq.decode(), '-'),
384
+ ]:
385
+ for offset in range(3):
386
+ frame_codes = strand_codes[offset:]
387
+ if len(frame_codes) < 3:
388
+ continue
389
+ frame_label = f"{strand_sign}{offset + 1}"
390
+
391
+ try:
392
+ orf_start = cls._find_orf_start(frame_codes)
393
+ except (TranslationError, ValueError):
394
+ continue # no ATG in this frame — skip silently
395
+
396
+ orf = frame_codes[orf_start:]
397
+ n_codons = len(orf) // 3
398
+ if n_codons == 0:
399
+ continue
400
+
401
+ # Translate codons (C engine or NumPy)
402
+ if _C_AVAILABLE:
403
+ aa_codes: np.ndarray = _c_translate(cls.CODON_LUT, orf, n_codons)
404
+ else:
405
+ codons = orf[: n_codons * 3].reshape(n_codons, 3)
406
+ idx_u16 = (codons.astype(np.uint16) * cls._CODON_WEIGHTS).sum(axis=1)
407
+ valid = idx_u16 < np.uint16(64)
408
+ safe_idx = np.where(valid, idx_u16, np.uint16(0)).astype(np.uint8)
409
+ aa_codes = np.where(
410
+ valid, cls.CODON_LUT[safe_idx], np.uint8(BioCode.UNK)
411
+ ).astype(np.uint8)
412
+
413
+ # Truncate at first in-frame STOP codon
414
+ stop_mask = aa_codes == np.uint8(BioCode.STOP)
415
+ if stop_mask.any():
416
+ aa_codes = aa_codes[: int(np.argmax(stop_mask))]
417
+
418
+ n_aa = int(len(aa_codes))
419
+ if n_aa == 0:
420
+ continue
421
+
422
+ if warn_short and n_aa < cls._MIN_AA_LEN:
423
+ warnings.warn(
424
+ f"Frame {frame_label}: proteína corta "
425
+ f"({n_aa} aa < {cls._MIN_AA_LEN} aa).",
426
+ UserWarning,
427
+ stacklevel=2,
428
+ )
429
+
430
+ abs_pos = offset + orf_start
431
+ results.append(PackedSequence(
432
+ header = f"[PROT | frame {frame_label} | ORF@{abs_pos}] {seq.header}",
433
+ seq_type = SeqType.PROTEIN,
434
+ n_symbols = n_aa,
435
+ data = BitPacker.pack(aa_codes),
436
+ ))
437
+
438
+ return results
439
+
440
+ # ── Private helpers ────────────────────────────────────────────────────────
441
+
442
+ @classmethod
443
+ def _find_orf_start(cls, codes: np.ndarray) -> int:
444
+ """
445
+ Locate the first ATG/AUG codon using a vectorised sliding-window view.
446
+
447
+ Uses ``numpy.lib.stride_tricks.sliding_window_view`` to create a
448
+ zero-copy ``(n-2, 3)`` view over *codes*, then compares every
449
+ 3-mer against ``[0, 3, 2]`` (= A, T/U, G) in one broadcast op.
450
+ ``np.argmax`` performs a C-level scan and returns the first hit index.
451
+
452
+ Parameters
453
+ ----------
454
+ codes : np.ndarray, dtype uint8
455
+ Decoded nucleotide code array (values 0–3 for ACGTU).
456
+
457
+ Returns
458
+ -------
459
+ int
460
+ Zero-based index of the first nucleotide of the ATG codon.
461
+
462
+ Raises
463
+ ------
464
+ ValueError
465
+ If no ATG/AUG codon is found (Fail-Fast).
466
+ """
467
+ if _C_AVAILABLE:
468
+ pos = _c_find_atg(codes)
469
+ if pos == -1:
470
+ raise TranslationError(
471
+ "No se encontró ningún codón de inicio ATG/AUG en la secuencia. "
472
+ "Comprueba que sea una secuencia codificante (CDS) completa. "
473
+ "Si la auto-detección de tipo falla, importa con force_type=SeqType.NUCLEOTIDE."
474
+ )
475
+ return pos
476
+
477
+ # fallback NumPy: sliding-window vectorizado
478
+ windows: np.ndarray = sliding_window_view(codes, window_shape=3)
479
+ hit_mask: np.ndarray = np.all(windows == cls._START_CODON, axis=1)
480
+ first_hit: int = int(np.argmax(hit_mask))
481
+ if not hit_mask[first_hit]:
482
+ raise TranslationError(
483
+ "No se encontró ningún codón de inicio ATG/AUG en la secuencia. "
484
+ "Asegúrate de que sea una secuencia codificante (CDS) válida."
485
+ )
486
+ return first_hit
487
+
488
+
489
+ # ══════════════════════════════════════════════════════════════════════════════
490
+ # §3 DEMO / SELF-TEST (python smart_translator.py)
491
+ # ══════════════════════════════════════════════════════════════════════════════
492
+
493
+ if __name__ == "__main__":
494
+ import sys
495
+ import time
496
+ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
497
+ from bioforge import SmartImporter, compute_stats
498
+
499
+ W = 65
500
+ print("═" * W)
501
+ print(" smart_translator.py — Vectorised Genetic Code demo")
502
+ print("═" * W)
503
+
504
+ # ── Inspect LUT ────────────────────────────────────────────────────────────
505
+ lut = SmartTranslator.CODON_LUT
506
+ n_stop = int((lut == BioCode.STOP).sum())
507
+ n_aa = int(((lut >= 4) & (lut <= 23)).sum())
508
+ print(f"\n CODON_LUT ({len(lut)} entries)")
509
+ print(f" ├─ Sense codons (AA 4–23) : {n_aa}")
510
+ print(f" ├─ STOP codons (24) : {n_stop}")
511
+ print(f" └─ Filled / UNK entries : {n_aa + n_stop} / {64 - n_aa - n_stop}")
512
+
513
+ # ── Test 1: HBB_HUMAN — long CDS with upstream noise ──────────────────────
514
+ # First 48 codons of human haemoglobin beta-chain mRNA (NM_000518),
515
+ # prefixed with 8 nt of 5′ UTR noise to exercise the ORF finder.
516
+ HBB_FASTA = """\
517
+ >NM_000518.5|HBB_HUMAN|partial CDS with 5-UTR noise
518
+ NNNAACCCATGGTGCACCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGGTGAACGT
519
+ GGATGAAGTTGGTGGTGAGGCCCTGGGCAGGCTGCTGGTGGTCTACCCTTGGACCCAGAGGTTCTTTGAG
520
+ TCCTTTGGGGATCTGTCCACTCCTGATGCTGTTATGGGCAACCCTAAGGTGAAGGCTCATGGCAAGAAAG
521
+ TGCTCGGTGCCTTTAGTGATGGCCTGGCTCACCTGGACAACCTCAAGGGCACCTTTGCCACACTGAGTGA
522
+ GCTGCACTGTGACAAGCTGCACGTGGATCCTGAGAACTTCAGGCTCCTGGGCAACGTGCTGGTCTGTGTG
523
+ CTGGCCCATCACTTTGGCAAAGAATTCACCCCACCAGTGCAGGCTGCCTATCAGAAAGTGGTGGCTGGTGT
524
+ GGCTAATGCCCTGGCCCACAAGTATCACTAA
525
+ """
526
+
527
+ # Test 2: short synthetic ORF to trigger UserWarning
528
+ SHORT_FASTA = """\
529
+ >synthetic|short_ORF|triggers_warning
530
+ ATGAAACGCATTAGCACCACCATTACCACCACCATCACCATTACCACAGGTAACGGTGCGGGCTGA
531
+ """
532
+
533
+ test_cases = [
534
+ ("HBB_HUMAN (full CDS)", HBB_FASTA, False),
535
+ ("Synthetic short ORF", SHORT_FASTA, True),
536
+ ]
537
+
538
+ for title, fasta, expect_warn in test_cases:
539
+ print(f"\n {'─'*55}")
540
+ print(f" {title}")
541
+ print(f" {'─'*55}")
542
+
543
+ nuc_seq = SmartImporter.from_string(fasta, force_type=SeqType.NUCLEOTIDE)[0]
544
+ print(f" Input : {repr(nuc_seq)}")
545
+
546
+ import warnings as _w
547
+ with _w.catch_warnings(record=True) as caught:
548
+ _w.simplefilter("always")
549
+ t0 = time.perf_counter()
550
+ prot_seq = SmartTranslator.translate(nuc_seq)
551
+ elapsed_us = (time.perf_counter() - t0) * 1e6
552
+
553
+ prot_str = prot_seq.to_string()
554
+ stats = compute_stats(prot_seq)
555
+
556
+ print(f" Output : {repr(prot_seq)}")
557
+ print(f" Protein : {prot_str[:60]}{'…' if len(prot_str)>60 else ''}")
558
+ print(f" Elapsed : {elapsed_us:.1f} µs")
559
+ print(f" Mem save: {stats.compression_pct:.1f}% "
560
+ f"({stats.n_packed_bytes} B packed vs {stats.n_symbols} B naive)")
561
+
562
+ if caught:
563
+ print(f" ⚠ UserWarning: {caught[0].message}")
564
+ if expect_warn:
565
+ print(" UserWarning raised as expected ✅")
566
+ else:
567
+ print(" No warning (sequence ≥ 50 aa) ✅")
568
+
569
+ # Round-trip integrity
570
+ aa_codes = prot_seq.decode()
571
+ repacked = BitPacker.pack(aa_codes)
572
+ rt_ok = np.array_equal(repacked, prot_seq.data)
573
+ print(f" Round-trip: {'✅' if rt_ok else '❌'} "
574
+ f"({prot_seq.n_symbols} aa → {prot_seq.packed_bytes} B → {'OK' if rt_ok else 'FAIL'})")
575
+
576
+ # ── Test 3: Error paths ────────────────────────────────────────────────────
577
+ print(f"\n {'─'*55}")
578
+ print(" Error-path validation")
579
+ print(f" {'─'*55}")
580
+
581
+ # TypeError: protein input
582
+ prot_input = PackedSequence(
583
+ header="fake_prot", seq_type=SeqType.PROTEIN, n_symbols=3,
584
+ data=BitPacker.pack(np.array([4, 5, 6], dtype=np.uint8)),
585
+ )
586
+ try:
587
+ SmartTranslator.translate(prot_input)
588
+ print(" TypeError not raised ❌")
589
+ except TypeError as exc:
590
+ print(f" TypeError ✅ → {exc}")
591
+
592
+ # ValueError: no ATG
593
+ no_atg_fasta = ">noatg\nCCCGGGTTTACCCACC\n"
594
+ no_atg_seq = SmartImporter.from_string(no_atg_fasta, force_type=SeqType.NUCLEOTIDE)[0]
595
+ try:
596
+ SmartTranslator.translate(no_atg_seq)
597
+ print(" ValueError (no ATG) not raised ❌")
598
+ except ValueError as exc:
599
+ print(f" ValueError (no ATG) ✅ → {exc}")
600
+
601
+ print(f"\n{'═' * W}\n")