bioforge 3.4.0__tar.gz → 4.0.0__tar.gz

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.
Files changed (38) hide show
  1. {bioforge-3.4.0 → bioforge-4.0.0}/PKG-INFO +16 -7
  2. {bioforge-3.4.0 → bioforge-4.0.0}/README.md +15 -6
  3. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/__init__.py +1 -1
  4. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/genomemap.py +110 -20
  5. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge.egg-info/PKG-INFO +16 -7
  6. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_genomemap.py +50 -0
  7. {bioforge-3.4.0 → bioforge-4.0.0}/LICENSE +0 -0
  8. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/aligner.py +0 -0
  9. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/analyze.py +0 -0
  10. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/bgzf.py +0 -0
  11. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/biocore.py +0 -0
  12. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/engine/__init__.py +0 -0
  13. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/engine/_loader.py +0 -0
  14. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/engine/build.py +0 -0
  15. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/engine/engine.c +0 -0
  16. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/engine/engine.dll +0 -0
  17. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/minimizers.py +0 -0
  18. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/qcreport.py +0 -0
  19. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/refindex.py +0 -0
  20. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge/smart_translator.py +0 -0
  21. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge.egg-info/SOURCES.txt +0 -0
  22. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge.egg-info/dependency_links.txt +0 -0
  23. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge.egg-info/entry_points.txt +0 -0
  24. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge.egg-info/requires.txt +0 -0
  25. {bioforge-3.4.0 → bioforge-4.0.0}/bioforge.egg-info/top_level.txt +0 -0
  26. {bioforge-3.4.0 → bioforge-4.0.0}/pyproject.toml +0 -0
  27. {bioforge-3.4.0 → bioforge-4.0.0}/setup.cfg +0 -0
  28. {bioforge-3.4.0 → bioforge-4.0.0}/setup.py +0 -0
  29. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_aligner.py +0 -0
  30. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_analyze.py +0 -0
  31. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_bgzf.py +0 -0
  32. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_biocore.py +0 -0
  33. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_errors.py +0 -0
  34. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_minimizers.py +0 -0
  35. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_qcreport.py +0 -0
  36. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_refindex.py +0 -0
  37. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_streaming.py +0 -0
  38. {bioforge-3.4.0 → bioforge-4.0.0}/tests/test_translator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bioforge
3
- Version: 3.4.0
3
+ Version: 4.0.0
4
4
  Summary: High-performance bioinformatics engine for Edge Computing — 5-bit encoding, vectorised NumPy core, optional C backend
5
5
  Author-email: Aarón Aranda Torrijos <noe9601@gmail.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -562,17 +562,26 @@ minimap2-style: minimizer seeding → chaining (C DP) → banded extension.
562
562
  ```python
563
563
  from bioforge import GenomeAligner
564
564
 
565
- mapper = GenomeAligner(reference_sequence, k=15, w=10) # builds the index once
565
+ # Single sequence, or a whole multi-contig genome:
566
+ mapper = GenomeAligner({"chr1": chr1_seq, "chr2": chr2_seq, "plasmid": p_seq})
566
567
 
567
568
  for m in mapper.map(read):
568
569
  print(m.to_paf()) # standard PAF, one line per mapping
569
- # read1 1000 0 1000 + chr1 4600000 733120 734118 980 998 60 ...
570
- print(m.strand, m.target_start, f"{m.identity:.1%}")
570
+ print(m.target_name, m.strand, m.target_start, f"{m.identity:.1%}")
571
+
572
+ # Map many reads in parallel (uses processes):
573
+ results = mapper.map_batch(reads, n_processes=0) # 0 = all cores
571
574
  ```
572
575
 
573
- Handles both strands, tolerates mismatches/indels, and reports a mapping
574
- quality. The chaining dynamic program runs in the C engine (with a NumPy
575
- fallback).
576
+ Handles multi-chromosome references (reports the contig + local coordinates),
577
+ both strands, aligns the full read, tolerates mismatches/indels, and reports a
578
+ mapping quality. The minimizer and chaining kernels run in the C engine (with
579
+ NumPy fallbacks).
580
+
581
+ > **Speed, honestly:** indexing is fast, but read mapping is not yet
582
+ > competitive with hand-tuned C mappers like minimap2 — the mapping pipeline is
583
+ > being moved into the C engine. Use BioForge's mapper where its strengths fit:
584
+ > pure-Python/NumPy core, tiny footprint, `pip install` and go, no C toolchain.
576
585
 
577
586
  ### Full mutation analysis pipeline (DNA + protein)
578
587
 
@@ -242,17 +242,26 @@ minimap2-style: minimizer seeding → chaining (C DP) → banded extension.
242
242
  ```python
243
243
  from bioforge import GenomeAligner
244
244
 
245
- mapper = GenomeAligner(reference_sequence, k=15, w=10) # builds the index once
245
+ # Single sequence, or a whole multi-contig genome:
246
+ mapper = GenomeAligner({"chr1": chr1_seq, "chr2": chr2_seq, "plasmid": p_seq})
246
247
 
247
248
  for m in mapper.map(read):
248
249
  print(m.to_paf()) # standard PAF, one line per mapping
249
- # read1 1000 0 1000 + chr1 4600000 733120 734118 980 998 60 ...
250
- print(m.strand, m.target_start, f"{m.identity:.1%}")
250
+ print(m.target_name, m.strand, m.target_start, f"{m.identity:.1%}")
251
+
252
+ # Map many reads in parallel (uses processes):
253
+ results = mapper.map_batch(reads, n_processes=0) # 0 = all cores
251
254
  ```
252
255
 
253
- Handles both strands, tolerates mismatches/indels, and reports a mapping
254
- quality. The chaining dynamic program runs in the C engine (with a NumPy
255
- fallback).
256
+ Handles multi-chromosome references (reports the contig + local coordinates),
257
+ both strands, aligns the full read, tolerates mismatches/indels, and reports a
258
+ mapping quality. The minimizer and chaining kernels run in the C engine (with
259
+ NumPy fallbacks).
260
+
261
+ > **Speed, honestly:** indexing is fast, but read mapping is not yet
262
+ > competitive with hand-tuned C mappers like minimap2 — the mapping pipeline is
263
+ > being moved into the C engine. Use BioForge's mapper where its strengths fit:
264
+ > pure-Python/NumPy core, tiny footprint, `pip install` and go, no C toolchain.
256
265
 
257
266
  ### Full mutation analysis pipeline (DNA + protein)
258
267
 
@@ -36,7 +36,7 @@ from .biocore import (
36
36
  from .genomemap import GenomeAligner, Mapping
37
37
  from .smart_translator import SmartTranslator
38
38
 
39
- __version__ = "3.4.0"
39
+ __version__ = "4.0.0"
40
40
  __author__ = "Aarón Aranda Torrijos"
41
41
 
42
42
  __all__ = [
@@ -255,12 +255,19 @@ class Mapping(NamedTuple):
255
255
  identity: float
256
256
  chain_score: float
257
257
  cigar: Optional[str]
258
+ target_name: str = "ref" # nombre del contig/cromosoma al que mapea
258
259
 
259
- def to_paf(self, query_name: str = "query", target_name: str = "ref") -> str:
260
- """Serializa a una línea PAF (el formato de minimap2)."""
260
+ def to_paf(self, query_name: str = "query",
261
+ target_name: Optional[str] = None) -> str:
262
+ """Serializa a una línea PAF (el formato de minimap2).
263
+
264
+ Si no se pasa ``target_name``, se usa el del propio mapeo (el contig al
265
+ que mapeó, útil con referencias multi-cromosoma).
266
+ """
267
+ tname = target_name if target_name is not None else self.target_name
261
268
  fields = [
262
269
  query_name, self.query_len, self.query_start, self.query_end,
263
- self.strand, target_name, self.target_len,
270
+ self.strand, tname, self.target_len,
264
271
  self.target_start, self.target_end,
265
272
  self.num_matches, self.block_len, self.mapq,
266
273
  ]
@@ -300,12 +307,31 @@ def _cigar(aln_ref: str, aln_read: str) -> tuple[str, int, int]:
300
307
  return cigar, n_match, int(op.size)
301
308
 
302
309
 
303
- def _extend(ref: str, read: str, ch: Chain) -> Optional[Mapping]:
304
- """Fase 5: alinea (banded) la región de la cadena y arma el Mapping."""
305
- ref_sub = ref[ch.ref_start:ch.ref_end]
306
- read_sub = read[ch.read_start:ch.read_end]
307
- if ch.strand == 1:
308
- read_sub = _revcomp(read_sub)
310
+ def _extend(ref: str, read: str, ch: Chain, k: int,
311
+ cstart: int, cend: int) -> Optional[Mapping]:
312
+ """Fase 5: alinea el READ COMPLETO contra la referencia y arma el Mapping.
313
+
314
+ La ventana de referencia se sitúa por la diagonal de la cadena
315
+ (``d`` = posición ref donde cae el inicio del read) y se recorta al contig
316
+ ``[cstart, cend)``. Así la alineación cubre todo el read (no solo la región
317
+ de la cadena); si el read sobresale por un borde del contig, esa parte queda
318
+ recortada (soft-clip) de forma natural.
319
+ """
320
+ Lr = len(read)
321
+ if ch.strand == 0:
322
+ d = int(np.median(ch.anchor_ref - ch.anchor_read))
323
+ oriented = read
324
+ else:
325
+ # hebra inversa: se alinea revcomp(read) hacia delante; diagonal:
326
+ d = int(np.median(ch.anchor_ref + ch.anchor_read)) - (Lr - k)
327
+ oriented = _revcomp(read)
328
+
329
+ rs = max(cstart, d)
330
+ re = min(cend, d + Lr)
331
+ if re - rs < 1:
332
+ return None
333
+ ref_sub = ref[rs:re]
334
+ read_sub = oriented[rs - d:re - d] # porción del read dentro de la ventana
309
335
  if not ref_sub or not read_sub:
310
336
  return None
311
337
 
@@ -317,15 +343,20 @@ def _extend(ref: str, read: str, ch: Chain) -> Optional[Mapping]:
317
343
  cigar, n_match, block = _cigar(res.aligned_a, res.aligned_b)
318
344
  identity = res.identity
319
345
  except Exception: # noqa: BLE001 — extensión best-effort
320
- cigar, n_match, block, identity = None, ch.n_anchors * ch.k, \
321
- ch.ref_end - ch.ref_start, 1.0
346
+ cigar, n_match, block, identity = None, ch.n_anchors * k, re - rs, 1.0
347
+
348
+ # coords de query en el read ORIGINAL (hacia delante), aun en hebra inversa
349
+ if ch.strand == 0:
350
+ q_start, q_end = rs - d, re - d
351
+ else:
352
+ q_start, q_end = Lr - (re - d), Lr - (rs - d)
322
353
 
323
354
  return Mapping(
324
- query_len=len(read),
325
- query_start=ch.read_start, query_end=ch.read_end,
355
+ query_len=Lr,
356
+ query_start=q_start, query_end=q_end,
326
357
  strand="+" if ch.strand == 0 else "-",
327
358
  target_len=len(ref),
328
- target_start=ch.ref_start, target_end=ch.ref_end,
359
+ target_start=rs, target_end=re,
329
360
  num_matches=n_match, block_len=block,
330
361
  mapq=0, identity=identity, chain_score=ch.score, cigar=cigar,
331
362
  )
@@ -357,19 +388,74 @@ def _mp_worker(args) -> "list[Mapping]":
357
388
 
358
389
 
359
390
  class GenomeAligner:
360
- """Mapeador de reads contra una referencia (seed-chain-align)."""
391
+ """Mapeador de reads contra una referencia (seed-chain-align).
392
+
393
+ La referencia puede ser:
394
+ • una cadena (un solo contig), o
395
+ • un dict ``{nombre: secuencia}`` o iterable de ``(nombre, secuencia)``
396
+ para varios cromosomas/contigs (genomas reales).
397
+
398
+ Multi-contig: los contigs se concatenan con separadores de ``N`` (que los
399
+ minimizers excluyen → ningún k-mer cruza fronteras). Se indexa el conjunto y
400
+ al mapear la posición global se traduce a (contig, posición local).
401
+ """
361
402
 
362
- def __init__(self, reference: str, k: int = 15, w: int = 10,
403
+ def __init__(self, reference, k: int = 15, w: int = 10,
363
404
  max_occ: Optional[int] = 50, name: str = "ref"):
364
- self.reference = reference.upper()
365
- self.name = name
405
+ contigs = self._normalize(reference, name) # [(nombre, seq), ...]
406
+ sep = "N" * k # rompe k-meros de frontera
407
+ parts: list[str] = []
408
+ self._names: list[str] = []
409
+ self._starts_list: list[int] = []
410
+ self._lengths: list[int] = []
411
+ g = 0
412
+ for i, (nm, seq) in enumerate(contigs):
413
+ seq = seq.upper()
414
+ if i > 0:
415
+ parts.append(sep); g += len(sep)
416
+ self._names.append(str(nm))
417
+ self._starts_list.append(g)
418
+ self._lengths.append(len(seq))
419
+ parts.append(seq); g += len(seq)
420
+
421
+ self.reference = "".join(parts) # concatenado (con N)
422
+ self._starts = np.array(self._starts_list, dtype=np.int64)
423
+ self.name = self._names[0] if len(self._names) == 1 else "multi"
366
424
  self.index = ReferenceIndex.from_sequence(self.reference, k=k, w=w,
367
425
  max_occ=max_occ)
368
426
 
427
+ @staticmethod
428
+ def _normalize(reference, name: str) -> "list[tuple[str, str]]":
429
+ if isinstance(reference, str):
430
+ return [(name, reference)]
431
+ if isinstance(reference, dict):
432
+ return list(reference.items())
433
+ return [(n, s) for n, s in reference] # iterable de pares
434
+
369
435
  @property
370
436
  def k(self) -> int:
371
437
  return self.index.k
372
438
 
439
+ @property
440
+ def n_contigs(self) -> int:
441
+ return len(self._names)
442
+
443
+ def _contig_of(self, gpos: int) -> int:
444
+ """Índice del contig que contiene la posición global ``gpos``."""
445
+ i = int(np.searchsorted(self._starts, gpos, side="right")) - 1
446
+ return max(0, i)
447
+
448
+ def _localize(self, mp: Mapping) -> Mapping:
449
+ """Traduce coords globales de un mapeo a (contig, coords locales)."""
450
+ ci = self._contig_of(mp.target_start)
451
+ cstart, clen = self._starts_list[ci], self._lengths[ci]
452
+ return mp._replace(
453
+ target_name=self._names[ci],
454
+ target_len=clen,
455
+ target_start=mp.target_start - cstart,
456
+ target_end=min(mp.target_end - cstart, clen),
457
+ )
458
+
373
459
  def map(self, read: str, min_chain_score: float = 40.0,
374
460
  max_hits: int = 5) -> list[Mapping]:
375
461
  """Mapea un read → lista de Mapping (primaria primero)."""
@@ -378,9 +464,13 @@ class GenomeAligner:
378
464
  chains = chain(anchors, min_score=min_chain_score)[:max_hits]
379
465
  mappings: list[Mapping] = []
380
466
  for i, ch in enumerate(chains):
381
- mp = _extend(self.reference, read, ch)
467
+ # Contig de la cadena → sus límites acotan la ventana de extensión.
468
+ ci = self._contig_of(ch.ref_start)
469
+ cstart = self._starts_list[ci]
470
+ cend = cstart + self._lengths[ci]
471
+ mp = _extend(self.reference, read, ch, self.k, cstart, cend)
382
472
  if mp is not None:
383
- mappings.append(mp._replace(mapq=_mapq(chains, i)))
473
+ mappings.append(self._localize(mp._replace(mapq=_mapq(chains, i))))
384
474
  return mappings
385
475
 
386
476
  def map_batch(self, reads, n_processes: int = 0,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bioforge
3
- Version: 3.4.0
3
+ Version: 4.0.0
4
4
  Summary: High-performance bioinformatics engine for Edge Computing — 5-bit encoding, vectorised NumPy core, optional C backend
5
5
  Author-email: Aarón Aranda Torrijos <noe9601@gmail.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -562,17 +562,26 @@ minimap2-style: minimizer seeding → chaining (C DP) → banded extension.
562
562
  ```python
563
563
  from bioforge import GenomeAligner
564
564
 
565
- mapper = GenomeAligner(reference_sequence, k=15, w=10) # builds the index once
565
+ # Single sequence, or a whole multi-contig genome:
566
+ mapper = GenomeAligner({"chr1": chr1_seq, "chr2": chr2_seq, "plasmid": p_seq})
566
567
 
567
568
  for m in mapper.map(read):
568
569
  print(m.to_paf()) # standard PAF, one line per mapping
569
- # read1 1000 0 1000 + chr1 4600000 733120 734118 980 998 60 ...
570
- print(m.strand, m.target_start, f"{m.identity:.1%}")
570
+ print(m.target_name, m.strand, m.target_start, f"{m.identity:.1%}")
571
+
572
+ # Map many reads in parallel (uses processes):
573
+ results = mapper.map_batch(reads, n_processes=0) # 0 = all cores
571
574
  ```
572
575
 
573
- Handles both strands, tolerates mismatches/indels, and reports a mapping
574
- quality. The chaining dynamic program runs in the C engine (with a NumPy
575
- fallback).
576
+ Handles multi-chromosome references (reports the contig + local coordinates),
577
+ both strands, aligns the full read, tolerates mismatches/indels, and reports a
578
+ mapping quality. The minimizer and chaining kernels run in the C engine (with
579
+ NumPy fallbacks).
580
+
581
+ > **Speed, honestly:** indexing is fast, but read mapping is not yet
582
+ > competitive with hand-tuned C mappers like minimap2 — the mapping pipeline is
583
+ > being moved into the C engine. Use BioForge's mapper where its strengths fit:
584
+ > pure-Python/NumPy core, tiny footprint, `pip install` and go, no C toolchain.
576
585
 
577
586
  ### Full mutation analysis pipeline (DNA + protein)
578
587
 
@@ -146,6 +146,56 @@ def test_map_batch_igual_que_secuencial():
146
146
  assert [m.target_start for m in got] == [m.target_start for m in exp]
147
147
 
148
148
 
149
+ def test_multicontig_mapea_al_contig_correcto():
150
+ rng = np.random.default_rng(30)
151
+ chr1 = "".join("ACGT"[i] for i in rng.integers(0, 4, 40_000))
152
+ chr2 = "".join("ACGT"[i] for i in rng.integers(0, 4, 25_000))
153
+ plas = "".join("ACGT"[i] for i in rng.integers(0, 4, 6_000))
154
+ ga = GenomeAligner({"chr1": chr1, "chr2": chr2, "plasmid": plas}, k=15, w=10)
155
+ assert ga.n_contigs == 3
156
+
157
+ # read de chr2: contig y coords LOCALES correctas
158
+ mp = ga.map(chr2[12_000:12_500])[0]
159
+ assert mp.target_name == "chr2"
160
+ assert mp.target_len == len(chr2)
161
+ assert mp.target_start - mp.query_start == 12_000 # diagonal local
162
+ assert mp.identity > 0.99
163
+
164
+ # read del plasmid en hebra inversa
165
+ mp = ga.map(_revcomp(plas[2_000:2_400]))[0]
166
+ assert mp.target_name == "plasmid"
167
+ assert mp.strand == "-"
168
+ assert mp.target_start < 2_400 and mp.target_end > 0
169
+
170
+ # el PAF lleva el contig correcto sin pasar target_name
171
+ assert ga.map(chr1[5_000:5_500])[0].to_paf().split("\t")[5] == "chr1"
172
+
173
+
174
+ def test_extension_cubre_el_read_entero():
175
+ # La extensión alinea el read COMPLETO (no solo la región de la cadena).
176
+ genoma = _rng_seq(60_000, 33)
177
+ ga = GenomeAligner(genoma, k=15, w=10)
178
+ o, Lr = 25_000, 500
179
+ mp = ga.map(genoma[o : o + Lr])[0]
180
+ assert mp.query_start == 0 # empieza en el inicio del read…
181
+ assert mp.query_end == Lr # …y llega al final
182
+ assert mp.target_start == o # posición exacta (no + offset de minimizer)
183
+ assert mp.identity > 0.99
184
+ # con mutaciones también cubre el read entero
185
+ read = _mutate(genoma[o : o + 600], n_mut=12, seed_=2)
186
+ mp = ga.map(read)[0]
187
+ assert mp.query_start == 0 and mp.query_end == 600
188
+
189
+
190
+ def test_multicontig_lista_de_pares():
191
+ # también acepta un iterable de (nombre, secuencia)
192
+ a = _rng_seq(8000, 31)
193
+ b = _rng_seq(8000, 32)
194
+ ga = GenomeAligner([("A", a), ("B", b)], k=15, w=10)
195
+ assert ga.n_contigs == 2
196
+ assert ga.map(b[3000:3400])[0].target_name == "B"
197
+
198
+
149
199
  def test_formato_paf():
150
200
  genoma = _rng_seq(30_000, 16)
151
201
  ga = GenomeAligner(genoma, k=15, w=10)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes