Spaces:
Sleeping
Sleeping
GitHub Actions Deployer
Automated api deployment from GitHub commit eb7fcba7dafcd64fd399dd972d285df63c105991
2937ad4 Download sequence_analysis.py from omshrivastava/Omnibimol: direct link, hf CLI and curl.
- Browser
- Download file 64.9 kB
-
https://huggingface.co/spaces/omshrivastava/Omnibimol/resolve/main/sequence_analysis.py
- Command line
-
hf download hf://spaces/omshrivastava/Omnibimol/sequence_analysis.py
-
curl -L -o sequence_analysis.py https://huggingface.co/spaces/omshrivastava/Omnibimol/resolve/main/sequence_analysis.py
64.9 kB
| # mypy: enable-error-code=var-annotated | |
| """ | |
| Sequence Analysis Suite - Comprehensive computational analysis of biological sequences | |
| Supports DNA, RNA, and protein sequences with multiple analysis tools. | |
| """ | |
| import re | |
| import io | |
| import math | |
| from typing import Any, Dict, List, Optional, Tuple, TypedDict | |
| from dataclasses import dataclass | |
| from collections import Counter, defaultdict | |
| import numpy as np | |
| from Bio import SeqIO, Align | |
| from Bio.Seq import Seq | |
| from Bio.SeqRecord import SeqRecord | |
| from Bio.Align import MultipleSeqAlignment | |
| from Bio.Phylo.TreeConstruction import DistanceCalculator, DistanceTreeConstructor | |
| from Bio.Phylo import write | |
| import networkx as nx | |
| import json | |
| import warnings | |
| from backend.core.errors import DataValidationError | |
| warnings.filterwarnings('ignore') | |
| class AlignmentStats(TypedDict): | |
| alignment_length: int | |
| num_sequences: int | |
| conserved_positions: int | |
| conservation_percentage: float | |
| gap_positions: int | |
| gap_percentage: float | |
| class TreeMetadata(TypedDict, total=False): | |
| method: str | |
| num_taxa: int | |
| tree_length: float | |
| newick_format: str | |
| distance_method: str | |
| alignment_used: bool | |
| kmer_size: int | |
| class MotifAnnotation(TypedDict): | |
| motif: str | |
| start: int | |
| end: int | |
| length: int | |
| conservation: float | |
| sequences: list[str] | |
| class Sequence: | |
| """Represents a biological sequence with metadata""" | |
| id: str | |
| sequence: str | |
| description: str = "" | |
| sequence_type: str = "unknown" # 'dna', 'rna', 'protein' | |
| def __post_init__(self) -> None: | |
| """Auto-detect sequence type if not specified""" | |
| if self.sequence_type == "unknown": | |
| self.sequence_type = self._detect_sequence_type() | |
| def _detect_sequence_type(self) -> str: | |
| """Detect if sequence is DNA, RNA, or protein""" | |
| seq_upper = self.sequence.upper().replace('-', '').replace('N', '').replace('X', '') | |
| if not seq_upper: | |
| return "unknown" | |
| # Check for RNA-specific nucleotides | |
| if 'U' in seq_upper and 'T' not in seq_upper: | |
| return "rna" | |
| # Check for DNA (T but no U) | |
| if 'T' in seq_upper and 'U' not in seq_upper: | |
| # Could be DNA or protein, check for protein-specific amino acids | |
| protein_chars = set('ACDEFGHIKLMNPQRSTVWY') | |
| if all(c in protein_chars for c in seq_upper): | |
| # Check if it's likely protein (has many non-ATGC chars) | |
| non_dna = len([c for c in seq_upper if c not in 'ATGC']) | |
| if non_dna > len(seq_upper) * 0.1: # More than 10% non-DNA chars | |
| return "protein" | |
| return "dna" | |
| # Check for protein amino acids | |
| protein_chars = set('ACDEFGHIKLMNPQRSTVWY') | |
| if all(c in protein_chars for c in seq_upper): | |
| return "protein" | |
| return "unknown" | |
| class FASTAParser: | |
| """Parse and validate FASTA format sequences""" | |
| def parse(file_content: str) -> list[Sequence]: | |
| """Parse FASTA content from string""" | |
| sequences = [] | |
| try: | |
| raw_content = file_content if isinstance(file_content, str) else file_content.read() | |
| # Handle both file-like objects and strings | |
| file_handle = io.StringIO(raw_content) | |
| try: | |
| for record in SeqIO.parse(file_handle, "fasta"): | |
| seq = Sequence( | |
| id=record.id, | |
| sequence=str(record.seq), | |
| description=record.description | |
| ) | |
| sequences.append(seq) | |
| except ValueError: | |
| sequences = [] | |
| if not sequences: | |
| plain_sequence = FASTAParser._parse_plain_sequence(raw_content) | |
| if plain_sequence: | |
| sequences.append( | |
| Sequence( | |
| id="Pasted_Sequence", | |
| sequence=plain_sequence, | |
| description="Plain sequence input", | |
| ) | |
| ) | |
| except Exception as e: | |
| raise DataValidationError( | |
| user_message="Could not parse the FASTA sequence. Please check the format and try again.", | |
| internal_message=f"FASTA parsing error: {str(e)}", | |
| log_details={"error_type": type(e).__name__}, | |
| ) from e | |
| return sequences | |
| def _parse_plain_sequence(file_content: str) -> str: | |
| """Treat non-FASTA pasted text as one raw biological sequence.""" | |
| lines = [ | |
| line.strip() | |
| for line in str(file_content).splitlines() | |
| if line.strip() and not line.lstrip().startswith((">", ";", "#")) | |
| ] | |
| if not lines: | |
| return "" | |
| candidate = re.sub(r"\s+", "", "".join(lines)).upper() | |
| candidate = re.sub(r"[^A-Z*.-]", "", candidate) | |
| biological_symbols = set("ACDEFGHIKLMNPQRSTVWYUX*-.") | |
| if candidate and set(candidate) <= biological_symbols: | |
| return candidate.replace(".", "-") | |
| return "" | |
| def validate(sequences: list[Sequence]) -> tuple[bool, list[str]]: | |
| """Validate sequences and return (is_valid, error_messages)""" | |
| errors = [] | |
| if not sequences: | |
| errors.append("No sequences found in input") | |
| return False, errors | |
| # Check for empty sequences | |
| for seq in sequences: | |
| if not seq.sequence or len(seq.sequence.strip()) == 0: | |
| errors.append(f"Sequence {seq.id} is empty") | |
| # Check for invalid characters | |
| valid_dna = set('ATCGN-') | |
| valid_rna = set('AUCGN-') | |
| valid_protein = set('ACDEFGHIKLMNPQRSTVWYX*-') | |
| for seq in sequences: | |
| seq_upper = seq.sequence.upper() | |
| if seq.sequence_type == "dna": | |
| invalid = set(seq_upper) - valid_dna | |
| elif seq.sequence_type == "rna": | |
| invalid = set(seq_upper) - valid_rna | |
| elif seq.sequence_type == "protein": | |
| invalid = set(seq_upper) - valid_protein | |
| else: | |
| continue | |
| if invalid: | |
| errors.append(f"Sequence {seq.id} contains invalid characters: {invalid}") | |
| return len(errors) == 0, errors | |
| class MultipleSequenceAligner: | |
| """Perform Multiple Sequence Alignment using various algorithms""" | |
| def __init__(self, method: str = "clustalw") -> None: | |
| """ | |
| Initialize aligner | |
| Methods: 'clustalw', 'muscle', 'mafft', 'simple' | |
| """ | |
| self.method = method.lower() | |
| self.aligner = Align.PairwiseAligner() | |
| self.aligner.mode = 'global' | |
| self.aligner.match_score = 2.0 | |
| self.aligner.mismatch_score = -1.0 | |
| self.aligner.open_gap_score = -0.5 | |
| self.aligner.extend_gap_score = -0.1 | |
| def align(self, sequences: list[Sequence]) -> tuple[MultipleSeqAlignment, AlignmentStats]: | |
| """ | |
| Perform multiple sequence alignment | |
| Returns (alignment, metadata) | |
| """ | |
| if len(sequences) < 2: | |
| raise ValueError("Need at least 2 sequences for alignment") | |
| # Convert to BioPython SeqRecord objects | |
| seq_records = [] | |
| for seq in sequences: | |
| seq_rec = SeqRecord(Seq(seq.sequence), id=seq.id, description=seq.description) | |
| seq_records.append(seq_rec) | |
| # For now, use progressive alignment (simple method) | |
| # In production, would call external tools like MUSCLE or MAFFT | |
| alignment = self._progressive_align(seq_records) | |
| # Calculate alignment statistics | |
| metadata = self._calculate_alignment_stats(alignment) | |
| return alignment, metadata | |
| def _progressive_align(self, seq_records: list[SeqRecord]) -> MultipleSeqAlignment: | |
| """Progressively align sequences to the first sequence as a center.""" | |
| if len(seq_records) == 1: | |
| return MultipleSeqAlignment(seq_records) | |
| center_sequence = str(seq_records[0].seq) | |
| center_aligned = center_sequence | |
| aligned_sequences = [center_aligned] | |
| for seq_record in seq_records[1:]: | |
| pair_center, pair_query = self._pairwise_align_strings( | |
| center_sequence, str(seq_record.seq) | |
| ) | |
| center_aligned, aligned_sequences = self._merge_center_alignment( | |
| center_sequence, | |
| center_aligned, | |
| aligned_sequences, | |
| pair_center, | |
| pair_query, | |
| ) | |
| aligned_records = [ | |
| SeqRecord( | |
| Seq(aligned_seq), | |
| id=record.id, | |
| description=record.description, | |
| ) | |
| for record, aligned_seq in zip(seq_records, aligned_sequences) | |
| ] | |
| return MultipleSeqAlignment(aligned_records) | |
| def _pairwise_align_strings(self, reference: str, query: str) -> tuple[str, str]: | |
| """Return one optimal pairwise alignment without enumerating all optima.""" | |
| try: | |
| alignments = self.aligner.align(reference, query) | |
| alignment = next(iter(alignments)) | |
| aligned = self._alignment_to_strings(alignment) | |
| if len(aligned) == 2 and len(aligned[0]) == len(aligned[1]): | |
| return aligned[0], aligned[1] | |
| except Exception: | |
| pass | |
| max_len = max(len(reference), len(query)) | |
| return reference.ljust(max_len, "-"), query.ljust(max_len, "-") | |
| def _alignment_to_strings(alignment: Any) -> list[str]: | |
| """Extract gapped sequence strings from a Biopython pairwise alignment.""" | |
| aligned_sequences: list[str] = [] | |
| current: list[str] = [] | |
| for line in format(alignment, "fasta").splitlines(): | |
| if line.startswith(">"): | |
| if current: | |
| aligned_sequences.append("".join(current)) | |
| current = [] | |
| else: | |
| current.append(line.strip()) | |
| if current: | |
| aligned_sequences.append("".join(current)) | |
| return aligned_sequences | |
| def _split_center_columns( | |
| aligned_center: str, aligned_members: list[str], center_length: int | |
| ) -> tuple[list[list[list[str]]], list[list[str]]]: | |
| """Group alignment columns by center-sequence position.""" | |
| gap_columns = [[] for _ in range(center_length + 1)] | |
| residue_columns: list[list[str]] = [] | |
| center_pos = 0 | |
| for column_index, center_char in enumerate(aligned_center): | |
| column = [member[column_index] for member in aligned_members] | |
| if center_char == "-": | |
| gap_columns[center_pos].append(column) | |
| else: | |
| residue_columns.append(column) | |
| center_pos += 1 | |
| if len(residue_columns) != center_length: | |
| raise ValueError("Center alignment does not match the reference sequence") | |
| return gap_columns, residue_columns | |
| def _merge_center_alignment( | |
| self, | |
| center_sequence: str, | |
| center_aligned: str, | |
| aligned_sequences: list[str], | |
| pair_center: str, | |
| pair_query: str, | |
| ) -> tuple[str, list[str]]: | |
| """Merge a new pairwise center alignment into the growing MSA.""" | |
| center_length = len(center_sequence) | |
| existing_count = len(aligned_sequences) | |
| old_gaps, old_residues = self._split_center_columns( | |
| center_aligned, aligned_sequences, center_length | |
| ) | |
| new_gaps, new_residues = self._split_center_columns( | |
| pair_center, [pair_query], center_length | |
| ) | |
| merged_center: list[str] = [] | |
| merged_members: list[list[str]] = [[] for _ in range(existing_count + 1)] | |
| for pos in range(center_length + 1): | |
| gap_count = max(len(old_gaps[pos]), len(new_gaps[pos])) | |
| for gap_index in range(gap_count): | |
| merged_center.append("-") | |
| old_column = ( | |
| old_gaps[pos][gap_index] | |
| if gap_index < len(old_gaps[pos]) | |
| else ["-"] * existing_count | |
| ) | |
| new_char = ( | |
| new_gaps[pos][gap_index][0] | |
| if gap_index < len(new_gaps[pos]) | |
| else "-" | |
| ) | |
| for member_index, char in enumerate(old_column): | |
| merged_members[member_index].append(char) | |
| merged_members[-1].append(new_char) | |
| if pos < center_length: | |
| merged_center.append(center_sequence[pos]) | |
| old_column = old_residues[pos] | |
| new_char = new_residues[pos][0] | |
| for member_index, char in enumerate(old_column): | |
| merged_members[member_index].append(char) | |
| merged_members[-1].append(new_char) | |
| return "".join(merged_center), ["".join(member) for member in merged_members] | |
| def _calculate_alignment_stats(self, alignment: MultipleSeqAlignment) -> AlignmentStats: | |
| """Calculate alignment statistics""" | |
| if not alignment: | |
| return { | |
| "alignment_length": 0, | |
| "num_sequences": 0, | |
| "conserved_positions": 0, | |
| "conservation_percentage": 0.0, | |
| "gap_positions": 0, | |
| "gap_percentage": 0.0, | |
| } | |
| length = alignment.get_alignment_length() | |
| num_seqs = len(alignment) | |
| # Count conserved positions | |
| conserved = 0 | |
| gaps = 0 | |
| for i in range(length): | |
| column = alignment[:, i] | |
| # Check if all non-gap characters are the same | |
| non_gaps = [c for c in column if c != '-'] | |
| if len(non_gaps) > 0 and len(set(non_gaps)) == 1: | |
| conserved += 1 | |
| if '-' in column: | |
| gaps += 1 | |
| stats: AlignmentStats = { | |
| "alignment_length": length, | |
| "num_sequences": num_seqs, | |
| "conserved_positions": conserved, | |
| "conservation_percentage": (conserved / length * 100) if length > 0 else 0, | |
| "gap_positions": gaps, | |
| "gap_percentage": (gaps / length * 100) if length > 0 else 0 | |
| } | |
| return stats | |
| class PhylogeneticTreeBuilder: | |
| """Construct phylogenetic trees from aligned sequences""" | |
| def __init__(self, method: str = "neighbor_joining") -> None: | |
| """ | |
| Initialize tree builder | |
| Methods: 'neighbor_joining', 'upgma' | |
| """ | |
| self.method = method.lower() | |
| self.calculator = DistanceCalculator('identity') # For protein, use 'blosum62' | |
| def build_tree(self, alignment: MultipleSeqAlignment) -> tuple[str, TreeMetadata]: | |
| """ | |
| Build phylogenetic tree | |
| Returns (newick_string, metadata) | |
| """ | |
| if len(alignment) < 2: | |
| raise ValueError("Need at least 2 sequences for tree construction") | |
| # Calculate distance matrix | |
| try: | |
| dm = self.calculator.get_distance(alignment) | |
| except Exception as e: | |
| # Fallback to simple distance calculation | |
| dm = self._simple_distance_matrix(alignment) | |
| # Build tree | |
| constructor = DistanceTreeConstructor() | |
| if self.method == "neighbor_joining": | |
| tree = constructor.nj(dm) | |
| else: # UPGMA | |
| tree = constructor.upgma(dm) | |
| # Convert to Newick format | |
| from io import StringIO | |
| handle = StringIO() | |
| write([tree], handle, "newick") | |
| newick_string = handle.getvalue().strip() | |
| # Calculate tree statistics | |
| metadata: TreeMetadata = { | |
| "method": self.method, | |
| "num_taxa": len(alignment), | |
| "tree_length": sum(tree.depths().values()), | |
| "newick_format": newick_string, | |
| "distance_method": "aligned_identity", | |
| "alignment_used": True, | |
| } | |
| return newick_string, metadata | |
| def _simple_distance_matrix(self, alignment: MultipleSeqAlignment) -> Any: | |
| """Simple distance matrix calculation""" | |
| from Bio.Phylo.TreeConstruction import DistanceMatrix | |
| num_seqs = len(alignment) | |
| distances = [] | |
| for i in range(num_seqs): | |
| row = [] | |
| for j in range(i + 1): | |
| if i == j: | |
| row.append(0.0) | |
| else: | |
| # Calculate pairwise identity | |
| seq1 = str(alignment[i].seq) | |
| seq2 = str(alignment[j].seq) | |
| matches = sum(c1 == c2 and c1 != '-' for c1, c2 in zip(seq1, seq2)) | |
| total = sum(1 for c1, c2 in zip(seq1, seq2) if c1 != '-' or c2 != '-') | |
| identity = matches / total if total > 0 else 0.0 | |
| distance = 1.0 - identity | |
| row.append(distance) | |
| distances.append(row) | |
| names = [record.id for record in alignment] | |
| return DistanceMatrix(names, distances) | |
| def build_kmer_tree( | |
| self, sequences: list[Sequence], sequence_type: str | |
| ) -> tuple[str, TreeMetadata]: | |
| """Build an alignment-free neighbor-joining tree from exact k-mer sets.""" | |
| if len(sequences) < 2: | |
| raise ValueError("Need at least 2 sequences for tree construction") | |
| kmer_size = 5 if sequence_type == "protein" else 9 | |
| signatures = [ | |
| self._kmer_signature(seq.sequence, kmer_size, sequence_type) | |
| for seq in sequences | |
| ] | |
| names = self._unique_taxon_names([seq.id for seq in sequences]) | |
| distances: list[list[float]] = [] | |
| for i, signature in enumerate(signatures): | |
| row: list[float] = [] | |
| for j in range(i + 1): | |
| if i == j: | |
| row.append(0.0) | |
| continue | |
| other = signatures[j] | |
| union_size = len(signature | other) | |
| similarity = len(signature & other) / union_size if union_size else 1.0 | |
| row.append(1.0 - similarity) | |
| distances.append(row) | |
| from Bio.Phylo.TreeConstruction import DistanceMatrix | |
| tree = DistanceTreeConstructor().nj(DistanceMatrix(names, distances)) | |
| handle = io.StringIO() | |
| write([tree], handle, "newick") | |
| newick_string = handle.getvalue().strip() | |
| metadata: TreeMetadata = { | |
| "method": self.method, | |
| "num_taxa": len(sequences), | |
| "tree_length": sum(tree.depths().values()), | |
| "newick_format": newick_string, | |
| "distance_method": "kmer_jaccard", | |
| "alignment_used": False, | |
| "kmer_size": kmer_size, | |
| } | |
| return newick_string, metadata | |
| def _unique_taxon_names(names: list[str]) -> list[str]: | |
| """Return stable unique labels accepted by Biopython distance matrices.""" | |
| counts: dict[str, int] = {} | |
| unique: list[str] = [] | |
| for index, raw_name in enumerate(names, start=1): | |
| base = raw_name or f"Sequence_{index}" | |
| counts[base] = counts.get(base, 0) + 1 | |
| unique.append(base if counts[base] == 1 else f"{base}_{counts[base]}") | |
| return unique | |
| def _kmer_signature(sequence: str, k: int, sequence_type: str) -> set[Any]: | |
| """Encode unambiguous k-mers as integers to bound memory for long inputs.""" | |
| cleaned = sequence.upper().replace("-", "") | |
| if sequence_type == "rna": | |
| cleaned = cleaned.replace("U", "T") | |
| alphabet = "ACGT" if sequence_type in {"dna", "rna"} else "ACDEFGHIKLMNPQRSTVWY" | |
| symbols = {symbol: index for index, symbol in enumerate(alphabet)} | |
| if len(cleaned) < k: | |
| short = "".join(symbol for symbol in cleaned if symbol in symbols) | |
| return {short} if short else set() | |
| base = len(alphabet) | |
| high_place = base ** (k - 1) | |
| signature: set[Any] = set() | |
| value = 0 | |
| valid_length = 0 | |
| for symbol in cleaned: | |
| encoded = symbols.get(symbol) | |
| if encoded is None: | |
| value = 0 | |
| valid_length = 0 | |
| continue | |
| if valid_length < k: | |
| value = value * base + encoded | |
| valid_length += 1 | |
| if valid_length == k: | |
| signature.add(value) | |
| else: | |
| value = (value % high_place) * base + encoded | |
| signature.add(value) | |
| return signature | |
| class DomainIdentifier: | |
| """Identify functional domains in protein sequences""" | |
| def __init__(self) -> None: | |
| """Initialize domain identifier""" | |
| # Common protein domains patterns (simplified) | |
| # In production, would use Pfam HMMs or InterPro | |
| self.domain_patterns = { | |
| "Zinc Finger": [r"C.{2,4}C.{12,15}H.{3,5}H", r"H.{3,5}H.{12,15}C.{2,4}C"], | |
| "Helix-Turn-Helix": [r"[ILV].{5,7}[ILV].{5,7}[ILV]"], | |
| "Leucine Zipper": [r"L.{6}L.{6}L.{6}L"], | |
| "EF-hand": [r"D.{3}D.{3}[ILV].{6}[DE].{6}Y"], | |
| } | |
| def identify_domains(self, sequences: list[Sequence], alignment: Optional[MultipleSeqAlignment] = None) -> dict[str, list[dict[str, Any]]]: | |
| """ | |
| Identify domains in sequences | |
| Returns dict mapping sequence_id to list of domain annotations | |
| """ | |
| results = {} | |
| for seq in sequences: | |
| if seq.sequence_type != "protein": | |
| continue | |
| domains = [] | |
| seq_upper = seq.sequence.upper() | |
| # Search for known domain patterns | |
| for domain_name, patterns in self.domain_patterns.items(): | |
| for pattern in patterns: | |
| matches = re.finditer(pattern, seq_upper) | |
| for match in matches: | |
| domains.append({ | |
| "domain_name": domain_name, | |
| "start": match.start() + 1, # 1-indexed | |
| "end": match.end(), | |
| "sequence": match.group(), | |
| "confidence": 0.7, # Simplified confidence score | |
| "method": "pattern_match" | |
| }) | |
| # Remove overlapping domains (keep first) | |
| domains = self._remove_overlaps(domains) | |
| results[seq.id] = domains | |
| return results | |
| def _remove_overlaps(self, domains: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| """Remove overlapping domain annotations""" | |
| if not domains: | |
| return [] | |
| # Sort by start position | |
| sorted_domains = sorted(domains, key=lambda x: x["start"]) | |
| non_overlapping: list[dict[str, Any]] = [sorted_domains[0]] | |
| for domain in sorted_domains[1:]: | |
| last = non_overlapping[-1] | |
| # Check if overlapping | |
| if domain["start"] > last["end"]: | |
| non_overlapping.append(domain) | |
| elif domain["end"] - domain["start"] > last["end"] - last["start"]: | |
| # Replace if current domain is longer | |
| non_overlapping[-1] = domain | |
| return non_overlapping | |
| class MotifFinder: | |
| """Find conserved motifs in sequences""" | |
| def __init__(self, min_length: int = 4, max_length: int = 20) -> None: | |
| """Initialize motif finder""" | |
| self.min_length = min_length | |
| self.max_length = max_length | |
| def find_motifs(self, sequences: list[Sequence], alignment: Optional[MultipleSeqAlignment] = None) -> dict[str, Any]: | |
| """ | |
| Find conserved motifs | |
| Returns dict with motifs and their positions | |
| """ | |
| if alignment: | |
| return self._find_motifs_in_alignment(alignment) | |
| else: | |
| return self._find_motifs_in_sequences(sequences) | |
| def _find_motifs_in_alignment(self, alignment: MultipleSeqAlignment) -> dict[str, Any]: | |
| """Find motifs in aligned sequences""" | |
| motifs: list[dict[str, Any]] = [] | |
| length = alignment.get_alignment_length() | |
| # Look for conserved regions | |
| for window_size in range(self.min_length, min(self.max_length + 1, length // 2)): | |
| for start in range(length - window_size + 1): | |
| column = alignment[:, start:start + window_size] | |
| # Check conservation | |
| conserved_seqs = [] | |
| for i, seq in enumerate(column): | |
| seq_str = ''.join(seq) | |
| if '-' not in seq_str: | |
| conserved_seqs.append((i, seq_str)) | |
| if len(conserved_seqs) >= len(alignment) * 0.7: # 70% conservation | |
| # Check if all sequences have same pattern | |
| patterns = [seq for _, seq in conserved_seqs] | |
| if len(set(patterns)) == 1: | |
| motifs.append({ | |
| "motif": patterns[0], | |
| "start": start + 1, | |
| "end": start + window_size, | |
| "length": window_size, | |
| "conservation": len(conserved_seqs) / len(alignment), | |
| "sequences": [alignment[i].id for i, _ in conserved_seqs] | |
| }) | |
| # Remove duplicates and overlapping motifs | |
| motifs = self._deduplicate_motifs(motifs) | |
| return { | |
| "motifs": motifs, | |
| "num_motifs": len(motifs), | |
| "method": "alignment_based" | |
| } | |
| def _find_motifs_in_sequences(self, sequences: list[Sequence]) -> dict[str, Any]: | |
| """Find motifs using k-mer frequency analysis""" | |
| if len(sequences) < 2: | |
| return {"motifs": [], "num_motifs": 0, "method": "kmer_frequency"} | |
| # Count k-mers across all sequences | |
| kmer_counts: defaultdict[str, list[Tuple[str, int]]] = defaultdict(list) | |
| for k in range(self.min_length, self.max_length + 1): | |
| for seq in sequences: | |
| seq_upper = seq.sequence.upper().replace('-', '') | |
| for i in range(len(seq_upper) - k + 1): | |
| kmer = seq_upper[i:i+k] | |
| kmer_counts[kmer].append((seq.id, i + 1)) | |
| # Find k-mers present in multiple sequences | |
| motifs: list[dict[str, Any]] = [] | |
| for kmer, positions in kmer_counts.items(): | |
| unique_seqs = set(seq_id for seq_id, _ in positions) | |
| if len(unique_seqs) >= max(2, len(sequences) * 0.5): # Present in at least 50% of sequences | |
| motifs.append({ | |
| "motif": kmer, | |
| "length": len(kmer), | |
| "frequency": len(positions), | |
| "sequences": list(unique_seqs), | |
| "positions": positions[:10] # Limit positions | |
| }) | |
| # Sort by frequency | |
| motifs.sort(key=lambda x: x["frequency"], reverse=True) | |
| return { | |
| "motifs": motifs[:20], # Top 20 motifs | |
| "num_motifs": len(motifs), | |
| "method": "kmer_frequency" | |
| } | |
| def _deduplicate_motifs(self, motifs: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| """Remove duplicate and overlapping motifs""" | |
| if not motifs: | |
| return [] | |
| # Sort by length (longer first) and conservation | |
| sorted_motifs = sorted(motifs, key=lambda x: (x["length"], x.get("conservation", 0)), reverse=True) | |
| unique: list[dict[str, Any]] = [] | |
| for motif in sorted_motifs: | |
| # Check if overlaps with existing motifs | |
| overlaps = False | |
| for existing in unique: | |
| if (motif["start"] <= existing["end"] and motif["end"] >= existing["start"]): | |
| overlaps = True | |
| break | |
| if not overlaps: | |
| unique.append(motif) | |
| return unique | |
| class ConservationScorer: | |
| """Calculate conservation scores for aligned sequences""" | |
| def __init__(self, method: str = "shannon_entropy") -> None: | |
| """ | |
| Initialize conservation scorer | |
| Methods: 'shannon_entropy', 'simple_frequency' | |
| """ | |
| self.method = method.lower() | |
| def calculate_conservation(self, alignment: MultipleSeqAlignment) -> dict[str, Any]: | |
| """ | |
| Calculate conservation scores for each position | |
| Returns dict with position-wise scores and statistics | |
| """ | |
| if not alignment: | |
| return {} | |
| length = alignment.get_alignment_length() | |
| num_seqs = len(alignment) | |
| scores: list[float] = [] | |
| positions: list[dict[str, Any]] = [] | |
| for i in range(length): | |
| column = alignment[:, i] | |
| # Remove gaps | |
| non_gaps = [c for c in column if c != '-'] | |
| if not non_gaps: | |
| score = 0.0 # All gaps | |
| elif self.method == "shannon_entropy": | |
| score = self._shannon_entropy(non_gaps) | |
| else: # simple_frequency | |
| score = self._simple_frequency(non_gaps) | |
| scores.append(score) | |
| positions.append({ | |
| "position": i + 1, | |
| "score": score, | |
| "residues": dict(Counter(non_gaps)), | |
| "gap_count": column.count('-') | |
| }) | |
| # Calculate statistics | |
| scores_array = np.array(scores) | |
| results: dict[str, Any] = { | |
| "scores": positions, | |
| "mean_conservation": float(np.mean(scores_array)), | |
| "std_conservation": float(np.std(scores_array)), | |
| "min_conservation": float(np.min(scores_array)), | |
| "max_conservation": float(np.max(scores_array)), | |
| "highly_conserved_positions": [i + 1 for i, s in enumerate(scores) if s > np.percentile(scores_array, 90)], | |
| "method": self.method | |
| } | |
| return results | |
| def _shannon_entropy(self, residues: List[str]) -> float: | |
| """Calculate Shannon entropy (lower = more conserved)""" | |
| if not residues: | |
| return 0.0 | |
| counts = Counter(residues) | |
| total = len(residues) | |
| entropy = 0.0 | |
| for count in counts.values(): | |
| p = count / total | |
| if p > 0: | |
| entropy -= p * np.log2(p) | |
| # Normalize to 0-1 scale (1 = completely conserved, 0 = highly variable) | |
| max_entropy = np.log2(len(set(residues))) if len(set(residues)) > 1 else 0 | |
| if max_entropy > 0: | |
| conservation = 1.0 - (entropy / max_entropy) | |
| else: | |
| conservation = 1.0 | |
| return max(0.0, conservation) | |
| def _simple_frequency(self, residues: List[str]) -> float: | |
| """Simple frequency-based conservation (fraction of most common residue)""" | |
| if not residues: | |
| return 0.0 | |
| counts = Counter(residues) | |
| most_common_count = max(counts.values()) | |
| total = len(residues) | |
| return most_common_count / total if total > 0 else 0.0 | |
| class SequenceSpecificAnalyzer: | |
| """Generate per-sequence findings so FASTA reports reflect the submitted input.""" | |
| DNA_COMPLEMENT = str.maketrans("ATCGNatcgn", "TAGCNtagcn") | |
| RNA_COMPLEMENT = str.maketrans("AUCGNaucgn", "UAGCNuagcn") | |
| PROTEIN_MASSES = { | |
| "A": 89.09, | |
| "R": 174.20, | |
| "N": 132.12, | |
| "D": 133.10, | |
| "C": 121.15, | |
| "E": 147.13, | |
| "Q": 146.15, | |
| "G": 75.07, | |
| "H": 155.16, | |
| "I": 131.17, | |
| "L": 131.17, | |
| "K": 146.19, | |
| "M": 149.21, | |
| "F": 165.19, | |
| "P": 115.13, | |
| "S": 105.09, | |
| "T": 119.12, | |
| "W": 204.23, | |
| "Y": 181.19, | |
| "V": 117.15, | |
| } | |
| KYTE_DOOLITTLE = { | |
| "A": 1.8, | |
| "R": -4.5, | |
| "N": -3.5, | |
| "D": -3.5, | |
| "C": 2.5, | |
| "Q": -3.5, | |
| "E": -3.5, | |
| "G": -0.4, | |
| "H": -3.2, | |
| "I": 4.5, | |
| "L": 3.8, | |
| "K": -3.9, | |
| "M": 1.9, | |
| "F": 2.8, | |
| "P": -1.6, | |
| "S": -0.8, | |
| "T": -0.7, | |
| "W": -0.9, | |
| "Y": -1.3, | |
| "V": 4.2, | |
| } | |
| HYDROPHOBIC = set("AILMFWYV") | |
| POLAR = set("STNQCY") | |
| POSITIVE = set("KRH") | |
| NEGATIVE = set("DE") | |
| AROMATIC = set("FWY") | |
| def analyze(self, sequences: list[Sequence]) -> dict[str, dict[str, Any]]: | |
| """Return sequence-specific metrics and short findings keyed by FASTA ID.""" | |
| return {seq.id: self._analyze_one(seq) for seq in sequences} | |
| def _analyze_one(self, seq: Sequence) -> dict[str, Any]: | |
| clean = self._clean_sequence(seq.sequence) | |
| common = self._common_metrics(clean) | |
| if seq.sequence_type in {"dna", "rna"}: | |
| specific = self._analyze_nucleotide(clean, seq.sequence_type) | |
| elif seq.sequence_type == "protein": | |
| specific = self._analyze_protein(clean) | |
| else: | |
| specific = { | |
| "metrics": {}, | |
| "features": [], | |
| "composition": dict(Counter(clean)), | |
| "summary": "Sequence type could not be confidently classified; only common metrics were calculated.", | |
| } | |
| summary_points = self._build_summary(seq, common, specific) | |
| return { | |
| "id": seq.id, | |
| "type": seq.sequence_type, | |
| "length": len(clean), | |
| "common": common, | |
| **specific, | |
| "summary_points": summary_points, | |
| } | |
| def _clean_sequence(sequence: str) -> str: | |
| return "".join(c for c in sequence.upper() if c.isalpha() or c in {"-", "*"}) | |
| def _common_metrics(self, sequence: str) -> dict[str, Any]: | |
| ungapped = sequence.replace("-", "") | |
| counts = Counter(ungapped) | |
| length = len(ungapped) | |
| top_symbols = counts.most_common(5) | |
| return { | |
| "ungapped_length": length, | |
| "gap_count": sequence.count("-"), | |
| "terminal_stop_count": 1 if ungapped.endswith("*") else 0, | |
| "complexity": round(self._normalized_entropy(ungapped), 3), | |
| "longest_homopolymer": self._longest_homopolymer(ungapped), | |
| "top_symbols": [{"symbol": symbol, "count": count} for symbol, count in top_symbols], | |
| } | |
| def _analyze_nucleotide(self, sequence: str, sequence_type: str) -> dict[str, Any]: | |
| alphabet = "AUCGN" if sequence_type == "rna" else "ATCGN" | |
| seq = "".join(c for c in sequence if c in alphabet) | |
| length = len(seq) | |
| counts = Counter(seq) | |
| gc_count = counts["G"] + counts["C"] | |
| ambiguous_count = counts["N"] | |
| at_or_au_count = counts["A"] + (counts["U"] if sequence_type == "rna" else counts["T"]) | |
| start_codon = "AUG" if sequence_type == "rna" else "ATG" | |
| stop_codons = {"UAA", "UAG", "UGA"} if sequence_type == "rna" else {"TAA", "TAG", "TGA"} | |
| longest_orf = self._longest_orf(seq, start_codon, stop_codons) | |
| repeat_features = self._find_repeated_kmers(seq, k=6, limit=5) | |
| rev_comp = seq.translate( | |
| self.RNA_COMPLEMENT if sequence_type == "rna" else self.DNA_COMPLEMENT | |
| )[::-1] | |
| metrics = { | |
| "gc_percent": round((gc_count / length * 100) if length else 0.0, 2), | |
| "at_or_au_percent": round((at_or_au_count / length * 100) if length else 0.0, 2), | |
| "ambiguous_base_percent": round((ambiguous_count / length * 100) if length else 0.0, 2), | |
| "start_codon_count": self._count_overlapping(seq, start_codon), | |
| "stop_codon_count": sum(self._count_overlapping(seq, codon) for codon in stop_codons), | |
| "cpg_count": self._count_overlapping(seq.replace("U", "T"), "CG"), | |
| "longest_orf_length": longest_orf["length"], | |
| "longest_orf_frame": longest_orf["frame"], | |
| "reverse_complement_preview": rev_comp[:60], | |
| } | |
| features: list[dict[str, Any]] = [] | |
| if longest_orf["length"] >= 90: | |
| features.append( | |
| { | |
| "feature": "Longest ORF", | |
| "location": f"{longest_orf['start']}-{longest_orf['end']} ({longest_orf['frame']})", | |
| "detail": f"{longest_orf['length']} nt, {longest_orf['aa_length']} codons", | |
| } | |
| ) | |
| if repeat_features: | |
| features.extend(repeat_features) | |
| return { | |
| "metrics": metrics, | |
| "features": features, | |
| "composition": {base: counts.get(base, 0) for base in alphabet}, | |
| "summary": self._nucleotide_summary(seq, sequence_type, metrics, repeat_features), | |
| } | |
| def _analyze_protein(self, sequence: str) -> dict[str, Any]: | |
| seq = "".join(c for c in sequence if c in self.PROTEIN_MASSES or c in {"X", "*"}) | |
| residues = seq.replace("*", "") | |
| length = len(residues) | |
| counts = Counter(residues) | |
| hydrophobic_count = sum(counts[aa] for aa in self.HYDROPHOBIC) | |
| polar_count = sum(counts[aa] for aa in self.POLAR) | |
| positive_count = sum(counts[aa] for aa in self.POSITIVE) | |
| negative_count = sum(counts[aa] for aa in self.NEGATIVE) | |
| aromatic_count = sum(counts[aa] for aa in self.AROMATIC) | |
| known_residues = [aa for aa in residues if aa in self.PROTEIN_MASSES] | |
| mass = sum(self.PROTEIN_MASSES[aa] for aa in known_residues) | |
| if known_residues: | |
| mass -= 18.015 * max(len(known_residues) - 1, 0) | |
| gravy = ( | |
| sum(self.KYTE_DOOLITTLE.get(aa, 0.0) for aa in known_residues) / len(known_residues) | |
| if known_residues | |
| else 0.0 | |
| ) | |
| net_charge_proxy = positive_count - negative_count | |
| features = self._find_protein_features(residues) | |
| metrics = { | |
| "molecular_weight_kda": round(mass / 1000, 2), | |
| "hydrophobic_percent": round((hydrophobic_count / length * 100) if length else 0.0, 2), | |
| "polar_percent": round((polar_count / length * 100) if length else 0.0, 2), | |
| "charged_percent": round( | |
| ((positive_count + negative_count) / length * 100) if length else 0.0, 2 | |
| ), | |
| "positive_residues": positive_count, | |
| "negative_residues": negative_count, | |
| "net_charge_proxy": net_charge_proxy, | |
| "aromatic_percent": round((aromatic_count / length * 100) if length else 0.0, 2), | |
| "cysteine_count": counts["C"], | |
| "gravy": round(gravy, 3), | |
| "unknown_residue_percent": round((counts["X"] / length * 100) if length else 0.0, 2), | |
| } | |
| return { | |
| "metrics": metrics, | |
| "features": features, | |
| "composition": {aa: counts.get(aa, 0) for aa in sorted(self.PROTEIN_MASSES)}, | |
| "summary": self._protein_summary(residues, metrics, features), | |
| } | |
| def _build_summary( | |
| self, seq: Sequence, common: dict[str, Any], specific: dict[str, Any] | |
| ) -> list[str]: | |
| points = [ | |
| f"{seq.id} is classified as {seq.sequence_type} with {common['ungapped_length']} ungapped symbols.", | |
| specific.get("summary", ""), | |
| ] | |
| homopolymer = common.get("longest_homopolymer", {}) | |
| if homopolymer.get("length", 0) >= 6: | |
| points.append( | |
| f"Longest single-symbol run is {homopolymer['symbol']} x{homopolymer['length']} at positions {homopolymer['start']}-{homopolymer['end']}." | |
| ) | |
| if common.get("complexity", 0) < 0.55: | |
| points.append("Sequence complexity is low, so repetitive or composition-biased regions may dominate downstream signals.") | |
| return [point for point in points if point] | |
| def _nucleotide_summary( | |
| self, | |
| sequence: str, | |
| sequence_type: str, | |
| metrics: dict[str, Any], | |
| repeat_features: list[dict[str, Any]], | |
| ) -> str: | |
| molecule = "RNA" if sequence_type == "rna" else "DNA" | |
| gc = metrics["gc_percent"] | |
| if gc >= 60: | |
| gc_text = "GC-rich" | |
| elif gc <= 40: | |
| gc_text = "AT/AU-rich" | |
| else: | |
| gc_text = "balanced GC" | |
| orf_text = ( | |
| f"longest ORF is {metrics['longest_orf_length']} nt in {metrics['longest_orf_frame']}" | |
| if metrics["longest_orf_length"] | |
| else "no start-to-stop ORF was detected" | |
| ) | |
| repeat_text = f"; {len(repeat_features)} repeated 6-mers flagged" if repeat_features else "" | |
| return f"{molecule} profile is {gc_text} ({gc:.2f}% GC); {orf_text}{repeat_text}." | |
| def _protein_summary( | |
| self, sequence: str, metrics: dict[str, Any], features: list[dict[str, Any]] | |
| ) -> str: | |
| if metrics["gravy"] > 0.4: | |
| solubility_hint = "hydrophobic" | |
| elif metrics["gravy"] < -0.4: | |
| solubility_hint = "hydrophilic" | |
| else: | |
| solubility_hint = "mixed hydrophobic/hydrophilic" | |
| charge = metrics["net_charge_proxy"] | |
| feature_names = ", ".join(feature["feature"] for feature in features[:3]) | |
| feature_text = f" Detected features include {feature_names}." if feature_names else "" | |
| return ( | |
| f"Protein profile is {solubility_hint} with charge proxy {charge:+d}, " | |
| f"{metrics['hydrophobic_percent']:.2f}% hydrophobic residues, and " | |
| f"estimated mass {metrics['molecular_weight_kda']:.2f} kDa." | |
| f"{feature_text}" | |
| ) | |
| def _normalized_entropy(sequence: str) -> float: | |
| if not sequence: | |
| return 0.0 | |
| counts = Counter(sequence) | |
| entropy = 0.0 | |
| for count in counts.values(): | |
| p = count / len(sequence) | |
| entropy -= p * math.log2(p) | |
| max_entropy = math.log2(len(counts)) if len(counts) > 1 else 1.0 | |
| return entropy / max_entropy if max_entropy else 0.0 | |
| def _longest_homopolymer(sequence: str) -> dict[str, Any]: | |
| if not sequence: | |
| return {"symbol": "", "length": 0, "start": 0, "end": 0} | |
| best_symbol = sequence[0] | |
| best_length = 1 | |
| best_start = 1 | |
| current_symbol = sequence[0] | |
| current_start = 1 | |
| current_length = 1 | |
| for index, symbol in enumerate(sequence[1:], start=2): | |
| if symbol == current_symbol: | |
| current_length += 1 | |
| else: | |
| if current_length > best_length: | |
| best_symbol = current_symbol | |
| best_length = current_length | |
| best_start = current_start | |
| current_symbol = symbol | |
| current_start = index | |
| current_length = 1 | |
| if current_length > best_length: | |
| best_symbol = current_symbol | |
| best_length = current_length | |
| best_start = current_start | |
| return { | |
| "symbol": best_symbol, | |
| "length": best_length, | |
| "start": best_start, | |
| "end": best_start + best_length - 1, | |
| } | |
| def _count_overlapping(sequence: str, pattern: str) -> int: | |
| if not sequence or not pattern: | |
| return 0 | |
| return sum(1 for i in range(0, len(sequence) - len(pattern) + 1) if sequence[i : i + len(pattern)] == pattern) | |
| def _longest_orf( | |
| self, sequence: str, start_codon: str, stop_codons: set[str] | |
| ) -> dict[str, Any]: | |
| best = {"start": 0, "end": 0, "length": 0, "frame": "N/A", "aa_length": 0} | |
| for frame in range(3): | |
| active_start: Optional[int] = None | |
| for pos in range(frame, len(sequence) - 2, 3): | |
| codon = sequence[pos : pos + 3] | |
| if active_start is None and codon == start_codon: | |
| active_start = pos | |
| elif active_start is not None and codon in stop_codons: | |
| length = pos + 3 - active_start | |
| if length > best["length"]: | |
| best = { | |
| "start": active_start + 1, | |
| "end": pos + 3, | |
| "length": length, | |
| "frame": f"+{frame + 1}", | |
| "aa_length": length // 3, | |
| } | |
| active_start = None | |
| if active_start is not None: | |
| length = len(sequence) - active_start | |
| length -= length % 3 | |
| if length > best["length"]: | |
| best = { | |
| "start": active_start + 1, | |
| "end": active_start + length, | |
| "length": length, | |
| "frame": f"+{frame + 1}", | |
| "aa_length": length // 3, | |
| } | |
| return best | |
| def _find_repeated_kmers(self, sequence: str, k: int, limit: int) -> list[dict[str, Any]]: | |
| if len(sequence) < k: | |
| return [] | |
| positions: defaultdict[str, list[int]] = defaultdict(list) | |
| for i in range(len(sequence) - k + 1): | |
| kmer = sequence[i : i + k] | |
| if "N" not in kmer: | |
| positions[kmer].append(i + 1) | |
| repeated = [ | |
| { | |
| "feature": "Repeated k-mer", | |
| "location": ", ".join(map(str, sites[:5])), | |
| "detail": f"{kmer} occurs {len(sites)} times", | |
| "count": len(sites), | |
| } | |
| for kmer, sites in positions.items() | |
| if len(sites) >= 3 | |
| ] | |
| repeated.sort(key=lambda item: item["count"], reverse=True) | |
| return repeated[:limit] | |
| def _find_protein_features(self, sequence: str) -> list[dict[str, Any]]: | |
| features: list[dict[str, Any]] = [] | |
| features.extend(self._regex_features(sequence, "N-glycosylation motif", r"N[^P][ST][^P]")) | |
| features.extend(self._regex_features(sequence, "Cysteine pair", r"C.{2,8}C")) | |
| features.extend(self._regex_features(sequence, "Basic cleavage-like site", r"[KR][KR].{0,2}[KR]")) | |
| features.extend(self._low_complexity_features(sequence)) | |
| features.extend(self._hydrophobic_segment_features(sequence)) | |
| signal = self._signal_peptide_hint(sequence) | |
| if signal: | |
| features.append(signal) | |
| return features[:20] | |
| def _regex_features(sequence: str, name: str, pattern: str) -> list[dict[str, Any]]: | |
| return [ | |
| { | |
| "feature": name, | |
| "location": f"{match.start() + 1}-{match.end()}", | |
| "detail": match.group(), | |
| } | |
| for match in re.finditer(pattern, sequence) | |
| ] | |
| def _low_complexity_features(self, sequence: str) -> list[dict[str, Any]]: | |
| features = [] | |
| for match in re.finditer(r"([A-Z])\1{5,}", sequence): | |
| features.append( | |
| { | |
| "feature": "Low-complexity run", | |
| "location": f"{match.start() + 1}-{match.end()}", | |
| "detail": f"{match.group(1)} x{len(match.group())}", | |
| } | |
| ) | |
| return features | |
| def _hydrophobic_segment_features(self, sequence: str) -> list[dict[str, Any]]: | |
| features = [] | |
| window = 19 | |
| if len(sequence) < window: | |
| return features | |
| for start in range(0, len(sequence) - window + 1): | |
| fragment = sequence[start : start + window] | |
| avg = sum(self.KYTE_DOOLITTLE.get(aa, 0.0) for aa in fragment) / window | |
| hydrophobic_fraction = sum(1 for aa in fragment if aa in self.HYDROPHOBIC) / window | |
| if avg >= 1.6 and hydrophobic_fraction >= 0.65: | |
| features.append( | |
| { | |
| "feature": "Hydrophobic segment", | |
| "location": f"{start + 1}-{start + window}", | |
| "detail": f"avg hydropathy {avg:.2f}; possible transmembrane/span region", | |
| } | |
| ) | |
| if len(features) >= 5: | |
| break | |
| return features | |
| def _signal_peptide_hint(self, sequence: str) -> Optional[dict[str, Any]]: | |
| n_terminal = sequence[:30] | |
| if len(n_terminal) < 18: | |
| return None | |
| positively_charged = any(aa in self.POSITIVE for aa in n_terminal[:8]) | |
| hydrophobic_core = any( | |
| sum(1 for aa in n_terminal[start : start + 8] if aa in self.HYDROPHOBIC) >= 6 | |
| for start in range(0, max(len(n_terminal) - 7, 1)) | |
| ) | |
| if positively_charged and hydrophobic_core: | |
| return { | |
| "feature": "Signal peptide-like N-terminus", | |
| "location": "1-30", | |
| "detail": "basic N-region with hydrophobic core", | |
| } | |
| return None | |
| class SequenceAnalysisSuite: | |
| """Main class orchestrating all sequence analysis tools""" | |
| ALIGNMENT_MAX_SEQUENCE_LENGTH = 5_000 | |
| ALIGNMENT_MAX_TOTAL_SYMBOLS = 25_000 | |
| INTERACTIVE_MAX_SEQUENCES = 20 | |
| INTERACTIVE_MAX_SEQUENCE_LENGTH = 100_000 | |
| INTERACTIVE_MAX_TOTAL_SYMBOLS = 2_000_000 | |
| def __init__(self): | |
| """Initialize the analysis suite""" | |
| self.parser = FASTAParser() | |
| self.aligner = MultipleSequenceAligner() | |
| self.tree_builder = PhylogeneticTreeBuilder() | |
| self.domain_identifier = DomainIdentifier() | |
| self.motif_finder = MotifFinder() | |
| self.conservation_scorer = ConservationScorer() | |
| self.sequence_specific_analyzer = SequenceSpecificAnalyzer() | |
| def analyze(self, fasta_content: str, run_alignment: bool = True, | |
| run_phylogeny: bool = True, run_domains: bool = True, | |
| run_motifs: bool = True, run_conservation: bool = True) -> Dict[str, Any]: | |
| """ | |
| Run complete analysis pipeline | |
| Returns comprehensive analysis results | |
| """ | |
| results: Dict[str, Any] = { | |
| "input_sequences": [], | |
| "alignment": None, | |
| "phylogenetic_tree": None, | |
| "domains": {}, | |
| "motifs": {}, | |
| "conservation": {}, | |
| "sequence_insights": {}, | |
| "workload": {}, | |
| "warnings": [], | |
| "errors": [], | |
| } | |
| try: | |
| # 1. Parse sequences | |
| sequences = self.parser.parse(fasta_content) | |
| validation_result, errors = self.parser.validate(sequences) | |
| if not validation_result: | |
| results["errors"].extend(errors) | |
| return results | |
| results["input_sequences"] = [ | |
| { | |
| "id": seq.id, | |
| "description": seq.description, | |
| "length": len(seq.sequence), | |
| "type": seq.sequence_type | |
| } | |
| for seq in sequences | |
| ] | |
| results["sequence_insights"] = self.sequence_specific_analyzer.analyze(sequences) | |
| sequence_count = len(sequences) | |
| sequence_lengths = [len(seq.sequence) for seq in sequences] | |
| max_sequence_length = max(sequence_lengths, default=0) | |
| total_symbols = sum(sequence_lengths) | |
| sequence_types = {seq.sequence_type for seq in sequences} | |
| alignment_eligible = ( | |
| sequence_count >= 2 | |
| and max_sequence_length <= self.ALIGNMENT_MAX_SEQUENCE_LENGTH | |
| and total_symbols <= self.ALIGNMENT_MAX_TOTAL_SYMBOLS | |
| ) | |
| phylogeny_eligible = ( | |
| sequence_count >= 2 | |
| and sequence_count <= self.INTERACTIVE_MAX_SEQUENCES | |
| and max_sequence_length <= self.INTERACTIVE_MAX_SEQUENCE_LENGTH | |
| and total_symbols <= self.INTERACTIVE_MAX_TOTAL_SYMBOLS | |
| ) | |
| if sequence_count < 2: | |
| strategy = "single_sequence" | |
| elif alignment_eligible: | |
| strategy = "alignment" | |
| elif phylogeny_eligible: | |
| strategy = "kmer_fallback" | |
| else: | |
| strategy = "per_sequence_only" | |
| results["workload"] = { | |
| "sequence_count": sequence_count, | |
| "max_sequence_length": max_sequence_length, | |
| "total_symbols": total_symbols, | |
| "alignment_eligible": alignment_eligible, | |
| "phylogeny_eligible": phylogeny_eligible, | |
| "strategy": strategy, | |
| } | |
| # 2. Multiple Sequence Alignment | |
| alignment = None | |
| needs_alignment = ( | |
| run_alignment | |
| or run_conservation | |
| or run_phylogeny | |
| or run_motifs | |
| ) and alignment_eligible | |
| if needs_alignment: | |
| try: | |
| alignment, alignment_metadata = self.aligner.align(sequences) | |
| if run_alignment: | |
| results["alignment"] = { | |
| "aligned_sequences": [ | |
| { | |
| "id": record.id, | |
| "sequence": str(record.seq), | |
| "length": len(record.seq) | |
| } | |
| for record in alignment | |
| ], | |
| "metadata": alignment_metadata, | |
| "format": "fasta_aligned" | |
| } | |
| except Exception as e: | |
| results["errors"].append(f"Alignment failed: {str(e)}") | |
| elif run_alignment and sequence_count < 2: | |
| results["warnings"].append( | |
| f"Alignment skipped: at least 2 sequences are required; parsed {len(sequences)}." | |
| ) | |
| elif run_alignment and not alignment_eligible: | |
| results["warnings"].append( | |
| "Multiple sequence alignment skipped because the full input exceeds the " | |
| f"interactive alignment envelope ({self.ALIGNMENT_MAX_SEQUENCE_LENGTH:,} " | |
| f"symbols per sequence or {self.ALIGNMENT_MAX_TOTAL_SYMBOLS:,} total)." | |
| ) | |
| # 3. Conservation Scoring (requires alignment) | |
| if run_conservation and alignment: | |
| try: | |
| conservation_results = self.conservation_scorer.calculate_conservation(alignment) | |
| results["conservation"] = conservation_results | |
| except Exception as e: | |
| results["errors"].append(f"Conservation scoring failed: {str(e)}") | |
| elif run_conservation: | |
| results["warnings"].append( | |
| "Conservation scoring skipped because a multiple sequence alignment is unavailable." | |
| ) | |
| # 4. Domain Identification | |
| if run_domains: | |
| try: | |
| domains = self.domain_identifier.identify_domains(sequences, alignment) | |
| results["domains"] = domains | |
| except Exception as e: | |
| results["errors"].append(f"Domain identification failed: {str(e)}") | |
| # 5. Motif Finding | |
| if run_motifs and (alignment or sequence_count < 2): | |
| try: | |
| motifs = self.motif_finder.find_motifs(sequences, alignment) | |
| results["motifs"] = motifs | |
| except Exception as e: | |
| results["errors"].append(f"Motif finding failed: {str(e)}") | |
| elif run_motifs: | |
| results["warnings"].append( | |
| "Alignment-based motif finding skipped because a multiple sequence alignment " | |
| "is unavailable." | |
| ) | |
| # 6. Phylogenetic Tree Construction | |
| if run_phylogeny and sequence_count < 2: | |
| results["warnings"].append( | |
| f"Phylogenetic tree skipped: at least 2 sequences are required; parsed {sequence_count}." | |
| ) | |
| elif run_phylogeny and (len(sequence_types) != 1 or "unknown" in sequence_types): | |
| results["warnings"].append( | |
| "Phylogenetic tree skipped because all sequences must have the same detected " | |
| "molecule type." | |
| ) | |
| elif run_phylogeny and not phylogeny_eligible: | |
| results["warnings"].append( | |
| "Phylogenetic tree skipped because the input exceeds the interactive envelope " | |
| f"({self.INTERACTIVE_MAX_SEQUENCES} sequences, " | |
| f"{self.INTERACTIVE_MAX_SEQUENCE_LENGTH:,} symbols per sequence, or " | |
| f"{self.INTERACTIVE_MAX_TOTAL_SYMBOLS:,} total symbols)." | |
| ) | |
| elif run_phylogeny and alignment: | |
| try: | |
| newick_tree, tree_metadata = self.tree_builder.build_tree(alignment) | |
| results["phylogenetic_tree"] = { | |
| "newick": newick_tree, | |
| "metadata": tree_metadata | |
| } | |
| except Exception as e: | |
| results["errors"].append(f"Phylogenetic tree construction failed: {str(e)}") | |
| elif run_phylogeny: | |
| try: | |
| sequence_type = next(iter(sequence_types)) | |
| newick_tree, tree_metadata = self.tree_builder.build_kmer_tree( | |
| sequences, sequence_type | |
| ) | |
| results["phylogenetic_tree"] = { | |
| "newick": newick_tree, | |
| "metadata": tree_metadata, | |
| } | |
| results["warnings"].append( | |
| "The phylogenetic tree uses full-sequence k-mer Jaccard distances because " | |
| "multiple sequence alignment was unavailable." | |
| ) | |
| except Exception as e: | |
| results["errors"].append(f"Phylogenetic tree construction failed: {str(e)}") | |
| except Exception as e: | |
| results["errors"].append(f"Analysis pipeline error: {str(e)}") | |
| return results | |
| def generate_report(self, results: Dict) -> str: | |
| """Generate a comprehensive text report from analysis results""" | |
| report = [] | |
| report.append("=" * 80) | |
| report.append("SEQUENCE ANALYSIS REPORT") | |
| report.append("=" * 80) | |
| report.append("") | |
| # Input sequences | |
| report.append("INPUT SEQUENCES") | |
| report.append("-" * 80) | |
| for seq in results.get("input_sequences", []): | |
| report.append(f"ID: {seq['id']}") | |
| report.append(f" Type: {seq['type']}") | |
| report.append(f" Length: {seq['length']} residues") | |
| report.append(f" Description: {seq.get('description', 'N/A')}") | |
| report.append("") | |
| if results.get("workload"): | |
| workload = results["workload"] | |
| report.append("WORKLOAD STRATEGY") | |
| report.append("-" * 80) | |
| report.append(f"Strategy: {workload.get('strategy', 'N/A')}") | |
| report.append(f"Total Symbols: {workload.get('total_symbols', 0)}") | |
| report.append("") | |
| # Alignment | |
| if results.get("alignment"): | |
| align_data = results["alignment"] | |
| report.append("MULTIPLE SEQUENCE ALIGNMENT") | |
| report.append("-" * 80) | |
| metadata = align_data.get("metadata", {}) | |
| report.append(f"Alignment Length: {metadata.get('alignment_length', 'N/A')}") | |
| report.append(f"Number of Sequences: {metadata.get('num_sequences', 'N/A')}") | |
| report.append(f"Conserved Positions: {metadata.get('conserved_positions', 'N/A')}") | |
| report.append(f"Conservation: {metadata.get('conservation_percentage', 0):.2f}%") | |
| report.append("") | |
| # Sequence-specific insights | |
| if results.get("sequence_insights"): | |
| report.append("SEQUENCE-SPECIFIC INSIGHTS") | |
| report.append("-" * 80) | |
| for seq_id, insight in results["sequence_insights"].items(): | |
| report.append(f"Sequence: {seq_id}") | |
| for point in insight.get("summary_points", []): | |
| report.append(f" - {point}") | |
| metrics = insight.get("metrics", {}) | |
| if metrics: | |
| metric_text = ", ".join( | |
| f"{key}: {value}" for key, value in metrics.items() if not isinstance(value, (dict, list)) | |
| ) | |
| if metric_text: | |
| report.append(f" Metrics: {metric_text}") | |
| features = insight.get("features", []) | |
| if features: | |
| report.append(" Features:") | |
| for feature in features[:10]: | |
| report.append( | |
| f" - {feature.get('feature', 'Feature')} at {feature.get('location', 'N/A')}: {feature.get('detail', '')}" | |
| ) | |
| report.append("") | |
| # Conservation | |
| if results.get("conservation"): | |
| cons_data = results["conservation"] | |
| report.append("CONSERVATION ANALYSIS") | |
| report.append("-" * 80) | |
| report.append(f"Mean Conservation Score: {cons_data.get('mean_conservation', 0):.4f}") | |
| report.append(f"Highly Conserved Positions (>90th percentile): {len(cons_data.get('highly_conserved_positions', []))}") | |
| report.append("") | |
| # Domains | |
| if results.get("domains"): | |
| report.append("DOMAIN IDENTIFICATION") | |
| report.append("-" * 80) | |
| for seq_id, domains in results["domains"].items(): | |
| if domains: | |
| report.append(f"Sequence: {seq_id}") | |
| for domain in domains: | |
| report.append(f" {domain['domain_name']}: positions {domain['start']}-{domain['end']} (confidence: {domain['confidence']:.2f})") | |
| report.append("") | |
| # Motifs | |
| if results.get("motifs"): | |
| motifs_data = results["motifs"] | |
| report.append("MOTIF ANALYSIS") | |
| report.append("-" * 80) | |
| report.append(f"Number of Motifs Found: {motifs_data.get('num_motifs', 0)}") | |
| report.append(f"Method: {motifs_data.get('method', 'N/A')}") | |
| for motif in motifs_data.get("motifs", [])[:10]: # Top 10 | |
| report.append(f" Motif: {motif.get('motif', 'N/A')} (length: {motif.get('length', 'N/A')})") | |
| report.append("") | |
| # Phylogenetic Tree | |
| if results.get("phylogenetic_tree"): | |
| tree_data = results["phylogenetic_tree"] | |
| report.append("PHYLOGENETIC TREE") | |
| report.append("-" * 80) | |
| metadata = tree_data.get("metadata", {}) | |
| report.append(f"Method: {metadata.get('method', 'N/A')}") | |
| report.append(f"Distance Method: {metadata.get('distance_method', 'N/A')}") | |
| report.append(f"Alignment Used: {metadata.get('alignment_used', 'N/A')}") | |
| report.append(f"Newick Format:") | |
| report.append(tree_data.get("newick", "N/A")) | |
| report.append("") | |
| if results.get("warnings"): | |
| report.append("WARNINGS") | |
| report.append("-" * 80) | |
| for warning in results["warnings"]: | |
| report.append(f" - {warning}") | |
| report.append("") | |
| # Errors | |
| if results.get("errors"): | |
| report.append("ERRORS") | |
| report.append("-" * 80) | |
| for error in results["errors"]: | |
| report.append(f" - {error}") | |
| report.append("") | |
| report.append("=" * 80) | |
| report.append("End of Report") | |
| report.append("=" * 80) | |
| return "\n".join(report) | |