Skip to content

Commit 9726514

Browse files
committed
Refactor sequence normalization and statistics functions: streamline code for improved readability and performance, and replace custom implementations with core functions.
1 parent 8e2cbc9 commit 9726514

7 files changed

Lines changed: 60 additions & 78 deletions

File tree

src/jsrc/analyze/core.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22
from Bio.Seq import Seq
33
from Bio.SeqRecord import SeqRecord
44

5+
_NORM_TABLE = str.maketrans("U", "T", "".join(
6+
c for c in map(chr, range(256)) if c not in "ACGTN"
7+
))
8+
59

610
def normalize_sequence(seq: str) -> str:
7-
cleaned = []
8-
for ch in seq.upper():
9-
if ch == "U":
10-
ch = "T"
11-
if ch in {"A", "C", "G", "T", "N"}:
12-
cleaned.append(ch)
13-
return "".join(cleaned)
11+
return seq.upper().translate(_NORM_TABLE)
1412

1513

1614
def pad_alignment(records: list[SeqRecord]) -> MultipleSeqAlignment:

src/jsrc/analyze/qc.py

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,11 @@
1-
import gzip
21
import json
32
import re
43
from argparse import Namespace
54
from typing import Any
65

76
from Bio import SeqIO
87

9-
10-
def _open_text(path: str):
11-
if path.endswith(".gz"):
12-
return gzip.open(path, "rt", encoding="utf-8")
13-
return open(path, encoding="utf-8")
14-
15-
16-
def _nxx(lengths: list[int], pct: float) -> int:
17-
if not lengths:
18-
return 0
19-
target = sum(lengths) * pct
20-
acc = 0
21-
for v in sorted(lengths, reverse=True):
22-
acc += v
23-
if acc >= target:
24-
return v
25-
return 0
8+
from jsrc.core import nxx, open_text
269

2710

2811
def _assembly_stats(fasta_path: str) -> dict[str, float | int]:
@@ -42,8 +25,8 @@ def _assembly_stats(fasta_path: str) -> dict[str, float | int]:
4225
"total_bases": total_len,
4326
"max_contig": max(lengths) if lengths else 0,
4427
"min_contig": min(lengths) if lengths else 0,
45-
"n50": _nxx(lengths, 0.5),
46-
"n90": _nxx(lengths, 0.9),
28+
"n50": nxx(lengths, 0.5),
29+
"n90": nxx(lengths, 0.9),
4730
"gc_percent": (gc / acgt * 100.0) if acgt else 0.0,
4831
"n_percent": (n_bases / total_len * 100.0) if total_len else 0.0,
4932
}
@@ -65,7 +48,7 @@ def _sam_stats(sam_path: str) -> dict[str, float | int]:
6548
mapped = 0
6649
covered_ref_bases = 0
6750
ref_len = 0
68-
with _open_text(sam_path) as f:
51+
with open_text(sam_path) as f:
6952
for line in f:
7053
if line.startswith("@SQ"):
7154
fields = line.rstrip("\n").split("\t")
@@ -100,7 +83,7 @@ def _fastq_stats(paths: list[str], genome_size: int | None) -> dict[str, float |
10083
reads = 0
10184
bases = 0
10285
for path in paths:
103-
with _open_text(path) as f:
86+
with open_text(path) as f:
10487
for i, line in enumerate(f, start=1):
10588
if i % 4 == 2:
10689
reads += 1
@@ -120,7 +103,7 @@ def _vcf_stats(vcf_path: str) -> dict[str, int]:
120103
snp = 0
121104
indel = 0
122105
other = 0
123-
with _open_text(vcf_path) as f:
106+
with open_text(vcf_path) as f:
124107
for line in f:
125108
if line.startswith("#"):
126109
continue

src/jsrc/core.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1+
import gzip
12
import sys
23
import time
34
from collections.abc import Generator, Iterable
4-
from typing import Any
5+
from typing import IO, Any
6+
7+
8+
def open_text(path: str) -> IO[str]:
9+
if path.endswith(".gz"):
10+
return gzip.open(path, "rt", encoding="utf-8")
11+
return open(path, encoding="utf-8")
12+
13+
14+
def nxx(lengths: list[int], pct: float) -> int:
15+
if not lengths:
16+
return 0
17+
target = sum(lengths) * pct
18+
acc = 0
19+
for v in sorted(lengths, reverse=True):
20+
acc += v
21+
if acc >= target:
22+
return v
23+
return 0
524

625

726
def _fmt_duration(seconds: float) -> str:

src/jsrc/seq/extract.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,10 @@ def cmd(args: Namespace) -> None:
8080
if chrom not in genome:
8181
continue
8282
chrom_seq = genome[chrom].seq
83-
seq = Seq("")
83+
seq_parts = []
8484
for start, end in regions:
85-
seq += chrom_seq[start:end]
85+
seq_parts.append(str(chrom_seq[start:end]))
86+
seq = Seq("".join(seq_parts))
8687
if strand == "-":
8788
seq = seq.reverse_complement()
8889
desc = f"feature={args.feature};match={args.match};locus={chrom};strand={strand}"

src/jsrc/seq/kmer.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
from jsrc.core import progressbar
1010

1111

12+
_VALID_BASES = frozenset("ACGT")
13+
14+
1215
def _kmer_counter(path: str, k: int) -> Counter:
1316
c: Counter[str] = Counter()
1417
records = list(SeqIO.parse(path, "fasta"))
@@ -17,7 +20,7 @@ def _kmer_counter(path: str, k: int) -> Counter:
1720
seq = str(rec.seq).upper().replace("U", "T")
1821
for i in range(0, len(seq) - k + 1):
1922
kmer = seq[i : i + k]
20-
if set(kmer) <= {"A", "C", "G", "T"}:
23+
if all(b in _VALID_BASES for b in kmer):
2124
c[kmer] += 1
2225
bar.update()
2326
bar.finish()

src/jsrc/seq/qc.py

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,15 @@
1-
import gzip
21
import json
32
import logging
43
from argparse import Namespace
5-
from typing import IO, Any
4+
from typing import Any
65

76
from Bio import SeqIO
87

9-
from jsrc.core import progressbar
8+
from jsrc.core import open_text, nxx, progressbar
109

1110
logger = logging.getLogger(__name__)
1211

1312

14-
def _open_text(path: str) -> IO[str]:
15-
if path.endswith(".gz"):
16-
return gzip.open(path, "rt", encoding="utf-8")
17-
return open(path, encoding="utf-8")
18-
19-
20-
def _nxx(lengths: list[int], pct: float) -> int:
21-
if not lengths:
22-
return 0
23-
target = sum(lengths) * pct
24-
acc = 0
25-
for x in sorted(lengths, reverse=True):
26-
acc += x
27-
if acc >= target:
28-
return x
29-
return 0
30-
31-
3213
def _fasta_stats(path: str) -> dict[str, float | int]:
3314
lengths = []
3415
gc = 0
@@ -46,8 +27,8 @@ def _fasta_stats(path: str) -> dict[str, float | int]:
4627
"total_bases": total,
4728
"max_len": max(lengths) if lengths else 0,
4829
"min_len": min(lengths) if lengths else 0,
49-
"n50": _nxx(lengths, 0.5),
50-
"n90": _nxx(lengths, 0.9),
30+
"n50": nxx(lengths, 0.5),
31+
"n90": nxx(lengths, 0.9),
5132
"gc_percent": (gc / acgt * 100.0) if acgt else 0.0,
5233
"n_percent": (n_bases / total * 100.0) if total else 0.0,
5334
}
@@ -58,7 +39,7 @@ def _fastq_stats(paths: list[str], genome_size: int | None) -> dict[str, float |
5839
bases = 0
5940
bar = progressbar(total=0, desc="FASTQ reads")
6041
for path in paths:
61-
with _open_text(path) as f:
42+
with open_text(path) as f:
6243
for line_no, line in enumerate(f, start=1):
6344
if line_no % 4 == 2:
6445
reads += 1

src/jsrc/vision/efd.py

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,27 +36,23 @@ def calculate(
3636
return np.zeros((order, 4), dtype=float)
3737

3838
phi = (2.0 * np.pi * t) / total_len
39-
coeffs = np.zeros((order, 4), dtype=float)
40-
dphi_cos = {}
41-
dphi_sin = {}
42-
43-
for n in range(1, order + 1):
44-
phi_n = phi * n
45-
dphi_cos[n] = np.cos(phi_n[1:]) - np.cos(phi_n[:-1])
46-
dphi_sin[n] = np.sin(phi_n[1:]) - np.sin(phi_n[:-1])
39+
ns = np.arange(1, order + 1)[:, None]
40+
phi_n = ns * phi[None, :]
41+
dphi_cos = np.cos(phi_n[:, 1:]) - np.cos(phi_n[:, :-1])
42+
dphi_sin = np.sin(phi_n[:, 1:]) - np.sin(phi_n[:, :-1])
4743

4844
dx_over_dt = np.zeros_like(dx, dtype=float)
4945
dy_over_dt = np.zeros_like(dy, dtype=float)
5046
dx_over_dt[valid] = dx[valid] / dt[valid]
5147
dy_over_dt[valid] = dy[valid] / dt[valid]
5248

53-
for n in range(1, order + 1):
54-
const = total_len / (2.0 * (n * np.pi) ** 2)
55-
an = const * np.sum(dx_over_dt * dphi_cos[n])
56-
bn = const * np.sum(dx_over_dt * dphi_sin[n])
57-
cn = const * np.sum(dy_over_dt * dphi_cos[n])
58-
dn = const * np.sum(dy_over_dt * dphi_sin[n])
59-
coeffs[n - 1] = [an, bn, cn, dn]
49+
consts = total_len / (2.0 * (ns[:, 0] * np.pi) ** 2)
50+
coeffs = np.column_stack([
51+
consts * (dphi_cos @ dx_over_dt),
52+
consts * (dphi_sin @ dx_over_dt),
53+
consts * (dphi_cos @ dy_over_dt),
54+
consts * (dphi_sin @ dy_over_dt),
55+
])
6056

6157
if normalize:
6258
coeffs = EllipticFourier.normalize(coeffs)
@@ -108,11 +104,12 @@ def normalize(coeffs: np.ndarray) -> np.ndarray:
108104
@staticmethod
109105
def reconstruct(coeffs: np.ndarray, num_points: int = 300) -> np.ndarray:
110106
t = np.linspace(0.0, 1.0, num_points)
111-
xt = np.zeros(num_points)
112-
yt = np.zeros(num_points)
113-
for n, (an, bn, cn, dn) in enumerate(coeffs, start=1):
114-
xt += an * np.cos(2.0 * np.pi * n * t) + bn * np.sin(2.0 * np.pi * n * t)
115-
yt += cn * np.cos(2.0 * np.pi * n * t) + dn * np.sin(2.0 * np.pi * n * t)
107+
ns = np.arange(1, len(coeffs) + 1)[:, None]
108+
angles = 2.0 * np.pi * ns * t[None, :]
109+
ab = coeffs[:, :2]
110+
cd = coeffs[:, 2:]
111+
xt = (ab[:, 0:1] * np.cos(angles) + ab[:, 1:2] * np.sin(angles)).sum(axis=0)
112+
yt = (cd[:, 0:1] * np.cos(angles) + cd[:, 1:2] * np.sin(angles)).sum(axis=0)
116113
return np.stack([xt, yt], axis=1)
117114

118115

0 commit comments

Comments
 (0)