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
|
Binary file
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qcreport.py
|
|
3
|
+
══════════════════════════════════════════════════════════════════════
|
|
4
|
+
Fast FASTQ quality report — la versión rápida y ligera de FastQC.
|
|
5
|
+
|
|
6
|
+
Una sola pasada sobre el archivo usando la API columnar de BioForge
|
|
7
|
+
(``SmartImporter.stream_fastq_batches``): sin crear un objeto por lectura,
|
|
8
|
+
RAM constante, y el grueso del trabajo en operaciones NumPy sobre el lote.
|
|
9
|
+
|
|
10
|
+
Métricas
|
|
11
|
+
────────
|
|
12
|
+
• Resumen: nº lecturas, bases, longitud (min/media/max), GC global,
|
|
13
|
+
calidad media global, % de lecturas con Q media ≥ 20 y ≥ 30.
|
|
14
|
+
• Histograma de calidad media por lectura.
|
|
15
|
+
• Histograma de %GC por lectura.
|
|
16
|
+
• Calidad media por posición (el gráfico estrella de FastQC).
|
|
17
|
+
• Composición por base (A/C/G/T/N) por posición.
|
|
18
|
+
|
|
19
|
+
Uso
|
|
20
|
+
───
|
|
21
|
+
python -m bioforge.qcreport reads.fastq.gz
|
|
22
|
+
python -m bioforge.qcreport reads.fastq --output informe.txt
|
|
23
|
+
bioforge-qc reads.fastq.gz (si está instalado el paquete)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from .biocore import SequenceValueError, SmartImporter
|
|
38
|
+
|
|
39
|
+
# Posiciones máximas que se siguen en los gráficos por-posición. Las lecturas
|
|
40
|
+
# más largas (p.ej. Nanopore) solo contribuyen sus primeras _MAXPOS bases a
|
|
41
|
+
# esos gráficos; las métricas escalares usan la lectura completa.
|
|
42
|
+
_MAXPOS = 1000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
46
|
+
# §1 RESULTADO
|
|
47
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class QCReport:
|
|
51
|
+
path: str
|
|
52
|
+
n_reads: int
|
|
53
|
+
total_bases: int
|
|
54
|
+
min_len: int
|
|
55
|
+
max_len: int
|
|
56
|
+
gc_overall: float # fracción 0..1
|
|
57
|
+
mean_q_overall: float
|
|
58
|
+
pct_q20: float # % de lecturas con calidad media ≥ 20
|
|
59
|
+
pct_q30: float
|
|
60
|
+
meanq_hist: np.ndarray # bincount de int(calidad media por lectura)
|
|
61
|
+
gc_hist: np.ndarray # bincount de int(%GC por lectura), 0..100
|
|
62
|
+
pos_q_mean: np.ndarray # calidad media por posición (len = pos seguidas)
|
|
63
|
+
base_frac: np.ndarray # (5, pos): fracción A/C/G/T/N por posición
|
|
64
|
+
elapsed_s: float
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def mean_len(self) -> float:
|
|
68
|
+
return self.total_bases / self.n_reads if self.n_reads else 0.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
72
|
+
# §2 CÁLCULO (una sola pasada, columnar)
|
|
73
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
74
|
+
|
|
75
|
+
def run(path: str) -> QCReport:
|
|
76
|
+
"""Calcula el informe de calidad de un FASTQ (.gz o plano)."""
|
|
77
|
+
t0 = time.perf_counter()
|
|
78
|
+
|
|
79
|
+
n_reads = 0
|
|
80
|
+
total_bases = 0
|
|
81
|
+
min_len = None
|
|
82
|
+
max_len = 0
|
|
83
|
+
q_sum_total = 0.0 # suma de todas las calidades → media global
|
|
84
|
+
gc_bases = 0.0 # total de bases G+C → GC global
|
|
85
|
+
pass20 = pass30 = 0
|
|
86
|
+
|
|
87
|
+
meanq_hist = np.zeros(64, dtype=np.int64) # Phred 0..63
|
|
88
|
+
gc_hist = np.zeros(101, dtype=np.int64) # %GC 0..100
|
|
89
|
+
|
|
90
|
+
pos_q_sum = np.zeros(_MAXPOS, dtype=np.float64)
|
|
91
|
+
pos_count = np.zeros(_MAXPOS, dtype=np.int64)
|
|
92
|
+
base_counts = np.zeros((5, _MAXPOS), dtype=np.int64) # A,C,G,T,N
|
|
93
|
+
|
|
94
|
+
for batch in SmartImporter.stream_fastq_batches(path):
|
|
95
|
+
m = len(batch)
|
|
96
|
+
if m == 0:
|
|
97
|
+
continue
|
|
98
|
+
nsy = np.asarray(batch.n_symbols)
|
|
99
|
+
n_reads += m
|
|
100
|
+
total_bases += int(nsy.sum())
|
|
101
|
+
bmin = int(nsy.min()); bmax = int(nsy.max())
|
|
102
|
+
min_len = bmin if min_len is None else min(min_len, bmin)
|
|
103
|
+
max_len = max(max_len, bmax)
|
|
104
|
+
|
|
105
|
+
# ── Por lectura (vectorizado) ──────────────────────────────────────
|
|
106
|
+
mq = batch.mean_quality() # float por lectura
|
|
107
|
+
gc = batch.gc_content() # fracción por lectura
|
|
108
|
+
q_sum_total += float((mq * nsy).sum())
|
|
109
|
+
gc_bases += float((gc * nsy).sum())
|
|
110
|
+
pass20 += int((mq >= 20).sum())
|
|
111
|
+
pass30 += int((mq >= 30).sum())
|
|
112
|
+
meanq_hist += np.bincount(
|
|
113
|
+
np.clip(mq, 0, 63).astype(np.int64), minlength=64)[:64]
|
|
114
|
+
gc_hist += np.bincount(
|
|
115
|
+
np.clip(gc * 100.0, 0, 100).astype(np.int64), minlength=101)[:101]
|
|
116
|
+
|
|
117
|
+
# ── Por posición ───────────────────────────────────────────────────
|
|
118
|
+
qm = batch.quality_matrix() # (m,L) o None
|
|
119
|
+
c2d = batch.decoded_2d() # (m,L) o None
|
|
120
|
+
if qm is not None and c2d is not None:
|
|
121
|
+
cols = min(qm.shape[1], _MAXPOS)
|
|
122
|
+
pos_q_sum[:cols] += qm[:, :cols].sum(axis=0)
|
|
123
|
+
pos_count[:cols] += m
|
|
124
|
+
sub = c2d[:, :cols]
|
|
125
|
+
for b in range(4):
|
|
126
|
+
base_counts[b, :cols] += (sub == b).sum(axis=0)
|
|
127
|
+
base_counts[4, :cols] += (sub > 3).sum(axis=0)
|
|
128
|
+
else:
|
|
129
|
+
# Longitud irregular: acumular por lectura (caso menos común).
|
|
130
|
+
for i in range(m):
|
|
131
|
+
qi = batch.quality_of(i)
|
|
132
|
+
ci = batch[i].sequence.decode()
|
|
133
|
+
c = min(qi.shape[0], _MAXPOS)
|
|
134
|
+
pos_q_sum[:c] += qi[:c]
|
|
135
|
+
pos_count[:c] += 1
|
|
136
|
+
sub = ci[:c]
|
|
137
|
+
for b in range(4):
|
|
138
|
+
base_counts[b, :c] += (sub == b)
|
|
139
|
+
base_counts[4, :c] += (sub > 3)
|
|
140
|
+
|
|
141
|
+
elapsed = time.perf_counter() - t0
|
|
142
|
+
if n_reads == 0:
|
|
143
|
+
raise SequenceValueError(
|
|
144
|
+
f"No se encontraron lecturas en {path!r}. "
|
|
145
|
+
"¿Es un archivo FASTQ (.fastq / .fastq.gz)?"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
eff = int(np.count_nonzero(pos_count)) # nº de posiciones con datos
|
|
149
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
150
|
+
pos_q_mean = np.where(
|
|
151
|
+
pos_count[:eff] > 0, pos_q_sum[:eff] / pos_count[:eff], 0.0)
|
|
152
|
+
col_tot = base_counts[:, :eff].sum(axis=0)
|
|
153
|
+
base_frac = np.where(col_tot > 0, base_counts[:, :eff] / col_tot, 0.0)
|
|
154
|
+
|
|
155
|
+
return QCReport(
|
|
156
|
+
path=path, n_reads=n_reads, total_bases=total_bases,
|
|
157
|
+
min_len=int(min_len), max_len=int(max_len),
|
|
158
|
+
gc_overall=(gc_bases / total_bases if total_bases else 0.0),
|
|
159
|
+
mean_q_overall=(q_sum_total / total_bases if total_bases else 0.0),
|
|
160
|
+
pct_q20=100.0 * pass20 / n_reads,
|
|
161
|
+
pct_q30=100.0 * pass30 / n_reads,
|
|
162
|
+
meanq_hist=meanq_hist, gc_hist=gc_hist,
|
|
163
|
+
pos_q_mean=pos_q_mean, base_frac=base_frac,
|
|
164
|
+
elapsed_s=elapsed,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
169
|
+
# §3 INFORME DE TEXTO
|
|
170
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
171
|
+
|
|
172
|
+
_SPARK = " .:-=+*#%@" # 10 niveles de densidad ASCII
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _sparkline(values: np.ndarray, vmin: float, vmax: float) -> str:
|
|
176
|
+
"""Convierte un array en una línea de caracteres de densidad."""
|
|
177
|
+
if values.size == 0:
|
|
178
|
+
return ""
|
|
179
|
+
span = vmax - vmin
|
|
180
|
+
if span <= 0:
|
|
181
|
+
return _SPARK[-1] * values.size
|
|
182
|
+
idx = np.clip(((values - vmin) / span) * (len(_SPARK) - 1), 0,
|
|
183
|
+
len(_SPARK) - 1).astype(int)
|
|
184
|
+
return "".join(_SPARK[i] for i in idx)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _hist_bars(hist: np.ndarray, lo: int, hi: int, step: int,
|
|
188
|
+
unit: str, width: int = 40) -> list[str]:
|
|
189
|
+
"""Histograma agrupado en cubos [lo, hi] de tamaño step, con barras."""
|
|
190
|
+
edges = list(range(lo, hi + 1, step))
|
|
191
|
+
buckets = []
|
|
192
|
+
for e in edges:
|
|
193
|
+
buckets.append(int(hist[e: e + step].sum()))
|
|
194
|
+
peak = max(buckets) if buckets else 0
|
|
195
|
+
out = []
|
|
196
|
+
for e, c in zip(edges, buckets, strict=True):
|
|
197
|
+
bar = "█" * int(round(width * c / peak)) if peak else ""
|
|
198
|
+
out.append(f" {e:>3}-{min(e+step-1, hi):<3} {unit} | {bar} {c:,}")
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_report(r: QCReport, width: int = 60) -> str:
|
|
203
|
+
W = 64
|
|
204
|
+
dbl = "═" * W
|
|
205
|
+
sep = "─" * W
|
|
206
|
+
L: list[str] = []
|
|
207
|
+
|
|
208
|
+
def add(*t: str) -> None:
|
|
209
|
+
L.extend(t)
|
|
210
|
+
|
|
211
|
+
add(dbl, " INFORME DE CALIDAD FASTQ (BioForge)", dbl, "")
|
|
212
|
+
add(f" Archivo : {Path(r.path).name}")
|
|
213
|
+
add(f" Lecturas : {r.n_reads:,}")
|
|
214
|
+
add(f" Bases totales : {r.total_bases:,}")
|
|
215
|
+
if r.min_len == r.max_len:
|
|
216
|
+
add(f" Longitud : {r.min_len} bp (fija)")
|
|
217
|
+
else:
|
|
218
|
+
add(f" Longitud : {r.min_len}–{r.max_len} bp "
|
|
219
|
+
f"(media {r.mean_len:.0f})")
|
|
220
|
+
add(f" GC global : {r.gc_overall * 100:.1f}%")
|
|
221
|
+
add(f" Calidad media : Q{r.mean_q_overall:.1f}")
|
|
222
|
+
add(f" Lecturas Q≥20 : {r.pct_q20:.1f}%")
|
|
223
|
+
add(f" Lecturas Q≥30 : {r.pct_q30:.1f}%")
|
|
224
|
+
add(f" Procesado en : {r.elapsed_s * 1000:.0f} ms "
|
|
225
|
+
f"({r.total_bases / r.elapsed_s / 1e6:.0f} M bases/s)", "")
|
|
226
|
+
|
|
227
|
+
add(sep, " CALIDAD MEDIA POR LECTURA", sep)
|
|
228
|
+
add(*_hist_bars(r.meanq_hist, 0, 45, 5, "Q"), "")
|
|
229
|
+
|
|
230
|
+
add(sep, " CONTENIDO GC POR LECTURA", sep)
|
|
231
|
+
add(*_hist_bars(r.gc_hist, 0, 100, 10, "%"), "")
|
|
232
|
+
|
|
233
|
+
add(sep, " CALIDAD MEDIA POR POSICIÓN", sep)
|
|
234
|
+
pos = r.pos_q_mean
|
|
235
|
+
if pos.size:
|
|
236
|
+
# Submuestrear a 'width' columnas para la sparkline.
|
|
237
|
+
if pos.size > width:
|
|
238
|
+
idx = np.linspace(0, pos.size - 1, width).astype(int)
|
|
239
|
+
line = pos[idx]
|
|
240
|
+
else:
|
|
241
|
+
line = pos
|
|
242
|
+
add(f" pos 1{' ' * (max(0, len(_sparkline(line, 0, 42)) - 6))}{pos.size}")
|
|
243
|
+
add(f" Q42 |{_sparkline(line, 0, 42)}|")
|
|
244
|
+
add(f" min Q{pos.min():.0f} · media Q{pos.mean():.0f} · "
|
|
245
|
+
f"max Q{pos.max():.0f}")
|
|
246
|
+
zona = "buena (Q≥28 en toda la lectura)" if pos.min() >= 28 else \
|
|
247
|
+
"cae al final (típico)" if pos[-1] < pos[0] else "irregular"
|
|
248
|
+
add(f" Diagnóstico: {zona}", "")
|
|
249
|
+
|
|
250
|
+
add(sep, " COMPOSICIÓN POR BASE (global)", sep)
|
|
251
|
+
names = "ACGTN"
|
|
252
|
+
tot = r.base_frac.sum(axis=1)
|
|
253
|
+
tot = tot / tot.sum() if tot.sum() else tot
|
|
254
|
+
for i, name in enumerate(names):
|
|
255
|
+
bar = "█" * int(round(30 * tot[i]))
|
|
256
|
+
add(f" {name} | {bar} {tot[i] * 100:.1f}%")
|
|
257
|
+
add("", dbl)
|
|
258
|
+
return "\n".join(L)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
262
|
+
# §4 CLI
|
|
263
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
264
|
+
|
|
265
|
+
def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
|
266
|
+
p = argparse.ArgumentParser(
|
|
267
|
+
prog="bioforge-qc",
|
|
268
|
+
description="Informe rápido de calidad de un archivo FASTQ (.gz o plano).",
|
|
269
|
+
)
|
|
270
|
+
p.add_argument("fastq", help="Archivo FASTQ (.fastq o .fastq.gz)")
|
|
271
|
+
p.add_argument("--output", "-o", metavar="FILE",
|
|
272
|
+
help="Guardar el informe en un archivo (si no, a pantalla).")
|
|
273
|
+
return p.parse_args(argv)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
277
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
278
|
+
args = _parse_args(argv)
|
|
279
|
+
try:
|
|
280
|
+
report = run(args.fastq)
|
|
281
|
+
except FileNotFoundError:
|
|
282
|
+
print(f"Archivo no encontrado: {args.fastq}", file=sys.stderr)
|
|
283
|
+
return 1
|
|
284
|
+
except (ValueError, OSError) as exc:
|
|
285
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
286
|
+
return 1
|
|
287
|
+
|
|
288
|
+
text = build_report(report)
|
|
289
|
+
if args.output:
|
|
290
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
291
|
+
print(f"Informe guardado en: {args.output}")
|
|
292
|
+
else:
|
|
293
|
+
print(text)
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
sys.exit(main())
|