File size: 13,799 Bytes
914512c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
"""bioai.sequence_utils -- shared sequence helpers (one-hot encode, FASTA IO,
k-mer off-target index, dsRNA tiling).

These are refactored copies of the helpers that previously lived in the
broken `src/` scaffold so that the new `bioai/` package is self-contained.
"""

from __future__ import annotations

from collections import defaultdict
from pathlib import Path
from typing import Dict, Iterator, List, Set, Tuple

import numpy as np

# Canonical safety panel — 12 species covering all ecological roles regulators
# care about: pollinators, beneficial insects, soil invertebrates, aquatic
# invertebrates, livestock (mammals + poultry + fish), and human safety.
# `homo_sapiens` is intentionally last so demo runs without human data still
# produce a column for it (always 0.0).
SAFETY_SPECIES: List[str] = [
    # Pollinators
    "apis_mellifera",        # Western honeybee
    "bombus_terrestris",     # Buff-tailed bumblebee
    "megachile_rotundata",   # Alfalfa leafcutter bee
    # Beneficial predators (biocontrol agents)
    "adalia_bipunctata",     # Two-spot ladybird beetle
    "chrysoperla_carnea",    # Green lacewing
    # Soil invertebrates
    "eisenia_fetida",        # Red wiggler compost worm
    # Aquatic invertebrates
    "daphnia_magna",         # Water flea (standard ecotox model)
    # Livestock & poultry
    "bos_taurus",            # Cattle
    "bos_indicus",           # Zebu
    "gallus_gallus",         # Chicken
    "ovis_aries",            # Sheep
    "sus_scrofa",            # Pig
    # Aquatic vertebrate
    "danio_rerio",           # Zebrafish
    # Human (applicator + consumer safety)
    "homo_sapiens",
]

# Pest species the demo can target. The orchestrator's keyword parser maps
# user input to one of these. Synthetic transcripts are generated for each.
PEST_SPECIES: List[str] = [
    "nilaparvata_lugens",     # Brown planthopper (rice)
    "spodoptera_frugiperda",  # Fall armyworm (maize)
    "schistocerca_gregaria",  # Desert locust (wheat, cereals)
    "chilo_suppressalis",     # Striped stem borer (rice)
    "myzus_persicae",         # Peach-potato aphid (vegetables)
    "leptinotarsa_decemlineata",  # Colorado potato beetle (potato)
    "bemisia_tabaci",         # Tobacco whitefly (tomato, cotton)
]

TARGET_SPECIES: str = "nilaparvata_lugens"

# Common-name -> scientific-name mapping for pest species normalization.
# The LLM (and users) may return "Brown Planthopper" or "brown planthopper"
# but the FASTA headers use "NILAPARVATA_LUGENS_FAKE_001". This mapping
# normalizes any of the accepted aliases to the canonical scientific name
# used in the FASTA headers and the PEST_SPECIES list.
PEST_SPECIES_ALIASES: Dict[str, str] = {
    # Brown planthopper
    "brown planthopper": "nilaparvata_lugens",
    "brown plant hopper": "nilaparvata_lugens",
    "nilaparvata lugens": "nilaparvata_lugens",
    "nilaparvata_lugens": "nilaparvata_lugens",
    "bph": "nilaparvata_lugens",
    # Fall armyworm
    "fall armyworm": "spodoptera_frugiperda",
    "fall army worm": "spodoptera_frugiperda",
    "spodoptera frugiperda": "spodoptera_frugiperda",
    "spodoptera_frugiperda": "spodoptera_frugiperda",
    "armyworm": "spodoptera_frugiperda",
    # Desert locust
    "desert locust": "schistocerca_gregaria",
    "locust": "schistocerca_gregaria",
    "schistocerca gregaria": "schistocerca_gregaria",
    "schistocerca_gregaria": "schistocerca_gregaria",
    # Striped stem borer
    "striped stem borer": "chilo_suppressalis",
    "stem borer": "chilo_suppressalis",
    "chilo suppressalis": "chilo_suppressalis",
    "chilo_suppressalis": "chilo_suppressalis",
    # Peach-potato aphid
    "peach-potato aphid": "myzus_persicae",
    "peach potato aphid": "myzus_persicae",
    "aphid": "myzus_persicae",
    "myzus persicae": "myzus_persicae",
    "myzus_persicae": "myzus_persicae",
    # Colorado potato beetle
    "colorado potato beetle": "leptinotarsa_decemlineata",
    "potato beetle": "leptinotarsa_decemlineata",
    "leptinotarsa decemlineata": "leptinotarsa_decemlineata",
    "leptinotarsa_decemlineata": "leptinotarsa_decemlineata",
    # Tobacco whitefly
    "tobacco whitefly": "bemisia_tabaci",
    "whitefly": "bemisia_tabaci",
    "white fly": "bemisia_tabaci",
    "bemisia tabaci": "bemisia_tabaci",
    "bemisia_tabaci": "bemisia_tabaci",
}


def normalize_pest_species(name: str) -> str:
    """Normalize a pest species name to the canonical scientific name.

    Handles common names ("Brown Planthopper"), scientific names with spaces
    ("Nilaparvata lugens"), and canonical names ("nilaparvata_lugens").
    Returns the input lowercased if no alias matches (defensive).
    """
    if not name:
        return "nilaparvata_lugens"  # default
    key = name.strip().lower()
    if key in PEST_SPECIES_ALIASES:
        return PEST_SPECIES_ALIASES[key]
    # Try removing underscores and spaces
    key_nospace = key.replace("_", " ").replace("-", " ")
    if key_nospace in PEST_SPECIES_ALIASES:
        return PEST_SPECIES_ALIASES[key_nospace]
    # If it's already a canonical name (with underscore), return as-is
    if key in PEST_SPECIES:
        return key
    # Default: return the original lowercased (will likely fail FASTA match,
    # but the caller falls back to using all transcripts)
    return key

# A/C/G/T (U -> T) one-hot mapping used by SiRNACNN.
NUCLEOTIDE_INDEX: Dict[str, int] = {"A": 0, "C": 1, "G": 2, "T": 3, "U": 3}


# --------------------------------------------------------------------------- #
# FASTA I/O
# --------------------------------------------------------------------------- #
def read_fasta(path: str | Path) -> Dict[str, str]:
    """Return ``{sequence_id: sequence}`` from a FASTA file (full load)."""
    path = Path(path)
    seqs: Dict[str, str] = {}
    current_id: str | None = None
    chunks: List[str] = []
    with path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            if line.startswith(">"):
                if current_id is not None:
                    seqs[current_id] = "".join(chunks)
                current_id = line[1:].split()[0]
                chunks = []
            else:
                chunks.append(line.upper())
        if current_id is not None:
            seqs[current_id] = "".join(chunks)
    return seqs


def fasta_iter(path: str | Path) -> Iterator[Tuple[str, str]]:
    """Generator over ``(id, sequence)`` for large FASTA files."""
    path = Path(path)
    with path.open("r", encoding="utf-8") as f:
        current_id: str | None = None
        chunks: List[str] = []
        for line in f:
            line = line.strip()
            if not line:
                continue
            if line.startswith(">"):
                if current_id is not None:
                    yield current_id, "".join(chunks)
                current_id = line[1:].split()[0]
                chunks = []
            else:
                chunks.append(line.upper())
        if current_id is not None:
            yield current_id, "".join(chunks)


# --------------------------------------------------------------------------- #
# One-hot encoding
# --------------------------------------------------------------------------- #
def one_hot_encode(seq: str, max_len: int = 21) -> np.ndarray:
    """Channel-first one-hot encoding with shape ``(4, max_len)``.

    Unknown characters get a uniform ``0.25`` distribution over the 4 bases
    so the model still sees a valid probability row instead of all-zeros.
    """
    seq = seq.upper().replace("U", "T")
    encoded = np.zeros((4, max_len), dtype=np.float32)
    for i, ch in enumerate(seq[:max_len]):
        if ch in NUCLEOTIDE_INDEX:
            encoded[NUCLEOTIDE_INDEX[ch], i] = 1.0
        else:
            encoded[:, i] = 0.25
    # pad remaining columns with the uniform distribution too
    for i in range(len(seq), max_len):
        encoded[:, i] = 0.25
    return encoded


def encode_batch(sequences: List[str], max_len: int = 21) -> np.ndarray:
    """Batch of sequences -> numpy array ``(batch, 4, max_len)``."""
    return np.stack([one_hot_encode(s, max_len) for s in sequences], axis=0)


def tokens_to_onehot(tokens: np.ndarray, seq_len: int) -> np.ndarray:
    """Convert ``(batch, seq_len)`` integer tokens in ``{0,1,2,3}`` to
    ``(batch, 4, seq_len)`` one-hot float32.
    """
    batch = tokens.shape[0]
    onehot = np.zeros((batch, 4, seq_len), dtype=np.float32)
    for b in range(batch):
        for i, t in enumerate(tokens[b]):
            if 0 <= int(t) < 4:
                onehot[b, int(t), i] = 1.0
            else:
                onehot[b, :, i] = 0.25
    return onehot


def onehot_to_tokens(onehot: np.ndarray) -> np.ndarray:
    """Inverse of :func:`tokens_to_onehot` -- ``(batch, 4, seq_len)`` ->
    ``(batch, seq_len)`` integer tokens.
    """
    return onehot.argmax(axis=1)


# --------------------------------------------------------------------------- #
# Reverse complement / k-mer generation
# --------------------------------------------------------------------------- #
_COMPLEMENT = {"A": "T", "T": "A", "U": "A", "C": "G", "G": "C"}


def reverse_complement(seq: str) -> str:
    return "".join(_COMPLEMENT.get(b, b) for b in reversed(seq))


def generate_kmers(seq: str, k: int = 21) -> Set[str]:
    seq = seq.upper().replace("U", "T")
    kmers: Set[str] = set()
    if len(seq) < k:
        return kmers
    for i in range(len(seq) - k + 1):
        kmer = seq[i:i + k]
        kmers.add(kmer)
        kmers.add(reverse_complement(kmer))
    return kmers


# --------------------------------------------------------------------------- #
# dsRNA precursor tiling
# --------------------------------------------------------------------------- #
def tile_sequence(
    sequence: str,
    window: int = 200,
    step: int = 100,
    max_candidates: int | None = None,
) -> List[Tuple[int, int, str]]:
    """Tile a transcript into ``window``-nt precursors.

    Returns a list of ``(start, end, subseq)`` tuples. ``step`` defaults to
    ``window // 2`` so precursors overlap by 50% (covers more splice variants
    without blowing up the candidate count).
    """
    seq = sequence.upper().replace("U", "T")
    if step <= 0:
        step = max(1, window // 2)
    candidates: List[Tuple[int, int, str]] = []
    for i in range(0, max(0, len(seq) - window + 1), step):
        if max_candidates is not None and len(candidates) >= max_candidates:
            break
        candidates.append((i, i + window, seq[i:i + window]))
    return candidates


def dice_precursor(precursor: str, sirna_len: int = 21, step: int = 21) -> List[str]:
    """Dice a 200-nt dsRNA precursor into 21-nt siRNAs (Dicer-style).

    Default ``step=21`` matches Dicer's processive 21-nt cut cadence. Use a
    smaller step (e.g. 7) if you want overlapping windows for dense coverage.
    """
    seq = precursor.upper().replace("U", "T")
    sirnas: List[str] = []
    for i in range(0, max(0, len(seq) - sirna_len + 1), step):
        sirnas.append(seq[i:i + sirna_len])
    return sirnas


# --------------------------------------------------------------------------- #
# K-mer off-target index (copied from src/offtarget/kmer_index.py, no edits
# to behaviour -- the API is what the rest of the pipeline expects)
# --------------------------------------------------------------------------- #
class KmerOffTargetIndex:
    """Exact k-mer (default 21-mer, with reverse complements) off-target index.

    ``build_from_fasta`` ingests one species; ``per_species_risk`` returns a
    dict mapping each indexed species -> fraction of the candidate's k-mers
    that hit that species.
    """

    def __init__(self, k: int = 21):
        self.k = k
        self.index: Dict[str, int] = defaultdict(int)
        self.species_kmers: Dict[str, Set[str]] = {}

    def build_from_fasta(
        self,
        fasta_path: str | Path,
        species_name: str,
        header_prefix: str | None = None,
    ) -> None:
        """Ingest one species from a FASTA file.

        If ``header_prefix`` is provided, only sequences whose FASTA header
        starts with ``header_prefix`` are ingested (e.g. ``apis_mellifera``
        matches headers like ``>apis_mellifera_fake_001``). This lets a single
        multi-species FASTA be indexed per-species without pre-splitting.

        If ``header_prefix`` is None (default), all sequences are ingested.
        """
        species_set: Set[str] = set()
        n_seqs = 0
        for header, seq in fasta_iter(fasta_path):
            if header_prefix is not None:
                # Header is e.g. "apis_mellifera_fake_001". Match the prefix.
                if not header.startswith(header_prefix):
                    continue
            n_seqs += 1
            for kmer in generate_kmers(seq, self.k):
                self.index[kmer] += 1
                species_set.add(kmer)
        self.species_kmers[species_name] = species_set
        print(f"Indexed {len(species_set)} unique {self.k}-mers from {species_name} ({n_seqs} seqs)")

    def candidate_offtarget_score(self, candidate_seq: str) -> float:
        cand_kmers = generate_kmers(candidate_seq, self.k)
        if not cand_kmers:
            return 0.0
        hits = sum(1 for kmer in cand_kmers if kmer in self.index)
        return hits / len(cand_kmers)

    def per_species_risk(self, candidate_seq: str) -> Dict[str, float]:
        cand_kmers = generate_kmers(candidate_seq, self.k)
        if not cand_kmers:
            return {sp: 0.0 for sp in self.species_kmers}
        return {
            sp: sum(1 for kmer in cand_kmers if kmer in kmers) / len(cand_kmers)
            for sp, kmers in self.species_kmers.items()
        }