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,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())