diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efc50f..4ce5fc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,21 @@ jobs: python-version: ${{ matrix.python-version }} - run: python -m pip install -e ".[dev]" - run: python -m pytest -q + + tests-linux: + runs-on: ubuntu-22.04 + env: + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install Qt runtime libraries + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-cursor0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ + libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xfixes0 libgl1 + - run: python -m pip install -e ".[dev]" + - run: python -m pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 627a36c..53ec85f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,22 +1,47 @@ name: Release +# workflow_dispatch builds and uploads the artifacts; a release is created only for v* tags. on: push: tags: ["v*"] workflow_dispatch: jobs: - windows-exe: - runs-on: windows-latest + build: + name: build (${{ matrix.artifact }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + artifact: ab1kit-windows + file: dist/ab1kit.exe + - os: ubuntu-22.04 + artifact: ab1kit-linux-x86_64 + file: ab1kit-linux-x86_64.tar.gz permissions: - contents: write + contents: read + env: + QT_QPA_PLATFORM: offscreen steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.13" + - name: Install Qt runtime libraries (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-cursor0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ + libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xfixes0 libgl1 - run: python -m pip install -e ".[dev]" "pyinstaller==6.22.3" + - name: Run tests + run: python -m pytest -q + - name: Build exe (errors surfaced as annotations) + if: runner.os == 'Windows' shell: pwsh run: | python -m PyInstaller ab1kit.spec --noconfirm --workpath build-pyinstaller 2>&1 | @@ -26,13 +51,80 @@ jobs: Write-Host "::error::PyInstaller failed, log tail: $tail" exit 1 } + - name: Build binary (errors surfaced as annotations) + if: runner.os == 'Linux' + shell: bash + run: | + set -o pipefail + if ! python -m PyInstaller ab1kit.spec --noconfirm --workpath build-pyinstaller 2>&1 | tee build_log.txt; then + tail_log=$(tail -n 30 build_log.txt | tr '\n' '|') + echo "::error::PyInstaller failed, log tail: ${tail_log}" + exit 1 + fi + + - name: Smoke-test frozen exe (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force smoke/reads | Out-Null + Copy-Item tests/data/real/Achl_ACHLO006-09_*.ab1 smoke/reads/ + # The exe is windowed: Start-Process -Wait -PassThru is needed to get the exit code. + $p = Start-Process -FilePath dist/ab1kit.exe -Wait -PassThru -ArgumentList @( + "verify-clones", "--construct", "tests/data/refs/coi_HM417896.gb", + "smoke/reads", "-o", "smoke/report.xlsx") + if ($p.ExitCode -ne 0) { + Write-Host "::error::smoke test: ab1kit.exe verify-clones exited with $($p.ExitCode)" + exit 1 + } + if (-not (Test-Path smoke/report.xlsx)) { + Write-Host "::error::smoke test: smoke/report.xlsx was not created" + exit 1 + } + - name: Smoke-test frozen binary (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p smoke/reads + cp tests/data/real/Achl_ACHLO006-09_*.ab1 smoke/reads/ + rc=0 + dist/ab1kit verify-clones --construct tests/data/refs/coi_HM417896.gb \ + smoke/reads -o smoke/report.xlsx || rc=$? + if [ "$rc" -ne 0 ]; then + echo "::error::smoke test: dist/ab1kit verify-clones exited with $rc" + exit 1 + fi + if [ ! -f smoke/report.xlsx ]; then + echo "::error::smoke test: smoke/report.xlsx was not created" + exit 1 + fi + + - name: Package tarball (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + cp README.md LICENSE dist/ + tar -czf ab1kit-linux-x86_64.tar.gz -C dist ab1kit README.md LICENSE + - uses: actions/upload-artifact@v4 with: - name: ab1kit-windows - path: dist/ab1kit.exe + name: ${{ matrix.artifact }} + path: ${{ matrix.file }} + if-no-files-found: error + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts - name: Create GitHub release - if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: - files: dist/ab1kit.exe + files: | + artifacts/ab1kit-windows/ab1kit.exe + artifacts/ab1kit-linux-x86_64/ab1kit-linux-x86_64.tar.gz generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f201bd..9c68348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ All notable changes to ab1kit are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is [SemVer](https://semver.org/). +## [1.1.0] — 2026-10-30 + +Clone verification: check a plate of clones against a construct, in the GUI or +from the command line. + +### Added +- Reference loading from FASTA, GenBank and SnapGene files, with circular + constructs and reads spanning the origin +- Normalised secondary-peak analysis at any read position +- Grouping of reads into clones by file name (default rules or a custom regex), + per-clone coverage by strand, merged differences and verdicts + (`ok` / `errors` / `incomplete` / `mixed` / `no_data`) +- Amino-acid consequences of differences inside CDS features +- Clone report as CSV and Excel (sheets `Clones` and `Differences`; unusable + reads listed per clone in `read_issues` and on the CLI's stderr); + `.ab1proj` project files for verification sessions +- Clone-verification workspace: summary bar, clone matrix, difference detail pane with stacked + chromatograms, grouping editor, drag-and-drop of reads and projects +- Background tasks with progress and cancel +- Command-line subcommands `verify-clones`, `qc` and `export` (no Qt import) +- Real-data test corpus (sangeranalyseR reads, COI and pUC19 references) +- Linux release build (`ab1kit-linux-x86_64.tar.gz`) alongside the Windows exe, built and smoke-tested by GitHub Actions +- Application icon (window and Windows exe) + +### Changed +- Refreshed UI: QSS theme, icons, one status palette everywhere +- Quality colour thresholds are now <20 / 20-29 / >=30 everywhere +- Main window split into the `ui.read_view` package (no behaviour change) +- Alignment counts distinct placements; repeated hits are flagged as ambiguous + +### Fixed +- Reverse-strand reads in expected-SNP verification and the reference overlay + were indexed in the wrong orientation (now correct) +- Reverse-complementing a read misplaced peak positions; editing sessions saved + after a reverse-complement with 1.0.0 or earlier hold wrong peak positions — + re-open the original file and reverse-complement again +- Untranslated verification notes; mean quality for reads without quality data + ## [1.0.0] — 2026-10-24 First feature-complete release: the full cycle «open a chromatogram → inspect → diff --git a/README.md b/README.md index aa04b32..cd8be3a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# ab1kit +# ab1kit Open-source desktop tool for viewing, editing and analyzing Sanger sequencing chromatograms (`.ab1` files) — a free, offline alternative to FinchTV / Chromas for everyday molecular biology lab work. English/Russian interface. ![viewer](docs/screenshots/viewer.png) +![construct check](docs/screenshots/construct.png) ## Features @@ -48,6 +49,73 @@ for everyday molecular biology lab work. English/Russian interface. ![dark theme](docs/screenshots/viewer-dark.png) ![batch mode](docs/screenshots/batch.png) +## Clone verification + +Check a set of plasmid or amplicon clones against the expected construct from +their Sanger reads: open the clone-verification workspace, load the reference (FASTA, +GenBank or SnapGene), drop the `.ab1` reads of the whole plate, optionally set +the region of interest, and read the verdict per clone off the colour matrix. + +![clone verification](docs/screenshots/construct.png) + +**Scenario.** Load the construct, add the reads (files or a folder), check the +automatic grouping into clones, choose the region; every clone gets its verdict right away. Click a +difference in the matrix to see the stacked chromatograms of every read at that +position. Export the result as Excel/CSV, or save the whole session as an +`.ab1proj` file. FASTA constructs are treated as circular (reads may span the +origin); GenBank/SnapGene files take the topology from the file; the *Circular* +checkbox (`--linear` on the command line) overrides the topology. + +**File names.** Reads are grouped by name as `_`: the primer is +the last known primer token (`F`, `R`, `T7`, `SP6`, `M13F`, `pUC-F`, ...); if +the name has none, the last token is taken as the primer. The clone is what +precedes the primer, minus a single-digit order token and a leading plate well +(`A05`), the well being stripped only when another clone token follows it +(`B2_F` is clone `B2`). `A05_clone3_2_R.ab1` is clone `clone3`, primer `R`; +`Achl_ACHLO006-09_1_F.ab1` is clone `Achl_ACHLO006-09`. Files whose name yields +no clone stay unassigned and are skipped. For other conventions give a regular +expression with named groups `clone` and `primer` that matches the whole file +name without the extension, e.g. `(?P\d+)-(?P[A-Za-z0-9]+)`. + +**Verdicts.** Checked in this order, the first match wins: + +| Verdict | Meaning | +| --- | --- | +| `no_data` (grey) | no read of the clone aligned to the construct | +| `mixed` (purple) | at least 3 positions (default) with a double peak: probably a mixed colony | +| `errors` (red) | at least one difference in the region that is confirmed or disputed | +| `incomplete` (yellow) | no such difference, but part of the region is not covered by any read | +| `ok` (green) | the region is fully covered and shows no reliable difference | + +A difference is *confirmed* (only supporting reads, Q >= min Q), *disputed* +(another read shows the reference there) or *low quality* (seen only below +min Q). Both confirmed and disputed differences give `errors`; only low-quality +ones do not change the verdict. Defaults: min Q 20, double-peak ratio 0.40. + +**Command line** (no GUI, no Qt needed): + +``` +ab1kit verify-clones --construct pET28-gene.gb --region 120-1850 reads/ -o report.xlsx +ab1kit qc reads/ -o qc.csv +ab1kit export reads/ --trim --fasta -o trimmed/ +``` + +`verify-clones` prints a table of clones and exits with 0 if at least one clone +is `ok`, 1 if none is, 2 on invalid input; `-o report.csv` writes `report.csv` +and `report_differences.csv`. The `differences` count (table and report) is the +in-region differences that are not low quality, i.e. those behind `errors`. +Reads that could not be used (unreadable, not aligned, ambiguous placement) are +listed on stderr as `ab1kit: : ` and in the `read_issues` column. Options: `--region A-B` (1-based, inclusive), +`--linear`, `--pattern REGEX`, `--min-q N`, `--het-ratio X`. + +The exit-code contract applies to the `ab1kit` command installed with pip. The +Windows release `ab1kit.exe` is a windowed program: the shell returns to the +prompt immediately, so exit codes and output ordering are unreliable and output +may appear after the prompt. Wait for it explicitly: `start /wait ab1kit.exe +verify-clones ...` (cmd), or `Start-Process -Wait -NoNewWindow` / +`(Start-Process ab1kit.exe -ArgumentList ... -Wait -PassThru).ExitCode` +(PowerShell). + ## Getting started Requires Python 3.11+: @@ -56,12 +124,20 @@ Requires Python 3.11+: python -m venv .venv .venv/Scripts/pip install -e ".[dev]" # Windows; on POSIX: .venv/bin/pip .venv/Scripts/python -m pytest # run the test suite +.venv/Scripts/python -m pytest -m perf # timing tests (skipped by default) .venv/Scripts/ab1kit # launch the GUI ``` -`ab1kit` also accepts a file or folder argument. On Windows, a prebuilt -executable is available on the [Releases](https://github.com/adrobi/ab1kit/releases) -page. +`ab1kit` also accepts a file or folder argument. Prebuilt binaries are on the +[Releases](https://github.com/adrobi/ab1kit/releases) page: + +- **Windows:** `ab1kit.exe` (single file, no installation). +- **Linux (x86_64):** `ab1kit-linux-x86_64.tar.gz`. Unpack with + `tar -xzf ab1kit-linux-x86_64.tar.gz` and run `./ab1kit`. Built on Ubuntu + 22.04, so it needs glibc 2.35 or newer. The GUI needs the usual Qt runtime + libraries; on Debian/Ubuntu: + `sudo apt install libegl1 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xfixes0 libgl1 libfontconfig1 libdbus-1-3 libglib2.0-0 libxcb-xkb1 libxcb-xinput0 libxcb-shm0`. + The Linux binary prints CLI output to the terminal normally. ## Keyboard shortcuts @@ -96,5 +172,6 @@ Example chromatograms in `examples/` come from the ## Status -v1.0.0 — viewer, editing, trimming, batch mode, alignment, SNP calling and -expected-SNP verification are all implemented. See [CHANGELOG.md](CHANGELOG.md). +v1.1.0 — adds clone verification, a command-line interface, a refreshed UI +and more accurate peak analysis on top of the 1.0 viewer, editing, batch mode, +alignment, SNP calling and expected-SNP verification. See [CHANGELOG.md](CHANGELOG.md). diff --git a/ab1kit.spec b/ab1kit.spec index b21c6f7..06ffbeb 100644 --- a/ab1kit.spec +++ b/ab1kit.spec @@ -1,15 +1,21 @@ # -*- mode: python ; coding: utf-8 -*- -"""PyInstaller spec for the ab1kit one-file Windows executable. +"""PyInstaller spec for the ab1kit one-file executable (Windows exe, Linux ELF). Build: pyinstaller ab1kit.spec --noconfirm """ +import sys + a = Analysis( ["chrom_cli.py"], pathex=[], binaries=[], - datas=[], - hiddenimports=["chrom_core", "ui", "i18n", "translations_en"], + datas=[ + ("ui/style.qss", "ui"), + ("ui/style_dark.qss", "ui"), + ("ui/icons", "ui/icons"), + ], + hiddenimports=["chrom_core", "ui", "ui.read_view", "ui.construct", "ui.status", "ui.tables", "i18n", "translations_en"], hookspath=[], excludes=["tkinter", "matplotlib", "numpy.tests"], noarchive=False, @@ -26,5 +32,8 @@ exe = EXE( debug=False, strip=False, upx=False, + # console=False only matters on Windows/macOS; a Linux ELF keeps stdout. console=False, + # .ico is a Windows/macOS-only option; PyInstaller does not use it on Linux. + icon="ui/icons/app/ab1kit.ico" if sys.platform == "win32" else None, ) diff --git a/chrom_cli.py b/chrom_cli.py index 66f4fb1..56760d7 100644 --- a/chrom_cli.py +++ b/chrom_cli.py @@ -1,14 +1,220 @@ -"""ab1kit entry point: the ``ab1kit`` command launches the GUI.""" +"""ab1kit entry point: the GUI by default, plus ``verify-clones`` / ``qc`` / ``export``. + +The subcommands are a thin shell over ``chrom_core`` and never import Qt. +""" from __future__ import annotations +import argparse import sys +from pathlib import Path + +COMMANDS = {"verify-clones", "qc", "export"} + + +class CliError(Exception): + """Bad input: reported on stderr, exit code 2.""" + + +def _console_encoding(code_page: int) -> str: + """Python codec for a console code page; utf-8 when unknown (0 = query failed).""" + import codecs + + if code_page: + try: + codecs.lookup(f"cp{code_page}") + return f"cp{code_page}" + except LookupError: + pass + return "utf-8" + + +def _attach_console() -> None: + """Frozen windowed build: reuse the parent console, writing in its own code page.""" + opened = [] + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + if not kernel32.AttachConsole(-1): + return + encoding = _console_encoding(kernel32.GetConsoleOutputCP()) # never change it + wanted = [name for name in ("stdout", "stderr") if getattr(sys, name) is None] + for name in wanted: + opened.append((name, open("CONOUT$", "w", encoding=encoding, errors="replace"))) + except (AttributeError, OSError, LookupError): + for _name, stream in opened: # all or nothing: keep both streams consistent + try: + stream.close() + except (AttributeError, OSError): + pass + return + for name, stream in opened: + setattr(sys, name, stream) + + +def _prepare_streams() -> None: + """Print Cyrillic safely on any console.""" + attached = sys.stdout is None or sys.stderr is None + if attached: + _attach_console() + for stream in (sys.stdout, sys.stderr): + # an attached console stream keeps the console's own code page + if stream is not None and hasattr(stream, "reconfigure") and not ( + attached and getattr(stream, "name", "") == "CONOUT$"): + stream.reconfigure(encoding="utf-8", errors="replace") + + +def _parse_region(text: str) -> tuple[int, int]: + try: + a, b = (int(x) for x in text.split("-")) + except ValueError: + raise CliError(f"invalid --region {text!r}: expected A-B (1-based, inclusive)") from None + if a < 1 or b < 1: + raise CliError(f"invalid --region {text!r}: positions start at 1") + return (a - 1, b) + + +def _reads_in(folder: str) -> list[Path]: + path = Path(folder) + if not path.is_dir(): + raise CliError(f"not a directory: {folder}") + return sorted(p for p in path.iterdir() if p.suffix.lower() == ".ab1") + + +def _verify_clones(args: argparse.Namespace) -> int: + from chrom_core.clone_report import ( + clone_report_xlsx, clones_csv, differences_csv, read_issue) + from chrom_core.clones import VerifyParams, check_region, group_reads, verify_all + from chrom_core.reference import load_construct + + try: + construct = load_construct(args.construct) + except (ValueError, OSError) as exc: + raise CliError(f"cannot load construct: {exc}") from exc + if args.linear: + construct = construct.with_circular(False) + out = Path(args.output) + if out.suffix.lower() not in (".xlsx", ".csv"): + raise CliError("report file must end in .xlsx or .csv") + region = _parse_region(args.region) if args.region else None + try: + check_region(region, len(construct.seq), construct.circular) + paths = _reads_in(args.reads_dir) + if not paths: + raise CliError(f"no .ab1 files in {args.reads_dir}") + grouping = group_reads(paths, pattern=args.pattern) + except ValueError as exc: + raise CliError(str(exc)) from exc + for path in grouping.unassigned: + print(f"ab1kit: skipped (clone name not recognised): {path.name}", file=sys.stderr) + if not grouping.reads: + raise CliError("no reads could be assigned to a clone") + params = VerifyParams(min_q=args.min_q, het_ratio=args.het_ratio) + reports = verify_all(construct, region, grouping, params) + for report in reports: + for read in report.reads: + if read_issue(read): + print(f"ab1kit: {read.ref.path.name}: {read_issue(read)}", file=sys.stderr) + + if out.suffix.lower() == ".xlsx": + clone_report_xlsx(out, reports, construct.name) + else: + out.write_text(clones_csv(reports), encoding="utf-8") + out.with_name(f"{out.stem}_differences.csv").write_text( + differences_csv(reports), encoding="utf-8") + + width = max(len(r.clone) for r in reports) + for r in reports: + print(f"{r.clone:<{width}} {r.verdict:<10} coverage {r.region_coverage_pct:5.1f}% " + f"differences {len(r.error_differences)}") + return 0 if any(r.verdict == "ok" for r in reports) else 1 + + +def _qc(args: argparse.Namespace) -> int: + from chrom_core import batch + + _reads_in(args.reads_dir) # validates the folder + text = batch.summary_csv(batch.scan_folder(args.reads_dir)) + if args.output: + Path(args.output).write_text(text, encoding="utf-8") + else: + print(text, end="") + return 0 + + +def _export(args: argparse.Namespace) -> int: + from chrom_core import batch + + if not args.trim: + raise CliError("export currently needs --trim") + reports = batch.scan_folder(args.reads_dir) + if not reports: + raise CliError(f"no .ab1 files in {args.reads_dir}") + results = batch.batch_export(reports, args.output, fmt="fasta" if args.fasta else "fastq") + failed = 0 + for source, _dest, error in results: + if error: + failed += 1 + print(f"ab1kit: {source.name}: {error}", file=sys.stderr) + print(f"exported {len(results) - failed} of {len(results)} reads to {args.output}") + return 1 if failed else 0 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="ab1kit") + sub = parser.add_subparsers(dest="command", required=True) + + v = sub.add_parser("verify-clones", help="verify clones against a construct") + v.add_argument("--construct", required=True, help="FASTA, GenBank or SnapGene file") + v.add_argument("--region", help="A-B, 1-based inclusive (default: whole construct)") + v.add_argument("--linear", action="store_true", help="treat the construct as linear") + v.add_argument("--pattern", help="regex with named groups 'clone' and 'primer'") + v.add_argument("--min-q", type=int, default=20) + v.add_argument("--het-ratio", type=float, default=0.40) + v.add_argument("reads_dir", metavar="READS_DIR") + v.add_argument("-o", "--output", required=True, help="report: .xlsx or .csv") + v.set_defaults(func=_verify_clones) + + q = sub.add_parser("qc", help="quality summary of every .ab1 in a folder") + q.add_argument("reads_dir", metavar="READS_DIR") + q.add_argument("-o", "--output", help="CSV file (default: stdout)") + q.set_defaults(func=_qc) + + e = sub.add_parser("export", help="export trimmed reads") + e.add_argument("reads_dir", metavar="READS_DIR") + e.add_argument("--trim", action="store_true") + fmt = e.add_mutually_exclusive_group() + fmt.add_argument("--fasta", action="store_true") + fmt.add_argument("--fastq", action="store_true", help="(default)") + e.add_argument("-o", "--output", required=True, metavar="DIR") + e.set_defaults(func=_export) + return parser + + +def _run_command(argv: list[str]) -> int: + _prepare_streams() + try: + args = _build_parser().parse_args(argv) + except SystemExit as exc: # argparse usage errors and --help + return exc.code if isinstance(exc.code, int) else 2 + try: + return args.func(args) + except CliError as exc: + print(f"ab1kit: {exc}", file=sys.stderr) + return 2 + except OSError as exc: + print(f"ab1kit: {exc}", file=sys.stderr) + return 2 def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if args and args[0] in COMMANDS: + return _run_command(args) from ui.main_window import run - return run(argv if argv is not None else sys.argv[1:]) + return run(args) if __name__ == "__main__": diff --git a/chrom_core/align.py b/chrom_core/align.py index 7417ca6..db70d2e 100644 --- a/chrom_core/align.py +++ b/chrom_core/align.py @@ -14,17 +14,24 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING from Bio import Align from chrom_core.seqops import IUPAC, reverse_complement +if TYPE_CHECKING: # reference.py imports this module; avoid a runtime cycle + from chrom_core.reference import Construct + MATCH_SCORE = 2.0 MISMATCH_SCORE = -3.0 GAP_OPEN = -10.0 GAP_EXTEND = -1.0 MAX_REFERENCE_LENGTH = 100_000 +CIRCULAR_MARGIN = 50 # extra unrolled bases past len(read) for circular constructs +MAX_COUNTED_ALTERNATIVES = 1000 # co-optimal paths examined to dedupe the circular copy +MAX_PLACEMENT_PATHS = 100 # co-optimal paths examined to count distinct placements @dataclass(frozen=True) @@ -40,8 +47,8 @@ class Difference: class ReadAlignment: strand: str # '+' or '-', relative to the read as passed in score: float - ref_start: int # covered reference span, 0-based [start, end) - ref_end: int + ref_start: int # covered reference span, 0-based [start, end); + ref_end: int # ref_end < ref_start: wraps the origin of a circle read_start: int read_end: int ref_to_read: dict[int, int] # covered ref position -> read index @@ -50,11 +57,32 @@ class ReadAlignment: alternatives: int # number of co-optimal alignments (1 = unique) oriented_read: str # the read in its aligned orientation blocks: list # biopython aligned blocks, for gapped reconstruction + # (unrolled coordinates for circular constructs) + ref_length: int = 0 # construct length (0 = unknown, from align_read) + placements: int = 1 # distinct (non-overlapping) co-optimal placements; + # 0 = unknown. Unlike ``alternatives``, alternative + # gap positions inside one placement count once. + + @property + def wraps(self) -> bool: + return self.ref_end < self.ref_start @property def covered(self) -> int: + if self.wraps: + return self.ref_length - self.ref_start + self.ref_end return self.ref_end - self.ref_start + def covers(self, pos: int) -> bool: + if self.wraps: + return self.ref_start <= pos < self.ref_length or 0 <= pos < self.ref_end + return self.ref_start <= pos < self.ref_end + + def covered_positions(self) -> list[int]: + if self.wraps: + return list(range(self.ref_start, self.ref_length)) + list(range(self.ref_end)) + return list(range(self.ref_start, self.ref_end)) + def _make_aligner() -> Align.PairwiseAligner: aligner = Align.PairwiseAligner() @@ -85,20 +113,37 @@ def parse_sequence(text: str) -> str: return seq -def align_read(read: str, reference: str) -> ReadAlignment: - """Align a (possibly reverse-strand) read into the reference.""" +def _clean(read: str, reference: str) -> tuple[str, str]: read = "".join(read.split()).upper() reference = "".join(reference.split()).upper() if not read: raise ValueError("empty read") if not reference: raise ValueError("empty reference") - if len(reference) > MAX_REFERENCE_LENGTH: + return read, reference + + +def _check_length(length: int) -> None: + if length > MAX_REFERENCE_LENGTH: raise ValueError( - f"reference of {len(reference)} bp is too long for exact alignment " + f"reference of {length} bp is too long for exact alignment " f"(limit {MAX_REFERENCE_LENGTH})" ) + +def align_read(read: str, reference: str) -> ReadAlignment: + """Align a (possibly reverse-strand) read into the reference.""" + read, reference = _clean(read, reference) + _check_length(len(reference)) + return _align(read, reference)[0] + + +def _align(read: str, reference: str, L: int | None = None) -> tuple[ReadAlignment, object]: + """align_read without the size limit (callers check it on the real length). + + Also returns biopython's co-optimal alignments for the winning strand. + ``L``: the circle length when ``reference`` is an unrolled circle. + """ aligner = _make_aligner() best: tuple[float, object, str, str] | None = None for strand, oriented in (("+", read), ("-", reverse_complement(read))): @@ -114,6 +159,102 @@ def align_read(read: str, reference: str) -> ReadAlignment: except Exception: # counting can blow up on pathological repeats alternatives = 0 # "unknown" + ref_to_read, differences, identity = _walk_blocks(blocks, reference, oriented) + differences.sort(key=lambda d: (d.ref_pos, d.kind)) + if blocks: + ref_start, ref_end = int(blocks[0][0][0]), int(blocks[-1][0][1]) + read_start, read_end = int(blocks[0][1][0]), int(blocks[-1][1][1]) + else: # no similarity at all: empty alignment (everything became free end gaps) + ref_start = ref_end = read_start = read_end = 0 + + return ReadAlignment( + strand=strand, + score=score, + ref_start=ref_start, + ref_end=ref_end, + read_start=read_start, + read_end=read_end, + ref_to_read=ref_to_read, + differences=differences, + identity=identity, + alternatives=alternatives, + oriented_read=oriented, + blocks=blocks, + placements=_placements(alignments, L), + ), alignments + + +def _placements(alignments, L: int | None = None) -> int: + """Clusters of co-optimal paths whose reference spans overlap. + + Up to ``MAX_PLACEMENT_PATHS`` paths are examined. With ``L`` (circular, + unrolled reference) spans are compared modulo L, so the unrolled copy of + the start is the same placement. 0 when iterating the paths fails. + """ + spans: list[tuple[int, int]] = [] + try: + for k, alignment in enumerate(alignments): + if k >= MAX_PLACEMENT_PATHS: + break + ts = alignment.aligned[0] + if len(ts): + spans.append((int(ts[0][0]), int(ts[-1][1]))) + except Exception: + return 0 + if not spans: + return 1 + + if L: # start in the first copy; compare with the neighbouring copies too + spans = [(t0 % L, t0 % L + t1 - t0) for t0, t1 in spans] + shifts = (-L, 0, L) if L else (0,) + + def overlap(a: tuple[int, int], b: tuple[int, int]) -> bool: + return any(a[0] < b[1] + s and b[0] + s < a[1] for s in shifts) + + parent = list(range(len(spans))) + + def root(i: int) -> int: + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for i in range(len(spans)): + for j in range(i): + if overlap(spans[i], spans[j]): + parent[root(i)] = root(j) + return len({root(i) for i in range(len(spans))}) + + +def _circular_alternatives(raw: ReadAlignment, alignments, L: int) -> int: + """Co-optimal alignments distinct modulo L. + + A read in the first ``len(read) + CIRCULAR_MARGIN`` bases also matches the + unrolled copy, shifted by exactly L; such pairs count once. 0 stays + "unknown". Beyond ``MAX_COUNTED_ALTERNATIVES`` paths the raw count is + returned: an upper bound, and at least half of it is distinct (at most two + copies of any path fit in the unrolled sequence), so it stays > 1. + """ + if raw.alternatives <= 1 or raw.alternatives > MAX_COUNTED_ALTERNATIVES: + return raw.alternatives + distinct = set() + try: + for alignment in alignments: + ts, qs = alignment.aligned + shift = (int(ts[0][0]) // L) * L if len(ts) else 0 + distinct.add(tuple( + (int(t0) - shift, int(t1) - shift, int(q0), int(q1)) + for (t0, t1), (q0, q1) in zip(ts, qs) + )) + except Exception: # same "unknown" convention as align_read + return 0 + return len(distinct) + + +def _walk_blocks( + blocks: list, reference: str, oriented: str +) -> tuple[dict[int, int], list[Difference], float]: + """Per-position mapping, differences (unsorted) and identity of a block list.""" ref_to_read: dict[int, int] = {} differences: list[Difference] = [] matches = 0 @@ -145,28 +286,79 @@ def align_read(read: str, reference: str) -> ReadAlignment: Difference(int(t0 + k), "substitution", ref_base, read_base, int(q0 + k)) ) prev_t, prev_q = t1, q1 - - differences.sort(key=lambda d: (d.ref_pos, d.kind)) identity = matches / columns if columns else 0.0 - if blocks: - ref_start, ref_end = int(blocks[0][0][0]), int(blocks[-1][0][1]) - read_start, read_end = int(blocks[0][1][0]), int(blocks[-1][1][1]) - else: # no similarity at all: empty alignment (everything became free end gaps) - ref_start = ref_end = read_start = read_end = 0 + return ref_to_read, differences, identity - return ReadAlignment( - strand=strand, - score=score, + +def align_to_construct(read: str, construct: Construct) -> ReadAlignment: + """Align a read to a construct; circular constructs allow reads across the origin. + + Linear: ``align_read`` plus ``ref_length``. Circular: the read is aligned to + the unrolled sequence ``seq + seq[:len(read) + CIRCULAR_MARGIN]`` and every + ``ref_*`` coordinate, ``ref_to_read`` key and ``Difference.ref_pos`` is + reported modulo ``L = len(seq)`` (``ref_end`` as ``((ref_end - 1) % L) + 1``), + so ``ref_end < ref_start`` means the span wraps the origin. ``blocks`` stay + in unrolled coordinates: pass ``construct.seq * 2`` to ``gapped_sequences``. + + A read longer than the circle can align over the same positions twice. The + first pass (in alignment order, from the alignment start) is kept: blocks + are clipped at ``start + L``, so at most L positions are reported, each + once, and identity/differences are computed over the clipped alignment. + ``score`` however stays that of the unclipped raw alignment. Full coverage + is normalised to ``(0, L)``. + + For a wrapped alignment ``differences`` is in alignment order — positions + after the origin come last — not ascending by ``ref_pos``. + + ``alternatives`` counts co-optimal alignments distinct modulo L, so the + unrolled copy of the construct's start is not mistaken for a repeat; + ``placements`` likewise compares reference spans modulo L. + + The size limit applies to ``L``, not to the unrolled sequence. + """ + read, seq = _clean(read, construct.seq) + L = len(seq) + _check_length(L) + if not construct.circular: + return replace(_align(read, seq)[0], ref_length=L) + + unrolled = seq + seq[: len(read) + CIRCULAR_MARGIN] + raw, alignments = _align(read, unrolled, L) + alternatives = _circular_alternatives(raw, alignments, L) + if not raw.blocks: + return replace(raw, ref_length=L, alternatives=alternatives) + + shift = (raw.blocks[0][0][0] // L) * L # make the alignment start in the first copy + limit = raw.blocks[0][0][0] - shift + L + clipped = [] + for (t0, t1), (q0, q1) in raw.blocks: + t0, t1 = t0 - shift, t1 - shift + if t0 >= limit: + break + if t1 > limit: + q1 = q0 + (limit - t0) + t1 = limit + clipped.append(((int(t0), int(t1)), (int(q0), int(q1)))) + + ref_to_read, differences, identity = _walk_blocks(clipped, unrolled, raw.oriented_read) + differences.sort(key=lambda d: (d.ref_pos, d.kind)) # unrolled order = alignment order + ref_start, ref_end = clipped[0][0][0], clipped[-1][0][1] + if ref_end - ref_start >= L: + ref_start, ref_end = 0, L + else: + ref_start, ref_end = ref_start % L, ((ref_end - 1) % L) + 1 + return replace( + raw, ref_start=ref_start, ref_end=ref_end, - read_start=read_start, - read_end=read_end, - ref_to_read=ref_to_read, - differences=differences, + read_start=clipped[0][1][0], + read_end=clipped[-1][1][1], + ref_to_read={p % L: q for p, q in ref_to_read.items()}, + differences=[replace(d, ref_pos=d.ref_pos % L) for d in differences], identity=identity, alternatives=alternatives, - oriented_read=oriented, - blocks=blocks, + blocks=clipped, + ref_length=L, ) @@ -175,8 +367,11 @@ def gapped_sequences(alignment: ReadAlignment, reference: str) -> tuple[str, str reference = reference.upper() ref_aligned: list[str] = [] read_aligned: list[str] = [] - prev_t = alignment.ref_start - prev_q = alignment.read_start + if not alignment.blocks: + return "", "" + # start at the first block (== ref_start/read_start for align_read; for a + # circular alignment the blocks are in unrolled coordinates) + (prev_t, _), (prev_q, _) = alignment.blocks[0] for (t0, t1), (q0, q1) in alignment.blocks: ref_aligned.extend(reference[prev_t:t0]) read_aligned.extend("-" * (t0 - prev_t)) diff --git a/chrom_core/batch.py b/chrom_core/batch.py index 90f867e..cc3efcf 100644 --- a/chrom_core/batch.py +++ b/chrom_core/batch.py @@ -6,7 +6,7 @@ from pathlib import Path from chrom_core import read_ab1, trim_window -from chrom_core import sessions +from chrom_core.clones import load_read from chrom_core.editing import EditableRead from chrom_core.export import to_fasta, to_fastq, write_text @@ -19,7 +19,7 @@ class FileReport: path: Path length: int = 0 - mean_q: float = 0.0 + mean_q: float | None = 0.0 # None: the read carries no quality values (all Q = 0) q20: int = 0 trim_start: int = 0 trim_end: int = 0 @@ -34,7 +34,10 @@ def status(self) -> str: """One of 'ok', 'warn', 'bad', 'error'.""" if self.error: return "error" - if self.trimmed_length >= MIN_OK_LENGTH and self.mean_q >= MIN_OK_MEAN_Q: + if ( + self.trimmed_length >= MIN_OK_LENGTH + and (self.mean_q or 0.0) >= MIN_OK_MEAN_Q + ): return "ok" if self.trimmed_length >= MIN_WARN_LENGTH: return "warn" @@ -56,7 +59,11 @@ def scan_folder(folder: str | Path, window: int = 20, min_q: int = 20) -> list[F FileReport( path=path, length=len(chrom.seq), - mean_q=round(sum(chrom.quality) / max(1, len(chrom.quality)), 1), + mean_q=( + round(sum(chrom.quality) / len(chrom.quality), 1) + if any(chrom.quality) + else None + ), q20=sum(1 for q in chrom.quality if q >= 20), trim_start=start, trim_end=end, @@ -65,18 +72,6 @@ def scan_folder(folder: str | Path, window: int = 20, min_q: int = 20) -> list[F return reports -def _edited_read(path: Path) -> EditableRead: - """Parse a file and apply its editing session sidecar when present.""" - model = EditableRead(read_ab1(path)) - data = sessions.load_session(sessions.session_path(path)) - if data and data.get("source") == path.name: - try: - model.restore_state(data) - except (KeyError, TypeError, ValueError): - pass - return model - - def batch_export( reports: list[FileReport], out_dir: str | Path, @@ -95,7 +90,7 @@ def batch_export( dest: Path | None = None error = "" try: - model = _edited_read(report.path) + model = EditableRead(load_read(report.path)) start, end = trim_window(model.quality, window=window, min_q=min_q) if end - start >= 2: model.apply_trim(start, end) @@ -113,13 +108,17 @@ def batch_export( return results -def _csv_field(value) -> str: +def csv_field(value, sep: str = ";") -> str: + """One CSV field, quoted when it contains ``sep``, a quote or a line break.""" text = str(value) - if any(ch in text for ch in ';"\n'): + if any(ch in text for ch in f'{sep}"\n\r'): text = '"' + text.replace('"', '""') + '"' return text +_csv_field = csv_field + + def summary_csv(reports: list[FileReport]) -> str: """Semicolon-separated summary (opens cleanly in RU-locale Excel).""" lines = ["file;length;mean_q;q20;trimmed_length;status;error"] @@ -130,7 +129,7 @@ def summary_csv(reports: list[FileReport]) -> str: for value in ( report.path.name, report.length, - report.mean_q, + "" if report.mean_q is None else report.mean_q, report.q20, report.trimmed_length, report.status, diff --git a/chrom_core/clone_report.py b/chrom_core/clone_report.py new file mode 100644 index 0000000..0a9c84a --- /dev/null +++ b/chrom_core/clone_report.py @@ -0,0 +1,120 @@ +"""CSV and Excel reports for clone verification (shared by the UI and the CLI).""" + +from __future__ import annotations + +from chrom_core.batch import csv_field +from chrom_core.clones import CloneDifference, CloneReport, ReadResult + +# verdict -> (light, dark) hex colours; the single source of the status palette. +STATUS_HEX: dict[str, tuple[str, str]] = { + "ok": ("#c6efce", "#2e7d32"), + "errors": ("#ffc7ce", "#c62828"), + "incomplete": ("#ffeb9c", "#f9a825"), + "mixed": ("#e1d5f0", "#7b1fa2"), + "no_data": ("#e0e0e0", "#757575"), +} + +CLONES_HEADER = ("clone", "verdict", "region_coverage_pct", "both_strands_pct", + "differences", "reads", "read_issues") +DIFFERENCES_HEADER = ("clone", "position", "kind", "ref", "read", "effect", + "support", "against", "status", "note") + + +def _plural(n: int, word: str) -> str: + return f"{n} {word}" if n == 1 else f"{n} {word}s" + + +def _note(d: CloneDifference) -> str: + """Short remark: ``disputed: N read(s) against`` / ``low quality: N read(s)``, + plus ``outside region`` (joined by `` / ``); empty for a confirmed in-region one.""" + parts = [] + if d.status == "disputed": + parts.append(f"disputed: {_plural(d.against, 'read')} against") + elif d.status == "low_quality": + parts.append(f"low quality: {_plural(d.low_quality, 'read')}") + if not d.in_region: + parts.append("outside region") + return " / ".join(parts) + + +_ERROR_TEXT = {"no_alignment": "did not align to the construct"} + + +def read_issue(read: ReadResult) -> str: + """Why a read was left out of the verdict, in plain English; ``""`` if it was used.""" + if read.error: + return _ERROR_TEXT.get(read.error, f"unreadable ({read.error})") + return "ambiguous placement" if read.ambiguous else "" + + +def _clone_row(r: CloneReport) -> tuple: + # ``reads``: file names of every read of the clone (used or not), joined by "|"; + # ``differences``: those the verdict counts; ``read_issues``: "name: reason; ...". + names = "|".join(read.ref.path.name for read in r.reads) + issues = "; ".join(f"{read.ref.path.name}: {read_issue(read)}" + for read in r.reads if read_issue(read)) + return (r.clone, r.verdict, round(r.region_coverage_pct, 1), round(r.both_strands_pct, 1), + len(r.error_differences), names, issues) + + +def _difference_rows(reports: list[CloneReport]): + for r in reports: + for d in r.differences: + # position is 1-based; an insertion is reported before this position + yield (r.clone, d.ref_pos + 1, d.kind, d.ref_base, d.read_base, d.effect, + d.support, d.against, d.status, _note(d)) + + +def _fmt(value) -> str: + return f"{value:.1f}" if isinstance(value, float) else str(value) + + +def _csv(header, rows) -> str: + lines = [",".join(header)] + for row in rows: + lines.append(",".join(csv_field(_fmt(v), sep=",") for v in row)) + return "\n".join(lines) + "\n" + + +def clones_csv(reports: list[CloneReport]) -> str: + """One row per clone, comma-separated.""" + return _csv(CLONES_HEADER, (_clone_row(r) for r in reports)) + + +def differences_csv(reports: list[CloneReport]) -> str: + """One row per difference of every clone (outside-region ones included).""" + return _csv(DIFFERENCES_HEADER, _difference_rows(reports)) + + +def clone_report_xlsx(path, reports: list[CloneReport], construct_name: str) -> None: + """Excel report: sheets ``Clones`` and ``Differences``; the verdict cell is filled + with its light ``STATUS_HEX`` colour. ``construct_name`` is the workbook title.""" + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill + except ImportError as exc: # pragma: no cover - depends on environment + raise RuntimeError("openpyxl is required for Excel reports: pip install openpyxl") from exc + + book = Workbook() + book.properties.title = construct_name + clones = book.active + clones.title = "Clones" + diffs = book.create_sheet("Differences") + for sheet, header in ((clones, CLONES_HEADER), (diffs, DIFFERENCES_HEADER)): + sheet.append(header) + for cell in sheet[1]: + cell.font = Font(bold=True) + for r in reports: + clones.append(_clone_row(r)) + palette = STATUS_HEX.get(r.verdict) + if palette: + colour = palette[0].lstrip("#").upper() + clones.cell(row=clones.max_row, column=2).fill = PatternFill( + "solid", start_color=colour, end_color=colour) + for row in _difference_rows(reports): + diffs.append(row) + for sheet, widths in ((clones, (16, 12, 20, 18, 12, 50, 50)), + (diffs, (16, 10, 14, 8, 8, 14, 9, 9, 12, 30))): + for i, width in enumerate(widths): + sheet.column_dimensions[chr(ord("A") + i)].width = width + book.save(path) diff --git a/chrom_core/clones.py b/chrom_core/clones.py new file mode 100644 index 0000000..3f412d0 --- /dev/null +++ b/chrom_core/clones.py @@ -0,0 +1,521 @@ +"""Clone verification: grouping reads into clones, per-clone checks and verdicts.""" + +from __future__ import annotations + +import re +from concurrent.futures import CancelledError +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Iterable + +from chrom_core import sessions +from chrom_core.abif import Chromatogram, read_ab1 +from chrom_core.align import Difference, ReadAlignment, align_to_construct +from chrom_core.editing import EditableRead +from chrom_core.effects import difference_effect +from chrom_core.peaks import scan_secondary_peaks +from chrom_core.reference import Construct +from chrom_core.seqops import iupac_compatible, reverse_complement +from chrom_core.trim import trim_window + +DEFAULT_PRIMER_TOKENS = ( + "F", "R", "FW", "FWD", "REV", "RV", "T7", "T7TERM", "SP6", "T3", + "M13F", "M13R", "M13", "PUC-F", "PUC-R", "BGH", "CMV", +) + +_SEP = r"[_\-. ]" +_TOKEN_RE = re.compile(r"[^_\-. ]+") +_WELL_RE = re.compile(r"[A-H]\d{1,2}") +_MAX_PRIMER_PARTS = 3 # "pUC-F" spans two tokens + + +def _norm(text: str) -> str: + return re.sub(f"{_SEP}+", "-", text).upper() + + +@dataclass(frozen=True) +class ReadRef: + path: Path + clone: str + primer: str + + +def _natural_key(text: str) -> list: + return [ + (0, int(part), "") if part.isdecimal() else (1, 0, part.casefold()) + for part in re.split(r"(\d+)", text) + if part != "" + ] + + +@dataclass +class Grouping: + reads: list[ReadRef] = field(default_factory=list) + unassigned: list[Path] = field(default_factory=list) + + def clones(self) -> dict[str, list[ReadRef]]: + out: dict[str, list[ReadRef]] = {} + for ref in self.reads: + out.setdefault(ref.clone, []).append(ref) + return {k: out[k] for k in sorted(out, key=_natural_key)} + + def assign(self, path: Path, clone: str, primer: str) -> None: + """Put ``path`` into ``clone`` (moving it if already grouped or unassigned).""" + path = Path(path) + self.unassigned = [p for p in self.unassigned if p != path] + ref = ReadRef(path, clone, primer) + for i, old in enumerate(self.reads): + if old.path == path: + self.reads[i] = ref + return + self.reads.append(ref) + + def to_dict(self) -> dict: + return { + "reads": [ + {"path": str(r.path), "clone": r.clone, "primer": r.primer} + for r in self.reads + ], + "unassigned": [str(p) for p in self.unassigned], + } + + @classmethod + def from_dict(cls, d: dict) -> "Grouping": + return cls( + reads=[ + ReadRef(Path(r["path"]), r["clone"], r["primer"]) + for r in d.get("reads", []) + ], + unassigned=[Path(p) for p in d.get("unassigned", [])], + ) + + +def parse_name( + stem: str, primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS +) -> tuple[str, str] | None: + """Split a file stem into ``(clone, primer)``; ``None`` if no clone remains. + + The primer is the last dictionary token (else the last token). The clone is + the text before it, minus trailing separators, a lone digit (primer order) + and a leading plate well (``A05``) -- the well is stripped only when another + clone token follows it, so ``B2_F`` is clone ``B2``. Anything after the + primer (e.g. a repeat suffix ``_2``) is ignored. + """ + tokens = list(_TOKEN_RE.finditer(stem)) + if not tokens: + return None + known = {_norm(t) for t in primer_tokens} + best: tuple[int, int] | None = None # (start, end) in ``stem`` + for i in range(len(tokens)): + for j in range(i, min(i + _MAX_PRIMER_PARTS, len(tokens))): + start, end = tokens[i].start(), tokens[j].end() + if _norm(stem[start:end]) not in known: + continue + # the last match wins; on equal end the longer (earlier start) one + if best is None or end > best[1] or (end == best[1] and start < best[0]): + best = (start, end) + if best is None: + best = (tokens[-1].start(), tokens[-1].end()) + start, end = best + primer = stem[start:end] + clone = re.sub(f"{_SEP}+$", "", stem[:start]) + clone = re.sub(rf"(?:^|{_SEP})\d$", "", clone) # primer order number + clone = re.sub(f"{_SEP}+$", "", clone) + # leading plate well, only if another clone token follows it + clone = re.sub(rf"^{_WELL_RE.pattern}{_SEP}+(?=[^_\-. ])", "", clone) + clone = re.sub(f"^{_SEP}+", "", clone) + return (clone, primer) if clone else None + + +def group_reads( + paths: Iterable[Path], + pattern: str | None = None, + primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS, +) -> Grouping: + """Group reads into clones by file name (default rules or a custom regex).""" + rx = None + if pattern: + try: + rx = re.compile(pattern) + except re.error as exc: + raise ValueError(f"invalid grouping pattern: {exc}") from exc + if not {"clone", "primer"} <= set(rx.groupindex): + raise ValueError("grouping pattern needs named groups 'clone' and 'primer'") + tokens = tuple(primer_tokens) + grouping = Grouping() + for p in map(Path, paths): + if rx is not None: + m = rx.fullmatch(p.stem) + parsed = (m.group("clone") or "", m.group("primer") or "") if m else None + if parsed is not None and not parsed[0]: + parsed = None + else: + parsed = parse_name(p.stem, tokens) + if parsed is None: + grouping.unassigned.append(p) + else: + grouping.reads.append(ReadRef(p, parsed[0], parsed[1])) + return grouping + + +# ------------------------------------------------------------------ verification + +MIN_ALIGNED_BP = 50 # a read counts as aligned only past both thresholds +MIN_IDENTITY = 0.80 +TRIM_WINDOW = 20 # quality trimming before alignment (sliding window) + +Region = tuple[int, int] # 0-based half-open; start > end wraps the origin (circular only) + + +@dataclass(frozen=True) +class VerifyParams: + min_q: int = 20 + het_ratio: float = 0.40 + mixed_min_positions: int = 3 + + +@dataclass(frozen=True) +class ReadResult: + ref: ReadRef + alignment: ReadAlignment | None # None when ``error`` is set + error: str = "" # "no_alignment" or why the file could not be used + ambiguous: bool = False # placements > 1: excluded from the verdict + + +@dataclass(frozen=True) +class CloneDifference: + ref_pos: int + kind: str # 'substitution' | 'deletion' | 'insertion' (before ref_pos) + ref_base: str + read_base: str + support: int # reads showing it with Q >= min_q + against: int # reads showing the reference there with Q >= min_q + low_quality: int # reads showing it with Q < min_q + status: str # 'confirmed' | 'disputed' | 'low_quality' + in_region: bool + effect: str = "" + + +@dataclass(frozen=True) +class CloneReport: + clone: str + reads: tuple[ReadResult, ...] + coverage: dict[int, tuple[int, int]] # region position -> (reads on +, reads on -) + differences: tuple[CloneDifference, ...] + mixed_positions: tuple[int, ...] + verdict: str # 'no_data' | 'mixed' | 'errors' | 'incomplete' | 'ok' + + def _pct(self, test) -> float: + if not self.coverage: + return 0.0 + hits = sum(1 for v in self.coverage.values() if test(v)) + return 100.0 * hits / len(self.coverage) + + @property + def region_coverage_pct(self) -> float: + return self._pct(lambda v: v[0] + v[1] > 0) + + @property + def both_strands_pct(self) -> float: + return self._pct(lambda v: v[0] > 0 and v[1] > 0) + + @property + def region_differences(self) -> tuple[CloneDifference, ...]: + return tuple(d for d in self.differences if d.in_region) + + @property + def error_differences(self) -> tuple[CloneDifference, ...]: + """In-region differences that are not low quality: what gives ``errors``.""" + return tuple(d for d in self.region_differences if d.status != "low_quality") + + +def check_region(region: Region | None, length: int, circular: bool) -> Region: + """``region`` normalised (``None`` -> whole construct); ``ValueError`` if invalid.""" + if region is None: + return (0, length) + start, end = region + if not (0 <= start < length and 0 <= end <= length): + raise ValueError(f"region {start}..{end} lies outside the construct (0..{length})") + if start == end: + raise ValueError("empty region") + if start > end and not circular: + raise ValueError("a region across the origin needs a circular construct") + return (start, end) + + +def region_positions(region: Region | None, length: int) -> list[int]: + """Construct positions of ``region`` in order (``None``: the whole construct).""" + start, end = check_region(region, length, circular=True) + if start < end: + return list(range(start, end)) + return list(range(start, length)) + list(range(end)) + + +def _load(path: Path) -> tuple[Chromatogram, bool]: + """Current chromatogram (editing session applied) and its reversed flag.""" + path = Path(path) + model = EditableRead(read_ab1(path)) + data = sessions.load_session(sessions.session_path(path)) + if data and data.get("source") == path.name: + try: + model.restore_state(data) + except (KeyError, TypeError, ValueError): + pass + return model.current(), model.reversed_complement + + +def load_read(path: Path) -> Chromatogram: + """Parse a read and apply its editing session sidecar when present.""" + return _load(path)[0] + + +def _align_trimmed(chrom: Chromatogram, construct: Construct, min_q: int) -> ReadAlignment: + """Align the quality-trimmed read; indices refer to the full displayed read. + + Trimming keeps low-quality overhangs from wrapping around a circle. A read + without a usable window (e.g. no quality values at all) is aligned whole. + """ + n = len(chrom.seq) + start, end = trim_window(chrom.quality, window=TRIM_WINDOW, min_q=min_q) + if end - start < 2: + start, end = 0, n + al = align_to_construct(chrom.seq[start:end], construct) + if (start, end) == (0, n): + return al + off = start if al.strand == "+" else n - end + return replace( + al, + read_start=al.read_start + off, + read_end=al.read_end + off, + ref_to_read={p: i + off for p, i in al.ref_to_read.items()}, + differences=[ + d if d.read_pos is None else replace(d, read_pos=d.read_pos + off) + for d in al.differences + ], + oriented_read=chrom.seq.upper() if al.strand == "+" + else reverse_complement(chrom.seq.upper()), + blocks=[(t, (q0 + off, q1 + off)) for t, (q0, q1) in al.blocks], + ) + + +class _Read: + """An aligned read used for the verdict: quality lookups at reference sites.""" + + def __init__(self, chrom: Chromatogram, al: ReadAlignment, strand: str, circular: bool): + self.chrom, self.al, self.circular = chrom, al, circular + self.strand = strand # physical strand: a reverse-complemented display is undone + self.map = al.ref_to_read + + def q(self, idx: int) -> int: + n = len(self.chrom.seq) + d = idx if self.al.strand == "+" else n - 1 - idx + return self.chrom.quality[d] if 0 <= d < n else 0 + + def _mapped_from(self, pos: int, step: int) -> int | None: + """Read index of the nearest mapped position beyond ``pos`` towards ``step``.""" + p = pos + for _ in range(self.al.ref_length): + p += step + if self.circular: + p %= self.al.ref_length + if not self.al.covers(p): + return None + if p in self.map: + return self.map[p] + return None + + def gap_q(self, pos: int) -> int: + """Q of the read bases flanking a deleted position or an insertion point.""" + left = self._mapped_from(pos, -1) + right = self.map[pos] if pos in self.map else self._mapped_from(pos, +1) + if left is None or right is None: + return 0 + return min(self.q(left), self.q(right)) + + def site_q(self, pos: int) -> int | None: + """Q at a reference position (flanks if deleted); None if not covered.""" + if not self.al.covers(pos): + return None + return self.q(self.map[pos]) if pos in self.map else self.gap_q(pos) + + def difference_q(self, d: Difference) -> int: + return self.q(d.read_pos) if d.kind == "substitution" else self.gap_q(d.ref_pos) + + def shows_reference(self, d: Difference, ref: str, min_q: int) -> bool: + m, pos = self.map, d.ref_pos + if d.kind == "insertion": + prev = (pos - 1) % len(ref) + return (prev in m and pos in m and m[pos] == m[prev] + 1 + and min(self.q(m[prev]), self.q(m[pos])) >= min_q) + return (pos in m and self.al.oriented_read[m[pos]] == ref[pos] + and self.q(m[pos]) >= min_q) + + +def _informative(d: Difference) -> bool: + """N/IUPAC in the reference is never a difference; an ambiguous read call + compatible with the reference base (e.g. N) is a no-call, not a difference.""" + if d.kind != "insertion" and d.ref_base not in "ACGT": + return False + if d.kind == "substitution" and d.read_base not in "ACGT": + return not iupac_compatible(d.read_base, d.ref_base) + return True + + +def _status(support: int, against: int) -> str: + if support == 0: + return "low_quality" + return "disputed" if against else "confirmed" + + +def _deletion_runs(found: dict, length: int, circular: bool): + """``(differences, showing reads, their Q)`` per merged difference, in position order. + + Consecutive deleted positions shown by the same set of reads form one run + (across the origin on a circular construct); every other difference is a + run of one. A run's flanking Q is the same at each of its positions. + """ + entries = [] # (sort key, run, showing, quals) + deletions = [] # (position, run, showing, quals, read ids) + for key in sorted(found): + d, showing, quals = found[key] + if d.kind != "deletion": + entries.append((key, [d], showing, quals)) + continue + ids = frozenset(map(id, showing)) + last = deletions[-1] if deletions else None + if last is not None and last[4] == ids and last[1][-1].ref_pos + 1 == d.ref_pos: + last[1].append(d) + else: + deletions.append((d.ref_pos, [d], showing, quals, ids)) + if (circular and len(deletions) > 1 and deletions[0][0] == 0 + and deletions[-1][1][-1].ref_pos == length - 1 + and deletions[0][4] == deletions[-1][4]): + deletions[-1][1].extend(deletions.pop(0)[1]) + for pos, run, showing, quals, _ids in deletions: + entries.append(((pos, "deletion", "-"), run, showing, quals)) + entries.sort(key=lambda e: e[0]) + for _key, run, showing, quals in entries: + yield run, showing, quals + + +def _verdict(used, mixed, differences, coverage, params: VerifyParams) -> str: + if not used: + return "no_data" + if len(mixed) >= params.mixed_min_positions: + return "mixed" + if any(d.in_region and d.status != "low_quality" for d in differences): + return "errors" + if any(plus + minus == 0 for plus, minus in coverage.values()): + return "incomplete" + return "ok" + + +def verify_clone( + construct: Construct, + region: Region | None, + reads: list[ReadRef], + params: VerifyParams = VerifyParams(), + annotate=difference_effect, +) -> CloneReport: + """Check one colony's reads against ``construct`` within ``region``. + + ``annotate(construct, difference) -> str`` fills ``CloneDifference.effect`` + (default: ``difference_effect``; ``None`` leaves it empty). + """ + L = len(construct.seq) + positions = region_positions(check_region(region, L, construct.circular), L) + in_region = set(positions) + ref = construct.seq.upper() + + results: list[ReadResult] = [] + used: list[_Read] = [] + for read_ref in reads: + try: + chrom, flipped = _load(read_ref.path) + al = _align_trimmed(chrom, construct, params.min_q) + except Exception as exc: # unreadable / unalignable file: mark it, go on + results.append(ReadResult(read_ref, None, error=str(exc) or type(exc).__name__)) + continue + if al.covered < MIN_ALIGNED_BP or al.identity < MIN_IDENTITY: + results.append(ReadResult(read_ref, None, error="no_alignment")) + continue + ambiguous = al.placements > 1 # distinct placements, not co-optimal paths + results.append(ReadResult(read_ref, al, ambiguous=ambiguous)) + if not ambiguous: + strand = al.strand if not flipped else {"+": "-", "-": "+"}[al.strand] + used.append(_Read(chrom, al, strand, construct.circular)) + + coverage: dict[int, tuple[int, int]] = {} + for pos in positions: + good = [r.strand for r in used + if r.al.covers(pos) and r.site_q(pos) >= params.min_q] + coverage[pos] = (good.count("+"), good.count("-")) + + # merge per (position, kind, read base): reads showing it, with their Q + found: dict[tuple, tuple[Difference, list[_Read], list[int]]] = {} + for r in used: + for d in r.al.differences: + if _informative(d): + entry = found.setdefault((d.ref_pos, d.kind, d.read_base), (d, [], [])) + entry[1].append(r) + entry[2].append(r.difference_q(d)) + + differences = [] + for run, showing, quals in _deletion_runs(found, L, construct.circular): + d = run[0] + support = sum(1 for q in quals if q >= params.min_q) + against = sum(1 for r in used if r not in showing + and all(r.shows_reference(x, ref, params.min_q) for x in run)) + inside = any(x.ref_pos in in_region for x in run) or ( + d.kind == "insertion" and (d.ref_pos - 1) % L in in_region) + ref_base = "".join(x.ref_base for x in run) + cd = CloneDifference( + d.ref_pos, d.kind, ref_base, d.read_base, support, against, + len(quals) - support, _status(support, against), inside, + ) + if annotate is not None: + cd = replace(cd, effect=annotate(construct, cd) or "") + differences.append(cd) + + mixed = tuple(sorted({ + pos + for r in used + for pos, _ in scan_secondary_peaks(r.chrom, r.al, params.het_ratio, params.min_q) + if pos in in_region + })) + + return CloneReport( + clone=reads[0].clone if reads else "", + reads=tuple(results), + coverage=coverage, + differences=tuple(differences), + mixed_positions=mixed, + verdict=_verdict(used, mixed, differences, coverage, params), + ) + + +def verify_all( + construct: Construct, + region: Region | None, + grouping: Grouping, + params: VerifyParams = VerifyParams(), + progress=None, + cancelled=None, + annotate=difference_effect, +) -> list[CloneReport]: + """``verify_clone`` for every clone of ``grouping``, in natural name order. + + ``progress(done, total)`` is called after each clone; ``cancelled()`` is + polled before each one and, when true, raises + ``concurrent.futures.CancelledError``. + """ + check_region(region, len(construct.seq), construct.circular) + groups = grouping.clones() + reports = [] + for done, reads in enumerate(groups.values(), start=1): + if cancelled is not None and cancelled(): + raise CancelledError() + reports.append(verify_clone(construct, region, reads, params, annotate)) + if progress is not None: + progress(done, len(groups)) + return reports diff --git a/chrom_core/config.py b/chrom_core/config.py index aada4a6..e79d1bb 100644 --- a/chrom_core/config.py +++ b/chrom_core/config.py @@ -7,6 +7,8 @@ from dataclasses import asdict, dataclass, field from pathlib import Path +from .clones import DEFAULT_PRIMER_TOKENS + @dataclass class AppConfig: @@ -17,6 +19,8 @@ class AppConfig: theme: str = "light" # "light" | "dark" language: str = "ru" # "ru" | "en" recent_files: list[str] = field(default_factory=list) + primer_tokens: list[str] = field(default_factory=lambda: list(DEFAULT_PRIMER_TOKENS)) + group_pattern: str = "" # regex with named groups clone/primer; "" = by file name def config_dir() -> Path: diff --git a/chrom_core/editing.py b/chrom_core/editing.py index fff1162..4fcd81e 100644 --- a/chrom_core/editing.py +++ b/chrom_core/editing.py @@ -120,9 +120,9 @@ def reverse_complement(self) -> None: n = len(self.seq) self.seq = list(reverse_complement("".join(self.seq))) self.quality.reverse() - if self.peaks: - last = self.peaks[-1] - self.peaks = [last - p for p in reversed(self.peaks)] + # current() reverses the traces over their full length: map peaks the same way + last = len(next(iter(self.original.traces.values()))) - 1 + self.peaks = [last - p for p in reversed(self.peaks)] self.edited = {n - 1 - k: v for k, v in self.edited.items()} self.reversed_complement = not self.reversed_complement diff --git a/chrom_core/effects.py b/chrom_core/effects.py new file mode 100644 index 0000000..e4b8678 --- /dev/null +++ b/chrom_core/effects.py @@ -0,0 +1,87 @@ +"""Amino-acid consequences of clone differences inside CDS features.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from Bio.Seq import Seq + + +if TYPE_CHECKING: + from chrom_core.clones import CloneDifference + from chrom_core.reference import Construct, Feature + +TRANSLATION_TABLE = 11 +_BASES = frozenset("ACGT") +_COMPLEMENT = str.maketrans("ACGT", "TGCA") + + +def _feature_positions(feature: Feature, length: int) -> list[int]: + """Construct positions of ``feature`` in transcript order (wrap and strand aware).""" + if feature.start <= feature.end: + pos = list(range(feature.start, feature.end)) + else: + pos = list(range(feature.start, length)) + list(range(0, feature.end)) + return pos[::-1] if feature.strand == -1 else pos + + +def _translate(codon: str) -> str: + return str(Seq(codon).translate(table=TRANSLATION_TABLE)) + + +def difference_effect(construct: Construct, diff: CloneDifference) -> str: + """Effect of ``diff`` on the first CDS of ``construct`` that contains it. + + Substitution: ``"synonymous"``, ``"D45N"`` (one-letter codes, amino-acid + number counted from the CDS start, frame from ``codon_start``) or + ``"W12*"`` for a stop. Insertion/deletion: ``"frameshift"`` when the length + is not a multiple of 3, else ``"inframe_ins"`` / ``"inframe_del"``; a + multi-base deletion arrives as one ``CloneDifference`` whose ``ref_base`` is + the whole deleted run. ``""`` outside any CDS, with no features, or when the + read base is not A/C/G/T or the codon is incomplete. + """ + seq = construct.seq.upper() + length = len(seq) + if not length: + return "" + for feature in construct.features: + if feature.kind != "CDS": + continue + pos = _feature_positions(feature, length) + index = {p: i for i, p in enumerate(pos)} + if diff.ref_pos not in index: + continue + if diff.kind == "insertion": + if (diff.ref_pos - 1) % length not in index: + continue + return "frameshift" if len(diff.read_base) % 3 else "inframe_ins" + if diff.kind == "deletion": + return "frameshift" if len(diff.ref_base.replace("-", "")) % 3 else "inframe_del" + if diff.kind != "substitution": + return "" + alt = diff.read_base.upper() + if len(alt) != 1 or alt not in _BASES: + return "" + offset = index[diff.ref_pos] - (feature.codon_start - 1) + if offset < 0: + return "" + number, within = divmod(offset, 3) + first = number * 3 + feature.codon_start - 1 + codon_pos = pos[first:first + 3] + if len(codon_pos) < 3: + return "" + if feature.strand == -1: # positions are in transcript order: complement only + alt = alt.translate(_COMPLEMENT) + codon = "".join(seq[p].translate(_COMPLEMENT) for p in codon_pos) + else: + codon = "".join(seq[p] for p in codon_pos) + mutated = codon[:within] + alt + codon[within + 1:] + return _label(codon, mutated, number + 1) + return "" + + +def _label(codon: str, mutated: str, number: int) -> str: + ref_aa, alt_aa = _translate(codon), _translate(mutated) + if ref_aa == alt_aa: + return "synonymous" + return f"{ref_aa}{number}{alt_aa}" diff --git a/chrom_core/peaks.py b/chrom_core/peaks.py new file mode 100644 index 0000000..9f548e0 --- /dev/null +++ b/chrom_core/peaks.py @@ -0,0 +1,175 @@ +"""Normalised secondary-peak analysis: the single source of double-peak calls. + +Raw channel heights are not comparable between dyes: one channel can be +systematically weaker than the others along the whole read, which inflates +(or hides) a "second peak" ratio measured on raw intensities. Here every +channel height at a position is divided by that channel's local baseline — +the median height of nearby peaks where that channel was the called base — +and the ratio is taken between normalised heights. + +The measurement window follows the local peak spacing (half the distance to +the nearest neighbouring peak) so a neighbour's peak never leaks in. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from statistics import median + +from chrom_core.abif import Chromatogram +from chrom_core.align import ReadAlignment +from chrom_core.seqops import IUPAC + +BASELINE_SPAN = 20 # nearest called bases used for a channel's baseline +MIN_WINDOW = 2 # smallest half-window, trace points +_LONE_PEAK_WINDOW = 5 # half-window for a read with a single peak + + +@dataclass(frozen=True) +class SecondaryPeak: + primary_base: str # strongest (normalised) channel among the called alleles + base: str # strongest channel outside the called alleles; '' if none + ratio: float | None # normalised secondary / primary; None if unmeasurable + + +def peak_window(chrom: Chromatogram, i: int) -> int: + """Half the distance to the nearest neighbouring peak, at least 2.""" + peaks = chrom.peak_indices + gaps = [] + if i > 0: + gaps.append(peaks[i] - peaks[i - 1]) + if i + 1 < len(peaks): + gaps.append(peaks[i + 1] - peaks[i]) + if not gaps: + return _LONE_PEAK_WINDOW + return max(MIN_WINDOW, min(gaps) // 2) + + +def _heights(chrom: Chromatogram, i: int) -> dict[str, float]: + """Per-channel peak height within ``peak_window`` of called peak ``i``. + + A channel's height is its tallest *local maximum* strictly inside the + window, or its value at the called peak position if that is higher. A + plain window maximum is not used: at half the peak spacing a neighbour's + peak is still ~half its height, so its rising/falling shoulder at the + window edge would masquerade as a second peak (on real reads this alone + produced dozens of false double peaks per read). + """ + peak = chrom.peak_indices[i] + window = peak_window(chrom, i) + heights = {} + for base, trace in chrom.traces.items(): + if not 0 <= peak < len(trace): + heights[base] = 0.0 + continue + best = trace[peak] + lo = max(0, peak - window) + hi = min(len(trace) - 1, peak + window) + for k in range(lo + 1, hi): + if trace[k - 1] <= trace[k] >= trace[k + 1] and trace[k] > best: + best = trace[k] + heights[base] = float(best) + return heights + + +def _called(chrom: Chromatogram, i: int) -> str: + return chrom.seq[i].upper() + + +def _neighbours(n: int, i: int, span: int) -> list[int]: + """The ``span`` indices nearest to ``i`` (excluding ``i``) within [0, n).""" + out: list[int] = [] + d = 1 + while len(out) < span and (i - d >= 0 or i + d < n): + if i - d >= 0: + out.append(i - d) + if len(out) < span and i + d < n: + out.append(i + d) + d += 1 + return out + + +def _baseline(chrom: Chromatogram, i: int, span: int, heights_of) -> dict[str, float]: + per_channel: dict[str, list[float]] = {base: [] for base in chrom.traces} + primaries: list[float] = [] + for j in _neighbours(len(chrom.seq), i, span): + heights = heights_of(j) + called = _called(chrom, j) + if called in per_channel: + per_channel[called].append(heights[called]) + primaries.append(heights[called]) + else: # mixed / N call: its tallest channel stands in for the peak height + primaries.append(max(heights.values(), default=0.0)) + fallback = median(primaries) if primaries else 0.0 + return { + base: median(values) if values else fallback + for base, values in per_channel.items() + } + + +def channel_baseline( + chrom: Chromatogram, i: int, span: int = BASELINE_SPAN +) -> dict[str, float]: + """Per-channel baseline height around read position ``i``. + + For each channel: the median height of that channel over the ``span`` + nearest called bases whose called base is that channel. A channel never + called nearby falls back to the median peak height of all ``span`` peaks. + """ + return _baseline(chrom, i, span, lambda j: _heights(chrom, j)) + + +def _secondary(heights: dict[str, float], baseline: dict[str, float], called: str) -> SecondaryPeak: + norm = { + base: h / baseline[base] if baseline.get(base, 0) > 0 else h + for base, h in heights.items() + } + called_alleles = set(IUPAC.get(called, "ACGT")) & set(norm) + if not called_alleles: + called_alleles = set(norm) + primary_base = max(sorted(called_alleles), key=lambda b: norm[b]) + primary = norm[primary_base] + outside = [b for b in sorted(norm) if b not in called_alleles] + if not outside: + return SecondaryPeak(primary_base, "", None) + base = max(outside, key=lambda b: norm[b]) + ratio = norm[base] / primary if primary > 0 else None + return SecondaryPeak(primary_base, base, ratio) + + +def secondary_peak(chrom: Chromatogram, i: int) -> SecondaryPeak: + """Strongest second channel under the called peak ``i``, normalised per dye.""" + return _secondary(_heights(chrom, i), channel_baseline(chrom, i), _called(chrom, i)) + + +def scan_secondary_peaks( + chrom: Chromatogram, + alignment: ReadAlignment, + min_ratio: float, + min_q: int, +) -> list[tuple[int, SecondaryPeak]]: + """Every covered reference position whose read peak has a second peak. + + ``chrom`` is the read in its displayed orientation (as for ``call_snps``); + positions with quality below ``min_q`` are skipped. Sorted by ``ref_pos``. + """ + n = len(chrom.seq) + cache: dict[int, dict[str, float]] = {} + + def heights_of(j: int) -> dict[str, float]: + if j not in cache: + cache[j] = _heights(chrom, j) + return cache[j] + + hits: list[tuple[int, SecondaryPeak]] = [] + for ref_pos in sorted(alignment.ref_to_read): + idx = alignment.ref_to_read[ref_pos] + if alignment.strand == "-": + idx = n - 1 - idx + if not 0 <= idx < n or chrom.quality[idx] < min_q: + continue + baseline = _baseline(chrom, idx, BASELINE_SPAN, heights_of) + peak = _secondary(heights_of(idx), baseline, _called(chrom, idx)) + if peak.ratio is not None and peak.ratio >= min_ratio: + hits.append((ref_pos, peak)) + return hits diff --git a/chrom_core/project.py b/chrom_core/project.py new file mode 100644 index 0000000..87b5dc1 --- /dev/null +++ b/chrom_core/project.py @@ -0,0 +1,103 @@ +"""``.ab1proj`` project files: a clone-verification session on disk.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path + +from chrom_core.clones import Grouping, Region, VerifyParams + +PROJECT_VERSION = 1 + + +@dataclass +class Project: + construct_path: Path + circular: bool + region: Region | None + grouping: Grouping + params: VerifyParams + + +def _to_stored(path: Path, base: Path) -> str: + """Relative POSIX path when ``path`` is inside ``base``, else absolute.""" + absolute = Path(os.path.abspath(path)) + try: + return absolute.relative_to(base).as_posix() + except ValueError: + return str(absolute) + + +def _from_stored(text: str, base: Path) -> Path: + p = Path(text) + return p if p.is_absolute() else base / p + + +def save_project(path: Path, project: Project) -> None: + path = Path(path) + base = Path(os.path.abspath(path.parent)) + grouping = project.grouping.to_dict() + for r in grouping["reads"]: + r["path"] = _to_stored(Path(r["path"]), base) + grouping["unassigned"] = [ + _to_stored(Path(p), base) for p in grouping["unassigned"] + ] + p = project.params + payload = { + "app": "ab1kit", + "version": PROJECT_VERSION, + "construct_path": _to_stored(project.construct_path, base), + "circular": project.circular, + "region": list(project.region) if project.region is not None else None, + "grouping": grouping, + "params": { + "min_q": p.min_q, + "het_ratio": p.het_ratio, + "mixed_min_positions": p.mixed_min_positions, + }, + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def load_project(path: Path) -> Project: + path = Path(path) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Project file is not valid JSON: {path} ({e})") from e + except (OSError, UnicodeDecodeError) as e: + raise ValueError(f"Cannot read project file: {path} ({e})") from e + if not isinstance(data, dict) or data.get("app") != "ab1kit": + raise ValueError(f"Not an ab1kit project file: {path}") + version = data.get("version") + if not isinstance(version, int) or version > PROJECT_VERSION: + raise ValueError( + f"Unsupported project version {version!r} (this ab1kit supports " + f"up to version {PROJECT_VERSION}): {path}" + ) + base = Path(os.path.abspath(path.parent)) + try: + grouping = Grouping.from_dict( + { + "reads": [ + {**r, "path": str(_from_stored(r["path"], base))} + for r in data["grouping"].get("reads", []) + ], + "unassigned": [ + str(_from_stored(p, base)) + for p in data["grouping"].get("unassigned", []) + ], + } + ) + region = data["region"] + return Project( + construct_path=_from_stored(data["construct_path"], base), + circular=bool(data["circular"]), + region=(int(region[0]), int(region[1])) if region is not None else None, + grouping=grouping, + params=VerifyParams(**data["params"]), + ) + except (KeyError, TypeError, ValueError, IndexError, AttributeError) as e: + raise ValueError(f"Project file is incomplete or damaged: {path} ({e!r})") from e diff --git a/chrom_core/reference.py b/chrom_core/reference.py new file mode 100644 index 0000000..8a9cc21 --- /dev/null +++ b/chrom_core/reference.py @@ -0,0 +1,91 @@ +"""Reference constructs (plasmid / insert): loading from FASTA, GenBank, SnapGene.""" +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path + +from Bio import SeqIO + +from .align import parse_sequence + +_FORMAT_BY_EXT = { + ".fa": "fasta", ".fasta": "fasta", ".fna": "fasta", ".txt": "fasta", + ".gb": "genbank", ".gbk": "genbank", ".genbank": "genbank", + ".dna": "snapgene", +} +_NAME_QUALIFIERS = ("label", "gene", "product", "note") + + +@dataclass(frozen=True) +class Feature: + """Annotated region; 0-based, half-open. start > end: wraps the origin.""" + + name: str + kind: str + start: int + end: int + strand: int + codon_start: int = 1 + + +@dataclass(frozen=True) +class Construct: + name: str + seq: str + circular: bool + features: tuple[Feature, ...] = () + + def with_circular(self, flag: bool) -> Construct: + return replace(self, circular=flag) + + +def construct_from_text(text: str, name: str = "pasted") -> Construct: + return Construct(name, parse_sequence(text), True) + + +def _detect_format(path: Path) -> str: + fmt = _FORMAT_BY_EXT.get(path.suffix.lower()) + if fmt: + return fmt + with open(path, "rb") as fh: + head = fh.read(512).lstrip() + if head.startswith(b">"): + return "fasta" + if head.startswith(b"LOCUS"): + return "genbank" + raise ValueError(f"cannot determine reference format of {path.name}") + + +def _feature(f) -> Feature: + loc = f.location + parts = list(loc.parts) + if loc.strand == -1: + parts.reverse() # Biopython lists complement(join()) parts in transcript order + start, end = int(parts[0].start), int(parts[-1].end) + q = f.qualifiers + name = next((q[k][0] for k in _NAME_QUALIFIERS if q.get(k)), f.type) + try: + codon_start = int(q["codon_start"][0]) if f.type == "CDS" else 1 + except (KeyError, IndexError, ValueError): + codon_start = 1 + return Feature(str(name), f.type, start, end, int(loc.strand or 0), codon_start) + + +def load_construct(path: str | Path) -> Construct: + path = Path(path) + fmt = _detect_format(path) + try: + record = next(SeqIO.parse(path, fmt), None) + except Exception as exc: # Biopython raises assorted parser errors + raise ValueError(f"cannot parse {path.name} as {fmt}: {exc}") from exc + if record is None: + raise ValueError(f"no sequence records in {path.name}") + seq = parse_sequence(str(record.seq)) + if not seq: + raise ValueError(f"empty sequence in {path.name}") + if fmt == "fasta": + circular, features = True, () + else: + circular = record.annotations.get("topology") == "circular" + features = tuple(_feature(f) for f in record.features) + return Construct(record.id or path.stem, seq, circular, features) diff --git a/chrom_core/snps.py b/chrom_core/snps.py index 56be395..7c97c70 100644 --- a/chrom_core/snps.py +++ b/chrom_core/snps.py @@ -4,7 +4,8 @@ - ``homozygous`` — clean single peak of a non-reference base; - ``heterozygous``— double peak: a second channel rises to at least ``het_ratio`` of the primary height at the same position (classic ~50/50 - Sanger het), or the basecaller itself emitted an IUPAC mixed base; + Sanger het; heights normalised per dye, see :mod:`chrom_core.peaks`), or + the basecaller itself emitted an IUPAC mixed base; - ``low_quality`` — phred below ``min_q``; reported but flagged untrusted. """ @@ -14,6 +15,7 @@ from chrom_core.abif import Chromatogram from chrom_core.align import ReadAlignment +from chrom_core.peaks import secondary_peak from chrom_core.seqops import IUPAC DEFAULT_MIN_Q = 20 @@ -41,7 +43,7 @@ class SnpCall: read_base: str # called base at the position (may itself be IUPAC) kind: str # 'homozygous' | 'heterozygous' | 'low_quality' quality: int - ratio: float | None # secondary/primary peak height; None if unmeasurable + ratio: float | None # normalised secondary/primary peak height; None if unmeasurable iupac: str # code summarizing the observed alleles note: str = "" @@ -49,7 +51,10 @@ class SnpCall: def peak_heights( chrom: Chromatogram, read_index: int, window: int = PEAK_WINDOW ) -> dict[str, float]: - """Max trace intensity per channel around the called peak.""" + """Raw max trace intensity per channel around the called peak (tooltips). + + Not used for het calls: those go through :func:`chrom_core.peaks.secondary_peak`. + """ peak = chrom.peak_indices[read_index] heights = {} for base, trace in chrom.traces.items(): @@ -80,21 +85,9 @@ def call_snps( continue quality = chrom.quality[read_idx] called = chrom.seq[read_idx].upper() - heights = peak_heights(chrom, read_idx) - + peak = secondary_peak(chrom, read_idx) called_concrete = set(IUPAC.get(called, "ACGT")) - primary = max(heights.get(b, 0.0) for b in called_concrete) or max( - heights.values() - ) - secondary_base, secondary = max( - ((b, h) for b, h in heights.items()), key=lambda kv: kv[1] - ) - # the strongest channel should be one of the called alleles; the - # secondary candidate is the strongest channel outside them - outside = [(b, h) for b, h in heights.items() if b not in called_concrete] - if outside: - secondary_base, secondary = max(outside, key=lambda kv: kv[1]) - ratio = secondary / primary if primary > 0 else None + secondary_base, ratio = peak.base, peak.ratio alleles = set(called_concrete) if ratio is not None and ratio >= het_ratio: diff --git a/chrom_core/verify.py b/chrom_core/verify.py index e35397c..42bf607 100644 --- a/chrom_core/verify.py +++ b/chrom_core/verify.py @@ -17,12 +17,13 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from chrom_core.abif import Chromatogram from chrom_core.align import ReadAlignment from chrom_core.seqops import IUPAC -from chrom_core.snps import DEFAULT_HET_RATIO, DEFAULT_MIN_Q, alleles_to_iupac, peak_heights +from chrom_core.peaks import secondary_peak +from chrom_core.snps import DEFAULT_HET_RATIO, DEFAULT_MIN_Q, alleles_to_iupac @dataclass(frozen=True) @@ -41,7 +42,12 @@ class VariantVerdict: quality: int | None = None ratio: float | None = None read_pos: int | None = None # 0-based index in the displayed read - note: str = "" + note: str = "" # English text for CSV/XLSX reports + note_code: str = "" # key for the localised UI text: verify.note. + note_args: dict = field(default_factory=dict) + + +_COMPLEMENT = {"A": "T", "C": "G", "G": "C", "T": "A"} # ------------------------------------------------------------------- parsing @@ -150,43 +156,50 @@ def _verify_one( and (len(variant.ref) == 1 or variant.ref in (".", "", "-")) ) if not is_snv: - return VariantVerdict(variant, "unsupported", "", note="only SNV rows are supported") + return VariantVerdict( + variant, "unsupported", "", note="only SNV rows are supported", note_code="only_snv" + ) if not 0 <= pos < len(reference): - return VariantVerdict(variant, "unsupported", "", note="position outside the reference") + return VariantVerdict( + variant, "unsupported", "", note="position outside the reference", + note_code="pos_outside_ref", + ) if variant.ref not in (".", "", "-") and variant.ref.upper() != reference[pos]: return VariantVerdict( - variant, "ref_mismatch", reference[pos], note="table ref base differs from the reference" + variant, "ref_mismatch", reference[pos], + note="table ref base differs from the reference", note_code="ref_differs", ) if pos not in alignment.ref_to_read: - note = ( - "deleted in the read" - if alignment.ref_start <= pos < alignment.ref_end - else "outside read coverage" - ) - return VariantVerdict(variant, "no_data", "", note=note) - - read_idx = alignment.ref_to_read[pos] + if alignment.ref_start <= pos < alignment.ref_end: + note, code = "deleted in the read", "deleted_in_read" + else: + note, code = "outside read coverage", "outside_coverage" + return VariantVerdict(variant, "no_data", "", note=note, note_code=code) + + # ref_to_read indexes the read in its aligned orientation; map it back to + # the displayed read (chrom) for '-' strand. + n = len(chrom.seq) + reverse = alignment.strand == "-" + read_idx = n - 1 - alignment.ref_to_read[pos] if reverse else alignment.ref_to_read[pos] quality = chrom.quality[read_idx] - heights = peak_heights(chrom, read_idx) called = chrom.seq[read_idx].upper() called_alleles = set(IUPAC.get(called, "ACGT")) - primary = max((heights.get(b, 0) for b in called_alleles), default=0) or max( - heights.values(), default=0 - ) - outside = [(b, h) for b, h in heights.items() if b not in called_alleles] - secondary_base, secondary = max(outside, key=lambda kv: kv[1]) if outside else ("", 0) - ratio = secondary / primary if primary > 0 else None + peak = secondary_peak(chrom, read_idx) + secondary_base, ratio = peak.base, peak.ratio alleles = set(called_alleles) if ratio is not None and ratio >= het_ratio and secondary_base: alleles.add(secondary_base) + if reverse: # express the observation on the reference strand + alleles = {_COMPLEMENT[a] for a in alleles} observed = alleles_to_iupac(alleles) if quality < min_q: return VariantVerdict( - variant, "low_quality", observed, quality, ratio, read_idx, f"Q{quality} < {min_q}" + variant, "low_quality", observed, quality, ratio, read_idx, f"Q{quality} < {min_q}", + "low_q", {"q": quality, "min_q": min_q}, ) alt_alleles = set(IUPAC.get(variant.alt, variant.alt)) @@ -225,6 +238,7 @@ def verdicts_xlsx(path, verdicts: list[VariantVerdict], sample_id: str = "") -> """Excel report via openpyxl (imported lazily; optional dependency).""" try: from openpyxl import Workbook + from openpyxl.styles import Font except ImportError as exc: # pragma: no cover - depends on environment raise RuntimeError("openpyxl is required for Excel reports: pip install openpyxl") from exc @@ -234,7 +248,7 @@ def verdicts_xlsx(path, verdicts: list[VariantVerdict], sample_id: str = "") -> header = ("sample",) + _CSV_HEADER sheet.append(header) for cell in sheet[1]: - cell.font = cell.font.copy(bold=True) + cell.font = Font(bold=True) for v in verdicts: ratio = round(v.ratio, 2) if v.ratio is not None else None sheet.append( diff --git a/docs/screenshots/batch.png b/docs/screenshots/batch.png index 74163ac..c2c1cdf 100644 Binary files a/docs/screenshots/batch.png and b/docs/screenshots/batch.png differ diff --git a/docs/screenshots/construct.png b/docs/screenshots/construct.png new file mode 100644 index 0000000..74d448d Binary files /dev/null and b/docs/screenshots/construct.png differ diff --git a/docs/screenshots/viewer-dark.png b/docs/screenshots/viewer-dark.png index 5d01858..b2d6955 100644 Binary files a/docs/screenshots/viewer-dark.png and b/docs/screenshots/viewer-dark.png differ diff --git a/docs/screenshots/viewer.png b/docs/screenshots/viewer.png index 28cb611..0a4af21 100644 Binary files a/docs/screenshots/viewer.png and b/docs/screenshots/viewer.png differ diff --git a/docs/superpowers/plans/2026-10-25-clone-verification.md b/docs/superpowers/plans/2026-10-25-clone-verification.md new file mode 100644 index 0000000..3419107 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-clone-verification.md @@ -0,0 +1,611 @@ +# Проверка клонов, точность пиков и новый интерфейс — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** ab1kit проверяет 4–12 клонов одного конструкта по заданному региону и показывает вердикт по каждому клону на экране-матрице, с хроматограммами всех ридов в месте каждого расхождения. + +**Architecture:** Вся логика — в UI-независимом `chrom_core/` (новые модули `peaks`, `reference`, `clones`, `effects`, `project`, `clone_report`; кольцевое выравнивание в `align`). Интерфейс на PySide6 Widgets: `main_window.py` разбивается на `ui/read_view/*`, новый экран — `ui/construct/*`, тяжёлые операции — через `ui/tasks.py` (`QThreadPool`). CLI — тонкая оболочка над ядром. + +**Tech Stack:** Python 3.11+, PySide6 ≥ 6.6, BioPython ≥ 1.83 (`Bio.Align`, `Bio.SeqIO`), openpyxl, pytest. + +**Spec:** `docs/superpowers/specs/2026-10-25-clone-verification-design.md` + +## Global Constraints + +- Python ≥ 3.11; новых runtime-зависимостей нет (только `biopython`, `PySide6`, `openpyxl`). +- `chrom_core/` не импортирует Qt. +- Все строки UI — через `tr("ключ")` из `i18n.py`; каждый новый ключ есть и в `RU` (`i18n.py`), и в `EN` (`translations_en.py`) — это проверяет `tests/test_i18n.py`. (В спецификации написано `_()` — в проекте функция называется `tr`.) +- Пороги по умолчанию: `min_q = 20`, `het_ratio = 0.40`, `mixed_min_positions = 3`; окно базовой линии `span = 20`; запас кольцевого выравнивания `+50` п.н.; лимит конструкта `MAX_REFERENCE_LENGTH = 100_000`. +- Тесты не ходят в сеть; данные из NCBI/GitHub скачиваются один раз скриптом и коммитятся. +- Данные с несовместимой с MIT лицензией (gear-genomics, GPL-3.0) в репозиторий не кладутся. +- Существующие тесты остаются зелёными после каждой задачи: `python -m pytest -q`. +- GUI-тесты — offscreen (`QT_QPA_PLATFORM=offscreen`), по образцу `tests/test_smoke_gui.py`. +- Палитра статусов: `ok` зелёный, `errors` красный, `incomplete` жёлтый, `mixed` фиолетовый, `no_data` серый — одни и те же цвета везде (матрица, сводка, отчёт XLSX). + +## Review Focus + +1. **Рид, развёрнутый реверс-комплементом в сессии правок** (`.ab1kit.json` с `reversed_complement: true`): покрытие и расхождения должны относиться к тем же позициям конструкта, что и у неразвёрнутого рида. Тест — Task 7. +2. **Регион через начало координат кольцевого конструкта** (`start > end`, например 4300–200 на 4420 п.н.): покрытие, расхождения и вердикт считаются по обёрнутому региону. Тест — Task 7. +3. **Рид без данных качества** (все Q = 0, как `examples/310.ab1`): расхождения не «подтверждаются», клон получает `incomplete`, а не `errors`; в батч-таблице «—» вместо 0. Тесты — Tasks 3 и 7. +4. **Конструкт с несколькими записями, строчными буквами или `N`**: берётся первая запись, верхний регистр, `N` в референсе не считается расхождением. Тесты — Tasks 4 и 7. +5. **Повторное секвенирование тем же праймером** (`cl3_T7.ab1` и `cl3_T7_2.ab1`) и **кириллица/пробелы в путях**: оба рида остаются в клоне `cl3`; проект и CLI работают с путями вида `D:\Данные\клоны 1\`. Тесты — Tasks 6, 10, 17. + +--- + +## Фаза 1 — данные, анализ пиков, исправления + +### Task 1: Корпус реальных тестовых данных + +**Files:** +- Create: `scripts/fetch_test_data.py` +- Create: `tests/data/real/` (8 файлов `.ab1`), `tests/data/refs/`, `tests/data/README.md` +- Create: `tests/clone_fixtures.py` +- Test: `tests/test_data_corpus.py` + +**Interfaces:** +- Produces (`tests/clone_fixtures.py`): + - `DATA: Path` = `tests/data` + - `REAL_READS: list[Path]` — 8 файлов, отсортированы + - `real_read(name: str) -> Chromatogram` + - `COI_GB: Path`, `PUC19_GB: Path`, `SNAPGENE_DNA: Path` + - `coi_reference() -> str` — последовательность COI, верхний регистр + +- [ ] **Step 1: Написать `scripts/fetch_test_data.py`.** Скачивает в `tests/data/`: + - из `https://raw.githubusercontent.com/roblanf/sangeranalyseR//inst/extdata/Allolobophora_chlorotica/ACHLO/` файлы `Achl_ACHLO006-09_{1_F,2_R}.ab1`, `Achl_ACHLO007-09_{1_F,2_R}.ab1`, `Achl_ACHLO040-09_{1_F,2_R}.ab1`, `Achl_ACHLO041-09_{1_F,2_R}.ab1` → `tests/data/real/`; `` — SHA HEAD на момент скачивания, зашит константой; + - pUC19 `L09137` в формате GenBank через `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=nuccore&id=L09137&rettype=gbwithparts&retmode=text` → `tests/data/refs/pUC19_L09137.gb`; + - COI: accession передаётся аргументом `--coi ACC` → `tests/data/refs/coi_.gb` (тот же efetch); + - SnapGene-образец из `https://raw.githubusercontent.com/biopython/biopython//Tests/SnapGene/sample-d.dna` → `tests/data/refs/sample-d.dna`. + Только `urllib`; идемпотентен (существующие файлы не перекачивает без `--force`). +- [ ] **Step 2: Подобрать accession COI.** Отправить `Achl_ACHLO006-09_1_F` в BLAST (`chrom_core.blast.build_request`/`submit`, результат открыть вручную) и выбрать запись GenBank *Allolobophora chlorotica* с аннотированной CDS, покрывающую рид целиком с идентичностью ≥ 98%. Запустить `python scripts/fetch_test_data.py --coi `. +- [ ] **Step 3: Написать `tests/data/README.md`** — источник каждого файла, commit/accession, лицензия (sangeranalyseR — MIT; BioPython — Biopython License; NCBI — public domain), пометка «полусинтетические данные генерируются в `tests/clone_fixtures.py`, на диске не лежат». +- [ ] **Step 4: Написать падающий тест** + +```python +# tests/test_data_corpus.py +from clone_fixtures import REAL_READS, COI_GB, PUC19_GB, SNAPGENE_DNA, real_read, coi_reference +from chrom_core.align import align_read + +def test_corpus_files_present(): + assert len(REAL_READS) == 8 + assert COI_GB.exists() and PUC19_GB.exists() and SNAPGENE_DNA.exists() + +def test_every_real_read_aligns_to_coi_with_high_identity(): + ref = coi_reference() + for path in REAL_READS: + aln = align_read(real_read(path.name).seq, ref) + assert aln.identity >= 0.97, path.name + assert aln.strand == ("+" if "_F" in path.name else "-") +``` + +- [ ] **Step 5: Запустить** `python -m pytest tests/test_data_corpus.py -v` → FAIL (`ModuleNotFoundError: clone_fixtures`). +- [ ] **Step 6: Реализовать `tests/clone_fixtures.py`** (константы и функции из Interfaces; `coi_reference` читает `COI_GB` через `Bio.SeqIO`). Добавить `pythonpath = ["tests"]` в `[tool.pytest.ini_options]` в `pyproject.toml`. +- [ ] **Step 7: Запустить** тест → PASS. Если идентичность ниже порога у отдельных ридов — сменить accession (Step 2), а не порог. +- [ ] **Step 8: Commit** `git add scripts/fetch_test_data.py tests/data tests/clone_fixtures.py tests/test_data_corpus.py pyproject.toml && git commit -m "Test corpus: real sangeranalyseR reads, COI and pUC19 references"` + +### Task 2: `peaks.py` — нормированный анализ вторичных пиков + +**Files:** +- Create: `chrom_core/peaks.py` +- Modify: `chrom_core/snps.py` (использует `peaks`), `chrom_core/verify.py:166-181` (то же) +- Test: `tests/test_peaks.py`; регрессия — `tests/test_snps.py`, `tests/test_verify.py` + +**Interfaces:** +- Consumes: `Chromatogram` (`chrom_core/abif.py`), `ReadAlignment` (`chrom_core/align.py`) +- Produces (`chrom_core/peaks.py`): + - `BASELINE_SPAN = 20` + - `peak_window(chrom: Chromatogram, i: int) -> int` — половина расстояния до ближайшего соседнего пика, не меньше 2 + - `channel_baseline(chrom: Chromatogram, i: int, span: int = BASELINE_SPAN) -> dict[str, float]` — для каждого канала медиана высот пиков тех из `span` ближайших (по индексу) вызванных баз, у которых этот канал — вызванная база; если таких нет — медиана высот всех `span` пиков по всем каналам + - `@dataclass(frozen=True) SecondaryPeak(primary_base: str, base: str, ratio: float | None)` + - `secondary_peak(chrom: Chromatogram, i: int) -> SecondaryPeak` — высоты в окне `peak_window`, делённые на `channel_baseline`; `ratio = норм. второй вне вызванных / норм. максимальный среди вызванных` + - `scan_secondary_peaks(chrom: Chromatogram, alignment: ReadAlignment, min_ratio: float, min_q: int) -> list[tuple[int, SecondaryPeak]]` — по всем `ref_pos` из `alignment.ref_to_read` (в ориентации `chrom`, как в `call_snps`), где `quality ≥ min_q` и `ratio ≥ min_ratio`; список `(ref_pos, SecondaryPeak)` по возрастанию `ref_pos` + - `peak_heights` остаётся в `snps.py` с прежней сигнатурой (обратная совместимость, используется UI-тултипами) + +- [ ] **Step 1: Написать падающие тесты** (синтетика через `make_chrom` из `tests/test_snps.py` — перенести её в `tests/clone_fixtures.py` и импортировать в обоих местах): + +```python +def test_weak_channel_normalisation_removes_false_het(): + # G-канал систематически вдвое слабее: «второй пик» G на 45% сырой высоты + # при норм. высоте ≈ 0.9·baseline A → ratio по сырым 0.45 (ложный het), + # после нормировки — сам G-пик нормален, а под A-пиком G 45/500 → < 0.40 + ... + assert secondary_peak(chrom, 10).ratio < 0.40 + +def test_true_het_detected_after_normalisation(): + ... # второй пик C на 50% → ratio ≈ 0.5 + assert secondary_peak(chrom, 10).base == "C" + assert 0.45 <= secondary_peak(chrom, 10).ratio <= 0.55 + +def test_peak_window_tracks_spacing(): + assert peak_window(chrom_spacing_10, 5) == 5 + assert peak_window(chrom_spacing_3, 5) == 2 + +def test_scan_finds_het_where_reference_base_was_called(): + # рид = референс, но под позицией 30 второй пик T на 50% + hits = scan_secondary_peaks(chrom, align_read(chrom.seq, ref), 0.40, 20) + assert [pos for pos, _ in hits] == [30] +``` + + Конкретные значения трасс — в теле тестов; утверждения — как выше. +- [ ] **Step 2: Запустить** `python -m pytest tests/test_peaks.py -v` → FAIL (`ModuleNotFoundError: chrom_core.peaks`). +- [ ] **Step 3: Реализовать `chrom_core/peaks.py`** по Interfaces. +- [ ] **Step 4: Перевести `call_snps` и `_verify_one` на `secondary_peak`.** Логика вердиктов не меняется, меняется только источник `ratio`/`secondary_base`. +- [ ] **Step 5: Добавить регрессию на реальных ридах** в `tests/test_peaks.py`: + +```python +def test_real_reads_have_no_mass_false_hets(): + ref = coi_reference() + for path in REAL_READS: + chrom = real_read(path.name) + aln = align_read(chrom.seq, ref) + hits = scan_secondary_peaks(chrom, aln, 0.40, 20) + assert len(hits) <= 2, (path.name, hits) +``` + +- [ ] **Step 6: Запустить всё** `python -m pytest -q` → PASS (включая `test_snps.py`, `test_verify.py`). Если старый тест SNP падает из-за нормировки, разобрать конкретный случай; менять ожидание теста можно только если новое значение ближе к истине — с комментарием в тесте. +- [ ] **Step 7: Commit** `git commit -m "peaks: normalised secondary-peak analysis at any read position"` + +### Task 3: Исправить две ошибки интерфейса + +**Files:** +- Modify: `chrom_core/verify.py` (`VariantVerdict`), `chrom_core/batch.py` (`FileReport`, `scan_folder`), `ui/main_window.py:722-757` (`_fill_verify_table`), `ui/batch_panel.py:76`, `i18n.py`, `translations_en.py` +- Test: `tests/test_verify.py`, `tests/test_batch.py`, `tests/test_smoke_gui.py` + +**Interfaces:** +- Produces: + - `VariantVerdict.note_code: str = ""` — коды: `only_snv`, `pos_outside_ref`, `ref_differs`, `deleted_in_read`, `outside_coverage`, `low_q` (с `note_args={"q": ..., "min_q": ...}`); поле `note_args: dict = field(default_factory=dict)`. Поле `note` (английский текст) остаётся — его пишут CSV/XLSX-отчёты. + - i18n-ключи `verify.note.` в RU и EN. + - `FileReport.mean_q: float | None` — `None`, если у рида все Q = 0. + +- [ ] **Step 1: Падающие тесты** + +```python +# test_verify.py +def test_ref_mismatch_carries_note_code(): ... + assert verdict.note_code == "ref_differs" + +# test_batch.py +def test_read_without_quality_reports_none_mean_q(folder): + by_name = {r.path.name: r for r in batch.scan_folder(folder)} + assert by_name["310.ab1"].mean_q is None + +# test_smoke_gui.py +def test_verify_table_note_is_localised(qapp, ...): + # русский UI, вариант с неверной ref-базой → ячейка «Примечание» + assert "table ref base" not in note_cell_text + assert note_cell_text == tr("verify.note.ref_differs") +``` + +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать:** `_verify_one` заполняет `note_code`/`note_args`; `_fill_verify_table` показывает `tr(f"verify.note.{code}", **args)` (если код пуст — `note`); `scan_folder` ставит `mean_q=None` при `not any(chrom.quality)`; `BatchPanel` показывает `"—"` для `None`; `status` не меняется (рид без качества по-прежнему оценивается по длине обрезки). Проверить, что `summary_csv` пишет пустую ячейку для `None`. +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS. +- [ ] **Step 5: Commit** `git commit -m "Fix untranslated verify notes and Q=0 for reads without quality"` + +--- + +## Фаза 2 — ядро проверки клонов + +### Task 4: `reference.py` — загрузка конструкта + +**Files:** +- Create: `chrom_core/reference.py` +- Test: `tests/test_reference.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) Feature(name: str, kind: str, start: int, end: int, strand: int)` — 0-based, полуоткрытый; `start > end` — фича через начало координат; `codon_start: int = 1` (из квалификатора `codon_start` для CDS) + - `@dataclass(frozen=True) Construct(name: str, seq: str, circular: bool, features: tuple[Feature, ...] = ())` с методом `with_circular(flag: bool) -> Construct` + - `load_construct(path: str | Path) -> Construct` — `.fa .fasta .fna .txt` → `fasta`, `.gb .gbk .genbank` → `genbank`, `.dna` → `snapgene`; неизвестное расширение — пробовать по первому символу (`>` → fasta, `LOCUS` → genbank), иначе `ValueError`. Первая запись; `seq.upper()`; недопустимые символы → `ValueError` (как `align.parse_sequence`). `circular` — из `record.annotations["topology"] == "circular"`; для FASTA — `True`. Имя фичи — первый из квалификаторов `label`, `gene`, `product`, `note`, иначе `kind`. Составные локации (`CompoundLocation`) через начало: `start` первой части, `end` последней. + - `construct_from_text(text: str, name: str = "pasted") -> Construct` — через `align.parse_sequence`, `circular=True` + +- [ ] **Step 1: Падающие тесты** + +```python +def test_genbank_puc19(): + c = load_construct(PUC19_GB) + assert len(c.seq) == 2686 and c.circular + assert any(f.kind == "CDS" for f in c.features) + +def test_snapgene_sample(): c = load_construct(SNAPGENE_DNA); assert c.seq and c.features + +def test_fasta_multi_record_lowercase(tmp_path): + p = tmp_path / "x.fasta"; p.write_text(">a\nacgtn\n>b\nTTTT\n") + c = load_construct(p) + assert c.seq == "ACGTN" and c.circular and c.features == () + +def test_unknown_extension_sniffed(tmp_path): ... # "x.seq" с ">"-заголовком → FASTA + +def test_garbage_rejected(tmp_path): ... # pytest.raises(ValueError) + +def test_with_circular_toggle(): assert not construct_from_text("ACGT").with_circular(False).circular +``` + +- [ ] **Step 2: Запустить** `python -m pytest tests/test_reference.py -v` → FAIL. +- [ ] **Step 3: Реализовать** по Interfaces. +- [ ] **Step 4: Запустить** → PASS. +- [ ] **Step 5: Commit** `git commit -m "reference: load constructs from FASTA, GenBank and SnapGene"` + +### Task 5: Кольцевое выравнивание + +**Files:** +- Modify: `chrom_core/align.py` (новые функции; `align_read` не меняется) +- Test: `tests/test_align_circular.py` + +**Interfaces:** +- Consumes: `Construct` (Task 4) +- Produces (`chrom_core/align.py`): + - `CIRCULAR_MARGIN = 50` + - `ReadAlignment` получает поле `ref_length: int = 0` и методы `covers(pos: int) -> bool`, `covered_positions() -> list[int]` (для обёрнутых — `ref_start..L-1, 0..ref_end-1`); `ref_end < ref_start` означает обёртку + - `align_to_construct(read: str, construct: Construct) -> ReadAlignment` — линейный: `align_read` + `ref_length`; кольцевой: `align_read(read, seq + seq[:len(read) + CIRCULAR_MARGIN])` (лимит проверяется по `len(construct.seq)`), затем все координаты `ref_*`, ключи `ref_to_read`, `Difference.ref_pos` — по модулю `L`; для `ref_end` используется `((ref_end - 1) % L) + 1` + - `gapped_sequences` для обёрнутого выравнивания работает по развёрнутому референсу (`seq + seq[:…]`) — передавать ему `construct.seq * 2` + +- [ ] **Step 1: Падающие тесты** (на реальном риде и COI): + +```python +def test_linear_matches_align_read(): ... # те же differences/identity, ref_length == len(ref) + +def test_read_across_origin_of_rotated_reference(): + ref = coi_reference(); aln0 = align_read(read.seq, ref) + mid = (aln0.ref_start + aln0.ref_end) // 2 + rotated = ref[mid:] + ref[:mid] + aln = align_to_construct(read.seq, Construct("rot", rotated, True)) + assert aln.ref_end < aln.ref_start # обёрнуто + assert len(aln.covered_positions()) == aln0.covered + assert [ (d.ref_pos - (len(ref) - mid)) % len(ref) for d in aln.differences ] \ + == [ d.ref_pos for d in aln0.differences ] + assert aln.covers(0) and aln.covers(len(ref) - 1) + +def test_same_rotation_linear_breaks_read(): + aln = align_to_construct(read.seq, Construct("rot", rotated, False)) + assert aln.covered < aln0.covered # без кольца рид рвётся + +def test_limit_applies_to_original_length(): ... # 100_000 п.н. кольцо проходит; 100_001 — ValueError +``` + +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать** по Interfaces. +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS (включая старые `test_align.py`). +- [ ] **Step 5: Commit** `git commit -m "align: circular constructs with origin-spanning reads"` + +### Task 6: Группировка ридов в клоны + +**Files:** +- Create: `chrom_core/clones.py` (раздел группировки) +- Modify: `chrom_core/config.py` (`AppConfig.primer_tokens: list[str]`, `AppConfig.group_pattern: str = ""`) +- Test: `tests/test_grouping.py` + +**Interfaces:** +- Produces: + - `DEFAULT_PRIMER_TOKENS = ("F", "R", "FW", "FWD", "REV", "RV", "T7", "T7TERM", "SP6", "T3", "M13F", "M13R", "M13", "PUC-F", "PUC-R", "BGH", "CMV")` + - `@dataclass(frozen=True) ReadRef(path: Path, clone: str, primer: str)` + - `@dataclass Grouping(reads: list[ReadRef], unassigned: list[Path])` с методами `clones() -> dict[str, list[ReadRef]]` (порядок клонов — натуральная сортировка: `cl2` < `cl10`), `assign(path: Path, clone: str, primer: str) -> None`, `to_dict() -> dict`, `from_dict(d: dict) -> Grouping` + - `parse_name(stem: str, primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS) -> tuple[str, str] | None` — алгоритм из спецификации §2.4 (разделители `_ - .` и пробел; токен праймера — последний совпавший со словарём без учёта регистра, иначе последний токен; клон — исходная подстрока до токена праймера без хвостовых разделителей, без одиночной цифры прямо перед праймером и без ведущей лунки `^[A-H]\d{1,2}$`; хвостовой суффикс повтора `_2`/`-2` **после** токена праймера отбрасывается). Пустой клон → `None`. + - `group_reads(paths: Iterable[Path], pattern: str | None = None, primer_tokens=DEFAULT_PRIMER_TOKENS) -> Grouping` — при `pattern` используется `re.fullmatch` с группами `clone`, `primer`; несовпавшие → `unassigned`. Невалидный regex → `ValueError`. + +- [ ] **Step 1: Падающие тесты** + +```python +@pytest.mark.parametrize("stem,expected", [ + ("Achl_ACHLO006-09_1_F", ("Achl_ACHLO006-09", "F")), + ("Achl_ACHLO006-09_2_R", ("Achl_ACHLO006-09", "R")), + ("cl3_T7", ("cl3", "T7")), + ("cl3_T7_2", ("cl3", "T7")), # повторное секвенирование + ("A05_K3-F", ("K3", "F")), + ("clone12.rev", ("clone12", "rev")), + ("клон 14_SP6", ("клон 14", "SP6")), + ("T7", None), +]) +def test_parse_name(stem, expected): assert parse_name(stem) == expected + +def test_group_real_reads_into_four_clones(): + g = group_reads(REAL_READS) + assert len(g.clones()) == 4 and all(len(v) == 2 for v in g.clones().values()) + +def test_repeat_reads_both_kept(): ... # cl3_T7.ab1 + cl3_T7_2.ab1 → 2 рида в cl3 + +def test_custom_pattern_and_unassigned(): ... # r"(?PS\d+)-(?P\w+)" ; "junk" → unassigned + +def test_bad_regex_raises(): ... + +def test_natural_sort(): ... # cl2, cl10 + +def test_grouping_roundtrip_and_manual_assign(): ... +``` + +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать** по Interfaces; `AppConfig` — новые поля со значениями по умолчанию (`list(DEFAULT_PRIMER_TOKENS)`, `""`). +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS. +- [ ] **Step 5: Commit** `git commit -m "clones: group reads into clones by file name"` + +### Task 7: Проверка клона и вердикт + +**Files:** +- Modify: `chrom_core/clones.py` (раздел проверки) +- Modify: `tests/clone_fixtures.py` (полусинтетика) +- Test: `tests/test_clone_verify.py` + +**Interfaces:** +- Consumes: `Construct` (Task 4), `align_to_construct`, `ReadAlignment.covers/covered_positions` (Task 5), `secondary_peak`, `scan_secondary_peaks` (Task 2), `ReadRef` (Task 6), `sessions.load_session/session_path`, `EditableRead.restore_state/current` +- Produces (`chrom_core/clones.py`): + - `@dataclass(frozen=True) VerifyParams(min_q: int = 20, het_ratio: float = 0.40, mixed_min_positions: int = 3)` + - `Region = tuple[int, int]` — 0-based полуоткрытый; `start > end` — через начало (только для кольцевого); `region_positions(region, length) -> list[int]`; `None` = весь конструкт + - `@dataclass(frozen=True) ReadResult(ref: ReadRef, alignment: ReadAlignment | None, error: str = "", ambiguous: bool = False)` + - `@dataclass(frozen=True) CloneDifference(ref_pos: int, kind: str, ref_base: str, read_base: str, support: int, against: int, low_quality: int, status: str, in_region: bool, effect: str = "")` — `status ∈ {"confirmed", "disputed", "low_quality"}` + - `@dataclass(frozen=True) CloneReport(clone: str, reads: tuple[ReadResult, ...], coverage: dict[int, tuple[int, int]], differences: tuple[CloneDifference, ...], mixed_positions: tuple[int, ...], verdict: str)` + свойства `region_coverage_pct: float`, `both_strands_pct: float`, `region_differences` (только `in_region`). `coverage[pos] = (ридов на «+», ридов на «−»)` с Q ≥ `min_q`, только для позиций региона + - `load_read(path: Path) -> Chromatogram` — с учётом сессии правок (как `batch._edited_read`, вынести её в общую функцию и переиспользовать) + - `verify_clone(construct: Construct, region: Region | None, reads: list[ReadRef], params: VerifyParams = VerifyParams(), annotate=None) -> CloneReport` — `annotate` — колбэк `(construct, CloneDifference) -> str` (эффект; подключается в Task 8) + - `verify_all(construct, region, grouping: Grouping, params, progress=None, cancelled=None) -> list[CloneReport]` — `progress(done: int, total: int)`, `cancelled() -> bool` (при `True` — `CancelledError` из `concurrent.futures`) +- Правила (спецификация §2.4): `N`/IUPAC в **референсе** — не расхождение; расхождение «подтверждает» рид с Q ≥ `min_q` в этой позиции (для вставки — Q соседних баз), «против» — рид, покрывающий позицию с референсной базой и Q ≥ `min_q`; `alternatives > 1` → `ambiguous=True`, рид исключается из покрытия и расхождений. Вердикт — первое сработавшее: `no_data`, `mixed`, `errors` (есть `confirmed` или `disputed` в регионе), `incomplete`, `ok`. +- Produces (`tests/clone_fixtures.py`): + - `mutate(seq: str, pos: int, kind: str, base: str = "") -> str` — `kind ∈ {"sub", "ins", "del"}` + - `with_secondary_peak(chrom: Chromatogram, index: int, base: str, ratio: float) -> Chromatogram` — копирует форму пика вызванной базы в канал `base`, масштабируя к `ratio` × высота основного пика + - `write_read(chrom: Chromatogram, folder: Path, name: str) -> Path` — сохраняет как сессию правок рядом с копией исходного `.ab1` (через `sessions.save_session`), чтобы `load_read` вернул изменённую хроматограмму + +- [ ] **Step 1: Падающие тесты** — один набор клонов, собранный фикстурой из реальных ридов `ACHLO006-09` (F+R), регион = покрытая обоими ридами часть COI: + +```python +def test_ok_clone(): assert report.verdict == "ok" and report.region_differences == () + +def test_substitution_found_with_support(): + ref = mutate(coi, P, "sub", other_base) # P внутри региона + r = verify_clone(Construct("c", ref, False), region, reads) + d, = r.region_differences + assert (d.ref_pos, d.kind, d.status, d.support, d.against) == (P, "substitution", "confirmed", 2, 0) + assert r.verdict == "errors" + +def test_insertion_and_deletion_found(): ... # mutate "del" → read shows insertion; "ins" → deletion + +def test_disputed_when_reads_disagree(): ... # мутация в одном риде (через write_read) → support 1, against 1, "disputed", verdict "errors" + +def test_outside_region_does_not_affect_verdict(): ... + +def test_incomplete_when_read_trimmed(): ... # только F, обрезан → "incomplete", region_coverage_pct < 100 + +def test_mixed_50_percent_flagged(): # 3 позиции с ratio 0.5 + assert r.verdict == "mixed" and len(r.mixed_positions) == 3 + +def test_mixed_30_percent_not_flagged(): ... # ratio 0.3 → "ok" + +def test_no_data_for_foreign_read(): ... # рид pUC19-несовместимый (COI-рид против pUC19) → "no_data" + +def test_reverse_complemented_session_same_result(): ... # Review Focus 1 + +def test_region_across_origin(): ... # повёрнутый кольцевой COI, регион start > end → те же расхождения (Review Focus 2) + +def test_read_without_quality_is_incomplete_not_errors(): ... # все Q=0 → "incomplete" (Review Focus 3) + +def test_reference_N_is_not_a_difference(): ... # Review Focus 4 + +def test_unreadable_file_marked_not_fatal(): ... # битый .ab1 → ReadResult.error, остальные обработаны + +def test_ambiguous_read_excluded(): ... # линейный конструкт = COI + COI → ReadResult.ambiguous, verdict "no_data" + +def test_invalid_region_rejected(): ... # пустой регион, вне конструкта, start > end на линейном → ValueError до выравнивания + +def test_verify_all_progress_and_cancel(): ... + +def test_performance_12_clones_x_4_reads(): + # конструкт ~10 т.п.н. = COI + случайный фланг (seed 1); 12 клонов × 4 рида из реальных + t = time.perf_counter(); verify_all(...); elapsed = time.perf_counter() - t + assert elapsed < LIMIT # LIMIT = 2 × замер на машине разработчика, вписать числом +``` + +- [ ] **Step 2: Запустить** `python -m pytest tests/test_clone_verify.py -v` → FAIL. +- [ ] **Step 3: Реализовать** фикстуры в `tests/clone_fixtures.py` и проверку в `chrom_core/clones.py` по Interfaces. +- [ ] **Step 4: Запустить** → PASS; замерить `test_performance…`, вписать `LIMIT` числом. +- [ ] **Step 5: Запустить всё** `python -m pytest -q` → PASS. +- [ ] **Step 6: Commit** `git commit -m "clones: per-clone coverage, merged differences and verdicts"` + +### Task 8: Эффект расхождения в CDS + +**Files:** +- Create: `chrom_core/effects.py` +- Test: `tests/test_effects.py` + +**Interfaces:** +- Consumes: `Construct`, `Feature` (Task 4), `CloneDifference` (Task 7) +- Produces: + - `difference_effect(construct: Construct, diff: CloneDifference) -> str` — первая CDS, содержащая `diff.ref_pos` (с учётом обёртки и `strand`, рамка из `codon_start`). Замена: `"synonymous"` / `"<номер>"` (однобуквенные коды, например `"D45N"`) / `"<номер>*"` для стопа; вставка/делеция: `"frameshift"`, если длина не кратна 3, иначе `"inframe_ins"` / `"inframe_del"`. Вне CDS или без фич — `""`. Трансляция — `Bio.Seq.translate`, таблица 11. + - Строка-код эффекта переводится в UI ключами `effect.synonymous`, `effect.frameshift`, `effect.inframe_ins`, `effect.inframe_del` (миссенс/стоп показываются как есть). + +- [ ] **Step 1: Падающие тесты** — на COI из GenBank (с CDS) и на pUC19 (`bla` на минус-цепи): + +```python +def test_synonymous_third_codon_position(): ... # замена 3-го нуклеотида кодона на синонимичный +def test_missense_label(): assert difference_effect(coi_c, d) == f"{aa_ref}{n}{aa_alt}" +def test_stop_gained(): assert difference_effect(...).endswith("*") +def test_frameshift_and_inframe(): ... +def test_minus_strand_cds_puc19(): ... # замена в bla даёт верный номер аминокислоты +def test_no_features_empty(): assert difference_effect(Construct("x", seq, False), d) == "" +``` + + Ожидаемые значения вычисляются в тесте из известного кодона, а не хардкодятся «на глаз». +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать**; подключить `difference_effect` как `annotate` по умолчанию в `verify_clone`. +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS. +- [ ] **Step 5: Commit** `git commit -m "effects: amino-acid consequences of clone differences in CDS"` + +### Task 9: Отчёты по клонам + +**Files:** +- Create: `chrom_core/clone_report.py` +- Test: `tests/test_clone_report.py` + +**Interfaces:** +- Consumes: `CloneReport`, `CloneDifference` (Task 7) +- Produces: + - `clones_csv(reports: list[CloneReport]) -> str` — колонки `clone, verdict, region_coverage_pct, both_strands_pct, differences, reads` + - `differences_csv(reports: list[CloneReport]) -> str` — колонки `clone, position, kind, ref, read, effect, support, against, status, note`; `position` 1-based + - `STATUS_HEX: dict[str, tuple[str, str]]` — `verdict -> (светлая, тёмная)` hex-цвета для `ok, errors, incomplete, mixed, no_data` (единый источник палитры; UI импортирует его в Task 13) + - `clone_report_xlsx(path, reports: list[CloneReport], construct_name: str) -> None` — листы `Clones` и `Differences` с теми же колонками; ячейка вердикта залита светлым цветом из `STATUS_HEX` + - CSV в том же стиле, что `verify.verdicts_csv` (разделитель `,`, экранирование как `batch._csv_field`) + +- [ ] **Step 1: Падающие тесты** — строки заголовков, число строк, 1-based позиция, цвет заливки ячейки `errors` в XLSX (`openpyxl.load_workbook`). +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4: Запустить** → PASS. +- [ ] **Step 5: Commit** `git commit -m "clone_report: CSV and Excel reports for clone verification"` + +### Task 10: Проект `.ab1proj` + +**Files:** +- Create: `chrom_core/project.py` +- Test: `tests/test_project.py` + +**Interfaces:** +- Consumes: `Construct`, `load_construct` (Task 4), `Grouping` (Task 6), `VerifyParams`, `Region` (Task 7) +- Produces: + - `PROJECT_VERSION = 1` + - `@dataclass Project(construct_path: Path, circular: bool, region: Region | None, grouping: Grouping, params: VerifyParams)` + - `save_project(path: Path, project: Project) -> None` — JSON UTF-8 (`ensure_ascii=False`); пути к конструкту и ридам относительные к папке проекта, если лежат внутри неё, иначе абсолютные + - `load_project(path: Path) -> Project` — `ValueError` с понятным сообщением при битом JSON, чужом файле (`app != "ab1kit"`) или `version > PROJECT_VERSION` + +- [ ] **Step 1: Падающие тесты:** round-trip; относительные пути при переносе папки проекта; путь `tmp_path / "Данные" / "клоны 1"` (Review Focus 5); битый файл и будущая версия → `ValueError`. +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS. +- [ ] **Step 5: Commit** `git commit -m "project: .ab1proj files for clone verification sessions"` + +--- + +## Фаза 3 — интерфейс + +### Task 11: Фоновые задачи + +**Files:** +- Create: `ui/tasks.py` +- Modify: `ui/main_window.py` (`_scan_folder`, `_blast_submit` — через задачи) +- Test: `tests/test_ui_tasks.py` + +**Interfaces:** +- Produces: + - `class Task(QObject)` с сигналами `progress(int, int)`, `finished(object)`, `failed(str)`, `cancelled()`; методы `cancel()`, `is_cancelled() -> bool` + - `run_task(fn: Callable[[Callable[[int, int], None], Callable[[], bool]], object], parent: QObject) -> Task` — `fn(progress, is_cancelled)` выполняется в `QThreadPool.globalInstance()`; сигналы приходят в GUI-поток; исключение → `failed(str(exc))`; `CancelledError` → `cancelled` + - В строке состояния `MainWindow` — `QProgressBar` и кнопка «Отмена» (ключи `task.cancel`, `task.running`), видимые во время задачи + +- [ ] **Step 1: Падающие тесты** (offscreen, `qtbot`-free: крутить `QApplication.processEvents()` до сигнала с таймаутом 5 с): результат приходит в `finished`; исключение — в `failed`; отмена — в `cancelled`; GUI-поток не блокируется (таймер в GUI-потоке тикает, пока задача спит 0,3 с). +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать** `ui/tasks.py`; перевести сканирование папки и BLAST на `run_task`. +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS. +- [ ] **Step 5: Commit** `git commit -m "ui: background tasks with progress and cancel"` + +### Task 12: Разбить `main_window.py` + +**Files:** +- Create: `ui/read_view/__init__.py`, `ui/read_view/dialogs.py` (`SearchDialog`, `TrimDialog`), `ui/read_view/analysis_dock.py`, `ui/read_view/verify_dock.py`, `ui/read_view/batch_dock.py` (+ перенос `ui/batch_panel.py`), `ui/read_view/read_view.py` (`ReadView(QMainWindow)` — всё, что сейчас относится к одному риду: вью, миникарта, доки, правка, обрезка, экспорт, поиск) +- Modify: `ui/main_window.py` — остаётся оболочка: меню «Файл/Вид/Справка», язык, тема, недавние файлы, `run()` +- Test: существующий `tests/test_smoke_gui.py` (без изменения утверждений; правятся только пути monkeypatch) + +**Interfaces:** +- Produces: + - `ReadView.open_path(path) -> bool`, `ReadView.refresh_read()`, `ReadView.current_model() -> EditableRead | None`, сигнал `ReadView.read_edited(Path)` (испускается после правки, undo/redo, обрезки и сохранения сессии) + - `MainWindow.read_view: ReadView`; публичные методы `MainWindow`, которые используют тесты (`open_path`, `apply_base_*`, `undo_read`, …), остаются как делегаты, чтобы `test_smoke_gui.py` не менялся по смыслу + +- [ ] **Step 1: Перенести код** без изменения поведения, по одному блоку (диалоги → доки → ReadView), после каждого — `python -m pytest tests/test_smoke_gui.py -q` → PASS. +- [ ] **Step 2: Проверить размер:** ни один файл в `ui/` не длиннее ~450 строк (`wc -l ui/*.py ui/read_view/*.py`). +- [ ] **Step 3: Запустить всё** `python -m pytest -q` → PASS. +- [ ] **Step 4: Commit** `git commit -m "ui: split main window into read_view modules (no behaviour change)"` + +### Task 13: Тема, иконки, палитра статусов + +**Files:** +- Create: `ui/style.qss` (светлая), `ui/style_dark.qss`, `ui/icons/*.svg` + `ui/icons/LICENSE` (набор Lucide, ISC/MIT — проверить лицензию при скачивании; иначе Tabler Icons, MIT), `ui/status.py` +- Modify: `ui/theme.py` (загрузка QSS), `ui/read_view/*` (иконки на тулбаре, растяжение колонок, тултипы длинных ячеек), `ui/chrom_view.py` (цвет полос качества по Q), `ab1kit.spec` (включить `ui/style*.qss`, `ui/icons`), `pyproject.toml` (`package-data` для `ui`) +- Test: `tests/test_smoke_gui.py` + +**Interfaces:** +- Produces (`ui/status.py`): + - `status_color(verdict: str, theme: str) -> QColor` — из `chrom_core.clone_report.STATUS_HEX` (Task 9) + - `quality_color(q: int, theme: str) -> QColor` — `<20` красный, `20–29` жёлтый, `≥30` зелёный (те же пороги, что миникарта) + - `status_chip(verdict: str, text: str) -> QLabel` — скруглённая плашка + - `icon(name: str) -> QIcon` + +- [ ] **Step 1: Падающие тесты:** у каждого действия тулбара есть иконка; `quality_color(15) != quality_color(35)`; таблицы растягивают последнюю колонку и имеют тултип с полным текстом примечания; QSS применяется (`app.styleSheet()` не пуст) в обеих темах. +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать.** Пакет иконок: только используемые (~20 файлов). +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS; сделать скриншоты обеих тем через `scripts/render_preview.py` и посмотреть глазами. +- [ ] **Step 5: Commit** `git commit -m "ui: QSS theme, icons, unified status palette, coloured quality bars"` + +### Task 14: Строка-сводка и матрица клонов + +**Files:** +- Create: `ui/construct/__init__.py`, `ui/construct/summary_bar.py`, `ui/construct/matrix.py` +- Test: `tests/test_construct_ui.py` + +**Interfaces:** +- Consumes: `Construct`, `Region`, `CloneReport` (Tasks 4, 7), `status_color`, `status_chip` (Task 13) +- Produces: + - `SummaryBar(QWidget)`: `set_data(construct: Construct, region: Region | None, reports: list[CloneReport])`; сигнал `filter_changed(set[str])` — множество показываемых вердиктов (клик по счётчику переключает) + - `CloneMatrix(QWidget)` (собственная отрисовка `paintEvent`): `set_data(construct, region, reports)`, `set_filter(verdicts: set[str])`; дорожка референса (фичи прямоугольниками с подписью, рамка региона), строка на клон: покрытие (две цепи — насыщенный, одна — светлый, Q<20/нет — фон), метки расхождений цветом статуса, плашка вердикта справа. Колёсико — масштаб общей оси, перетаскивание — сдвиг. Протяжка по дорожке референса с зажатым Shift — задать регион. + - Сигналы: `difference_clicked(str, int)` (клон, `ref_pos`), `clone_clicked(str)`, `region_changed(object)` (`Region`) + - `CloneMatrix.hit_test(pos: QPoint) -> tuple[str, int] | None` — для тестов и кликов + +- [ ] **Step 1: Падающие тесты** (offscreen, данные — `verify_all` на наборе из Task 7): сводка показывает счётчики по вердиктам; фильтр скрывает строки; `hit_test` по координате метки возвращает `(clone, ref_pos)`; Shift-протяжка испускает `region_changed`; виджет рендерится в `QImage` без исключений в обеих темах; 12 строк помещаются в 600 px по высоте. +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4: Запустить** → PASS; отрендерить PNG матрицы и проверить глазами против макета B. +- [ ] **Step 5: Commit** `git commit -m "ui: construct summary bar and clone matrix"` + +### Task 15: Панель деталей расхождения + +**Files:** +- Create: `ui/construct/detail_pane.py` +- Test: `tests/test_construct_ui.py` + +**Interfaces:** +- Consumes: `CloneReport`, `ReadResult`, `load_read` (Task 7), `ChromatogramWidget` (`ui/chrom_view.py`: `set_chromatogram`, `set_reference`, `goto_base`, `set_highlights`) +- Produces: + - `DetailPane(QWidget)`: `show_clone(report: CloneReport, construct: Construct)`, `show_position(ref_pos: int)`; слева — стопка `ChromatogramWidget` по одному на рид клона (компактная высота, общий масштаб), каждый центрирован на базе, соответствующей `ref_pos` (через `alignment.ref_to_read` и ориентацию), с подсветкой; над стопкой — строка референса ±10 п.н.; справа — таблица расхождений клона (позиция 1-based, `ref→read`, эффект через `tr`, за/против, статус); выбор строки → `show_position` + - Сигнал `open_read(Path)` — по двойному клику на хроматограмму + +- [ ] **Step 1: Падающие тесты:** число хроматограмм = числу ридов клона с выравниванием; после `show_position(P)` центральная база каждого вью — та, что выровнена на `P`; для рида на минус-цепи — тоже; выбор строки таблицы вызывает `show_position`; двойной клик испускает `open_read`. +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4: Запустить** → PASS. +- [ ] **Step 5: Commit** `git commit -m "ui: difference detail pane with stacked chromatograms"` + +### Task 16: Экран «Проверка конструкта», группировка, связка с просмотрщиком + +**Files:** +- Create: `ui/construct/grouping_dialog.py`, `ui/construct/workspace.py` +- Modify: `ui/main_window.py` (вкладки «Проверка конструкта» / «Просмотр рида», меню «Проект»: новый/открыть/сохранить, экспорт отчёта), `i18n.py`, `translations_en.py` +- Test: `tests/test_construct_ui.py`, `tests/test_smoke_gui.py` + +**Interfaces:** +- Consumes: всё из Tasks 4–15; `run_task` (Task 11); `ReadView.read_edited` (Task 12) +- Produces: + - `GroupingDialog(QDialog)`: `__init__(grouping: Grouping, pattern: str, parent)`, `result_grouping() -> Grouping`, `result_pattern() -> str`; таблица «файл / клон / праймер» с правкой; поле шаблона с предпросмотром (невалидный regex — красная рамка, кнопка OK неактивна); строки «без клона» выделены + - `ConstructWorkspace(QWidget)`: `set_construct(path)`, `add_reads(paths)`, `set_region(region)`, `load_project(path)`, `save_project(path)`, `export_report(path)`; пустое состояние с двумя зонами перетаскивания (конструкт; папка/файлы ридов); любое изменение (конструкт, риды, группировка, регион, параметры, `read_edited`) → перезапуск `verify_all` через `run_task` с отменой предыдущего; сигнал `open_read(Path)` + - Поля параметров `min_q`, `het_ratio`, `mixed_min_positions` и галочка «кольцевая» — в строке над матрицей; сохраняются в проекте + - `MainWindow`: вкладка «Проверка конструкта» первая и активна при старте без аргументов; `open_read` → переключение на «Просмотр рида» + `open_path`; `ab1kit file.ab1proj` открывает проект + +- [ ] **Step 1: Падающие тесты:** перетаскивание COI `.gb` и 8 реальных ридов → после завершения задачи 4 строки в матрице и вердикты как в Task 7 (для неизменённого референса — все `ok` или `incomplete`); правка рида в «Просмотре рида» (замена базы в позиции расхождения) → пересчёт и смена вердикта; сохранение/открытие проекта восстанавливает регион и группировку; битый `.ab1proj` → сообщение и пустое рабочее пространство без исключения; недопустимый регион → сообщение, проверка не запускается; отмена пересчёта оставляет прежние вердикты в матрице; `GroupingDialog` с невалидным regex блокирует OK; пустое состояние видно без данных; все новые ключи есть в RU и EN. +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4: Запустить** `python -m pytest -q` → PASS. +- [ ] **Step 5: Ручная проверка:** запустить `ab1kit`, пройти сценарий из критерия успеха спецификации (перетащить конструкт и папку, задать регион, открыть расхождение, перейти в рид, поправить, вернуться) в светлой и тёмной теме, RU и EN. Обновить скриншоты `docs/screenshots/` (`scripts/render_preview.py`), добавить скриншот экрана проверки. +- [ ] **Step 6: Commit** `git commit -m "ui: construct verification workspace with grouping and project files"` + +--- + +## Фаза 4 — CLI и выпуск + +### Task 17: Подкоманды CLI, документация, версия + +**Files:** +- Modify: `chrom_cli.py`, `README.md`, `CHANGELOG.md`, `pyproject.toml` (`version = "1.1.0"`) +- Test: `tests/test_cli.py` + +**Interfaces:** +- Consumes: `load_construct`, `group_reads`, `verify_all`, `VerifyParams`, `clones_csv`/`differences_csv`/`clone_report_xlsx`, `batch.scan_folder`/`summary_csv`/`batch_export` +- Produces: + - `main(argv) -> int`: если `argv[0] in {"verify-clones", "qc", "export"}` — `argparse`-подкоманда, иначе GUI (как сейчас) + - `verify-clones --construct FILE [--region A-B] [--linear] [--pattern REGEX] [--min-q N] [--het-ratio X] READS_DIR -o REPORT` — `A-B` 1-based включительно → `Region(A-1, B)`; `.xlsx` → `clone_report_xlsx`, `.csv` → `clones_csv` в `REPORT` и `differences_csv` в `REPORT` с суффиксом `_differences.csv`; печать короткой таблицы клонов в stdout; код возврата 0 — есть хотя бы один `ok`, 1 — нет, 2 — ошибка входных данных (сообщение в stderr) + - `qc READS_DIR [-o CSV]` — `scan_folder` + `summary_csv` (stdout без `-o`) + - `export READS_DIR --trim [--fasta|--fastq] -o DIR` — `batch_export` + +- [ ] **Step 1: Падающие тесты:** `verify-clones` на `tmp_path / "Данные клоны"` с копиями реальных ридов и COI → код 0, XLSX с листами `Clones`/`Differences`; на изменённом референсе — строка с `errors`; без ридов/битый конструкт → 2; `qc` печатает CSV; `export` создаёт файлы; `main([])` и `main(["x.ab1"])` по-прежнему вызывают GUI (`monkeypatch` на `ui.main_window.run`). +- [ ] **Step 2: Запустить** → FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4: Документация:** в README — раздел «Clone verification» (сценарий, формат имён, шаблон, вердикты, CLI-пример) и новый скриншот; CHANGELOG — `[1.1.0]` с Added/Changed/Fixed по задачам 2–17. +- [ ] **Step 5: Запустить всё** `python -m pytest -q` → PASS; собрать exe `pyinstaller ab1kit.spec` и запустить — экран проверки открывается, иконки и тема на месте. +- [ ] **Step 6: Commit** `git commit -m "CLI subcommands verify-clones/qc/export; docs and v1.1.0"` diff --git a/docs/superpowers/specs/2026-10-25-clone-verification-design.md b/docs/superpowers/specs/2026-10-25-clone-verification-design.md new file mode 100644 index 0000000..2443beb --- /dev/null +++ b/docs/superpowers/specs/2026-10-25-clone-verification-design.md @@ -0,0 +1,275 @@ +# Проверка клонов, точность анализа пиков и новый интерфейс — дизайн + +Дата: 2026-10-25 · Статус: согласован в обсуждении, ждёт ревью спецификации + +## 1. Цель и сценарий + +Главный сценарий ab1kit после этой работы — **проверка клонов одного +конструкта**: в лаборатории отобрали 4–12 колоний, каждую отсеквенировали +1–4 праймерами; нужно быстро понять, какой клон правильный, а если нет — +где и какая ошибка. Остальные сценарии (генотипирование ожидаемых SNP, поиск +мутаций, просмотр/экспорт одного рида, батч-QC) сохраняются как есть. + +Пользователи — биологи, не биоинформатики. Платформа — Windows (как сейчас), +лицензия MIT. + +**Критерий успеха:** пользователь перетаскивает файл конструкта и папку с +ридами, задаёт регион проверки и за минуту видит по каждому клону вердикт, +а по каждому расхождению — хроматограммы всех ридов в этой позиции. + +### Принятые решения + +| Вопрос | Решение | +| --- | --- | +| Главный сценарий | Проверка клонов (B); остальные сохраняются | +| Формат конструкта | Основной — FASTA (без аннотаций); также GenBank и SnapGene `.dna`; Benchling — нет | +| Размер сессии | Один конструкт, 4–12 клонов × 1–4 рида | +| Группировка ридов в клоны | Автоматически по имени файла + ручная правка; свой шаблон-regex в настройках | +| Что проверяется | Регион, заданный пользователем; вне региона расхождения видны, но на вердикт не влияют | +| Архитектура UI | PySide6 Widgets, обновлённый стиль; без переписывания на QML/веб | +| Раскладка главного экрана | «Матрица»: все клоны строками на общей оси с референсом | + +## 2. Ядро (`chrom_core/`, без зависимостей от Qt, покрыто pytest) + +### 2.1 `reference.py` — загрузка конструкта + +- `load_construct(path) -> Construct`; формат по расширению (`.fa/.fasta/.fna/.txt`, + `.gb/.gbk/.genbank`, `.dna`) с проверкой содержимого; чтение через + `Bio.SeqIO` (`fasta`, `genbank`, `snapgene`). Берётся первая запись. +- `Construct`: `name`, `seq` (верхний регистр, IUPAC), `circular: bool`, + `features: list[Feature]` (может быть пустым). +- `Feature`: `name`, `kind` (`CDS`, `promoter`, `primer_bind`, …), `start`, + `end` (0-based, полуоткрытый), `strand` (+1/−1); фичи через начало + координат поддерживаются (`start > end` означает обёртку). +- `circular`: из файла для GenBank/SnapGene (topology); для FASTA — `True` по + умолчанию, переключается пользователем. +- Существующий `align.parse_sequence` остаётся для вставки текстом + (результат — `Construct` без фич, `circular=True`). + +### 2.2 `align.py` — кольцевой референс + +- Новая функция `align_to_construct(read, construct) -> ReadAlignment`. + Для линейного — вызывает существующий `align_read`. Для кольцевого — + выравнивает на `seq + seq[:len(read) + 50]`, затем приводит все + координаты (`ref_start/ref_end`, `ref_to_read`, `differences`) по модулю + длины. Для обёрнутого выравнивания `ref_end` может быть меньше + `ref_start`; добавляется метод `covers(pos)` и `covered_positions()`. +- `align_read` и его тесты не меняются. `MAX_REFERENCE_LENGTH` (100 т.п.н.) + действует на исходную длину конструкта. + +### 2.3 `peaks.py` — анализ вторичных пиков (выносится из `snps.py`) + +- `channel_baseline(chrom, i, span=20) -> dict[base, float]` — медиана + высоты пиков каждого канала среди ~20 ближайших вызванных баз этого + канала; нормирует разную яркость красителей. +- `peak_window(chrom, i)` — половина расстояния до соседних пиков (не + меньше 2 точек) вместо фиксированных ±5. +- `secondary_peak(chrom, i) -> SecondaryPeak(base, ratio, primary_base)` — + на **любой** позиции: нормированная высота сильнейшего канала вне + вызванной базы, делённая на нормированную высоту вызванной. +- `snps.py` и `verify.py` переходят на `peaks.py`; публичные сигнатуры + `call_snps`, `verify_expected`, `peak_heights` сохраняются (последняя + остаётся как обёртка). Добавляется `scan_secondary_peaks(chrom, + alignment, min_ratio)` — поиск гетерозигот/смесей по всему выровненному + риду, включая позиции, где вызвана референсная база. + +### 2.4 `clones.py` — проверка клонов + +**Группировка.** `group_reads(paths, pattern=None) -> Grouping`. +- По умолчанию: имя файла без расширения делится по `_`, `-`, `.`, пробелу; + метка праймера — последний токен, совпавший со словарём (`F`, `R`, `FW`, + `REV`, `FWD`, `T7`, `T7term`, `SP6`, `T3`, `M13F`, `M13R`, `pUC-F`, … — + регистр не важен; словарь расширяется в настройках); если совпадений со + словарём нет — последний токен. Клон — исходная подстрока имени **до** + токена праймера (с сохранением исходных разделителей), из которой убраны + хвостовые разделители, номер порядка праймера (`^\d$` непосредственно + перед праймером) и ведущая позиция лунки планшета (`^[A-H]\d{1,2}$`). + Если после этого клон пустой — файл попадает в «без клона». + Пример: `Achl_ACHLO006-09_1_F` → клон `Achl_ACHLO006-09`, праймер `F`. +- Свой шаблон — regex с именованными группами `clone` и `primer`. +- Нераспознанные файлы → группа «без клона» (в проверку не идут, пока + пользователь их не назначит). +- `Grouping` сериализуется вместе с ручными правками. + +**Проверка.** `verify_clone(construct, region, reads, params) -> CloneReport`. +- `region`: `(start, end)` в координатах конструкта, обёртка через начало + допустима для кольцевого. +- Для каждого рида: текущая версия с учётом сессии правок + (`<файл>.ab1kit.json`), выравнивание `align_to_construct`. Рид с + `alternatives > 1` помечается «неоднозначно» и не участвует в вердикте. +- **Покрытие** по позиции региона: число ридов, покрывающих её с Q ≥ `min_q` + (по умолчанию 20), отдельно по цепям. +- **Расхождения** (`CloneDifference`): замены, вставки, делеции из + выравниваний всех ридов, объединённые по позиции и типу; для каждого — + `support` (ридов с этим расхождением и Q ≥ `min_q`), `against` (ридов, + видящих референс с Q ≥ `min_q`), статус `confirmed` (support ≥ 1, + against = 0) / `disputed` (support ≥ 1, against ≥ 1) / `low_quality` + (support = 0, есть только низкокачественные). +- **Эффект** (если в конструкте есть CDS, перекрывающая позицию): для + замены — синонимичная / миссенс (`D45N`) / стоп; для вставки/делеции — + «сдвиг рамки» или «вставка/делеция в рамке». Без аннотаций — пусто. +- **Смешанность:** позиции региона, где `secondary_peak.ratio ≥ het_ratio` + (по умолчанию 0.40) хотя бы в одном риде с Q ≥ `min_q`. + +**Вердикт** (первое сработавшее правило): +1. `no_data` — ни один рид не выровнялся (или все неоднозначны); +2. `mixed` — ≥ `mixed_min_positions` (по умолчанию 3) позиций со вторым пиком; +3. `errors` — есть `confirmed` или `disputed` расхождение в регионе; +4. `incomplete` — есть позиции региона без покрытия с Q ≥ `min_q`; +5. `ok`. + +Дополнительно в отчёте: доля региона, покрытая обеими цепями. + +Пороги (`min_q`, `het_ratio`, `mixed_min_positions`) — в `VerifyParams`, +значения по умолчанию выше, настраиваются в интерфейсе. + +### 2.5 Проект и отчёты + +- `project.py`: файл `.ab1proj` (JSON) — путь к конструкту (относительный, + если рядом), `circular`, регион, список ридов с привязкой клон/праймер, + ручные правки группировки, `VerifyParams`. Версионируется полем `version`. +- Отчёт `clone_report_csv` / `clone_report_xlsx` (openpyxl): лист «Клоны» + (клон, вердикт, покрытие региона %, обе цепи %, расхождений, риды) и лист + «Расхождения» (клон, позиция, тип, ref→read, эффект, за/против, статус, + примечание). + +### 2.6 Производительность + +Сценарий 12 клонов × 4 рида на конструкте 10 т.п.н. замеряется тестом; +порог фиксируется по замеру с запасом ×2. В интерфейсе всё выполняется в +фоне, поэтому длительность влияет на ожидание, но не на отзывчивость. + +## 3. Интерфейс (`ui/`) + +### 3.1 Структура окна + +- Две вкладки-режима: **«Проверка конструкта»** (новый главный экран) и + **«Просмотр рида»** (нынешний просмотрщик с панелями анализа, проверки + SNP и батч-QC). +- Двойной клик по риду/расхождению → рид открывается в «Просмотре рида»; + после правки проверка пересчитывается. + +### 3.2 Экран «Проверка конструкта» (раскладка «матрица») + +1. **Строка-сводка:** имя конструкта, длина, кольцевая/линейная, регион; + счётчики клонов по вердиктам — каждый работает как фильтр. +2. **Матрица:** сверху дорожка референса (фичи, если есть; рамка региона — + задаётся протяжкой мышью или вводом координат). Ниже по строке на клон: + покрытие (цвет — одна/две цепи, бледный — Q < 20), метки расхождений + (цвет по статусу), плашка вердикта. Колёсико масштабирует общую ось. +3. **Детали:** по клику на метку — хроматограммы всех ридов клона в этой + позиции друг под другом, референс сверху; рядом таблица расхождений + клона (позиция, ref→read, эффект, за/против, статус). +4. **Пустое состояние:** подсказки «перетащите файл конструкта» и «…папку с + ридами» вместо пустых панелей. + +### 3.3 Окно группировки + +Таблица «файл → клон, праймер» с правкой ячеек; поле своего шаблона с +мгновенным предпросмотром; группа «без клона» выделена. + +### 3.4 Внешний вид + +- Единая QSS-тема поверх Fusion (светлая/тёмная), общая палитра статусов: + зелёный — подходит, красный — ошибки, жёлтый — неполное покрытие, + фиолетовый — смесь, серый — нет данных; легенда в строке-сводке. +- Иконки на панели инструментов: набор SVG под MIT/Apache-2.0 в + `ui/icons/` с файлом лицензии. +- Полоски качества раскрашены по Q (с легендой). +- Колонки таблиц подстраиваются под содержимое; полный текст длинных ячеек + во всплывающей подсказке. +- Исправления найденных ошибок: английское примечание в русском UI + (`verify.py` отдаёт непереведённую строку); «Ср. Q = 0» в батч-режиме + для ридов без данных качества → «—». + +### 3.5 Внутреннее устройство UI + +- `main_window.py` (1163 строки) разбивается: оболочка окна и меню; + `ui/read_view/` — существующие панели (диалоги поиска/обрезки, анализ, + проверка SNP, батч), каждая в своём файле; `ui/construct/` — сводка, + матрица, панель деталей, диалог группировки. +- Фоновые задачи: обёртка над `QThreadPool`/`QRunnable` с сигналами + прогресса, результата, ошибки и флагом отмены. Через неё — проверка + клонов, сканирование папки, отправка в BLAST. +- Все новые строки — через `_()` с переводами в `translations_en.py`. + +## 4. CLI (`chrom_cli.py`) + +- Без аргументов или с путём — запуск GUI (как сейчас). +- `ab1kit verify-clones --construct FILE [--region A-B] [--linear] + [--pattern REGEX] READS_DIR -o REPORT.(csv|xlsx)`; код возврата 0, если + есть хотя бы один клон `ok`, иначе 1; 2 — ошибка входных данных. +- `ab1kit qc READS_DIR [-o CSV]` и `ab1kit export READS_DIR --trim + [--fasta|--fastq] -o DIR` — над существующим `batch.py`. +- Реализация на `argparse`; подкоманды распознаются по первому аргументу, + путь к файлу/папке по-прежнему открывает GUI. + +## 5. Обработка ошибок + +- Нечитаемый `.ab1` или невыровнявшийся рид не прерывает проверку: рид + помечается в таблице с причиной, остальные обрабатываются. +- Пустой регион, регион вне конструкта, конструкт больше лимита — понятное + сообщение до запуска. +- Неоднозначное выравнивание (повторы) — рид помечен «неоднозначно», в + вердикт не идёт. +- Отмена фоновой задачи сохраняет предыдущий результат. +- Битый или устаревший `.ab1proj` — сообщение и открытие без проекта. + +## 6. Тестовые данные и тесты + +### 6.1 Данные + +- **Реальные риды:** sangeranalyseR (MIT), `inst/extdata/Allolobophora_chlorotica` + — 4 образца × F/R = 8 файлов (~1,7 МБ) в `tests/data/real/` с README + (источник, коммит, лицензия). +- **Референсы с NCBI:** запись COI *A. chlorotica*, соответствующая + образцам (accession подбирается BLAST-ом ридов и фиксируется в README), и + pUC19 (L09137, GenBank, кольцевая, с аннотациями). Скачиваются один раз + `scripts/fetch_test_data.py` (E-utilities) и коммитятся; тесты сеть не + используют. +- Наборы gear-genomics (GPL-3.0) не используются. + +### 6.2 Контролируемая истина + +- **Ошибки клона:** в копию референса вносится известная замена / + вставка / делеция; реальный рид даёт расхождение в известной позиции. +- **Смесь:** полусинтетическая копия реального рида, в которой под + выбранной позицией в другой канал добавлен пик формы соседнего реального + пика заданной высоты (30% и 50%). Генерируется в фикстуре, явно помечена. +- **Набор клонов:** «подходит» (реальные риды, неизменённый референс), + «ошибка» (изменённый референс), «неполный» (обрезанный рид), + «смесь» (полусинтетика), «нет данных» (рид другого гена). + +### 6.3 Проверки + +- Каждая внесённая ошибка найдена с верными позицией, типом, `support`. +- Ноль ложных расхождений в регионе на неизменённом референсе при Q ≥ 20. +- Второй пик 30% — не смесь, 50% — смесь (при `het_ratio` 0.40). +- Кольцевое выравнивание: рид через начало координат повёрнутого pUC19 + даёт непрерывное покрытие и верные координаты. +- Группировка имён sangeranalyseR и синтетических имён (`cl3_T7`, `A05_K3-F`, + `clone12.rev`); свой regex. +- Чтение FASTA/GenBank/SnapGene. BioPython не умеет писать `.dna`, поэтому + берётся образец из `Tests/SnapGene/` набора тестов BioPython (Biopython + License, как и нынешние `examples/`), с указанием источника в README. +- Регрессия: существующие тесты `snps`/`verify` проходят после выноса в + `peaks.py`; плюс прогон на реальных ридах. +- Производительность 12 × 4 на 10 т.п.н. (порог по замеру). +- GUI smoke в offscreen: экран проверки открывается, проект загружается, + матрица рисуется, фоновая задача завершается. + +## 7. Порядок работ + +Каждый шаг оставляет программу рабочей: + +1. Тестовые данные; `peaks.py`; исправления двух ошибок UI. +2. Ядро: `reference.py`, кольцевое выравнивание, `clones.py`, `.ab1proj`, + отчёты. +3. Интерфейс: тема и иконки, разбиение `main_window.py`, фоновые задачи, + экран «Проверка конструкта», окно группировки. +4. CLI. + +## 8. Вне рамок + +Планшеты на 96 ридов разных конструктов; автопоиск ORF в FASTA; `.scf`; +экспорт из Benchling; mafft/MSA; сборка контигов. diff --git a/i18n.py b/i18n.py index 3eb5254..dd9acde 100644 --- a/i18n.py +++ b/i18n.py @@ -79,6 +79,12 @@ "verify.ref_mismatch": "ref не совпал", "verify.low_quality": "низкое качество", "verify.unsupported": "не поддерживается", + "verify.note.only_snv": "поддерживаются только замены одного нуклеотида", + "verify.note.pos_outside_ref": "позиция вне референса", + "verify.note.ref_differs": "ref-основание из таблицы не совпадает с референсом", + "verify.note.deleted_in_read": "делеция в риде", + "verify.note.outside_coverage": "вне покрытия рида", + "verify.note.low_q": "Q{q} < {min_q}", "verify.summary": "{n} позиций: {confirmed} подтверждено, {het} гетерозигот, {notfound} не подтверждено, {nodata} без данных", "verify.need_reference": "Сначала загрузите референс и выровняйте чтение", "verify.save_csv": "Сохранить CSV…", @@ -88,6 +94,8 @@ "tb.zoom_out": "Отдалить", "tb.fit": "Весь рид", "tb.reset": "Сброс вида", + "tb.quality_legend": "Качество Q:", + "tb.quality_legend.tip": "Цвет полос качества: красный Q<20, жёлтый 20–29, зелёный ≥30", "goto.title": "Перейти к базе", "goto.label": "Номер базы", "search.title": "Поиск подпоследовательности", @@ -148,10 +156,107 @@ "batch.col.q20": "Q≥20", "batch.col.trim": "После обрезки", "batch.col.status": "Статус", + "task.cancel": "Отмена", + "task.running": "Выполняется…", + "task.cancelled": "Операция отменена", "batch.scan_done": "Просканировано файлов: {n}", "batch.exported": "Экспортировано: {n}, ошибок: {err}", "batch.export_dir": "Куда экспортировать", "err.open_failed": "Не удалось открыть файл", + "construct.bp": "{n} п.н.", + "construct.circular": "кольцевая", + "construct.linear": "линейная", + "construct.region": "регион {start}–{end}", + "construct.region_all": "регион: весь конструкт", + "construct.count.ok": "Подходят: {n}", + "construct.count.errors": "Ошибки: {n}", + "construct.count.incomplete": "Неполные: {n}", + "construct.count.mixed": "Смесь?: {n}", + "construct.count.no_data": "Нет данных: {n}", + "construct.filter_tip": "Показать или скрыть клоны с этим вердиктом", + "matrix.reference": "референс", + "matrix.chip.ok": "подходит", + "matrix.chip.errors": "{n} расх.", + "matrix.chip.incomplete": "{pct}%", + "matrix.chip.mixed": "смесь?", + "matrix.chip.no_data": "нет данных", + "matrix.legend.cov_both": "две цепи", + "matrix.legend.cov_one": "одна цепь", + "matrix.legend.cov_none": "нет (Q<20)", + "matrix.legend.confirmed": "подтверждено", + "matrix.legend.disputed": "спорно", + "matrix.legend.low_quality": "низкое Q", + "matrix.legend.mixed": "двойной пик", + "matrix.legend.unchecked": "не проверяется", + "matrix.legend.outside": "вне региона", + "matrix.empty": "Нет клонов для показа", + "matrix.hint": "Колёсико — масштаб, перетаскивание — сдвиг, Shift+протяжка по референсу — задать регион", + "detail.col.pos": "Позиция", + "detail.col.change": "Реф.→чтение", + "detail.col.effect": "Эффект", + "detail.col.support": "За / против", + "detail.col.status": "Статус", + "effect.synonymous": "синонимичная", + "effect.frameshift": "сдвиг рамки", + "effect.inframe_ins": "вставка в рамке", + "effect.inframe_del": "делеция в рамке", + "detail.err.no_alignment": "нет выравнивания", + "detail.ambiguous": "неоднозначно — не учитывается в вердикте", + "detail.uncovered": "позиция не покрыта", + "tab.construct": "Проверка конструкта", + "tab.read": "Просмотр рида", + "menu.project": "Про&ект", + "menu.project.new": "&Новый", + "menu.project.open": "&Открыть проект…", + "menu.project.save": "&Сохранить проект", + "menu.project.save_as": "Сохранить проект &как…", + "menu.project.export": "&Экспорт отчёта…", + "grouping.title": "Группировка ридов по клонам", + "grouping.pattern": "Шаблон имени:", + "grouping.pattern_placeholder": "пусто — по имени файла автоматически", + "grouping.pattern_tip": "Регулярное выражение по имени файла без расширения с группами (?P…) и (?P…)", + "grouping.col.file": "Файл", + "grouping.col.clone": "Клон", + "grouping.col.primer": "Праймер", + "grouping.no_clone": "Без клона — рид не участвует в проверке", + "grouping.summary": "Клонов: {clones} · ридов: {reads} · без клона: {unassigned}", + "workspace.empty_title": "Проверка клонов", + "workspace.empty_hint": "Укажите конструкт и риды — вердикт по каждому клону появится сразу", + "workspace.drop_construct": "Перетащите файл конструкта (FASTA, GenBank, SnapGene)", + "workspace.drop_reads": "…и папку или файлы ридов .ab1", + "workspace.click_to_choose": "или нажмите, чтобы выбрать", + "workspace.choose_files": "Выбрать отдельные файлы…", + "workspace.reads_loaded": "ридов: {n}", + "workspace.region_from": "Регион:", + "workspace.region_to": "–", + "workspace.whole": "Весь конструкт", + "workspace.whole_tip": "Проверять всю последовательность конструкта", + "workspace.circular": "Кольцевая", + "workspace.circular_tip": "Конструкт кольцевой: риды и регион могут переходить через начало", + "workspace.min_q": "Мин. Q", + "workspace.min_q_tip": "Базы с качеством ниже порога не подтверждают и не опровергают расхождения", + "workspace.het_ratio": "Двойной пик", + "workspace.het_ratio_tip": "Доля второго пика от основного, начиная с которой позиция считается смешанной", + "workspace.mixed_min": "Смесь от", + "workspace.mixed_min_tip": "Сколько смешанных позиций нужно, чтобы клон получил вердикт «смесь?»", + "workspace.grouping": "Группировка…", + "workspace.add_reads": "Добавить риды…", + "workspace.construct_button": "Конструкт…", + "workspace.construct_failed": "Не удалось открыть конструкт", + "workspace.construct_too_long": "Конструкт слишком длинный: {n} п.н. (предел {limit})", + "workspace.no_new_reads": "Новых файлов .ab1 не найдено", + "workspace.region_invalid": "Недопустимый регион", + "workspace.region_invalid_text": "Регион {start}–{end} нельзя проверить: {reason}", + "workspace.verify_failed": "Ошибка проверки клонов", + "workspace.verified": "Проверено клонов: {n}", + "workspace.project_failed": "Не удалось открыть проект", + "workspace.project_loaded": "Проект открыт: {name}", + "workspace.project_save_failed": "Не удалось сохранить проект", + "workspace.need_construct": "Сначала укажите файл конструкта", + "workspace.project_saved": "Проект сохранён: {name}", + "workspace.export_failed": "Не удалось сохранить отчёт", + "workspace.no_results": "Пока нет результатов проверки", + "workspace.exported": "Отчёт сохранён: {name}", } _lang = "ru" diff --git a/pyproject.toml b/pyproject.toml index 3ad6ba9..a01357e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ab1kit" -version = "1.0.0" +version = "1.1.0" description = "Open-source desktop tool for viewing, editing and analyzing Sanger sequencing chromatograms (.ab1)" readme = "README.md" license = { file = "LICENSE" } @@ -22,8 +22,14 @@ dev = ["pytest>=8"] ab1kit = "chrom_cli:main" [tool.setuptools] -packages = ["chrom_core", "ui"] +packages = ["chrom_core", "ui", "ui.read_view", "ui.construct"] py-modules = ["chrom_cli", "i18n", "translations_en"] +[tool.setuptools.package-data] +ui = ["style.qss", "style_dark.qss", "icons/*.svg", "icons/LICENSE", "icons/README.md", "icons/app/*"] + [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["tests"] +markers = ["perf: timing-sensitive tests, run with -m perf"] +addopts = "-m 'not perf'" diff --git a/scripts/fetch_test_data.py b/scripts/fetch_test_data.py new file mode 100644 index 0000000..0c2d26b --- /dev/null +++ b/scripts/fetch_test_data.py @@ -0,0 +1,87 @@ +"""One-time download of the real-data test corpus into ``tests/data``. + +Usage:: + + python scripts/fetch_test_data.py --coi ACC [--coi ACC ...] [--force] + +Uses only ``urllib``. Idempotent: files that already exist are skipped +unless ``--force`` is given. Tests never touch the network; the downloaded +files are committed to the repository. +""" + +from __future__ import annotations + +import argparse +import time +import urllib.parse +import urllib.request +from pathlib import Path + +DATA = Path(__file__).resolve().parent.parent / "tests" / "data" + +SANGER_COMMIT = "4c5ebc9298a873c209819f12b7dfd85e126f1406" +BIOPYTHON_COMMIT = "2fbe8755bb44731e60b7bd1e2cc55cf34d94e15b" + +SANGER_BASE = ( + "https://raw.githubusercontent.com/roblanf/sangeranalyseR/" + f"{SANGER_COMMIT}/inst/extdata/Allolobophora_chlorotica/ACHLO/" +) +SNAPGENE_URL = ( + "https://raw.githubusercontent.com/biopython/biopython/" + f"{BIOPYTHON_COMMIT}/Tests/SnapGene/sample-d.dna" +) +EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi" + +READS = [ + f"Achl_{sample}_{suffix}.ab1" + for sample in ("ACHLO006-09", "ACHLO007-09", "ACHLO040-09", "ACHLO041-09") + for suffix in ("1_F", "2_R") +] + +_last_ncbi_request = 0.0 + + +def _download(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "ab1kit-fetch-test-data"}) + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + + +def _genbank_url(accession: str) -> str: + query = urllib.parse.urlencode( + {"db": "nuccore", "id": accession, "rettype": "gbwithparts", "retmode": "text"} + ) + return f"{EFETCH}?{query}" + + +def _fetch(url: str, target: Path, force: bool, ncbi: bool = False) -> None: + global _last_ncbi_request + if target.exists() and not force: + print(f"skip {target.relative_to(DATA)}") + return + if ncbi: # stay polite: at most ~3 requests per second + wait = 0.4 - (time.monotonic() - _last_ncbi_request) + if wait > 0: + time.sleep(wait) + _last_ncbi_request = time.monotonic() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(_download(url)) + print(f"fetched {target.relative_to(DATA)}") + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--coi", metavar="ACC", action="append", default=[], help="GenBank accession of a COI reference (repeatable)") + parser.add_argument("--force", action="store_true", help="re-download existing files") + args = parser.parse_args(argv) + + for name in READS: + _fetch(SANGER_BASE + name, DATA / "real" / name, args.force) + _fetch(_genbank_url("L09137"), DATA / "refs" / "pUC19_L09137.gb", args.force, ncbi=True) + for acc in args.coi: + _fetch(_genbank_url(acc), DATA / "refs" / f"coi_{acc}.gb", args.force, ncbi=True) + _fetch(SNAPGENE_URL, DATA / "refs" / "sample-d.dna", args.force) + + +if __name__ == "__main__": + main() diff --git a/scripts/make_icon.py b/scripts/make_icon.py new file mode 100644 index 0000000..9b620d2 --- /dev/null +++ b/scripts/make_icon.py @@ -0,0 +1,81 @@ +"""Regenerate ui/icons/app/ab1kit.png and ab1kit.ico from ab1kit.svg. + +Usage: python scripts/make_icon.py [--out DIR] + +The SVG is the source of truth (letters are vector paths, so no fonts are +needed). The ICO holds PNG-compressed entries, assembled here without extra +dependencies. + +Note: the SVG letter outlines were extracted once from Consolas Bold 22px and are +baked in as paths; this script does not touch them. +""" + +from __future__ import annotations + +import argparse +import os +import struct +import sys +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QBuffer, QByteArray, QIODevice +from PySide6.QtGui import QGuiApplication, QImage, QPainter +from PySide6.QtSvg import QSvgRenderer + +APP_ICONS = Path(__file__).resolve().parent.parent / "ui" / "icons" / "app" +ICO_SIZES = (16, 24, 32, 48, 64, 128, 256) + + +def render(renderer: QSvgRenderer, size: int) -> QImage: + image = QImage(size, size, QImage.Format.Format_ARGB32) + image.fill(0) + painter = QPainter(image) + painter.setRenderHints(QPainter.RenderHint.Antialiasing + | QPainter.RenderHint.SmoothPixmapTransform) + renderer.render(painter) + painter.end() + return image + + +def png_bytes(image: QImage) -> bytes: + data = QByteArray() + buf = QBuffer(data) + buf.open(QIODevice.OpenModeFlag.WriteOnly) + image.save(buf, "PNG") + buf.close() + return bytes(data) + + +def build_ico(entries: list[tuple[int, bytes]]) -> bytes: + header = struct.pack("= 256 else size + directory += struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", type=Path, default=APP_ICONS, help="output folder") + args = parser.parse_args(argv) + + QGuiApplication.instance() or QGuiApplication([]) + renderer = QSvgRenderer(str(APP_ICONS / "ab1kit.svg")) + if not renderer.isValid(): + print("invalid SVG", file=sys.stderr) + return 1 + args.out.mkdir(parents=True, exist_ok=True) + (args.out / "ab1kit.png").write_bytes(png_bytes(render(renderer, 256))) + entries = [(s, png_bytes(render(renderer, s))) for s in ICO_SIZES] + (args.out / "ab1kit.ico").write_bytes(build_ico(entries)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/render_preview.py b/scripts/render_preview.py index 7a8da22..a4e2c07 100644 --- a/scripts/render_preview.py +++ b/scripts/render_preview.py @@ -1,6 +1,12 @@ """Render the chromatogram widget offscreen to a PNG (dev smoke check). -Usage: python scripts/render_preview.py [sample.ab1] [out.png] +Usage: python scripts/render_preview.py [sample.ab1] [out.png] [--theme light|dark] [--window] + python scripts/render_preview.py --construct [out.png] [--theme light|dark] [--lang ru|en] [--empty] + +--window renders the whole main window (toolbar, docks, tables) instead of the bare widget. +--construct renders the "construct check" tab: the COI reference of tests/data with one +substitution introduced (so one clone shows an error) against the 8 real reads, with the +detail pane open on that difference; --empty renders the empty state instead. """ from __future__ import annotations @@ -14,16 +20,123 @@ from PySide6.QtWidgets import QApplication # noqa: E402 -from chrom_core import read_ab1 # noqa: E402 +from chrom_core import batch, read_ab1 # noqa: E402 from ui.chrom_view import ChromatogramWidget # noqa: E402 +def render_window(src: Path, out: Path, theme: str, uncheck: str = "") -> None: + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + app = QApplication.instance() + app.setStyle("Fusion") + window = MainWindow(AppConfig(recent_files=[], theme=theme)) + window.resize(1300, 820) + window.show() + window.open_path(src) + seq = "".join(window.read_model.seq) + ref = list(seq[100:400]) + ref[50] = "A" if ref[50] != "A" else "C" + ref[120] = "G" if ref[120] != "G" else "T" + window._set_reference_seq("".join(ref)) + rows = ["name;pos;ref;alt", f"snp1;51;{seq[150]};T", f"snp2;121;{seq[220]};G"] + window._verify_variants("\n".join(rows) + "\n") + for base in uncheck: + window.read_view._channel_boxes[base].setChecked(False) + window.batch_dock.show() + window.batch_panel.show_reports(batch.scan_folder(src.parent)) + for _ in range(20): + app.processEvents() + window.grab().save(str(out)) + print(f"{src.name} [{theme}] -> {out}") + window.close() + + +ROOT = Path(__file__).resolve().parent.parent +MUTATION_AT = 250 # 0-based reference position of the introduced substitution + + +def _mutated_reference(folder: Path) -> Path: + from Bio import SeqIO + from Bio.Seq import Seq + + record = SeqIO.read(ROOT / "tests" / "data" / "refs" / "coi_HM417896.gb", "genbank") + seq = str(record.seq).upper() + base = "G" if seq[MUTATION_AT] != "G" else "T" + record.seq = Seq(seq[:MUTATION_AT] + base + seq[MUTATION_AT + 1:]) + target = folder / "COI_construct.gb" + SeqIO.write(record, target, "genbank") + return target + + +def render_construct(out: Path, theme: str, lang: str, empty: bool) -> None: + import time + + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + import ui.main_window as main_window + import ui.read_view.read_view as read_view + + main_window.save_config = read_view.save_config = lambda *a, **k: None # keep the user's config + app = QApplication.instance() + app.setStyle("Fusion") + window = MainWindow(AppConfig(recent_files=[], theme=theme, language=lang)) + window.resize(1300, 820) + window.show() + ws = window.workspace + if not empty: + folder = Path(tempfile.mkdtemp(prefix="ab1kit_construct_")) + ws.handle_drop([_mutated_reference(folder), ROOT / "tests" / "data" / "real"]) + deadline = time.monotonic() + 60 + while ws.is_verifying() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + ws.set_region((6, 647)) + while ws.is_verifying() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + clone = next(r.clone for r in ws.reports() if r.verdict == "errors") + ws.matrix.difference_clicked.emit(clone, MUTATION_AT) + for _ in range(20): + app.processEvents() + window.grab().save(str(out)) + print(f"construct [{theme}, {lang}{', empty' if empty else ''}] -> {out}") + window.close() + + def main() -> None: examples = sorted((Path(__file__).resolve().parent.parent / "examples").glob("*.ab1")) - src = Path(sys.argv[1]) if len(sys.argv) > 1 else examples[0] - out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(tempfile.gettempdir()) / "ab1kit_preview.png" + argv = sys.argv[1:] + theme = "light" + if "--theme" in argv: + i = argv.index("--theme") + theme = argv[i + 1] + del argv[i:i + 2] + uncheck = "" + if "--uncheck" in argv: + i = argv.index("--uncheck") + uncheck = argv[i + 1] + del argv[i:i + 2] + lang = "ru" + if "--lang" in argv: + i = argv.index("--lang") + lang = argv[i + 1] + del argv[i:i + 2] + if "--construct" in argv: + rest = [a for a in argv if a not in ("--construct", "--empty")] + out = Path(rest[0]) if rest else Path(tempfile.gettempdir()) / "ab1kit_construct.png" + app = QApplication([]) # noqa: F841 - must outlive the window + render_construct(out, theme, lang, "--empty" in argv) + return + window_mode = "--window" in argv + argv = [a for a in argv if a != "--window"] + src = Path(argv[0]) if argv else examples[0] + out = Path(argv[1]) if len(argv) > 1 else Path(tempfile.gettempdir()) / "ab1kit_preview.png" app = QApplication([]) + if window_mode: + render_window(src, out, theme, uncheck) + return view = ChromatogramWidget() view.resize(1200, 700) view.set_chromatogram(read_ab1(src)) diff --git a/tests/clone_fixtures.py b/tests/clone_fixtures.py new file mode 100644 index 0000000..c2842f7 --- /dev/null +++ b/tests/clone_fixtures.py @@ -0,0 +1,190 @@ +"""Shared fixtures for clone-verification tests: the real-data corpus in ``tests/data``.""" + +from __future__ import annotations + +import os +import shutil +import struct +from dataclasses import replace +from functools import cache +from pathlib import Path + +from Bio import SeqIO + +from chrom_core import read_ab1, sessions +from chrom_core.abif import DEFAULT_DYE_ORDER, Chromatogram +from chrom_core.peaks import channel_baseline, peak_window +from chrom_core.seqops import IUPAC + +DATA = Path(__file__).resolve().parent / "data" + +REAL_READS: list[Path] = sorted((DATA / "real").glob("*.ab1")) + +COI_GB = DATA / "refs" / "coi_HM417896.gb" + +# Sample prefix -> COI accession of the closest-lineage reference (A. chlorotica +# is a cryptic aggregate; see tests/data/README.md). HM417896 is the primary one. +SAMPLE_REFERENCE: dict[str, str] = { + "Achl_ACHLO006-09": "HM417896", + "Achl_ACHLO007-09": "HM417896", + "Achl_ACHLO040-09": "KC569719", + "Achl_ACHLO041-09": "HM417923", +} +PUC19_GB = DATA / "refs" / "pUC19_L09137.gb" +SNAPGENE_DNA = DATA / "refs" / "sample-d.dna" + + +def perf_factor() -> float: + """Multiplier for timing bounds (``AB1KIT_PERF_FACTOR``, default 1) on slow machines.""" + return float(os.environ.get("AB1KIT_PERF_FACTOR", "1")) + + +def real_read(name: str) -> Chromatogram: + return read_ab1(DATA / "real" / name) + + +def coi_reference() -> str: + return str(SeqIO.read(COI_GB, "genbank").seq).upper() + + +def reference_for(read_name: str) -> str: + """COI sequence (upper case) of the lineage-matched reference for a real read.""" + for prefix, accession in SAMPLE_REFERENCE.items(): + if read_name.startswith(prefix): + return str(SeqIO.read(DATA / "refs" / f"coi_{accession}.gb", "genbank").seq).upper() + raise KeyError(read_name) + + +SPACING = 10 # trace points between called bases in synthetic chromatograms + + +def make_chrom( + seq: str, + quality: int = 40, + heights: dict[tuple[int, str], float] | None = None, + spacing: int = SPACING, + primary: dict[str, float] | None = None, +) -> Chromatogram: + """Synthetic chromatogram: clean primary peaks, optional extra heights. + + ``heights`` maps (base index, channel) -> trace value at that peak, e.g. + {(10, "C"): 500} models a secondary C peak at read position 10. + ``primary`` overrides the height of a called peak per channel (default + 1000), e.g. {"G": 500} models a systematically weak G dye. + """ + heights = heights or {} + primary = primary or {} + n = len(seq) + total = (n + 1) * spacing + traces = {base: [80] * total for base in "ACGT"} + peaks = [] + for i, called in enumerate(seq.upper()): + peak = (i + 1) * spacing + peaks.append(peak) + # an IUPAC called base lights up all of its channels + active = set(IUPAC[called]) if called in IUPAC else set("ACGT") + for base in "ACGT": + traces[base][peak] = primary.get(base, 1000) if base in active else 60 + for (index, base), value in heights.items(): + traces[base][(index + 1) * spacing] = value + return Chromatogram( + sample_id="synthetic", + seq=seq, + quality=(quality,) * n, + traces={b: tuple(t) for b, t in traces.items()}, + peak_indices=tuple(peaks), + ) + + +# ---------------------------------------------------------------- semi-synthetic + + +def mutate(seq: str, pos: int, kind: str, base: str = "") -> str: + """``seq`` with a substitution / insertion (before ``pos``) / deletion at ``pos``.""" + if kind == "sub": + return seq[:pos] + base + seq[pos + 1:] + if kind == "ins": + return seq[:pos] + base + seq[pos:] + if kind == "del": + return seq[:pos] + seq[pos + 1:] + raise ValueError(kind) + + +def with_secondary_peak(chrom: Chromatogram, index: int, base: str, ratio: float) -> Chromatogram: + """Add a second peak in channel ``base`` under called base ``index``. + + The called peak's shape is copied into ``base`` (so it is a real local + maximum, as ``peaks`` measures it), scaled so the *dye-normalised* + secondary/primary ratio is ``ratio``. + """ + called = chrom.seq[index].upper() + baseline = channel_baseline(chrom, index) + scale = ratio * baseline[base] / baseline[called] + centre = chrom.peak_indices[index] + window = peak_window(chrom, index) + source = chrom.traces[called] + trace = list(chrom.traces[base]) + for k in range(max(0, centre - window + 1), min(len(trace), centre + window)): + trace[k] = max(trace[k], round(source[k] * scale)) + traces = dict(chrom.traces) + traces[base] = tuple(trace) + return replace(chrom, traces=traces) + + +@cache +def _source_by_sample_id() -> dict[str, Path]: + return {read_ab1(p).sample_id: p for p in REAL_READS} + +_DYE_OF = {base: tag for tag, base in DEFAULT_DYE_ORDER.items()} +_RC_CHANNEL = {"A": "T", "T": "A", "C": "G", "G": "C"} + + +def _flip(traces: dict[str, tuple[int, ...]]) -> dict[str, tuple[int, ...]]: + """Display traces of a reverse-complemented read <-> file traces.""" + return {b: tuple(reversed(traces[_RC_CHANNEL[b]])) for b in traces} + + +def _mismatches(a: dict, b: dict) -> int: + return sum(x != y for base in a for x, y in zip(a[base], b[base])) + + +def _patch_traces(path: Path, traces: dict[str, tuple[int, ...]]) -> None: + """Overwrite the DATA9-12 dye channels of an ABIF file in place (same lengths).""" + data = bytearray(path.read_bytes()) + _, _, _, _, count, _, offset = struct.unpack(">4siHHiii", data[6:30]) + wanted = {_DYE_OF[b]: values for b, values in traces.items()} + for e in range(count): + entry = offset + 28 * e + name, number, _, _, n, _, where = struct.unpack(">4siHHiii", data[entry:entry + 24]) + tag = f"{name.decode('latin-1')}{number}" + if tag in wanted: + assert n == len(wanted[tag]), "trace length must not change" + data[where:where + 2 * n] = struct.pack(f">{n}h", *wanted[tag]) + path.write_bytes(bytes(data)) + + +def write_read(chrom: Chromatogram, folder: Path, name: str) -> Path: + """Save a modified real read as ``folder/name`` + editing session. + + The source ``.ab1`` (found by sample id) is copied; the called bases, + quality and peak positions go to the session sidecar, so ``load_read`` + returns ``chrom``. A reverse-complemented display is detected from the + traces; traces changed by ``with_secondary_peak`` are written into the copy. + """ + source = _source_by_sample_id()[chrom.sample_id] + path = Path(folder) / (name if name.lower().endswith(".ab1") else name + ".ab1") + shutil.copyfile(source, path) + original = read_ab1(source).traces + flipped = _mismatches(_flip(chrom.traces), original) < _mismatches(chrom.traces, original) + file_traces = _flip(chrom.traces) if flipped else dict(chrom.traces) + if file_traces != original: + _patch_traces(path, file_traces) + sessions.save_session(sessions.session_path(path), { + "source": path.name, + "seq": chrom.seq, + "quality": list(chrom.quality), + "peaks": list(chrom.peak_indices), + "edited": [], + "reversed_complement": flipped, + }) + return path diff --git a/tests/data/README.md b/tests/data/README.md new file mode 100644 index 0000000..f2e2e5f --- /dev/null +++ b/tests/data/README.md @@ -0,0 +1,33 @@ +# Test data corpus + +Downloaded once by `scripts/fetch_test_data.py --coi HM417896 --coi KC569719 --coi HM417923` and committed. +Tests never use the network. Semi-synthetic data is generated in +`tests/clone_fixtures.py` and is not stored on disk. + +| File | Source | Pin | License | +|---|---|---|---| +| `real/Achl_ACHLO{006,007,040,041}-09_{1_F,2_R}.ab1` (8 files) | [sangeranalyseR](https://github.com/roblanf/sangeranalyseR) `inst/extdata/Allolobophora_chlorotica/ACHLO/` | commit `4c5ebc9298a873c209819f12b7dfd85e126f1406` | MIT | +| `refs/pUC19_L09137.gb` | NCBI nuccore `L09137` (efetch, `gbwithparts`) | accession | public domain | +| `refs/coi_{HM417896,KC569719,HM417923}.gb` | NCBI nuccore (efetch, `gbwithparts`) | accessions | public domain | +| `refs/sample-d.dna` | [Biopython](https://github.com/biopython/biopython) `Tests/SnapGene/sample-d.dna` | commit `2fbe8755bb44731e60b7bd1e2cc55cf34d94e15b` | Biopython License | + +## COI references (per-lineage) + +*Allolobophora chlorotica* is a cryptic-species aggregate: its mitochondrial COI +lineages differ by up to ~16%, so no single GenBank record is >= 97% identical to +all eight reads. Candidates were found with NCBI E-utilities (esearch, ~930 +records) and checked locally with `chrom_core.align.align_read`. Each record +below is an iBOL/BOLD mirror with an annotated CDS; `SAMPLE_REFERENCE` in +`tests/clone_fixtures.py` maps each sample to its reference. +`coi_reference()` is HM417896, the primary reference for ACHLO006/007. + +| Accession | Samples | Identity F / R | +|---|---|---| +| `HM417896.1` (voucher AC2, 658 bp) | ACHLO006-09 | 0.995 / 0.973 | +| | ACHLO007-09 | 0.995 / 0.992 | +| `KC569719.1` (voucher Acy224, 625 bp) | ACHLO040-09 | 0.986 / 0.977 | +| `HM417923.1` (voucher B2, 658 bp) | ACHLO041-09 | 0.997 / 0.989 | + +Cross-lineage: ACHLO040-09 F against HM417896 is only 0.822 identical (041: +0.836; 006/007 against KC569719/HM417923: 0.82-0.84), which makes it a real +"different lineage" read for later tests. diff --git a/tests/data/real/Achl_ACHLO006-09_1_F.ab1 b/tests/data/real/Achl_ACHLO006-09_1_F.ab1 new file mode 100644 index 0000000..bef8260 Binary files /dev/null and b/tests/data/real/Achl_ACHLO006-09_1_F.ab1 differ diff --git a/tests/data/real/Achl_ACHLO006-09_2_R.ab1 b/tests/data/real/Achl_ACHLO006-09_2_R.ab1 new file mode 100644 index 0000000..51b5fe6 Binary files /dev/null and b/tests/data/real/Achl_ACHLO006-09_2_R.ab1 differ diff --git a/tests/data/real/Achl_ACHLO007-09_1_F.ab1 b/tests/data/real/Achl_ACHLO007-09_1_F.ab1 new file mode 100644 index 0000000..8dfd463 Binary files /dev/null and b/tests/data/real/Achl_ACHLO007-09_1_F.ab1 differ diff --git a/tests/data/real/Achl_ACHLO007-09_2_R.ab1 b/tests/data/real/Achl_ACHLO007-09_2_R.ab1 new file mode 100644 index 0000000..b55e890 Binary files /dev/null and b/tests/data/real/Achl_ACHLO007-09_2_R.ab1 differ diff --git a/tests/data/real/Achl_ACHLO040-09_1_F.ab1 b/tests/data/real/Achl_ACHLO040-09_1_F.ab1 new file mode 100644 index 0000000..99e9161 Binary files /dev/null and b/tests/data/real/Achl_ACHLO040-09_1_F.ab1 differ diff --git a/tests/data/real/Achl_ACHLO040-09_2_R.ab1 b/tests/data/real/Achl_ACHLO040-09_2_R.ab1 new file mode 100644 index 0000000..86abb77 Binary files /dev/null and b/tests/data/real/Achl_ACHLO040-09_2_R.ab1 differ diff --git a/tests/data/real/Achl_ACHLO041-09_1_F.ab1 b/tests/data/real/Achl_ACHLO041-09_1_F.ab1 new file mode 100644 index 0000000..b66d36f Binary files /dev/null and b/tests/data/real/Achl_ACHLO041-09_1_F.ab1 differ diff --git a/tests/data/real/Achl_ACHLO041-09_2_R.ab1 b/tests/data/real/Achl_ACHLO041-09_2_R.ab1 new file mode 100644 index 0000000..453c866 Binary files /dev/null and b/tests/data/real/Achl_ACHLO041-09_2_R.ab1 differ diff --git a/tests/data/refs/coi_HM417896.gb b/tests/data/refs/coi_HM417896.gb new file mode 100644 index 0000000..dbe0fb0 --- /dev/null +++ b/tests/data/refs/coi_HM417896.gb @@ -0,0 +1,78 @@ +LOCUS HM417896 658 bp DNA linear INV 25-JUL-2016 +DEFINITION Allolobophora chlorotica voucher AC2 cytochrome oxidase subunit 1 + (COI) gene, partial cds; mitochondrial. +ACCESSION HM417896 +VERSION HM417896.1 +DBLINK BioProject: PRJNA37833 +KEYWORDS BARCODE. +SOURCE mitochondrion Allolobophora chlorotica + ORGANISM Allolobophora chlorotica + Eukaryota; Metazoa; Spiralia; Lophotrochozoa; Annelida; Clitellata; + Oligochaeta; Crassiclitellata; Lumbricina; Lumbricidae; + Lumbricinae; Allolobophora. +REFERENCE 1 (bases 1 to 658) + AUTHORS Dupont,L., Lazrek,F., Porco,D., King,R.A., Rougerie,R., + Symondson,W.O.C., Livet,A., Richard,B., Decaens,T., Butt,K. and + Mathieu,J. + TITLE New insight into the genetic structure of the Allolobophora + chlorotica aggregate in Europe using microsatellite and + mitochondrial data + JOURNAL Pedobiologia (Jena) (2011) In press +REFERENCE 2 (bases 1 to 658) + CONSRTM International Barcode of Life (iBOL) + TITLE iBOL Data Release + JOURNAL Unpublished +REFERENCE 3 (bases 1 to 658) + CONSRTM International Barcode of Life (iBOL) + TITLE Direct Submission + JOURNAL Submitted (01-JUN-2010) Biodiversity Institute of Ontario, + University of Guelph, 50 Stone Rd West, Guelph, Ontario N1G2W1, + Canada +COMMENT ##International Barcode of Life (iBOL)Data-START## + Barcode Index Number :: BOLD:AAA2170 + Order Assignment :: Haplotaxida + iBOL Working Group :: iBOL:WG1.9 + iBOL Release Status :: Phase 2 + ##International Barcode of Life (iBOL)Data-END## +FEATURES Location/Qualifiers + source 1..658 + /organism="Allolobophora chlorotica" + /organelle="mitochondrion" + /mol_type="genomic DNA" + /specimen_voucher="AC2" + /db_xref="BOLD:ACHLO007-09.COI-5P" + /db_xref="taxon:302034" + /geo_loc_name="France: Seine-et-Marne, Ile de France, + Chaumes en brie (77390) -Maurevert" + /lat_lon="49.1458 N 4.43806 E" + /collection_date="29-Mar-2009" + /PCR_primers="fwd_seq: + tgtaaaacgacggccagtggtcaacaaatcataaagatattgg, rev_seq: + caggaaacagctatgactaaacttcagggtgaccaaaaaatca" + gene <1..>658 + /gene="COI" + CDS <1..>658 + /gene="COI" + /codon_start=2 + /transl_table=5 + /product="cytochrome oxidase subunit 1" + /protein_id="ADK40636.1" + /translation="TLYFILGVWAGMVGAGMSLLIRIELSQPGAFLGSDQLYNTIVTA + HAFVMIFFLVMPVFIGGFGNWLLPLMLGAPDMAFPRLNNMSFWLLPPSLILLVSSAAV + EKGAGTGWTVYPPLASNLAHAGPSVDLAIFSLHLAGASSILGAINFITTVINMRWSGL + RLERIPLFVWAVLITVVLLLLSLPVLAGAITMLLTDRNLNTSFFDPAGGGDPILYQHL + F" +ORIGIN + 1 aactttatat tttattctgg gcgtctgagc aggaatggtt ggagccggta taagacttct + 61 aattcgaatc gagctaagac aaccaggagc gttcctgggc agagaccaac tatacaatac + 121 tatcgttact gcacacgcat ttgtaataat cttctttcta gtaatgcctg tattcatcgg + 181 gggattcgga aactggcttt tacctttaat acttggagcc cccgatatag cattccctcg + 241 actcaacaac atgagattct gactacttcc cccatcactg atccttttag tgtcctctgc + 301 ggcggtagaa aaaggcgctg gtacggggtg aactgtttat ccgcctctag caagaaatct + 361 tgcccacgca ggcccgtctg tagatttagc catcttttcc cttcatttag cgggtgcgtc + 421 ttctattcta ggggctatta attttatcac cacagttatt aatatgcgtt gaagaggatt + 481 acgtcttgaa cgaattcccc tgtttgtctg agctgtgcta attacagttg ttcttctact + 541 tctatcttta ccagtgctag caggtgccat taccatactt cttaccgacc gaaacctcaa + 601 tacttcattc tttgatcctg ccggtggtgg agaccccatc ctctaccaac acttattc +// + diff --git a/tests/data/refs/coi_HM417923.gb b/tests/data/refs/coi_HM417923.gb new file mode 100644 index 0000000..c9e8b53 --- /dev/null +++ b/tests/data/refs/coi_HM417923.gb @@ -0,0 +1,78 @@ +LOCUS HM417923 658 bp DNA linear INV 25-JUL-2016 +DEFINITION Allolobophora chlorotica voucher B2 cytochrome oxidase subunit 1 + (COI) gene, partial cds; mitochondrial. +ACCESSION HM417923 +VERSION HM417923.1 +DBLINK BioProject: PRJNA37833 +KEYWORDS BARCODE. +SOURCE mitochondrion Allolobophora chlorotica + ORGANISM Allolobophora chlorotica + Eukaryota; Metazoa; Spiralia; Lophotrochozoa; Annelida; Clitellata; + Oligochaeta; Crassiclitellata; Lumbricina; Lumbricidae; + Lumbricinae; Allolobophora. +REFERENCE 1 (bases 1 to 658) + AUTHORS Dupont,L., Lazrek,F., Porco,D., King,R.A., Rougerie,R., + Symondson,W.O.C., Livet,A., Richard,B., Decaens,T., Butt,K. and + Mathieu,J. + TITLE New insight into the genetic structure of the Allolobophora + chlorotica aggregate in Europe using microsatellite and + mitochondrial data + JOURNAL Pedobiologia (Jena) (2011) In press +REFERENCE 2 (bases 1 to 658) + CONSRTM International Barcode of Life (iBOL) + TITLE iBOL Data Release + JOURNAL Unpublished +REFERENCE 3 (bases 1 to 658) + CONSRTM International Barcode of Life (iBOL) + TITLE Direct Submission + JOURNAL Submitted (01-JUN-2010) Biodiversity Institute of Ontario, + University of Guelph, 50 Stone Rd West, Guelph, Ontario N1G2W1, + Canada +COMMENT ##International Barcode of Life (iBOL)Data-START## + Barcode Index Number :: BOLD:AAA2176 + Order Assignment :: Haplotaxida + iBOL Working Group :: iBOL:WG1.9 + iBOL Release Status :: Phase 2 + ##International Barcode of Life (iBOL)Data-END## +FEATURES Location/Qualifiers + source 1..658 + /organism="Allolobophora chlorotica" + /organelle="mitochondrion" + /mol_type="genomic DNA" + /specimen_voucher="B2" + /db_xref="BOLD:ACHLO041-09.COI-5P" + /db_xref="taxon:302034" + /geo_loc_name="France: Seine-saint-Denis, Ile de France, + centre IRD Bondy" + /lat_lon="50.2769 N 2.52444 E" + /collection_date="07-Apr-2009" + /PCR_primers="fwd_seq: + tgtaaaacgacggccagtggtcaacaaatcataaagatattgg, rev_seq: + caggaaacagctatgactaaacttcagggtgaccaaaaaatca" + gene <1..>658 + /gene="COI" + CDS <1..>658 + /gene="COI" + /codon_start=2 + /transl_table=5 + /product="cytochrome oxidase subunit 1" + /protein_id="ADK40663.1" + /translation="TLYFILGVWAGMVGAGMSLLIRIELSQPGAFLGSDQLYNTIVTA + HAFVMIFFLVMPVFIGGFGNWLLPLMLGAPDMAFPRLNNMSFWLLPPSLILLVSSAAV + EKGAGTGWTVYPPLASNIAHAGPSVDLAIFSLHLAGASSILGAINFITTVINMRWSGL + RLERIPLFVWAVLITVVLLLLSLPVLAGAITMLLTDRNLNTSFFDPAGGGDPILYQHL + F" +ORIGIN + 1 aactctatac tttattttag gcgtctgagc aggaatggta ggagctggta taagactcct + 61 aattcgaatt gagctaagac agccgggagc attcctagga agggatcagc tctataatac + 121 tattgtaact gctcacgcat ttgtaataat tttctttcta gtaatacctg tatttattgg + 181 ggggttcggt aattgacttc tacctttaat acttggagcc cctgacatgg cattcccacg + 241 acttaacaac ataagattct gactccttcc cccgtcacta attcttctag tgtcctctgc + 301 tgcagtagaa aaaggtgcag gaactggatg aactgtttat ccgcccctag caagaaatat + 361 tgctcatgcc ggcccatctg tagacttagc tattttttct cttcatttag cgggtgcttc + 421 atcaatctta ggggccatta attttattac tactgttatt aacatgcgat gaagaggctt + 481 acgacttgaa cgaatcccat tattcgtttg agccgtacta atcacagtgg tgcttctact + 541 cctatcccta ccagtgttag ccggtgcaat tactatatta cttaccgatc gaaatctaaa + 601 tacctccttc tttgatcctg ctgggggtgg agatccaatc ctatatcaac acttattc +// + diff --git a/tests/data/refs/coi_KC569719.gb b/tests/data/refs/coi_KC569719.gb new file mode 100644 index 0000000..5eb1867 --- /dev/null +++ b/tests/data/refs/coi_KC569719.gb @@ -0,0 +1,62 @@ +LOCUS KC569719 625 bp DNA linear INV 01-JAN-2015 +DEFINITION Allolobophora chlorotica voucher Acy224 cytochrome oxidase subunit + 1 (COI) gene, partial cds; mitochondrial. +ACCESSION KC569719 +VERSION KC569719.1 +KEYWORDS . +SOURCE mitochondrion Allolobophora chlorotica + ORGANISM Allolobophora chlorotica + Eukaryota; Metazoa; Spiralia; Lophotrochozoa; Annelida; Clitellata; + Oligochaeta; Crassiclitellata; Lumbricina; Lumbricidae; + Lumbricinae; Allolobophora. +REFERENCE 1 (bases 1 to 625) + AUTHORS Dupont,L., Gresille,Y., Richard,B., Decaens,T. and Mathieu,J. + TITLE Fine-scale spatial genetic structure of two earthworm species, + Allolobophora chlorotica and Aporrectodea icterica (Annelida, + Clitellata) in temperate pastures related to dispersal constraints + JOURNAL Unpublished +REFERENCE 2 (bases 1 to 625) + AUTHORS Dupont,L., Gresille,Y., Richard,B., Decaens,T. and Mathieu,J. + TITLE Direct Submission + JOURNAL Submitted (01-FEB-2013) UMR7618 BIOEMCO IBIOS, Faculte des Sciences + et Technologie UPEC, 61 Avn General de Gaulle, Creteil, IDF 94010, + France +FEATURES Location/Qualifiers + source 1..625 + /organism="Allolobophora chlorotica" + /organelle="mitochondrion" + /mol_type="genomic DNA" + /specimen_voucher="Acy224" + /db_xref="taxon:302034" + /haplogroup="L2" + /geo_loc_name="France" + /collection_date="01-Apr-2009" + /PCR_primers="fwd_name: lco1490, fwd_seq: + ggtcaacaaatcataaagatattgg, rev_name: hco2198, rev_seq: + taaacttcagggtgaccaaaaaatca" + gene <1..>625 + /gene="COI" + CDS <1..>625 + /gene="COI" + /codon_start=2 + /transl_table=5 + /product="cytochrome oxidase subunit 1" + /protein_id="AHB64231.1" + /translation="SLLIRIELSQPGAFLGSDQLYNTIVTAHAFVMIFFLVMPVFIGG + FGNWLLPLMLGAPDMAFPRLNNMSFWLLPPSLILLVSSAAVEKGAGTGWTVYPPLASN + IAHAGPSVDLAIFSLHLAGASSILGAINFITTVINMRWSGLRLERIPLFVWAVLITVV + LLLLSLPVLAGAITMLLTDRNLNTSFFDPAGGGDPILYQHLFWFFGHP" +ORIGIN + 1 aagactccta attcgaattg agctaagaca gccgggagca tttctaggaa gggatcaact + 61 ctataacact attgtaactg ctcacgcatt tgtaataatt ttctttctag taatacctgt + 121 atttattggg gggttcggta attgacttct gcctttaata cttggagccc ctgacatggc + 181 attcccacgt cttaacaaca taagattttg actccttccc ccatcactaa tccttctagt + 241 atcctctgct gcagtagaaa agggtgcggg aactggatga actgtttatc cacccctagc + 301 aagaaacatt gctcatgccg gcccatctgt agacttagct attttttctc ttcatttagc + 361 aggtgcttca tcaatcttgg gtgccattaa ttttattact actgttatta atatacgatg + 421 aagaggctta cgacttgaac gaatcccatt attcgtttga gccgtactaa ttacagtggt + 481 ccttctactc ttatccttac cagtattagc cggtgcaatt actatactac ttaccgatcg + 541 aaatctaaat acctccttct ttgaccctgc tggaggtggg gatccaatcc tatatcaaca + 601 cttattctga ttttttggtc accct +// + diff --git a/tests/data/refs/pUC19_L09137.gb b/tests/data/refs/pUC19_L09137.gb new file mode 100644 index 0000000..40467ad --- /dev/null +++ b/tests/data/refs/pUC19_L09137.gb @@ -0,0 +1,157 @@ +LOCUS SYNPUC19CV 2686 bp DNA circular SYN 22-MAY-2002 +DEFINITION Cloning vector pUC19c, complete sequence. +ACCESSION L09137 X02514 +VERSION L09137.2 +KEYWORDS . +SOURCE Cloning vector pUC19c + ORGANISM Cloning vector pUC19c + other sequences; artificial sequences; vectors. +REFERENCE 1 (bases 1 to 2686) + AUTHORS Yanisch-Perron,C., Vieira,J. and Messing,J. + TITLE Improved M13 phage cloning vectors and host strains: nucleotide + sequences of the M13mp18 and pUC19 vectors + JOURNAL Gene 33 (1), 103-119 (1985) + PUBMED 2985470 +REFERENCE 2 (bases 1 to 2686) + AUTHORS Chambers,S.P., Prior,S.E., Barstow,D.A. and Minton,N.P. + TITLE The pMTL nic- cloning vectors. I. Improved pUC polylinker regions + to facilitate the use of sonicated DNA for nucleotide sequencing + JOURNAL Gene 68 (1), 139-149 (1988) + PUBMED 2851488 +REFERENCE 3 (bases 1 to 2686) + AUTHORS Gilbert,W. + TITLE Obtained from VecBase 3.0 + JOURNAL Unpublished +REFERENCE 4 (bases 1 to 2686) + AUTHORS Messing,J. + TITLE Direct Submission + JOURNAL Submitted (27-APR-1993) Department of Biochemistry, University of + Minnesota, St. Paul, MN 55108, USA +REFERENCE 5 (bases 1 to 2686) + AUTHORS Messing,J. + TITLE Direct Submission + JOURNAL Submitted (11-APR-2002) Rutgers, The State University of New + Jersey, Waksman Institute of Microbiology, 190 Frelinghuysen Road, + Piscataway, NJ 08854-8020, USA + REMARK Sequence update by submitter +COMMENT On Apr 11, 2002 this sequence version replaced L09137.1. + These data and their annotation were supplied to GenBank by Will + Gilbert under the auspices of the GenBank Currator Program. pUC19c + - Cloning vector (beta-galactosidase mRNA on complementary strand) + ENTRY PUC19C #TYPE DNA CIRCULAR TITLE pUC19c - + Cloning vector + (beta-galactosidase mRNA on complementary strand) + DATE 03-FEB-1986 + #sequence 16-DEC-1986 + ACCESSION VB0033 + SOURCE artificial + COLLECTION ATCC 37254 + REFERENCE + #number + #authors Norrander J., Kempe T., Messing J. + #journal Gene (1983) 26: 101-106 + REFERENCE + #number 1 + #authors Yanisch-Perron C., Vieira J., Messing J. + #journal Gene (1985) 33: 103-119 + #comment shows the complete compiled sequence + REFERENCE + #number 2 + #authors Chambers,S.P., et al. + #journal Gene (1988) 68: 139-149 + #describes mutation at nt1308 and its effect on copy number + REFERENCE + #number + #authors Pouwels P.H., Enger-Valk B.E., Brammar W.J. + #book Cloning Vectors, Elsvier 1985 and supplements + #comment vector I-A-iv-20 + COMMENT + This Sequence was obtained 3-MAR-1986 from J. Messing, Waksman + Institute, NJ on floppy disk. + Revised 16-DEC-1986 by F. Pfeiffer: + 1062/3 'AT' to 'TA' to match revised sequence of PBR322 + The beta-galactosidase mRNA sequence including the multiple + cloning site of M13mp19 is on the strand complementary to that + shown. + KEYWORDS + CROSSREFERENCE + #complement + VecBase(3):pUC19 + #prerevised + GenBank(50):M11662, EMBL(11):ARPuc19 + #parent + VecBase(3):pUC13, VecBase(3):M13mp19, VecSource(3):bGal19 + PARENT + Features of pUC19c (2686 bp) + residue source + 1- 137 2074-2210 pBR322 + 138- 237 2252-2351 pBR322 + 238- 395 1461-1304 (c) Lac-Operon + 396- 452 57- 1 (c) polylinker of M13mp19 + 455- 682 1298-1071 (c) Lac-Operon + 683-2686 2352-4355 pBR322 + Conflict (cfl) and Mutations (mut): + pUC19c source + mut 1308 A G 2977 pBR322 linked to increased copy number + mut 1942 A G 3611 pBR322 + mut 2243 T C 3912 pBR322 + FEATURE + 1629-2417 789-1 (c) Ap-R; b-lactamase + POLYLINKER HindIII-SphI-PstI-SalI-XbaI-BamHI-SmaI-KpnI-SacI-EcoRI + SELECTION + #resistance Ap + #indicator beta-galactosidase + SUMMARY pUC19c #length 2686 #checksum 4465. +FEATURES Location/Qualifiers + source 1..2686 + /organism="Cloning vector pUC19c" + /mol_type="genomic DNA" + /db_xref="taxon:174689" +ORIGIN + 1 tcgcgcgttt cggtgatgac ggtgaaaacc tctgacacat gcagctcccg gagacggtca + 61 cagcttgtct gtaagcggat gccgggagca gacaagcccg tcagggcgcg tcagcgggtg + 121 ttggcgggtg tcggggctgg cttaactatg cggcatcaga gcagattgta ctgagagtgc + 181 accatatgcg gtgtgaaata ccgcacagat gcgtaaggag aaaataccgc atcaggcgcc + 241 attcgccatt caggctgcgc aactgttggg aagggcgatc ggtgcgggcc tcttcgctat + 301 tacgccagct ggcgaaaggg ggatgtgctg caaggcgatt aagttgggta acgccagggt + 361 tttcccagtc acgacgttgt aaaacgacgg ccagtgaatt cgagctcggt acccggggat + 421 cctctagagt cgacctgcag gcatgcaagc ttggcgtaat catggtcata gctgtttcct + 481 gtgtgaaatt gttatccgct cacaattcca cacaacatac gagccggaag cataaagtgt + 541 aaagcctggg gtgcctaatg agtgagctaa ctcacattaa ttgcgttgcg ctcactgccc + 601 gctttccagt cgggaaacct gtcgtgccag ctgcattaat gaatcggcca acgcgcgggg + 661 agaggcggtt tgcgtattgg gcgctcttcc gcttcctcgc tcactgactc gctgcgctcg + 721 gtcgttcggc tgcggcgagc ggtatcagct cactcaaagg cggtaatacg gttatccaca + 781 gaatcagggg ataacgcagg aaagaacatg tgagcaaaag gccagcaaaa ggccaggaac + 841 cgtaaaaagg ccgcgttgct ggcgtttttc cataggctcc gcccccctga cgagcatcac + 901 aaaaatcgac gctcaagtca gaggtggcga aacccgacag gactataaag ataccaggcg + 961 tttccccctg gaagctccct cgtgcgctct cctgttccga ccctgccgct taccggatac + 1021 ctgtccgcct ttctcccttc gggaagcgtg gcgctttctc atagctcacg ctgtaggtat + 1081 ctcagttcgg tgtaggtcgt tcgctccaag ctgggctgtg tgcacgaacc ccccgttcag + 1141 cccgaccgct gcgccttatc cggtaactat cgtcttgagt ccaacccggt aagacacgac + 1201 ttatcgccac tggcagcagc cactggtaac aggattagca gagcgaggta tgtaggcggt + 1261 gctacagagt tcttgaagtg gtggcctaac tacggctaca ctagaagaac agtatttggt + 1321 atctgcgctc tgctgaagcc agttaccttc ggaaaaagag ttggtagctc ttgatccggc + 1381 aaacaaacca ccgctggtag cggtggtttt tttgtttgca agcagcagat tacgcgcaga + 1441 aaaaaaggat ctcaagaaga tcctttgatc ttttctacgg ggtctgacgc tcagtggaac + 1501 gaaaactcac gttaagggat tttggtcatg agattatcaa aaaggatctt cacctagatc + 1561 cttttaaatt aaaaatgaag ttttaaatca atctaaagta tatatgagta aacttggtct + 1621 gacagttacc aatgcttaat cagtgaggca cctatctcag cgatctgtct atttcgttca + 1681 tccatagttg cctgactccc cgtcgtgtag ataactacga tacgggaggg cttaccatct + 1741 ggccccagtg ctgcaatgat accgcgagac ccacgctcac cggctccaga tttatcagca + 1801 ataaaccagc cagccggaag ggccgagcgc agaagtggtc ctgcaacttt atccgcctcc + 1861 atccagtcta ttaattgttg ccgggaagct agagtaagta gttcgccagt taatagtttg + 1921 cgcaacgttg ttgccattgc tacaggcatc gtggtgtcac gctcgtcgtt tggtatggct + 1981 tcattcagct ccggttccca acgatcaagg cgagttacat gatcccccat gttgtgcaaa + 2041 aaagcggtta gctccttcgg tcctccgatc gttgtcagaa gtaagttggc cgcagtgtta + 2101 tcactcatgg ttatggcagc actgcataat tctcttactg tcatgccatc cgtaagatgc + 2161 ttttctgtga ctggtgagta ctcaaccaag tcattctgag aatagtgtat gcggcgaccg + 2221 agttgctctt gcccggcgtc aatacgggat aataccgcgc cacatagcag aactttaaaa + 2281 gtgctcatca ttggaaaacg ttcttcgggg cgaaaactct caaggatctt accgctgttg + 2341 agatccagtt cgatgtaacc cactcgtgca cccaactgat cttcagcatc ttttactttc + 2401 accagcgttt ctgggtgagc aaaaacagga aggcaaaatg ccgcaaaaaa gggaataagg + 2461 gcgacacgga aatgttgaat actcatactc ttcctttttc aatattattg aagcatttat + 2521 cagggttatt gtctcatgag cggatacata tttgaatgta tttagaaaaa taaacaaata + 2581 ggggttccgc gcacatttcc ccgaaaagtg ccacctgacg tctaagaaac cattattatc + 2641 atgacattaa cctataaaaa taggcgtatc acgaggccct ttcgtc +// + diff --git a/tests/data/refs/sample-d.dna b/tests/data/refs/sample-d.dna new file mode 100644 index 0000000..30a1efa Binary files /dev/null and b/tests/data/refs/sample-d.dna differ diff --git a/tests/test_align.py b/tests/test_align.py index cb0aa0e..c931509 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -104,6 +104,27 @@ def test_ambiguous_repetitive_reference_flagged() -> None: assert a.identity == 1.0 +def test_homopolymer_indel_is_one_placement() -> None: + import random + rng = random.Random(5) + left = "".join(rng.choice("ACGT") for _ in range(80)) + right = "".join(rng.choice("ACGT") for _ in range(80)) + reference = left + "GTTTTC" + right + read = left + "GTTTTTC" + right # one extra T in the run + a = align_read(read, reference) + assert a.alternatives > 1 # co-optimal gap placements ... + assert a.placements == 1 # ... but a single placement + + +def test_distinct_repeat_copies_are_two_placements() -> None: + import random + rng = random.Random(6) + unit = "".join(rng.choice("ACGT") for _ in range(60)) + spacer = "".join(rng.choice("ACGT") for _ in range(200)) + a = align_read(unit, spacer[:50] + unit + spacer + unit + spacer[50:]) + assert a.placements == 2 + + def test_empty_inputs_raise(reference: str) -> None: with pytest.raises(ValueError): align_read("", reference) diff --git a/tests/test_align_circular.py b/tests/test_align_circular.py new file mode 100644 index 0000000..028abf8 --- /dev/null +++ b/tests/test_align_circular.py @@ -0,0 +1,203 @@ +import random + +import pytest + +from clone_fixtures import coi_reference, real_read +from chrom_core.align import ( + CIRCULAR_MARGIN, + MAX_REFERENCE_LENGTH, + align_read, + align_to_construct, + gapped_sequences, +) +from chrom_core.reference import Construct + +READ_NAME = "Achl_ACHLO006-09_1_F.ab1" + + +@pytest.fixture(scope="module") +def read_seq() -> str: + return real_read(READ_NAME).seq + + +@pytest.fixture(scope="module") +def ref() -> str: + return coi_reference() + + +@pytest.fixture(scope="module") +def insert_read(read_seq, ref) -> str: + """The part of the real read that lies inside the COI reference. + + The raw read (703 bp) overhangs BOTH ends of the 658 bp reference. Biopython's + end gaps are free on one sequence per end only, so on an unrolled circle the + 44-base junk tail is cheaper aligned onto the start of the circle than gapped + — the read then legitimately "covers" the whole ring. A clone read sits + inside its construct, so the rotation tests use the inside part. + """ + full = align_read(read_seq, ref) + return read_seq[full.read_start:full.read_end] + + +@pytest.fixture(scope="module") +def aln0(insert_read, ref): + return align_read(insert_read, ref) + + +@pytest.fixture(scope="module") +def mid(aln0) -> int: + m = (aln0.ref_start + aln0.ref_end) // 2 + # the new origin must fall well inside the read's covered span + assert aln0.ref_start + 100 < m < aln0.ref_end - 100 + return m + + +@pytest.fixture(scope="module") +def rotated(ref, mid) -> str: + return ref[mid:] + ref[:mid] + + +def _random_seq(n: int, seed: int) -> str: + rng = random.Random(seed) + return "".join(rng.choice("ACGT") for _ in range(n)) + + +def test_margin_constant() -> None: + assert CIRCULAR_MARGIN == 50 + + +def test_linear_matches_align_read(read_seq, ref) -> None: + aln0 = align_read(read_seq, ref) + aln = align_to_construct(read_seq, Construct("coi", ref, False)) + assert aln.differences == aln0.differences + assert aln.identity == aln0.identity + assert (aln.ref_start, aln.ref_end) == (aln0.ref_start, aln0.ref_end) + assert aln.ref_to_read == aln0.ref_to_read + assert aln.ref_length == len(ref) + assert aln.covered == aln0.covered + assert aln.covered_positions() == list(range(aln0.ref_start, aln0.ref_end)) + assert aln0.ref_start > 0 + assert aln.covers(aln0.ref_start) and not aln.covers(aln0.ref_start - 1) + + +def test_read_across_origin_of_rotated_reference(insert_read, ref, aln0, mid, rotated) -> None: + aln = align_to_construct(insert_read, Construct("rot", rotated, True)) + L = len(ref) + assert aln.ref_length == L + assert aln.ref_end < aln.ref_start # wrapped + assert len(aln.covered_positions()) == aln0.covered + assert aln.covered == aln0.covered + assert [(d.ref_pos - (L - mid)) % L for d in aln.differences] == [ + d.ref_pos for d in aln0.differences + ] + assert aln.covers(0) and aln.covers(L - 1) + assert aln.identity == pytest.approx(aln0.identity) + # every key is a real construct position; the mapping is the rotation of aln0's + assert all(0 <= p < L for p in aln.ref_to_read) + assert {(p + mid) % L: q for p, q in aln.ref_to_read.items()} == aln0.ref_to_read + # gapped reconstruction over the unrolled reference gives the same pair + assert gapped_sequences(aln, rotated * 2) == gapped_sequences(aln0, ref) + + +def test_same_rotation_linear_breaks_read(insert_read, aln0, rotated) -> None: + aln = align_to_construct(insert_read, Construct("rot", rotated, False)) + assert aln.covered < aln0.covered # without the ring the read is cut at the origin + # only one side of the origin can be aligned: roughly half of the read's span + assert aln.covered <= aln0.covered * 0.6 + assert aln.ref_end >= aln.ref_start + + +def test_real_read_longer_than_circular_construct(read_seq, ref) -> None: + assert len(read_seq) > len(ref) + aln = align_to_construct(read_seq, Construct("coi", ref, True)) + positions = aln.covered_positions() + assert len(positions) == len(set(positions)) <= len(ref) + assert all(0 <= p < len(ref) for p in positions) + assert set(aln.ref_to_read) <= set(positions) + assert all(0 <= d.ref_pos < len(ref) for d in aln.differences) + assert aln.covered == len(positions) + + +def test_tandem_read_longer_than_circle_reports_each_position_once() -> None: + circle = _random_seq(300, seed=7) + read = (circle * 3)[50:50 + 400] # wraps the circle more than once + aln = align_to_construct(read, Construct("small", circle, True)) + L = len(circle) + positions = aln.covered_positions() + assert len(positions) == L and sorted(positions) == list(range(L)) + assert aln.covered == L + assert (aln.ref_start, aln.ref_end) == (0, L) + assert all(aln.covers(p) for p in range(L)) + assert sorted(aln.ref_to_read) == list(range(L)) + # the first pass over the circle wins: read index 0 sits at construct position 50 + assert aln.ref_to_read[50] == 0 + assert aln.ref_to_read[49] == L - 1 + assert aln.differences == [] + assert aln.identity == 1.0 + ref_g, read_g = gapped_sequences(aln, circle * 2) + assert len(ref_g) == L and ref_g == read_g == read[:L] + + +def test_wrapped_coordinates_minus_strand() -> None: + from chrom_core.seqops import reverse_complement + + circle = _random_seq(500, seed=11) + insert = circle[420:] + circle[:90] # 170 bp across the origin + aln = align_to_construct(reverse_complement(insert), Construct("c", circle, True)) + assert aln.strand == "-" + assert (aln.ref_start, aln.ref_end) == (420, 90) + assert aln.covered == 170 + assert aln.covered_positions() == list(range(420, 500)) + list(range(90)) + assert aln.ref_to_read[420] == 0 and aln.ref_to_read[0] == 80 and aln.ref_to_read[89] == 169 + assert not aln.covers(90) and not aln.covers(419) + + +def test_wrapped_differences_are_mod_length() -> None: + circle = _random_seq(400, seed=3) + insert = list(circle[350:] + circle[:60]) # covers 350..399, 0..59 + insert[55] = "A" if insert[55] != "A" else "C" # construct position 5 + aln = align_to_construct("".join(insert), Construct("c", circle, True)) + assert [(d.ref_pos, d.kind) for d in aln.differences] == [(5, "substitution")] + + +def test_limit_applies_to_original_length() -> None: + big = _random_seq(MAX_REFERENCE_LENGTH, seed=5) + read = big[1000:1030] + aln = align_to_construct(read, Construct("big", big, True)) + assert aln.ref_length == MAX_REFERENCE_LENGTH + with pytest.raises(ValueError): + align_to_construct(read, Construct("big", big + "A", True)) + with pytest.raises(ValueError): + align_to_construct(read, Construct("big", big + "A", False)) + + +def test_read_in_unrolled_prefix_is_not_ambiguous() -> None: + circle = _random_seq(1000, seed=21) + read = circle[10:210] # also present in the unrolled copy of the start + aln = align_to_construct(read, Construct("c", circle, True)) + assert (aln.ref_start, aln.ref_end) == (10, 210) + assert aln.alternatives == 1 + assert aln.placements == 1 + linear = align_to_construct(read, Construct("c", circle, False)) + assert (linear.alternatives, linear.placements) == (1, 1) + + +def test_genuine_repeat_in_circle_stays_ambiguous() -> None: + circle = list(_random_seq(1000, seed=23)) + repeat = _random_seq(100, seed=24) + circle[20:120] = repeat # inside the unrolled prefix as well + circle[600:700] = repeat + circle = "".join(circle) + aln = align_to_construct(repeat, Construct("c", circle, True)) + assert aln.alternatives == 2 + assert aln.placements == 2 + + +def test_homopolymer_indel_across_origin_is_one_placement() -> None: + circle = _random_seq(1000, seed=31) + circle = circle[:995] + "GTTTC" # a TTT run just before the origin + read = circle[900:996] + "TTTT" + circle[999:] + circle[:100] # one extra T + aln = align_to_construct(read, Construct("c", circle, True)) + assert aln.wraps + assert aln.alternatives > 1 + assert aln.placements == 1 diff --git a/tests/test_app_icon.py b/tests/test_app_icon.py new file mode 100644 index 0000000..4dbc851 --- /dev/null +++ b/tests/test_app_icon.py @@ -0,0 +1,96 @@ +"""App icon: committed assets, regeneration script, window icon.""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import struct +import subprocess +import sys +from pathlib import Path + +import pytest +from PySide6.QtGui import QIcon, QImage + +ROOT = Path(__file__).resolve().parent.parent +APP_ICONS = ROOT / "ui" / "icons" / "app" + + +def test_png_and_ico_exist(): + assert (APP_ICONS / "ab1kit.png").is_file() + assert (APP_ICONS / "ab1kit.ico").is_file() + + +def test_ico_has_required_sizes(): + data = (APP_ICONS / "ab1kit.ico").read_bytes() + reserved, kind, count = struct.unpack_from(" list[tuple[int, bytes]]: + count = struct.unpack_from(" QImage: + image = QImage.fromData(blob, "PNG") + assert not image.isNull() + return image.convertToFormat(QImage.Format.Format_ARGB32) + + +def _assert_pixels_close(a: QImage, b: QImage) -> None: + """Max per-channel diff <= 2, except at most 0.1% outlier pixels.""" + assert (a.width(), a.height()) == (b.width(), b.height()) + outliers = 0 + for y in range(a.height()): + for x in range(a.width()): + pa, pb = a.pixel(x, y), b.pixel(x, y) + if any(abs(((pa >> s) & 255) - ((pb >> s) & 255)) > 2 for s in (0, 8, 16, 24)): + outliers += 1 + assert outliers <= max(1, a.width() * a.height() // 1000) + + +def test_make_icon_regenerates_same_pixels(tmp_path): + env = {k: v for k, v in os.environ.items() if k != "QT_QPA_FONTDIR"} + env["QT_QPA_PLATFORM"] = "offscreen" + subprocess.run([sys.executable, str(ROOT / "scripts" / "make_icon.py"), + "--out", str(tmp_path)], check=True, env=env, cwd=ROOT) + _assert_pixels_close(_decode((tmp_path / "ab1kit.png").read_bytes()), + _decode((APP_ICONS / "ab1kit.png").read_bytes())) + new = _ico_entries((tmp_path / "ab1kit.ico").read_bytes()) + old = _ico_entries((APP_ICONS / "ab1kit.ico").read_bytes()) + assert [size for size, _ in new] == [size for size, _ in old] + for (_, blob_new), (_, blob_old) in zip(new, old): + _assert_pixels_close(_decode(blob_new), _decode(blob_old)) + + +def test_main_window_sets_application_icon(monkeypatch): + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None) + from ui.main_window import MainWindow + + app.setWindowIcon(QIcon()) + window = MainWindow() + icon = app.windowIcon() + assert not icon.isNull() + pix = icon.pixmap(32, 32) + assert (pix.width(), pix.height()) == (32, 32) + assert pix.toImage().convertToFormat(QImage.Format.Format_ARGB32).pixelColor(16, 16).alpha() > 0 + window.close() diff --git a/tests/test_batch.py b/tests/test_batch.py index 01685c2..4b31ab5 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -101,3 +101,14 @@ def test_summary_csv(folder: Path) -> None: def test_csv_field_quoting() -> None: assert batch._csv_field("plain") == "plain" assert batch._csv_field('a"b;c') == '"a""b;c"' + + +def test_read_without_quality_reports_none_mean_q(folder: Path) -> None: + by_name = {r.path.name: r for r in batch.scan_folder(folder)} + assert by_name["310.ab1"].mean_q is None + assert by_name["3730.ab1"].mean_q is not None + row = next( + line for line in batch.summary_csv(list(by_name.values())).splitlines() + if line.startswith("310.ab1") + ) + assert row.split(";")[2] == "" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..eb2290c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,279 @@ +"""Command-line subcommands: verify-clones, qc, export (thin shell over the core).""" + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from Bio import SeqIO +from openpyxl import load_workbook + +import chrom_cli +from clone_fixtures import COI_GB, DATA, coi_reference, mutate + +ROOT = Path(__file__).resolve().parent.parent +READS = ["Achl_ACHLO006-09_1_F.ab1", "Achl_ACHLO006-09_2_R.ab1", + "Achl_ACHLO007-09_1_F.ab1", "Achl_ACHLO007-09_2_R.ab1"] + + +@pytest.fixture +def reads_dir(tmp_path: Path) -> Path: + d = tmp_path / "Данные клоны" + d.mkdir() + for name in READS: + shutil.copy(DATA / "real" / name, d / name) + return d + + +def test_verify_clones_xlsx(reads_dir: Path, tmp_path: Path, capsys) -> None: + out = tmp_path / "отчёт.xlsx" + code = chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir), + "-o", str(out)]) + assert code == 0 + book = load_workbook(out) + assert book.sheetnames == ["Clones", "Differences"] + shown = capsys.readouterr().out + assert "Achl_ACHLO006-09" in shown and "ok" in shown + + +def test_verify_clones_csv_region_and_errors(reads_dir: Path, tmp_path: Path, capsys) -> None: + ref = tmp_path / "mutated.fasta" + seq = coi_reference() + ref.write_text(">mut\n" + mutate(seq, 250, "sub", "A" if seq[250] != "A" else "C") + "\n") + out = tmp_path / "rep.csv" + code = chrom_cli.main(["verify-clones", "--construct", str(ref), "--region", "7-647", + "--linear", "--min-q", "20", "--het-ratio", "0.4", + str(reads_dir), "-o", str(out)]) + assert code == 1 # every clone carries the planted difference + assert "errors" in out.read_text(encoding="utf-8") + diffs = tmp_path / "rep_differences.csv" + assert diffs.exists() and "251" in diffs.read_text(encoding="utf-8") + assert "errors" in capsys.readouterr().out + + +def test_verify_clones_input_errors(reads_dir: Path, tmp_path: Path, capsys) -> None: + empty = tmp_path / "empty" + empty.mkdir() + out = tmp_path / "x.xlsx" + base = ["verify-clones", "-o", str(out)] + assert chrom_cli.main(base + ["--construct", str(COI_GB), str(empty)]) == 2 + bad = tmp_path / "bad.gb" + bad.write_text("not a sequence file") + assert chrom_cli.main(base + ["--construct", str(bad), str(reads_dir)]) == 2 + assert chrom_cli.main(base + ["--construct", str(COI_GB), "--region", "9-3", + "--linear", str(reads_dir)]) == 2 + assert chrom_cli.main(base + ["--construct", str(COI_GB), "--region", "abc", + str(reads_dir)]) == 2 + assert chrom_cli.main(base + ["--construct", str(COI_GB), "--pattern", "(", + str(reads_dir)]) == 2 + assert chrom_cli.main(base + ["--construct", str(COI_GB), + str(tmp_path / "missing")]) == 2 + assert capsys.readouterr().err.count("ab1kit:") >= 6 + assert not out.exists() + + +def test_unassigned_files_reported_on_stderr(reads_dir: Path, tmp_path: Path, capsys) -> None: + shutil.copy(DATA / "real" / READS[0], reads_dir / "!.ab1") + out = tmp_path / "r.csv" + chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir), "-o", str(out)]) + assert "!.ab1" in capsys.readouterr().err + + +def test_qc_stdout_and_file(reads_dir: Path, tmp_path: Path, capsys) -> None: + assert chrom_cli.main(["qc", str(reads_dir)]) == 0 + printed = capsys.readouterr().out + assert printed.startswith("file;length;mean_q") and READS[0] in printed + out = tmp_path / "qc.csv" + assert chrom_cli.main(["qc", str(reads_dir), "-o", str(out)]) == 0 + assert READS[1] in out.read_text(encoding="utf-8") + + +def test_export(reads_dir: Path, tmp_path: Path) -> None: + out = tmp_path / "вывод" + assert chrom_cli.main(["export", str(reads_dir), "--trim", "--fasta", "-o", str(out)]) == 0 + files = sorted(p.name for p in out.glob("*.trimmed.fasta")) + assert len(files) == len(READS) + out2 = tmp_path / "fq" + assert chrom_cli.main(["export", str(reads_dir), "--trim", "-o", str(out2)]) == 0 + assert len(list(out2.glob("*.trimmed.fastq"))) == len(READS) + + +@pytest.mark.parametrize("argv", [[], ["x.ab1"]]) +def test_gui_path_unchanged(monkeypatch, argv) -> None: + import ui.main_window as mw + seen = [] + monkeypatch.setattr(mw, "run", lambda a: seen.append(a) or 0) + assert chrom_cli.main(argv) == 0 + assert seen == [argv] + + +def _run_cli(*args, **kw): + return subprocess.run([sys.executable, "-m", "chrom_cli", *map(str, args)], + capture_output=True, cwd=ROOT, **kw) + + +def test_cli_does_not_import_qt_and_survives_legacy_console(reads_dir: Path, tmp_path: Path) -> None: + out = tmp_path / "r.csv" + code = ( + "import sys, chrom_cli\n" + f"rc = chrom_cli.main(['verify-clones','--construct',r'{COI_GB}',r'{reads_dir}','-o',r'{out}'])\n" + "assert not any(m == 'PySide6' or m.startswith('PySide6.') for m in sys.modules), 'Qt imported'\n" + "sys.exit(rc)\n" + ) + env = {**__import__("os").environ, "PYTHONIOENCODING": "cp1251"} + env.pop("PYTHONUTF8", None) + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, cwd=ROOT, env=env) + assert proc.returncode == 0, proc.stderr.decode("utf-8", "replace") + + +def test_cyrillic_paths_on_legacy_console(tmp_path: Path) -> None: + d = tmp_path / "Данные клоны" + d.mkdir() + for name in READS[:2]: + shutil.copy(DATA / "real" / name, d / name) + shutil.copy(DATA / "real" / READS[0], d / "Клон ж_1_F.ab1") + shutil.copy(DATA / "real" / READS[0], d / "Ё!.ab1") + env = {**__import__("os").environ, "PYTHONIOENCODING": "cp437"} # cannot encode Cyrillic + env.pop("PYTHONUTF8", None) + proc = subprocess.run( + [sys.executable, "-m", "chrom_cli", "verify-clones", "--construct", str(COI_GB), + str(d), "-o", str(tmp_path / "o.csv")], + capture_output=True, cwd=ROOT, env=env) + assert proc.returncode in (0, 1), proc.stderr.decode("utf-8", "replace") + assert b"Traceback" not in proc.stderr + assert "Клон ж".encode() in proc.stdout # utf-8 forced regardless of the console + assert "Ё!.ab1".encode() in proc.stderr + + +def test_attached_console_uses_console_code_page(monkeypatch) -> None: + import builtins + import ctypes + import types + + kernel32 = types.SimpleNamespace(AttachConsole=lambda pid: 1, GetConsoleOutputCP=lambda: 866, + SetConsoleOutputCP=lambda cp: pytest.fail("must not change")) + monkeypatch.setattr(ctypes, "windll", types.SimpleNamespace(kernel32=kernel32), raising=False) + opened = [] + monkeypatch.setattr(builtins, "open", lambda *a, **k: opened.append((a, k)) or object()) + monkeypatch.setattr(sys, "stdout", None) + monkeypatch.setattr(sys, "stderr", None) + chrom_cli._attach_console() + assert [a[0] for a, _ in opened] == ["CONOUT$", "CONOUT$"] + assert all(k["encoding"] == "cp866" and k["errors"] == "replace" for _, k in opened) + + +def test_qc_without_stdout(monkeypatch, reads_dir: Path) -> None: + monkeypatch.setattr(sys, "stdout", None) + assert chrom_cli.main(["qc", str(reads_dir)]) == 0 # nowhere to print, must not crash + + +@pytest.mark.parametrize("text, expected", [("1-10", (0, 10)), ("600-50", (599, 50))]) +def test_parse_region(text, expected) -> None: + assert chrom_cli._parse_region(text) == expected + + +@pytest.mark.parametrize("text", ["abc", "5", "0-10", "-3-4", "1-2-3", ""]) +def test_parse_region_rejects(text) -> None: + with pytest.raises(chrom_cli.CliError): + chrom_cli._parse_region(text) + + +def test_wrap_region_circular_ok_linear_rejected(reads_dir: Path, tmp_path: Path) -> None: + base = ["verify-clones", "--construct", str(COI_GB), "--region", "600-50", str(reads_dir)] + assert chrom_cli.main(base + ["--linear", "-o", str(tmp_path / "a.csv")]) == 2 + ref = tmp_path / "circ.fasta" + ref.write_text(">c\n" + coi_reference() + "\n") # FASTA defaults to circular + base[2] = str(ref) + assert chrom_cli.main(base + ["-o", str(tmp_path / "b.csv")]) in (0, 1) + + +def test_bad_output_extension_rejected_before_verifying(reads_dir: Path, tmp_path: Path, monkeypatch) -> None: + import chrom_core.clones as clones + monkeypatch.setattr(clones, "verify_all", lambda *a, **k: pytest.fail("ran verify_all")) + assert chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir), + "-o", str(tmp_path / "r.txt")]) == 2 + + +@pytest.mark.parametrize("code_page", [0, 99999]) +def test_attached_console_unknown_code_page_falls_back_to_utf8(monkeypatch, code_page) -> None: + import builtins + import ctypes + import types + + kernel32 = types.SimpleNamespace(AttachConsole=lambda pid: 1, + GetConsoleOutputCP=lambda: code_page) + monkeypatch.setattr(ctypes, "windll", types.SimpleNamespace(kernel32=kernel32), raising=False) + opened = [] + monkeypatch.setattr(builtins, "open", lambda *a, **k: opened.append(k) or object()) + monkeypatch.setattr(sys, "stdout", None) + monkeypatch.setattr(sys, "stderr", None) + chrom_cli._attach_console() + assert len(opened) == 2 + assert all(k["encoding"] == "utf-8" and k["errors"] == "replace" for k in opened) + + +def test_attached_console_is_all_or_nothing(monkeypatch) -> None: + import builtins + import ctypes + import types + + kernel32 = types.SimpleNamespace(AttachConsole=lambda pid: 1, GetConsoleOutputCP=lambda: 866) + monkeypatch.setattr(ctypes, "windll", types.SimpleNamespace(kernel32=kernel32), raising=False) + calls = [] + + def fake_open(*a, **k): + calls.append(a) + if len(calls) == 2: + raise OSError("no console") + return types.SimpleNamespace(close=lambda: None) + + monkeypatch.setattr(builtins, "open", fake_open) + monkeypatch.setattr(sys, "stdout", None) + monkeypatch.setattr(sys, "stderr", None) + chrom_cli._attach_console() + assert sys.stdout is None and sys.stderr is None + + +def test_unusable_reads_reported_on_stderr(reads_dir: Path, tmp_path: Path, capsys) -> None: + good = chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir), + "-o", str(tmp_path / "a.csv")]) + capsys.readouterr() + (reads_dir / "Achl_ACHLO006-09_3_F.ab1").write_bytes(b"not an ab1 file at all") + source = (DATA / "real" / READS[0]).read_bytes() + (reads_dir / "Achl_ACHLO006-09_4_F.ab1").write_bytes(source[:len(source) // 3]) + out = tmp_path / "b.csv" + code = chrom_cli.main(["verify-clones", "--construct", str(COI_GB), str(reads_dir), + "-o", str(out)]) + assert code == good + err = capsys.readouterr().err.splitlines() + for name in ("Achl_ACHLO006-09_3_F.ab1", "Achl_ACHLO006-09_4_F.ab1"): + assert any(line.startswith(f"ab1kit: {name}: unreadable") for line in err), err + assert "Achl_ACHLO006-09_3_F.ab1: unreadable" in out.read_text(encoding="utf-8") + + +def test_read_issue_wording() -> None: + from chrom_core.clone_report import read_issue + from chrom_core.clones import ReadRef, ReadResult + + ref = ReadRef(Path("a.ab1"), "c", "F") + assert read_issue(ReadResult(ref, None, error="no_alignment")) == \ + "did not align to the construct" + assert read_issue(ReadResult(ref, None, error="bad header")) == "unreadable (bad header)" + + +def test_cli_difference_count_matches_report(reads_dir: Path, tmp_path: Path, capsys) -> None: + ref = tmp_path / "mutated.fasta" + seq = coi_reference() + alt = lambda b: "A" if b != "A" else "C" + seq = mutate(mutate(seq, 250, "sub", alt(seq[250])), 300, "sub", alt(seq[300])) + ref.write_text(">mut\n" + seq + "\n") + out = tmp_path / "rep.csv" + chrom_cli.main(["verify-clones", "--construct", str(ref), "--region", "7-280", + "--linear", str(reads_dir), "-o", str(out)]) + shown = capsys.readouterr().out + rows = [line.split(",") for line in out.read_text(encoding="utf-8").splitlines()[1:]] + assert rows + for row in rows: + line = next(l for l in shown.splitlines() if l.startswith(row[0] + " ")) + assert line.rstrip().endswith(f"differences {row[4]}") diff --git a/tests/test_clone_report.py b/tests/test_clone_report.py new file mode 100644 index 0000000..905768e --- /dev/null +++ b/tests/test_clone_report.py @@ -0,0 +1,199 @@ +"""CSV / Excel reports for clone verification, built from real reads (HM417896 COI).""" + +from __future__ import annotations + +import csv +import io +from dataclasses import replace + +from pathlib import Path + +import pytest +from openpyxl import load_workbook + +from chrom_core import batch +from chrom_core.clone_report import ( + STATUS_HEX, + clone_report_xlsx, + clones_csv, + differences_csv, +) +from chrom_core.clones import Grouping, ReadRef, verify_all, verify_clone +from chrom_core.reference import Construct, load_construct +from clone_fixtures import COI_GB, DATA, REAL_READS, coi_reference, mutate + +F = DATA / "real" / "Achl_ACHLO006-09_1_F.ab1" +R = DATA / "real" / "Achl_ACHLO006-09_2_R.ab1" +REGION = (6, 647) +P = 250 # inside the region +OUTSIDE = 652 # covered by F only, outside the region + +assert F in REAL_READS and R in REAL_READS + + +@pytest.fixture(scope="module") +def coi() -> str: + return coi_reference() + + +def _reads(clone: str, *paths_primers) -> list[ReadRef]: + return [ReadRef(p, clone, primer) for p, primer in paths_primers] + + +@pytest.fixture(scope="module") +def reports(coi): + """c1: clean (ok) and c2: substitution at P and outside-region one at OUTSIDE.""" + clean = verify_clone(Construct("c", coi, False), REGION, _reads("c1", (F, "F"), (R, "R"))) + bad_ref = mutate(mutate(coi, P, "sub", "G" if coi[P] != "G" else "A"), + OUTSIDE, "sub", "G" if coi[OUTSIDE] != "G" else "A") + bad = verify_clone(Construct("c", bad_ref, False), REGION, _reads("c2", (F, "F"), (R, "R"))) + return [clean, bad] + + +def _rows(text: str) -> list[list[str]]: + return list(csv.reader(io.StringIO(text))) + + +def test_clones_csv_header_and_rows(reports): + rows = _rows(clones_csv(reports)) + assert rows[0] == ["clone", "verdict", "region_coverage_pct", "both_strands_pct", + "differences", "reads", "read_issues"] + assert len(rows) == 3 + c1, c2 = rows[1], rows[2] + assert c1[:2] == ["c1", "ok"] + assert c1[2] == "100.0" and c1[3] == "97.0" # F and R overlap on most of the region + assert c1[4] == "0" + assert c1[5] == f"{F.name}|{R.name}" and c1[6] == "" + assert c2[:2] == ["c2", "errors"] + assert c2[4] == "1" # in-region, not low quality: the outside one is not counted + + +def test_clones_csv_empty(): + assert clones_csv([]).splitlines() == [ + "clone,verdict,region_coverage_pct,both_strands_pct,differences,reads,read_issues"] + + +def _diff(**kw): + from chrom_core.clones import CloneDifference + + base = dict(ref_pos=1, kind="substitution", ref_base="A", read_base="G", support=1, + against=0, low_quality=0, status="confirmed", in_region=True) + return CloneDifference(**{**base, **kw}) + + +def test_differences_column_counts_what_the_verdict_uses(): + from chrom_core.clones import CloneReport + + diffs = (_diff(), _diff(ref_pos=2, status="disputed", against=1), + _diff(ref_pos=3, status="low_quality", support=0, low_quality=2), + _diff(ref_pos=4, in_region=False)) + report = CloneReport("c", (), {}, diffs, (), "errors") + assert report.error_differences == diffs[:2] + assert _rows(clones_csv([report]))[1][4] == "2" + + +def test_read_issues_column(): + from chrom_core.clones import CloneReport, ReadResult + + ok = verify_clone(Construct("c", coi_reference(), False), REGION, + _reads("c", (F, "F"))).reads[0] + results = ( + ok, + ReadResult(ReadRef(Path("x.ab1"), "c", "F"), None, error="not an ABIF file"), + ReadResult(ReadRef(Path("n.ab1"), "c", "F"), None, error="no_alignment"), + replace(ok, ref=ReadRef(Path("y.ab1"), "c", "R"), ambiguous=True), + ) + report = CloneReport("c", results, {}, (), (), "no_data") + assert _rows(clones_csv([report]))[1][6] == ( + "x.ab1: unreadable (not an ABIF file); n.ab1: did not align to the construct; " + "y.ab1: ambiguous placement") + + +def test_differences_csv_header_position_and_notes(reports): + rows = _rows(differences_csv(reports)) + assert rows[0] == ["clone", "position", "kind", "ref", "read", "effect", + "support", "against", "status", "note"] + body = rows[1:] + assert len(body) == len(reports[1].differences) == 2 # clean clone contributes none + by_pos = {int(r[1]): r for r in body} + assert set(by_pos) == {P + 1, OUTSIDE + 1} # 1-based + inside, outside = by_pos[P + 1], by_pos[OUTSIDE + 1] + assert inside[0] == "c2" and inside[2] == "substitution" + assert inside[8] == "confirmed" and inside[9] == "" + assert inside[6] == "2" and inside[7] == "0" + assert outside[9] == "outside region" + + +def test_differences_note_variants(): + from chrom_core.clone_report import _note + from chrom_core.clones import CloneDifference + + def diff(**kw): + base = dict(ref_pos=1, kind="substitution", ref_base="A", read_base="G", support=1, + against=0, low_quality=0, status="confirmed", in_region=True) + return CloneDifference(**{**base, **kw}) + + assert _note(diff()) == "" + assert _note(diff(status="disputed", against=1)) == "disputed: 1 read against" + assert _note(diff(status="disputed", against=3)) == "disputed: 3 reads against" + assert _note(diff(status="low_quality", support=0, low_quality=2)) == "low quality: 2 reads" + assert _note(diff(status="low_quality", support=0, low_quality=1, in_region=False)) == \ + "low quality: 1 read / outside region" + + +def test_csv_quotes_commas_and_quotes(): + from chrom_core.clones import CloneReport + weird = CloneReport('a,"b"', (), {}, (), (), "no_data") + rows = _rows(clones_csv([weird])) + assert rows[1][0] == 'a,"b"' + assert len(rows[1]) == 7 + assert batch.csv_field("x,y", sep=",") == '"x,y"' + assert batch.csv_field("x,y") == "x,y" + + +def test_xlsx_sheets_and_fill(reports, tmp_path): + path = tmp_path / "r.xlsx" + clone_report_xlsx(path, reports, "COI") + book = load_workbook(path) + assert book.sheetnames == ["Clones", "Differences"] + clones, diffs = book["Clones"], book["Differences"] + assert [c.value for c in clones[1]] == ["clone", "verdict", "region_coverage_pct", + "both_strands_pct", "differences", "reads", + "read_issues"] + assert clones.max_row == 3 + assert clones["B2"].value == "ok" and clones["B3"].value == "errors" + assert clones["B3"].fill.fgColor.rgb[-6:] == STATUS_HEX["errors"][0].lstrip("#").upper() + assert clones["B2"].fill.fgColor.rgb[-6:] == STATUS_HEX["ok"][0].lstrip("#").upper() + assert clones["B1"].font.bold + assert clones["C2"].value == 100.0 + assert diffs.max_row == 3 + assert diffs["B2"].value in (P + 1, OUTSIDE + 1) + assert book.properties.title == "COI" + + +def test_status_hex_palette(): + assert set(STATUS_HEX) == {"ok", "errors", "incomplete", "mixed", "no_data"} + for light, dark in STATUS_HEX.values(): + for colour in (light, dark): + assert len(colour) == 7 and colour[0] == "#" + int(colour[1:], 16) + assert len({light for light, _ in STATUS_HEX.values()}) == 5 + + +def test_end_to_end_effect_from_cds_construct(coi, tmp_path): + construct = load_construct(COI_GB) + assert any(f.kind == "CDS" for f in construct.features) + alt = "G" if coi[P] != "G" else "A" + mutated = replace(construct, seq=mutate(construct.seq, P, "sub", alt)) + grouping = Grouping([ReadRef(F, "c1", "F"), ReadRef(R, "c1", "R")]) + + [report] = verify_all(mutated, REGION, grouping) + [diff] = [d for d in report.differences if d.in_region] + assert diff.effect + + [plain] = verify_all(mutated, REGION, grouping, annotate=None) + assert [d.effect for d in plain.differences] == [""] * len(plain.differences) + + rows = _rows(differences_csv([report])) + row = next(r for r in rows[1:] if int(r[1]) == P + 1) + assert row[5] == diff.effect diff --git a/tests/test_clone_verify.py b/tests/test_clone_verify.py new file mode 100644 index 0000000..cc70771 --- /dev/null +++ b/tests/test_clone_verify.py @@ -0,0 +1,430 @@ +"""Per-clone verification: coverage, merged differences and verdicts. + +The base clone is the real pair ``Achl_ACHLO006-09`` F+R against its own COI +reference (HM417896, 658 bp). Errors are introduced into the *reference* +(``mutate``), so the unmodified reads report them, or into a read through an +editing session (``write_read``), which is how a user's edits reach the check. +""" + +from __future__ import annotations + +import random +import time +from concurrent.futures import CancelledError +from dataclasses import replace + +import pytest + +from chrom_core import clones +from chrom_core.clones import ( + MIN_IDENTITY, + CloneReport, + VerifyParams, + load_read, + region_positions, + verify_all, + verify_clone, +) +from chrom_core.align import align_to_construct +from chrom_core.clones import Grouping, ReadRef +from chrom_core.editing import EditableRead +from chrom_core.peaks import secondary_peak +from chrom_core.reference import Construct, load_construct +from clone_fixtures import ( + DATA, + PUC19_GB, + coi_reference, + mutate, + perf_factor, + real_read, + with_secondary_peak, + write_read, +) + +F_NAME = "Achl_ACHLO006-09_1_F.ab1" +R_NAME = "Achl_ACHLO006-09_2_R.ab1" +F_PATH = DATA / "real" / F_NAME +R_PATH = DATA / "real" / R_NAME +# The part of COI covered by both trimmed reads: F aligns 6..658, R 0..647. +REGION = (6, 647) +P = 250 # inside the region; context AAC[A]TGA, no homopolymer +OUTSIDE = 652 # covered by F only, outside the region + + +@pytest.fixture(scope="module") +def coi() -> str: + return coi_reference() + + +def _reads(f=F_PATH, r=R_PATH) -> list[ReadRef]: + out = [] + if f is not None: + out.append(ReadRef(f, "c1", "F")) + if r is not None: + out.append(ReadRef(r, "c1", "R")) + return out + + +def _construct(seq: str, circular: bool = False) -> Construct: + return Construct("c", seq, circular) + + +def _f_index(coi: str, ref_pos: int) -> int: + """Displayed index in the F read of reference position ``ref_pos``.""" + al = align_to_construct(real_read(F_NAME).seq, _construct(coi)) + assert al.strand == "+" + return al.ref_to_read[ref_pos] + + +def test_ok_clone(coi): + report = verify_clone(_construct(coi), REGION, _reads()) + assert report.verdict == "ok" and report.region_differences == () + assert report.region_coverage_pct == 100.0 + assert 0 < report.both_strands_pct <= 100.0 + assert set(report.coverage) == set(range(*REGION)) + assert all(r.error == "" and not r.ambiguous for r in report.reads) + + +def test_substitution_found_with_support(coi): + ref = mutate(coi, P, "sub", "G") + r = verify_clone(_construct(ref), REGION, _reads()) + d, = r.region_differences + assert (d.ref_pos, d.kind, d.status, d.support, d.against) == (P, "substitution", "confirmed", 2, 0) + assert (d.ref_base, d.read_base, d.in_region) == ("G", "A", True) + assert r.verdict == "errors" + + +def test_insertion_and_deletion_found(coi): + # base missing from the reference -> the reads show an insertion + r = verify_clone(_construct(mutate(coi, P, "del")), REGION, _reads()) + d, = r.region_differences + assert (d.ref_pos, d.kind, d.read_base, d.status, d.support) == (P, "insertion", "A", "confirmed", 2) + assert r.verdict == "errors" + # extra base in the reference -> the reads show a deletion + r = verify_clone(_construct(mutate(coi, P, "ins", "G")), REGION, _reads()) + d, = r.region_differences + assert (d.ref_pos, d.kind, d.ref_base, d.status, d.support) == (P, "deletion", "G", "confirmed", 2) + assert r.verdict == "errors" + + +def _coi_with_extra(bases: str) -> Construct: + """Annotated COI with ``bases`` inserted before ``P`` (CDS stretched to match): + the unmodified reads then show a ``len(bases)``-bp deletion inside the CDS.""" + c = load_construct(DATA / "refs" / "coi_HM417896.gb") + cds = next(f for f in c.features if f.kind == "CDS") + assert (P - cds.start - (cds.codon_start - 1)) % 3 == 0 # codon-aligned + features = tuple(replace(f, end=f.end + len(bases)) if f.end > P else f + for f in c.features) + return replace(c, seq=mutate(c.seq.upper(), P, "ins", bases), features=features) + + +@pytest.mark.parametrize("extra, effect", [ + ("GCT", "inframe_del"), ("G", "frameshift"), ("GC", "frameshift")]) +def test_multi_base_deletion_is_one_difference(extra, effect): + construct = _coi_with_extra(extra) + r = verify_clone(construct, (6, 647 + len(extra)), _reads()) + d, = r.region_differences + assert (d.kind, d.read_base, d.status, d.support) == ("deletion", "-", "confirmed", 2) + assert len(d.ref_base) == len(extra) + assert construct.seq[d.ref_pos:d.ref_pos + len(extra)].upper() == d.ref_base + assert d.effect == effect + assert r.verdict == "errors" + + +def test_disputed_when_reads_disagree(coi, tmp_path): + # A C peak 4x the called A under P, then the call edited to C: the trace + # agrees with the edit (no artificial double peak at P). + i = _f_index(coi, P) + model = EditableRead(with_secondary_peak(real_read(F_NAME), i, "C", 4.0)) + model.set_base(i, "C") + assert secondary_peak(model.current(), i).ratio < VerifyParams().het_ratio + f = write_read(model.current(), tmp_path, F_NAME) + r = verify_clone(_construct(coi), REGION, _reads(f=f)) + d, = r.region_differences + assert (d.ref_pos, d.kind, d.read_base, d.support, d.against, d.status) == ( + P, "substitution", "C", 1, 1, "disputed") + assert r.verdict == "errors" + + +def test_outside_region_does_not_affect_verdict(coi): + r = verify_clone(_construct(mutate(coi, OUTSIDE, "sub", "A")), REGION, _reads()) + assert r.verdict == "ok" and r.region_differences == () + d, = r.differences + assert (d.ref_pos, d.in_region, d.status) == (OUTSIDE, False, "confirmed") + + +def test_incomplete_when_read_trimmed(coi, tmp_path): + model = EditableRead(real_read(F_NAME)) + model.apply_trim(100, 400) + f = write_read(model.current(), tmp_path, F_NAME) + r = verify_clone(_construct(coi), REGION, _reads(f=f, r=None)) + assert r.verdict == "incomplete" + assert r.region_coverage_pct < 100 + assert r.both_strands_pct == 0.0 + + +def _mixed_read(coi, tmp_path, ratio: float, positions) -> object: + chrom = real_read(F_NAME) + for pos in positions: + i = _f_index(coi, pos) + other = next(b for b in "ACGT" if b != chrom.seq[i]) + chrom = with_secondary_peak(chrom, i, other, ratio) + assert secondary_peak(chrom, i).ratio == pytest.approx(ratio, abs=0.05) + return write_read(chrom, tmp_path, F_NAME) + + +MIXED_AT = (200, 300, 400) + + +def test_mixed_50_percent_flagged(coi, tmp_path): + # The real R read itself has two second peaks in the region (595, 596): + # below the 3-position threshold, so the unmodified clone stays "ok". + baseline = verify_clone(_construct(coi), REGION, _reads()).mixed_positions + assert len(baseline) < VerifyParams().mixed_min_positions + f = _mixed_read(coi, tmp_path, 0.5, MIXED_AT) + r = verify_clone(_construct(coi), REGION, _reads(f=f)) + assert r.verdict == "mixed" + added = tuple(p for p in r.mixed_positions if p not in baseline) + assert added == MIXED_AT and len(added) == 3 + + +def test_mixed_50_percent_single_read_counts_exactly(coi, tmp_path): + f = _mixed_read(coi, tmp_path, 0.5, MIXED_AT) + r = verify_clone(_construct(coi), REGION, _reads(f=f, r=None)) + assert r.verdict == "mixed" and r.mixed_positions == MIXED_AT + + +def test_mixed_30_percent_not_flagged(coi, tmp_path): + f = _mixed_read(coi, tmp_path, 0.3, MIXED_AT) + r = verify_clone(_construct(coi), REGION, _reads(f=f)) + assert r.verdict == "ok" + assert not set(MIXED_AT) & set(r.mixed_positions) + + +def test_no_data_for_foreign_read(): + puc19 = load_construct(PUC19_GB) + raw = align_to_construct(real_read(F_NAME).seq, puc19) + assert raw.identity < MIN_IDENTITY or raw.covered < clones.MIN_ALIGNED_BP + r = verify_clone(puc19, None, [ReadRef(F_PATH, "c1", "F")]) + assert r.verdict == "no_data" + res, = r.reads + assert (res.error, res.alignment) == ("no_alignment", None) + + +def test_reverse_complemented_session_same_result(coi, tmp_path): + construct = _construct(mutate(coi, P, "sub", "G")) + plain = verify_clone(construct, REGION, _reads()) + paths = [] + for path, name in ((F_PATH, F_NAME), (R_PATH, R_NAME)): + model = EditableRead(real_read(name)) + model.reverse_complement() + paths.append(write_read(model.current(), tmp_path, name)) + flipped = verify_clone(construct, REGION, _reads(*paths)) + assert flipped.verdict == plain.verdict == "errors" + assert flipped.differences == plain.differences + assert flipped.coverage == plain.coverage # strands are physical, not displayed + assert flipped.mixed_positions == plain.mixed_positions + assert [r.alignment.strand for r in flipped.reads] == ["-", "+"] + + +def test_region_across_origin(coi, tmp_path): + # COI + a random spacer, so the circle is longer than a read; rotated so + # the origin falls inside COI and the region wraps it. The reads are cut + # to their COI part: their real flanks would align to the random spacer + # as noise. + paths = [] + for name in (F_NAME, R_NAME): + model = EditableRead(real_read(name)) + model.apply_trim(40, 640) + paths.append(write_read(model.current(), tmp_path, name)) + rng = random.Random(2) + plasmid = mutate(coi, P, "sub", "G") + "".join(rng.choice("ACGT") for _ in range(400)) + L, k = len(plasmid), 300 + rotated = plasmid[k:] + plasmid[:k] + region = ((REGION[0] - k) % L, (REGION[1] - k) % L) + assert region[0] > region[1] + linear = verify_clone(_construct(plasmid), REGION, _reads(*paths)) + circ = verify_clone(_construct(rotated, circular=True), region, _reads(*paths)) + assert all(x.alignment.wraps and not x.ambiguous for x in circ.reads) + assert circ.verdict == linear.verdict == "errors" + assert [replace(d, ref_pos=(d.ref_pos + k) % L) for d in circ.differences] == list( + linear.differences) + assert circ.region_differences[0].ref_pos == (P - k) % L + assert circ.region_coverage_pct == linear.region_coverage_pct > 90.0 + assert circ.both_strands_pct == linear.both_strands_pct > 50.0 + assert {(p + k) % L: v for p, v in circ.coverage.items()} == linear.coverage + + +def test_region_positions(): + assert region_positions((2, 5), 10) == [2, 3, 4] + assert region_positions((8, 2), 10) == [8, 9, 0, 1] + assert region_positions(None, 4) == [0, 1, 2, 3] + + +def test_read_without_quality_is_incomplete_not_errors(coi, tmp_path): + model = EditableRead(real_read(F_NAME)) + model.apply_trim(20, 680) # drop the noisy ends: no quality, no trimming + chrom = model.current() + chrom = replace(chrom, quality=(0,) * len(chrom.seq)) + f = write_read(chrom, tmp_path, F_NAME) + r = verify_clone(_construct(mutate(coi, P, "sub", "G")), REGION, _reads(f=f, r=None)) + assert r.verdict == "incomplete" + assert r.region_coverage_pct == 0.0 + d, = r.region_differences + assert (d.ref_pos, d.status, d.support, d.low_quality) == (P, "low_quality", 0, 1) + + +def test_reference_N_is_not_a_difference(coi): + ref = mutate(coi, P, "sub", "N") + ref = mutate(ref, P + 10, "sub", "R") # IUPAC too (coi[260] is compatible or not, never counted) + r = verify_clone(_construct(ref), REGION, _reads()) + assert r.verdict == "ok" and r.differences == () + + +def test_unreadable_file_marked_not_fatal(coi, tmp_path): + broken = tmp_path / "c1_X.ab1" + broken.write_bytes(b"definitely not an ABIF file") + reads = _reads() + [ReadRef(broken, "c1", "X")] + r = verify_clone(_construct(coi), REGION, reads) + assert r.verdict == "ok" + bad = r.reads[-1] + assert bad.error and bad.error != "no_alignment" and bad.alignment is None + assert all(x.alignment is not None for x in r.reads[:2]) + + +def test_ambiguous_read_excluded(coi, tmp_path): + # reads trimmed well inside COI: no overhang can favour one of the copies + paths = [] + for name in (F_NAME, R_NAME): + model = EditableRead(real_read(name)) + model.apply_trim(100, 500) + paths.append(write_read(model.current(), tmp_path, name)) + r = verify_clone(_construct(coi + coi), None, _reads(*paths)) + assert all(x.ambiguous and x.alignment.placements == 2 for x in r.reads) + assert r.verdict == "no_data" + + +def test_genuine_repeat_single_read_is_no_data(coi, tmp_path): + model = EditableRead(real_read(F_NAME)) + model.apply_trim(100, 500) + f = write_read(model.current(), tmp_path, F_NAME) + rng = random.Random(3) + spacer = "".join(rng.choice("ACGT") for _ in range(300)) + r = verify_clone(_construct(coi + spacer + coi), None, _reads(f=f, r=None)) + res, = r.reads + assert res.ambiguous and res.alignment.placements == 2 + assert r.verdict == "no_data" + assert all(v == (0, 0) for v in r.coverage.values()) + + +def test_homopolymer_deletion_is_an_error_not_no_data(coi): + # co-optimal gap placements inside a run are one placement, not ambiguity (R15) + r = verify_clone(_construct(mutate(coi, 200, "ins", "T")), REGION, _reads()) # TTTT -> TTTTT + assert all(x.alignment.alternatives > 1 and not x.ambiguous for x in r.reads) + assert r.verdict == "errors" + assert [(d.kind, d.status) for d in r.region_differences] == [("deletion", "confirmed")] + + +@pytest.mark.parametrize("region, circular", [ + ((10, 10), True), # empty + ((0, 659), False), # past the end + ((-1, 10), False), + ((658, 5), True), # start outside the construct + ((500, 100), False), # wraps on a linear construct +]) +def test_invalid_region_rejected(coi, monkeypatch, region, circular): + def boom(*a, **k): + raise AssertionError("aligned before validating the region") + monkeypatch.setattr(clones, "align_to_construct", boom) + monkeypatch.setattr(clones, "_load", boom) + with pytest.raises(ValueError): + verify_clone(_construct(coi, circular), region, _reads()) + with pytest.raises(ValueError): + verify_all(_construct(coi, circular), region, Grouping(_reads()), VerifyParams()) + + +def test_verify_all_progress_and_cancel(coi): + grouping = Grouping([ + ReadRef(F_PATH, "c2", "F"), ReadRef(R_PATH, "c2", "R"), + ReadRef(F_PATH, "c1", "F"), + ]) + calls = [] + reports = verify_all(_construct(coi), REGION, grouping, VerifyParams(), + progress=lambda d, t: calls.append((d, t))) + assert [r.clone for r in reports] == ["c1", "c2"] + assert all(isinstance(r, CloneReport) for r in reports) + assert [r.verdict for r in reports] == ["incomplete", "ok"] + assert calls == [(1, 2), (2, 2)] + + calls.clear() + with pytest.raises(CancelledError): + verify_all(_construct(coi), REGION, grouping, VerifyParams(), + progress=lambda d, t: calls.append((d, t)), + cancelled=lambda: bool(calls)) + assert calls == [(1, 2)] + + +def test_annotate_sets_effect(coi): + seen = [] + + def annotate(construct, diff): + seen.append((construct.name, diff.ref_pos)) + return "effect!" + + r = verify_clone(_construct(mutate(coi, P, "sub", "G")), REGION, _reads(), annotate=annotate) + assert [d.effect for d in r.differences] == ["effect!"] + assert seen == [("c", P)] + + +def test_load_read_applies_session(tmp_path): + model = EditableRead(real_read(F_NAME)) + model.set_base(10, "A") + model.apply_trim(5, 300) + path = write_read(model.current(), tmp_path, F_NAME) + chrom = load_read(path) + assert chrom.seq == "".join(model.seq) + assert chrom.quality == tuple(model.quality) + + +# Measured 2026-10-30 on the developer machine, after placements (R15) were +# added: 15.1-15.5 s (3 runs; the exact DP alignment against 10 kb dominates). +# LIMIT = 2 x 15.5, rounded up. +LIMIT = 32.0 + + +@pytest.mark.perf +def test_performance_12_clones_x_4_reads(coi): + rng = random.Random(1) + flank = "".join(rng.choice("ACGT") for _ in range(10_000 - len(coi))) + construct = _construct(coi + flank) # ~10 kb + names = ["Achl_ACHLO006-09_1_F.ab1", "Achl_ACHLO006-09_2_R.ab1", + "Achl_ACHLO007-09_1_F.ab1", "Achl_ACHLO007-09_2_R.ab1"] + grouping = Grouping([ + ReadRef(DATA / "real" / n, f"clone{c}", n[-5]) for c in range(12) for n in names + ]) + t = time.perf_counter() + reports = verify_all(construct, None, grouping, VerifyParams()) + elapsed = time.perf_counter() - t + print(f"verify_all 12x4 on {len(construct.seq)} bp: {elapsed:.2f} s") + assert len(reports) == 12 + assert all(r.verdict != "no_data" for r in reports) + assert not any(x.ambiguous or x.error for r in reports for x in r.reads) + assert elapsed < LIMIT * perf_factor() + + +def test_deletion_runs_split_by_reads_and_join_across_origin(): + from chrom_core.align import Difference + + a, b = object(), object() + + def dels(*specs): + return {(p, "deletion", "-"): (Difference(p, "deletion", "ACGTACGTAC"[p], "-", None), + list(reads), [30] * len(reads)) + for p, reads in specs} + + found = dels((0, [a]), (1, [a]), (4, [a, b]), (5, [a]), (8, [a]), (9, [a])) + found[(3, "substitution", "T")] = (Difference(3, "substitution", "T", "A", 3), [a], [30]) + runs = lambda circ: [("".join(d.ref_base for d in run), run[0].ref_pos) + for run, _s, _q in clones._deletion_runs(found, 10, circ)] + assert runs(False) == [("AC", 0), ("T", 3), ("A", 4), ("C", 5), ("AC", 8)] + assert runs(True) == [("T", 3), ("A", 4), ("C", 5), ("ACAC", 8)] diff --git a/tests/test_construct_ui.py b/tests/test_construct_ui.py new file mode 100644 index 0000000..9f3fd33 --- /dev/null +++ b/tests/test_construct_ui.py @@ -0,0 +1,1083 @@ +"""Construct screen widgets: summary bar and clone matrix (offscreen Qt). + +Reports come from ``verify_all`` on the real pair ``Achl_ACHLO006-09`` F+R +against its COI reference, with errors / trimming / double peaks introduced +through editing sessions (``write_read``), as in ``test_clone_verify``. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QAction, QColor, QImage, QMouseEvent, QWheelEvent +from PySide6.QtWidgets import QApplication + +from chrom_core.align import align_to_construct +from chrom_core.clones import ( + CloneDifference, CloneReport, Grouping, ReadRef, VerifyParams, verify_all, +) +from chrom_core.editing import EditableRead +from chrom_core.reference import load_construct +from clone_fixtures import COI_GB, DATA, perf_factor, real_read, with_secondary_peak, write_read + +F_NAME = "Achl_ACHLO006-09_1_F.ab1" +R_NAME = "Achl_ACHLO006-09_2_R.ab1" +F = DATA / "real" / F_NAME +R = DATA / "real" / R_NAME +REGION = (6, 647) +P = 250 # substitution site inside the region +OUTSIDE = 652 # covered by F only, outside the region +MIXED_AT = (200, 300, 400) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture(scope="module") +def construct(): + return load_construct(COI_GB) + + +def _f_index(construct, ref_pos: int) -> int: + al = align_to_construct(real_read(F_NAME).seq, construct) + assert al.strand == "+" + return al.ref_to_read[ref_pos] + + +@pytest.fixture(scope="module") +def reports(construct, tmp_path_factory): + """ok (c1), errors confirmed (c2, F only), errors disputed (c3), incomplete (c4), mixed (c5).""" + folder = tmp_path_factory.mktemp("clones") + i = _f_index(construct, P) + # a C peak 4x the called base, then the call edited to C: the trace agrees + # with the edit (no artificial double peak); same at OUTSIDE + chrom = real_read(F_NAME) + o = _f_index(construct, OUTSIDE) + edits = {i: "C" if chrom.seq[i] != "C" else "G", o: "C" if chrom.seq[o] != "C" else "G"} + for index, base in edits.items(): + chrom = with_secondary_peak(chrom, index, base, 4.0) + edited = EditableRead(chrom) + for index, base in edits.items(): + edited.set_base(index, base) + sub_dir = folder / "sub" + sub_dir.mkdir() + f_sub = write_read(edited.current(), sub_dir, F_NAME) + + trimmed = EditableRead(real_read(F_NAME)) + trimmed.apply_trim(100, 400) + trim_dir = folder / "trim" + trim_dir.mkdir() + f_trim = write_read(trimmed.current(), trim_dir, F_NAME) + + chrom = real_read(F_NAME) + for pos in MIXED_AT: + j = _f_index(construct, pos) + chrom = with_secondary_peak(chrom, j, next(b for b in "ACGT" if b != chrom.seq[j]), 0.5) + mix_dir = folder / "mix" + mix_dir.mkdir() + f_mix = write_read(chrom, mix_dir, F_NAME) + + grouping = Grouping([ + ReadRef(F, "c1", "F"), ReadRef(R, "c1", "R"), + ReadRef(f_sub, "c2", "F"), + ReadRef(f_sub, "c3", "F"), ReadRef(R, "c3", "R"), + ReadRef(f_trim, "c4", "F"), + ReadRef(f_mix, "c5", "F"), ReadRef(R, "c5", "R"), + ]) + out = verify_all(construct, REGION, grouping) + assert [r.verdict for r in out] == ["ok", "errors", "errors", "incomplete", "mixed"] + assert [d.status for d in out[1].region_differences] == ["confirmed"] + assert [d.status for d in out[2].region_differences] == ["disputed"] + assert [d.ref_pos for d in out[1].differences if not d.in_region] == [OUTSIDE] + return out + + +def _matrix(qapp, construct, reports, width=900, theme="light"): + from ui.construct.matrix import CloneMatrix + + m = CloneMatrix() + m.set_theme(theme) + m.set_data(construct, REGION, reports) + m.resize(width, m.sizeHint().height()) + return m + + +def _send_mouse(widget, kind, pos: QPoint, button, buttons, modifiers=Qt.KeyboardModifier.NoModifier): + local = QPointF(pos) + event = QMouseEvent(kind, local, widget.mapToGlobal(local), button, buttons, modifiers) + QApplication.sendEvent(widget, event) + + +# ------------------------------------------------------------------ summary bar + + +def test_summary_shows_construct_and_counts(qapp, construct, reports): + from i18n import tr + from ui.construct.summary_bar import SummaryBar + + bar = SummaryBar() + bar.set_data(construct, REGION, reports) + assert construct.name in bar.title_text() + info = bar.info_text() + assert "658" in info and "7–647" in info + assert tr("construct.linear") in info + counts = {v: bar.counter(v) for v in ("ok", "errors", "incomplete", "mixed")} + assert "1" in counts["ok"].text() and "2" in counts["errors"].text() + assert "1" in counts["incomplete"].text() and "1" in counts["mixed"].text() + assert bar.counter("no_data").isHidden() # zero counters are not shown + + +def test_summary_counter_toggles_filter(qapp, construct, reports): + from ui.construct.summary_bar import SummaryBar + + bar = SummaryBar() + bar.set_data(construct, REGION, reports) + seen: list[set] = [] + bar.filter_changed.connect(seen.append) + bar.counter("ok").click() + assert seen[-1] == {"errors", "incomplete", "mixed", "no_data"} + bar.counter("errors").click() + assert seen[-1] == {"incomplete", "mixed", "no_data"} + bar.counter("ok").click() + assert seen[-1] == {"ok", "incomplete", "mixed", "no_data"} + + +def test_summary_whole_construct_and_wrapping_region(qapp, construct, reports): + from dataclasses import replace + + from i18n import tr + from ui.construct.summary_bar import SummaryBar + + bar = SummaryBar() + bar.set_data(construct, None, reports) + assert tr("construct.region_all") in bar.info_text() + circ = replace(construct, circular=True) + bar.set_data(circ, (600, 50), reports) + assert "601–50" in bar.info_text() and tr("construct.circular") in bar.info_text() + + +# ------------------------------------------------------------------ matrix + + +def test_filter_hides_rows(qapp, construct, reports): + m = _matrix(qapp, construct, reports) + assert m.visible_clones() == ["c1", "c2", "c3", "c4", "c5"] + m.set_filter({"errors"}) + assert m.visible_clones() == ["c2", "c3"] + assert m.row_rect("c2").top() < m.row_rect("c3").top() + assert m.row_rect("c1").isNull() + m.set_filter({"ok", "mixed"}) + assert m.visible_clones() == ["c1", "c5"] + + +def test_hit_test_on_difference_mark(qapp, construct, reports): + m = _matrix(qapp, construct, reports) + point = m.mark_point("c2", P) + assert m.hit_test(point) == ("c2", P) + assert m.hit_test(m.mark_point("c3", P)) == ("c3", P) + # the same x on the clean clone, and far from any mark: nothing + assert m.hit_test(QPoint(point.x(), m.row_rect("c1").center().y())) is None + away = QPoint(round(m.x_for(500)), m.row_rect("c2").center().y()) + assert m.hit_test(away) is None + # mixed positions are marks too + assert m.hit_test(m.mark_point("c5", 300)) == ("c5", 300) + + +def test_click_on_mark_and_on_row(qapp, construct, reports): + m = _matrix(qapp, construct, reports) + diffs, clones = [], [] + m.difference_clicked.connect(lambda c, p: diffs.append((c, p))) + m.clone_clicked.connect(clones.append) + left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton + point = m.mark_point("c2", P) + _send_mouse(m, QEvent.Type.MouseButtonPress, point, left, left) + _send_mouse(m, QEvent.Type.MouseButtonRelease, point, left, none) + assert diffs == [("c2", P)] + row = QPoint(round(m.x_for(500)), m.row_rect("c4").center().y()) + _send_mouse(m, QEvent.Type.MouseButtonPress, row, left, left) + _send_mouse(m, QEvent.Type.MouseButtonRelease, row, left, none) + assert clones == ["c4"] + + +def test_shift_drag_on_reference_sets_region(qapp, construct, reports): + m = _matrix(qapp, construct, reports) + regions = [] + m.region_changed.connect(regions.append) + y = m.reference_rect().center().y() + a, b = QPoint(round(m.x_for(300)), y), QPoint(round(m.x_for(100)), y) + left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton + shift = Qt.KeyboardModifier.ShiftModifier + _send_mouse(m, QEvent.Type.MouseButtonPress, a, left, left, shift) + _send_mouse(m, QEvent.Type.MouseMove, QPoint((a.x() + b.x()) // 2, y), none, left, shift) + _send_mouse(m, QEvent.Type.MouseMove, b, none, left, shift) + _send_mouse(m, QEvent.Type.MouseButtonRelease, b, left, none, shift) + assert len(regions) == 1 + start, end = regions[0] + assert abs(start - 100) <= 2 and abs(end - 300) <= 2 + assert m.view_range() == (0.0, float(len(construct.seq))) # no pan happened + + +def test_wheel_zooms_and_drag_pans(qapp, construct, reports): + m = _matrix(qapp, construct, reports) + L = float(len(construct.seq)) + assert m.view_range() == (0.0, L) + centre = QPointF(m.x_for(329), m.row_rect("c1").center().y()) + wheel = QWheelEvent(centre, m.mapToGlobal(centre), QPoint(), QPoint(0, 240), + Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, False) + QApplication.sendEvent(m, wheel) + start, span = m.view_range() + assert span < L and start > 0 + assert abs(m.x_for(329) - centre.x()) < 1.5 # zoom keeps the point under the cursor + left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton + p = centre.toPoint() + _send_mouse(m, QEvent.Type.MouseButtonPress, p, left, left) + _send_mouse(m, QEvent.Type.MouseMove, p + QPoint(60, 0), none, left) + _send_mouse(m, QEvent.Type.MouseButtonRelease, p + QPoint(60, 0), left, none) + assert m.view_range()[0] < start and m.view_range()[1] == span + # zooming out again never shows more than the construct + out = QWheelEvent(centre, m.mapToGlobal(centre), QPoint(), QPoint(0, -2400), + Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, False) + QApplication.sendEvent(m, out) + assert m.view_range() == (0.0, L) + + +@pytest.mark.parametrize("theme", ["light", "dark"]) +def test_renders_in_both_themes(qapp, construct, reports, theme): + from ui.construct.matrix import mark_colors + from ui.status import status_color + + m = _matrix(qapp, construct, reports, theme=theme) + image = _render(m, QColor("#123456")) + assert image.pixelColor(2, 2) != QColor("#123456") # background painted + point = m.mark_point("c2", P) + assert image.pixelColor(point) == mark_colors()["confirmed"] + chip = m.chip_rect("c1") # sample the pill's padding, left of the text + assert image.pixelColor(chip.left() + 8, chip.center().y()) == status_color("ok", theme) + + +def _render(m, fill=QColor("#123456")) -> QImage: + image = QImage(m.size(), QImage.Format.Format_ARGB32) + image.fill(fill) + m.render(image) + return image + + +def test_disputed_mark_is_hollow(qapp, construct, reports): + from ui.construct.matrix import mark_colors + + m = _matrix(qapp, construct, reports) + image = _render(m) + red = mark_colors()["disputed"] + centre = m.mark_point("c3", P) + assert image.pixelColor(centre) != red # hollow inside + edges = [image.pixelColor(centre + QPoint(dx, 0)) for dx in range(-4, 5)] + assert edges.count(red) == 2 # left and right outline + assert sum(1 for dx in range(-4, 5) if image.pixelColor(centre + QPoint(dx, 0)) != red + and abs(dx) <= 1) == 3 # >= 5 px wide outline + assert image.pixelColor(m.mark_point("c2", P)) == mark_colors()["confirmed"] # solid + + +def test_out_of_region_difference_drawn_and_clickable(qapp, construct, reports): + from ui.theme import theme as palette + + m = _matrix(qapp, construct, reports) + image = _render(m) + point = m.mark_point("c2", OUTSIDE) + colour = image.pixelColor(point) + assert colour.red() > colour.green() + 40 and colour.red() > colour.blue() + 40 + assert m.hit_test(point) == ("c2", OUTSIDE) + clicked = [] + m.difference_clicked.connect(lambda c, p: clicked.append((c, p))) + left, none = Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton + _send_mouse(m, QEvent.Type.MouseButtonPress, point, left, left) + _send_mouse(m, QEvent.Type.MouseButtonRelease, point, left, none) + assert clicked == [("c2", OUTSIDE)] + # outside the region the track is hatched ("not checked"); a gap in the + # region's coverage stays the plain track colour ("no coverage") + track = QColor(palette("light")["track"]) + y = m.row_rect("c1").center().y() + hatched = [image.pixelColor(x, y + dy) for dx in range(10) + for x in [round(m.x_for(647)) + 2 + dx] for dy in range(-4, 5) + if x < round(m.x_for(len(construct.seq)))] + assert hatched and any(c != track for c in hatched) + y4 = m.row_rect("c4").center().y() + assert all(image.pixelColor(x, y4) == track + for x in range(round(m.x_for(450)), round(m.x_for(470)))) + + +def test_hit_test_while_zoomed(qapp, construct, reports): + m = _matrix(qapp, construct, reports) + anchor = QPointF(m.x_for(P + 0.5), m.row_rect("c2").center().y()) + zoom = QWheelEvent(anchor, m.mapToGlobal(anchor), QPoint(), QPoint(0, 120 * 12), + Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, False) + QApplication.sendEvent(m, zoom) + assert m.view_range()[1] < 100 + point = m.mark_point("c2", P) + assert m.hit_test(point) == ("c2", P) + neighbour = QPoint(round(m.x_for(P + 3.5)), point.y()) # three bases away + assert m.hit_test(neighbour) is None + assert m.hit_test(QPoint(point.x(), m.row_rect("c1").center().y())) is None + + +def test_ruler_labels_are_one_based(qapp, construct, reports): + m = _matrix(qapp, construct, reports) + ticks = m.ruler_ticks() + assert ticks and all(v % ticks[0][0] == 0 for v, _ in ticks) + for v, x in ticks: + assert int(m.pos_at(x)) + 1 == v # the tick sits on base number v (1-based) + + +def test_mark_at_last_position(qapp, construct): + from ui.construct.matrix import CloneMatrix, mark_colors + + last = len(construct.seq) - 1 + rep = _synthetic("end", "errors", len(construct.seq)) + rep = CloneReport(rep.clone, (), rep.coverage, ( + CloneDifference(last, "substitution", "A", "G", 2, 0, 0, "confirmed", True),), (), "errors") + m = CloneMatrix() + m.set_data(construct, None, [rep]) + m.resize(900, m.sizeHint().height()) + point = m.mark_point("end", last) + assert point.x() < m.chip_rect("end").left() + assert m.hit_test(point) == ("end", last) + assert _render(m).pixelColor(point) == mark_colors()["confirmed"] + + +# measured ~12 ms (best of 5) on the dev machine for 12 rows x 200 runs on 10 kb; +# the bound is about 2x that (it was ~440 ms before name widths were cached) +PAINT_BUDGET_S = 0.025 + + +@pytest.mark.perf +def test_paint_cost_10kb_12_rows(qapp): + import time + + from chrom_core.reference import Construct + from ui.construct.matrix import CloneMatrix + + length = 10_000 + big = Construct("big", "ACGT" * (length // 4), False, ()) + coverage = {p: ((1, 1) if (p // 50) % 2 else (1, 0)) for p in range(length)} # 200 runs + reps = [CloneReport(f"clone_{i}", (), coverage, (), (), "ok") for i in range(12)] + m = CloneMatrix() + m.set_data(big, None, reps) + m.resize(1200, m.sizeHint().height()) + image = QImage(m.size(), QImage.Format.Format_ARGB32) + m.render(image) # warm-up + best = min(_timed(m, image, time) for _ in range(3)) + assert best < PAINT_BUDGET_S * perf_factor(), f"paint took {best * 1000:.0f} ms" + + +def _timed(m, image, time) -> float: + start = time.perf_counter() + m.render(image) + return time.perf_counter() - start + + +def test_wrapping_region_draws_two_segments(): + from ui.construct.matrix import segments + + assert segments(10, 40, 100) == [(10, 40)] + assert segments(90, 5, 100) == [(90, 100), (0, 5)] + assert segments(0, 100, 100) == [(0, 100)] + + +def _synthetic(clone: str, verdict: str, length: int) -> CloneReport: + coverage = {p: ((1, 1) if p % 50 else (1, 0)) for p in range(length)} + diffs = (CloneDifference(100, "substitution", "A", "G", 2, 0, 0, "confirmed", True),) + return CloneReport(clone, (), coverage, diffs if verdict == "errors" else (), (), verdict) + + +def test_twelve_rows_fit_in_600_px(qapp, construct): + from ui.construct.matrix import CloneMatrix + + verdicts = ["ok", "errors", "incomplete", "mixed", "no_data", "ok"] * 2 + reps = [_synthetic(f"clone_{i + 1}", v, len(construct.seq)) for i, v in enumerate(verdicts)] + m = CloneMatrix() + m.set_data(construct, None, reps) + assert m.sizeHint().height() <= 600 + m.resize(900, 600) + assert m.row_rect("clone_12").bottom() < 600 + assert m.hit_test(m.mark_point("clone_2", 100)) == ("clone_2", 100) + + +# ------------------------------------------------------------------ detail pane + + +@pytest.fixture(scope="module") +def pane_report(construct, tmp_path_factory): + """ACHLO006-09: F (+) and R (-) with a substitution at P in F's session.""" + folder = tmp_path_factory.mktemp("pane") + i = _f_index(construct, P) + chrom = real_read(F_NAME) + base = "C" if chrom.seq[i] != "C" else "G" + chrom = with_secondary_peak(chrom, i, base, 4.0) + edited = EditableRead(chrom) + edited.set_base(i, base) + f_sub = write_read(edited.current(), folder, F_NAME) + out = verify_all(construct, REGION, Grouping([ReadRef(f_sub, "c1", "F"), ReadRef(R, "c1", "R")])) + report = out[0] + assert [r.alignment.strand for r in report.reads] == ["+", "-"] + assert [d.ref_pos for d in report.region_differences] == [P] + return report + + +def _pane(qapp, construct, report): + from ui.construct.detail_pane import DetailPane + + pane = DetailPane() + pane.resize(1000, 500) + pane.show_clone(report, construct) + return pane + + +def test_displayed_index_converts_once_for_both_strands(): + from chrom_core.align import ReadAlignment + from ui.construct.detail_pane import displayed_index + + def al(strand, mapping): + return ReadAlignment(strand=strand, score=0, ref_start=0, ref_end=10, read_start=0, read_end=10, + ref_to_read=mapping, differences=[], identity=1.0, alternatives=1, + oriented_read="", blocks=[], ref_length=100) + + plus, minus = al("+", {5: 3, 6: 4}), al("-", {5: 3, 6: 4}) + assert displayed_index(plus, 5, 10) == 3 + assert displayed_index(minus, 5, 10) == 10 - 1 - 3 + assert displayed_index(minus, 50, 10) is None # not covered + # a deleted position falls back to the nearest mapped neighbour + gap = al("+", {5: 3, 7: 4}) + assert displayed_index(gap, 6, 10) == 4 + + +def test_displayed_index_out_of_range_is_not_covered(): + from chrom_core.align import ReadAlignment + from ui.construct.detail_pane import displayed_index + + def al(strand): + return ReadAlignment(strand=strand, score=0, ref_start=0, ref_end=10, read_start=0, + read_end=10, ref_to_read={5: 8}, differences=[], identity=1.0, + alternatives=1, oriented_read="", blocks=[], ref_length=100) + + # the chromatogram was shortened by an edit after the alignment was stored + assert displayed_index(al("+"), 5, 6) is None # index 8 >= 6 + assert displayed_index(al("-"), 5, 6) is None # 6 - 1 - 8 < 0 + + +def test_pane_survives_alignment_past_a_shortened_read(qapp, construct, pane_report): + from dataclasses import replace + + first = pane_report.reads[0] + far = {p: i + 100_000 for p, i in first.alignment.ref_to_read.items()} + stale = replace(pane_report, reads=( + replace(first, alignment=replace(first.alignment, ref_to_read=far)), + *pane_report.reads[1:])) + pane = _pane(qapp, construct, stale) + pane.show_position(P) + assert not pane._notes[0].isHidden() and pane._notes[1].isHidden() + + +def test_pane_one_chromatogram_per_aligned_read(qapp, construct, pane_report): + pane = _pane(qapp, construct, pane_report) + assert len(pane.views()) == 2 + assert pane.table.rowCount() == len(pane_report.differences) + + +def test_pane_centres_every_read_on_the_position(qapp, construct, pane_report): + pane = _pane(qapp, construct, pane_report) + pane.show_position(P) + for result, view in zip(pane_report.reads, pane.views()): + n = len(view.chrom.seq) + idx = result.alignment.ref_to_read[P] + want = idx if result.alignment.strand == "+" else n - 1 - idx + assert view.highlights == [(want, want + 1)] + centre = view.offset + view.width() / view.zoom / 2 + assert abs(centre - view.chrom.peak_indices[want]) <= 1.0 + letters, mapping = view.reference + assert mapping[P] == want + assert len({v.zoom for v in pane.views()}) == 1 # shared scale + + +def test_pane_reference_strip_shows_neighbourhood(qapp, construct, pane_report): + pane = _pane(qapp, construct, pane_report) + pane.show_position(P) + text = pane.reference_text() + assert construct.seq[P - 10:P] in text and construct.seq[P + 1:P + 11] in text + assert f"[{construct.seq[P]}]" in text + + +def test_pane_table_row_selection_shows_position(qapp, construct, pane_report): + pane = _pane(qapp, construct, pane_report) + seen = [] + orig = pane.show_position + pane.show_position = lambda p: (seen.append(p), orig(p)) + pane.table.selectRow(0) + assert seen[-1] == P == pane_report.differences[0].ref_pos + assert pane.table.item(0, 0).text() == str(P + 1) + assert "→" in pane.table.item(0, 1).text() + + +def test_pane_double_click_opens_read(qapp, construct, pane_report): + pane = _pane(qapp, construct, pane_report) + opened = [] + pane.open_read.connect(opened.append) + view = pane.views()[1] + _send_mouse(view, QEvent.Type.MouseButtonDblClick, QPoint(20, 20), + Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton) + assert opened == [pane_report.reads[1].ref.path] + + +def test_pane_effect_labels_translated(qapp, construct, pane_report): + from dataclasses import replace + from i18n import tr + + d = pane_report.differences[0] + report = replace(pane_report, differences=(replace(d, effect="frameshift"), replace(d, effect="D45N"))) + pane = _pane(qapp, construct, report) + assert pane.table.item(0, 2).text() == tr("effect.frameshift") + assert pane.table.item(1, 2).text() == "D45N" + + +def test_pane_status_cells_keep_one_colour_per_meaning(qapp, construct, pane_report): + from dataclasses import replace + from ui import status + + d = pane_report.differences[0] + diffs = tuple(replace(d, status=s) for s in ("confirmed", "disputed", "low_quality")) + pane = _pane(qapp, construct, replace(pane_report, differences=diffs)) + bg = [pane.table.item(r, 4).background().color() for r in range(3)] + red = status.status_color("errors", "light") + assert bg[0] == red + assert bg[1] != red and bg[1].red() > bg[1].green() and bg[1].red() > bg[1].blue() # weaker red tint + assert bg[1] != status.status_color("incomplete", "light") + assert bg[2] == status.status_color("no_data", "light") + pane.set_theme("dark") + assert pane.table.item(0, 4).background().color() == status.status_color("errors", "dark") + + +def test_pane_dims_rows_outside_region(qapp, construct, pane_report): + from dataclasses import replace + from i18n import tr + + d = pane_report.differences[0] + pane = _pane(qapp, construct, replace(pane_report, differences=(d, replace(d, in_region=False)))) + inside, outside = pane.table.item(0, 0), pane.table.item(1, 0) + assert not inside.font().italic() and inside.toolTip() == "" + assert outside.font().italic() and outside.toolTip() == tr("matrix.legend.outside") + assert outside.foreground().color() != inside.foreground().color() + + +def test_pane_zoom_is_shared_and_kept(qapp, construct, pane_report): + pane = _pane(qapp, construct, pane_report) + views = pane.views() + assert len({v.zoom for v in views}) == 1 + views[0].zoom_in() # before any position is shown + assert views[1].zoom == views[0].zoom + pane.show_position(P) + chosen = views[0].zoom + assert views[1].zoom == chosen # show_position keeps the user's zoom + views[0].zoom_in() + assert views[1].zoom == views[0].zoom > chosen + for result, view in zip(pane_report.reads, views): + n = len(view.chrom.seq) + idx = result.alignment.ref_to_read[P] + want = idx if result.alignment.strand == "+" else n - 1 - idx + centre = view.offset + view.width() / view.zoom / 2 + assert abs(centre - view.chrom.peak_indices[want]) <= 1.0 + pane.resize(1100, 500) + assert views[0].zoom == views[1].zoom and views[0].zoom != chosen + + +def test_pane_failed_and_ambiguous_reads_are_shown(qapp, construct, pane_report): + from dataclasses import replace + from PySide6.QtWidgets import QLabel + from chrom_core.clones import ReadResult + from i18n import tr + + f, r = pane_report.reads + bad = ReadResult(ReadRef(Path("bad_read.ab1"), "c1", "F"), None, error="no_alignment") + report = replace(pane_report, reads=(f, bad, replace(r, ambiguous=True))) + pane = _pane(qapp, construct, report) + assert len(pane.views()) == 2 + texts = [lab.text() for lab in pane.findChildren(QLabel)] + assert any("bad_read.ab1" in s and tr("detail.err.no_alignment") in s for s in texts) + assert tr("detail.ambiguous") in pane.views()[1].toolTip() + assert tr("detail.ambiguous") not in pane.views()[0].toolTip() + gone = ReadResult(ReadRef(Path("missing.ab1"), "c1", "F"), r.alignment) + pane = _pane(qapp, construct, replace(pane_report, reads=(f, gone))) + assert len(pane.views()) == 1 + assert any("missing.ab1" in lab.text() for lab in pane.findChildren(QLabel)) + + +def test_pane_dims_read_that_does_not_cover_position(qapp, construct, pane_report): + pane = _pane(qapp, construct, pane_report) + covers = pane_report.reads[1].alignment + outside = next(p for p in range(len(construct.seq)) if not covers.covers(p) + and pane_report.reads[0].alignment.covers(p)) + pane.show_position(outside) + assert pane.views()[1].graphicsEffect() is not None + assert pane.views()[1].highlights == [] + assert pane.views()[0].graphicsEffect() is None + pane.show_position(P) + assert pane.views()[1].graphicsEffect() is None + + +def test_pane_renders_png(qapp, construct, pane_report): + target = os.environ.get("AB1KIT_DETAIL_PNG") + if not target: + pytest.skip("set AB1KIT_DETAIL_PNG to render the pane") + pane = _pane(qapp, construct, pane_report) + pane.show_position(P) + assert pane.grab().save(target) + + +def test_pane_survives_corrupt_ab1(qapp, construct, pane_report, tmp_path): + from dataclasses import replace + from PySide6.QtWidgets import QLabel + from chrom_core.abif import read_ab1 + from chrom_core.clones import ReadResult + + raw = bytearray((Path(__file__).resolve().parents[1] / "examples" / "310.ab1").read_bytes()) + directory = int.from_bytes(raw[26:30], "big") + raw[directory + 12] ^= 0xFF # deterministic: BioPython's ABIF parser asserts + corrupt = tmp_path / "corrupt.ab1" + corrupt.write_bytes(raw) + with pytest.raises(AssertionError): + read_ab1(corrupt) + + f, r = pane_report.reads + broken = ReadResult(ReadRef(corrupt, "c1", "F"), f.alignment) + pane = _pane(qapp, construct, replace(pane_report, reads=(broken, r))) + assert len(pane.views()) == 1 + assert any("corrupt.ab1" in lab.text() for lab in pane.findChildren(QLabel)) + pane.show_position(P) + assert pane.views()[0].highlights + + +# ------------------------------------------------------------------ workspace (Task 16) + +REAL_DIR = DATA / "real" + + +@pytest.fixture +def window(qapp, monkeypatch): + """Main window with config writes disabled and message boxes recorded.""" + from PySide6.QtWidgets import QMessageBox + + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + for target in ("ui.main_window.save_config", "ui.read_view.read_view.save_config", + "ui.construct.workspace.save_config"): + monkeypatch.setattr(target, lambda *a, **k: None) + messages: list[str] = [] + + def record(_parent, title, text, *a, **k): + messages.append(f"{title}: {text}") + return QMessageBox.StandardButton.Ok + + monkeypatch.setattr(QMessageBox, "warning", record) + monkeypatch.setattr(QMessageBox, "critical", record) + w = MainWindow(AppConfig(recent_files=[])) + w.messages = messages + yield w + w._cancel_tasks() + w.close() + _delete(qapp, w) + + +def _delete(qapp, widget): + """Really destroy a test window (closed windows otherwise live on and make + every later theme switch restyle them).""" + from PySide6.QtCore import QCoreApplication, QEvent + + widget.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value) + qapp.processEvents() + + +def _wait_idle(qapp, ws, timeout=30.0): + import time + + deadline = time.monotonic() + timeout + while ws.is_verifying() and time.monotonic() < deadline: + qapp.processEvents() + time.sleep(0.005) + qapp.processEvents() + assert not ws.is_verifying() + + +def _drop(widget, paths): + from PySide6.QtCore import QMimeData, QUrl + from PySide6.QtGui import QDropEvent + + mime = QMimeData() + mime.setUrls([QUrl.fromLocalFile(str(p)) for p in paths]) + event = QDropEvent(QPointF(10, 10), Qt.DropAction.CopyAction, mime, + Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier) + widget.dropEvent(event) + + +def _count_starts(ws): + calls = [] + original = ws._start_task + + def counting(*a, **k): + calls.append(k.get("key")) + return original(*a, **k) + + ws._start_task = counting + return calls + + +def _loaded(qapp, window, construct_path=COI_GB, reads=(REAL_DIR,)): + ws = window.workspace + _drop(ws, [construct_path, *reads]) + _wait_idle(qapp, ws) + return ws + + +def test_workspace_is_first_tab_and_shows_empty_state(window): + ws = window.workspace + assert window.tabs.indexOf(ws) == 0 + assert window.tabs.currentWidget() is ws + assert window.tabs.indexOf(window.read_view) == 1 + window.show() + assert ws.empty_state.isVisible() + assert not ws.matrix.isVisible() + texts = " ".join(z.text() for z in ws.empty_state.zones()) + assert "FASTA" in texts and ".ab1" in texts + + +def test_drop_construct_and_reads_folder(qapp, window, construct): + ws = _loaded(qapp, window) + window.show() + assert not ws.empty_state.isVisible() and ws.matrix.isVisible() + expected = verify_all(construct, None, ws.grouping()) + assert ws.matrix.visible_clones() == [r.clone for r in expected] + assert len(ws.matrix.visible_clones()) == 4 + assert [r.verdict for r in ws.reports()] == [r.verdict for r in expected] + # same-lineage clones against the unmodified reference: no errors + same = [r for r in ws.reports() if r.clone.startswith(("Achl_ACHLO006", "Achl_ACHLO007"))] + assert len(same) == 2 and all(r.verdict in ("ok", "incomplete") for r in same) + assert ws.summary.title_text() == construct.name + assert window.messages == [] + + +def test_matrix_click_shows_details_and_open_read_switches_tab(qapp, window, tmp_path, construct): + seq = construct.seq + mutant = "G" if seq[P] != "G" else "T" + fasta = tmp_path / "mut.fa" + fasta.write_text(f">mut\n{seq[:P]}{mutant}{seq[P + 1:]}\n") + f_copy = tmp_path / F_NAME + f_copy.write_bytes(F.read_bytes()) + ws = _loaded(qapp, window, fasta, [f_copy]) + ws.set_region((100, 600)) + _wait_idle(qapp, ws) + (report,) = ws.reports() + assert report.verdict == "errors" and [d.ref_pos for d in report.region_differences] == [P] + + ws.matrix.difference_clicked.emit(report.clone, P) + assert len(ws.detail.views()) == 1 + + ws.detail.open_read.emit(f_copy) + assert window.tabs.currentWidget() is window.read_view + assert window.read_model is not None + + # edit the read at the difference: the check re-runs and the verdict changes + window.apply_base_change(_f_index(construct, P), mutant) + assert ws.is_verifying() + _wait_idle(qapp, ws) + assert [r.verdict for r in ws.reports()] == ["ok"] + + window.undo_read() + _wait_idle(qapp, ws) + assert [r.verdict for r in ws.reports()] == ["errors"] + + +def test_read_edit_outside_project_does_not_reverify(qapp, window): + ws = _loaded(qapp, window) + starts = _count_starts(ws) + examples = Path(__file__).resolve().parents[1] / "examples" + window.read_view.read_edited.emit(examples / "310.ab1") + assert starts == [] + + +def test_project_roundtrip_restores_region_grouping_params(qapp, window, tmp_path): + ws = _loaded(qapp, window) + clone = ws.grouping().reads[0] + grouping = ws.grouping() + grouping.assign(clone.path, "renamed", clone.primer) + ws.set_grouping(grouping) + ws.set_region((100, 600)) + ws.controls.min_q.setValue(25) + _wait_idle(qapp, ws) + project = tmp_path / "check.ab1proj" + assert ws.save_project(project) + + ws.clear() + assert ws.reports() == [] and ws.region() is None + assert ws.load_project(project) + _wait_idle(qapp, ws) + assert ws.region() == (100, 600) + assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (101, 600) + assert ws.grouping().to_dict() == grouping.to_dict() + assert ws.params().min_q == 25 and ws.controls.min_q.value() == 25 + assert "renamed" in ws.matrix.visible_clones() + assert window.messages == [] + + +def test_broken_project_shows_message_and_empty_workspace(qapp, window, tmp_path): + ws = _loaded(qapp, window) + bad = tmp_path / "bad.ab1proj" + bad.write_text("{not json", encoding="utf-8") + assert ws.load_project(bad) is False + assert len(window.messages) == 1 and "bad.ab1proj" in window.messages[0] + window.show() + assert ws.empty_state.isVisible() + assert ws.reports() == [] and ws.grouping().reads == [] + + +def test_invalid_region_shows_message_and_does_not_run(qapp, window): + ws = _loaded(qapp, window) # COI .gb is linear + starts = _count_starts(ws) + assert ws.set_region((500, 100)) is False + assert starts == [] and ws.region() is None + assert len(window.messages) == 1 + assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (1, 658) + # the same through the spin boxes (1-based, inclusive): 500..658 is fine ... + ws.controls.start_spin.setValue(500) + assert starts == ["verify"] and ws.region() == (499, 658) + # ... 500..100 wraps the origin of a linear construct: refused, spins restored + ws.controls.end_spin.setValue(100) + assert starts == ["verify"] and ws.region() == (499, 658) and len(window.messages) == 2 + assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (500, 658) + _wait_idle(qapp, ws) + + +def test_region_spins_and_matrix_drag_stay_in_sync(qapp, window): + ws = _loaded(qapp, window) + starts = _count_starts(ws) + ws.matrix.region_changed.emit((50, 400)) + assert ws.region() == (50, 400) and starts == ["verify"] + assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (51, 400) + ws.controls.whole_button.click() + assert ws.region() is None and starts == ["verify", "verify"] + _wait_idle(qapp, ws) + + +def test_cancelled_reverification_keeps_previous_verdicts(qapp, window): + ws = _loaded(qapp, window) + before = ws.reports() + shown = ws.matrix.visible_clones() + info = ws.summary.info_text() + ws.set_region((100, 600)) + assert ws.is_verifying() + window._cancel_tasks() + _wait_idle(qapp, ws) + assert ws.reports() is before + assert ws.matrix.visible_clones() == shown and ws.summary.info_text() == info + + +def test_failed_reverification_keeps_previous_verdicts(qapp, window, monkeypatch): + ws = _loaded(qapp, window) + before = ws.reports() + + def boom(*a, **k): + raise RuntimeError("boom") + + monkeypatch.setattr("ui.construct.workspace.verify_all", boom) + ws.controls.min_q.setValue(30) + _wait_idle(qapp, ws) + assert ws.reports() is before + assert any("boom" in m for m in window.messages) + + +def test_params_and_circular_trigger_reverification(qapp, window): + ws = _loaded(qapp, window) + starts = _count_starts(ws) + ws.controls.het_ratio.setValue(0.5) + ws.controls.mixed_min.setValue(4) + ws.controls.circular.setChecked(True) + assert starts == ["verify"] * 3 + _wait_idle(qapp, ws) + assert ws.params().het_ratio == 0.5 and ws.params().mixed_min_positions == 4 + assert ws.construct().circular is True + + +def test_export_report_csv_and_xlsx(qapp, window, tmp_path): + ws = _loaded(qapp, window) + assert ws.export_report(tmp_path / "report.csv") + assert (tmp_path / "report.csv").read_text(encoding="utf-8").startswith("clone,verdict") + assert (tmp_path / "report_differences.csv").read_text(encoding="utf-8").startswith("clone,") + assert ws.export_report(tmp_path / "report.xlsx") + assert (tmp_path / "report.xlsx").stat().st_size > 0 + + +def test_grouping_dialog_pattern_preview_and_invalid_regex(qapp): + from PySide6.QtWidgets import QDialogButtonBox + + from chrom_core.clones import group_reads + from ui.construct.grouping_dialog import GroupingDialog + + grouping = group_reads(sorted(REAL_DIR.glob("*.ab1"))) + dialog = GroupingDialog(grouping, "", None) + ok = dialog.buttons.button(QDialogButtonBox.StandardButton.Ok) + assert dialog.table.rowCount() == 8 and ok.isEnabled() + + dialog.pattern_edit.setText("([") + assert not ok.isEnabled() and dialog.pattern_invalid() + dialog.pattern_edit.setText("(.*)") # no named groups + assert not ok.isEnabled() and dialog.pattern_invalid() + + pattern = r"Achl_ACHLO00(?P\d-09)_\d_(?P[FR])" + dialog.pattern_edit.setText(pattern) + assert ok.isEnabled() and not dialog.pattern_invalid() + result = dialog.result_grouping() + assert sorted(result.clones()) == ["6-09", "7-09"] + assert len(result.unassigned) == 4 + empty_rows = [r for r in range(8) if dialog.table.item(r, 1).text() == ""] + assert dialog.unassigned_rows() == empty_rows and len(empty_rows) == 4 + assert dialog.result_pattern() == pattern + + # manual edit: clear a clone cell -> that read becomes unassigned + first = next(r for r in range(8) if dialog.table.item(r, 1).text()) + dialog.table.item(first, 1).setText("") + assert first in dialog.unassigned_rows() + assert len(dialog.result_grouping().unassigned) == 5 + + +def test_new_ui_keys_exist_in_both_languages(): + import re as _re + + import i18n + + root = Path(__file__).resolve().parents[1] + files = [root / "ui" / "main_window.py", *sorted((root / "ui" / "construct").glob("*.py"))] + keys = set() + for path in files: + text = path.read_text(encoding="utf-8") + keys |= set(_re.findall(r"""\btr\(\s*["']([a-z_.]+)["']""", text)) + assert {"tab.construct", "menu.project.open", "workspace.drop_construct"} <= keys + assert not (keys - set(i18n.RU)), keys - set(i18n.RU) + assert not (keys - set(i18n.EN)), keys - set(i18n.EN) + + +# ------------------------------------------------------------------ Task 16 review fixes + + +def test_hidden_viewer_actions_do_nothing_on_construct_tab(qapp, window, tmp_path, construct): + f_copy = tmp_path / F_NAME + f_copy.write_bytes(F.read_bytes()) + ws = _loaded(qapp, window, COI_GB, [f_copy]) + ws.detail.open_read.emit(f_copy) + assert window.tabs.currentWidget() is window.read_view + window.apply_base_change(_f_index(construct, P), "N") + _wait_idle(qapp, ws) + edited = "".join(window.read_model.seq) + assert window.undo_action.isEnabled() + + window.tabs.setCurrentWidget(ws) + starts = _count_starts(ws) + assert not window.undo_action.isEnabled() + window.undo_action.trigger() + for action in window.read_view.findChildren(QAction): + if action.parent() is window.read_view: + assert not action.isEnabled(), action.text() + assert "".join(window.read_model.seq) == edited and starts == [] + + window.tabs.setCurrentWidget(window.read_view) + assert window.undo_action.isEnabled() and not window.redo_action.isEnabled() + + +def test_clear_resets_matrix_and_summary(qapp, window, tmp_path): + ws = _loaded(qapp, window) + assert ws.matrix.visible_clones() + ws.clear() + assert ws.matrix.visible_clones() == [] and ws.summary.title_text() == "" + assert not ws.detail.isVisible() + _drop(ws, [COI_GB, REAL_DIR]) + assert ws.is_verifying() + window._cancel_tasks() + _wait_idle(qapp, ws) + assert ws.matrix.visible_clones() == [] and ws.reports() == [] + assert ws.summary.title_text() == "" + + +def test_project_with_missing_construct_is_not_reported_open(qapp, window, tmp_path): + from chrom_core.project import Project, save_project + + project = tmp_path / "lost.ab1proj" + save_project(project, Project(tmp_path / "gone.gb", False, None, + Grouping([ReadRef(F, "c1", "F")]), VerifyParams())) + said = [] + window.workspace.status_message.connect(lambda text, _ms: said.append(text)) + assert window.workspace.load_project(project) is False + assert window.workspace.project_path() is None + assert len(window.messages) == 1 and "gone.gb" in window.messages[0] + assert not any("lost.ab1proj" in s for s in said) + + +def test_drop_without_changes_does_not_reverify(qapp, window, tmp_path): + ws = _loaded(qapp, window) + starts = _count_starts(ws) + _drop(ws, [REAL_DIR]) # nothing new + bad = tmp_path / "broken.gb" + bad.write_text("LOCUS nonsense\n", encoding="utf-8") + _drop(ws, [bad]) # construct fails to load + assert starts == [] + assert len(window.messages) == 1 + + +def test_wrapping_region_on_circular_construct_survives_project(qapp, window, tmp_path, construct): + fasta = tmp_path / "coi.fa" # FASTA constructs are circular + fasta.write_text(f">coi\n{construct.seq}\n") + ws = _loaded(qapp, window, fasta, [REAL_DIR]) + assert ws.construct().circular + ws.controls.start_spin.setValue(600) + ws.controls.end_spin.setValue(50) + assert ws.region() == (599, 50) + _wait_idle(qapp, ws) + project = tmp_path / "wrap.ab1proj" + assert ws.save_project(project) + ws.clear() + assert ws.load_project(project) + _wait_idle(qapp, ws) + assert ws.region() == (599, 50) and ws.construct().circular + assert (ws.controls.start_spin.value(), ws.controls.end_spin.value()) == (600, 50) + assert window.messages == [] + + +def test_grouping_dialog_flags_invalid_initial_pattern(qapp): + from PySide6.QtWidgets import QDialogButtonBox + + from chrom_core.clones import group_reads + from ui.construct.grouping_dialog import GroupingDialog + + grouping = group_reads(sorted(REAL_DIR.glob("*.ab1"))) + dialog = GroupingDialog(grouping, "(?P[", None) + assert dialog.pattern_invalid() + assert not dialog.buttons.button(QDialogButtonBox.StandardButton.Ok).isEnabled() + assert dialog.table.rowCount() == 8 and dialog.result_grouping().to_dict() == grouping.to_dict() + + +def test_double_spin_follows_ui_language(qapp): + from i18n import set_language + from ui.construct.controls import ControlsBar + + try: + set_language("en") + assert ControlsBar().het_ratio.text() == "0.40" + set_language("ru") + assert ControlsBar().het_ratio.text() == "0,40" + finally: + set_language("ru") diff --git a/tests/test_data_corpus.py b/tests/test_data_corpus.py new file mode 100644 index 0000000..3ee01cd --- /dev/null +++ b/tests/test_data_corpus.py @@ -0,0 +1,25 @@ +from clone_fixtures import ( + REAL_READS, COI_GB, PUC19_GB, SNAPGENE_DNA, real_read, coi_reference, reference_for, +) +from chrom_core.align import align_read + + +def _expected_strand(name: str) -> str: + return "+" if "_F" in name else "-" + + +def test_corpus_files_present(): + assert len(REAL_READS) == 8 + assert COI_GB.exists() and PUC19_GB.exists() and SNAPGENE_DNA.exists() + + +def test_every_real_read_aligns_to_coi_with_high_identity(): + for path in REAL_READS: + aln = align_read(real_read(path.name).seq, reference_for(path.name)) + assert aln.identity >= 0.97, path.name + assert aln.strand == _expected_strand(path.name), path.name + + +def test_achlo040_is_a_different_lineage_from_the_primary_reference(): + aln = align_read(real_read("Achl_ACHLO040-09_1_F.ab1").seq, coi_reference()) + assert aln.identity < 0.90 diff --git a/tests/test_editing.py b/tests/test_editing.py index 141f43e..5b6dee5 100644 --- a/tests/test_editing.py +++ b/tests/test_editing.py @@ -65,10 +65,14 @@ def test_reverse_complement(model: EditableRead) -> None: chrom = model.current() # flipped traces: A channel is original T channel reversed assert chrom.traces["A"] == tuple(reversed(model.original.traces["T"])) - # rc twice restores sequence and quality + # peaks follow the flipped traces (reversed over the full trace length) + last = len(model.original.traces["A"]) - 1 + assert chrom.peak_indices == tuple(last - p for p in reversed(model.original.peak_indices)) + # rc twice restores sequence, quality and peaks model.reverse_complement() assert "".join(model.seq) == seq assert model.quality == list(model.original.quality) + assert model.peaks == list(model.original.peak_indices) assert not model.reversed_complement diff --git a/tests/test_effects.py b/tests/test_effects.py new file mode 100644 index 0000000..cdc6463 --- /dev/null +++ b/tests/test_effects.py @@ -0,0 +1,139 @@ +"""Amino-acid effects of clone differences inside CDS features.""" + +from dataclasses import replace + +from Bio.Seq import Seq + +from chrom_core.clones import CloneDifference +from chrom_core.effects import difference_effect +from chrom_core.reference import Construct, Feature, load_construct +from chrom_core.seqops import reverse_complement +from tests.clone_fixtures import COI_GB + +COMP = {"A": "T", "C": "G", "G": "C", "T": "A"} + + +def aa(codon: str) -> str: + return str(Seq(codon).translate(table=11)) + + +def sub(pos: int, ref: str, alt: str) -> CloneDifference: + return CloneDifference(pos, "substitution", ref, alt, 2, 0, 0, "confirmed", True) + + +def coi(): + c = load_construct(COI_GB) + cds = next(f for f in c.features if f.kind == "CDS") + return c, cds + + +def codon_at(c, cds, n: int) -> tuple[int, str]: + """Start position and codon of amino acid ``n`` (1-based) on the plus strand.""" + start = cds.start + cds.codon_start - 1 + (n - 1) * 3 + return start, c.seq[start:start + 3].upper() + + +def alt_changing(codon: str, within: int, synonymous: bool, avoid_stop=True) -> str: + for b in "ACGT": + if b == codon[within]: + continue + new = codon[:within] + b + codon[within + 1:] + if (aa(new) == aa(codon)) == synonymous and not (avoid_stop and aa(new) == "*"): + return b + raise AssertionError("no such alternative") + + +def test_synonymous_third_codon_position(): + c, cds = coi() + start, codon = codon_at(c, cds, 10) + alt = alt_changing(codon, 2, synonymous=True) + assert aa(codon[:2] + alt) == aa(codon) + assert difference_effect(c, sub(start + 2, codon[2], alt)) == "synonymous" + + +def test_missense_label(): + c, cds = coi() + n = 45 + start, codon = codon_at(c, cds, n) + alt = alt_changing(codon, 0, synonymous=False) + d = sub(start, codon[0], alt) + aa_ref, aa_alt = aa(codon), aa(alt + codon[1:]) + assert difference_effect(c, d) == f"{aa_ref}{n}{aa_alt}" + + +def test_stop_gained(): + c, cds = coi() + for n in range(2, 200): + start, codon = codon_at(c, cds, n) + for within in range(3): + for b in "ACGT": + new = codon[:within] + b + codon[within + 1:] + if b != codon[within] and aa(new) == "*" and aa(codon) != "*": + got = difference_effect(c, sub(start + within, codon[within], b)) + assert got == f"{aa(codon)}{n}*" + return + raise AssertionError("no stop-gaining substitution found") + + +def test_frameshift_and_inframe(): + c, cds = coi() + pos = cds.start + 30 + ins1 = CloneDifference(pos, "insertion", "-", "A", 2, 0, 0, "confirmed", True) + ins3 = replace(ins1, read_base="ACG") + del1 = CloneDifference(pos, "deletion", c.seq[pos], "-", 2, 0, 0, "confirmed", True) + assert difference_effect(c, ins1) == "frameshift" + assert difference_effect(c, ins3) == "inframe_ins" + assert difference_effect(c, del1) == "frameshift" + del3 = replace(del1, ref_base=c.seq[pos:pos + 3]) + assert difference_effect(c, del3) == "inframe_del" + + +def test_minus_strand_cds(): + """Mirrored COI on the minus strand (controller ruling R11). + + pUC19's GenBank has no CDS (only 'source'), so ``bla`` cannot be used: the + COI construct is reverse-complemented and its CDS mirrored to strand -1. + """ + c, cds = coi() + L = len(c.seq) + rc = Construct( + "coi_rc", reverse_complement(c.seq.upper()), False, + (replace(cds, start=L - cds.end, end=L - cds.start, strand=-1),), + ) + for n in (1, 45, 100): + start, codon = codon_at(c, cds, n) + for within, synonymous in ((0, False), (2, True)): + alt = alt_changing(codon, within, synonymous) + plus = difference_effect(c, sub(start + within, codon[within], alt)) + mpos = L - 1 - (start + within) + assert rc.seq[mpos] == COMP[codon[within]] + minus = difference_effect(rc, sub(mpos, COMP[codon[within]], COMP[alt])) + assert minus == plus + + +def test_wrapping_cds(): + c, cds = coi() + # rotate so the CDS crosses the origin + shift = 100 + seq = c.seq.upper() + L = len(seq) + rot = Construct( + "rot", seq[shift:] + seq[:shift], True, + (replace(cds, start=(cds.start - shift) % L, end=(cds.start + 600 - shift) % L),), + ) + start, codon = codon_at(c, cds, 45) + alt = alt_changing(codon, 0, synonymous=False) + d = sub((start - shift) % L, codon[0], alt) + assert difference_effect(rot, d) == f"{aa(codon)}45{aa(alt + codon[1:])}" + + +def test_outside_cds_empty(): + c, cds = coi() + only = Construct("x", c.seq, False, (replace(cds, start=100, end=200),)) + assert difference_effect(only, sub(5, c.seq[5], "A" if c.seq[5] != "A" else "C")) == "" + + +def test_no_features_empty(): + c, _ = coi() + plain = Construct("x", c.seq, False) + assert difference_effect(plain, sub(10, "A", "C")) == "" diff --git a/tests/test_grouping.py b/tests/test_grouping.py new file mode 100644 index 0000000..ffbc480 --- /dev/null +++ b/tests/test_grouping.py @@ -0,0 +1,109 @@ +from pathlib import Path + +import pytest + +from chrom_core.clones import ( + DEFAULT_PRIMER_TOKENS, + Grouping, + ReadRef, + group_reads, + parse_name, +) +from chrom_core.config import AppConfig, load_config +from tests.clone_fixtures import REAL_READS + + +@pytest.mark.parametrize("stem,expected", [ + ("Achl_ACHLO006-09_1_F", ("Achl_ACHLO006-09", "F")), + ("Achl_ACHLO006-09_2_R", ("Achl_ACHLO006-09", "R")), + ("cl3_T7", ("cl3", "T7")), + ("cl3_T7_2", ("cl3", "T7")), # repeat sequencing + ("A05_K3-F", ("K3", "F")), + ("clone12.rev", ("clone12", "rev")), + ("клон 14_SP6", ("клон 14", "SP6")), + ("B2_F", ("B2", "F")), # lone well-shaped token is the clone + ("A1_F", ("A1", "F")), + ("T7", None), +]) +def test_parse_name(stem, expected): + assert parse_name(stem) == expected + + +def test_parse_name_fallback_to_last_token_and_custom_tokens(): + assert parse_name("cl3_xyz") == ("cl3", "xyz") + assert parse_name("cl3_XYZ", ["xyz"]) == ("cl3", "XYZ") + assert parse_name("single") is None + + +def test_parse_name_multi_part_primer_token(): + assert parse_name("cl5_pUC-F") == ("cl5", "pUC-F") + + +def test_default_tokens_contents(): + assert "T7TERM" in DEFAULT_PRIMER_TOKENS and len(DEFAULT_PRIMER_TOKENS) == 17 + + +def test_group_real_reads_into_four_clones(): + g = group_reads(REAL_READS) + assert len(g.clones()) == 4 and all(len(v) == 2 for v in g.clones().values()) + assert g.unassigned == [] + + +def test_repeat_reads_both_kept(): + g = group_reads([Path("d/cl3_T7.ab1"), Path("d/cl3_T7_2.ab1")]) + assert [r.path.name for r in g.clones()["cl3"]] == ["cl3_T7.ab1", "cl3_T7_2.ab1"] + + +def test_custom_pattern_and_unassigned(): + g = group_reads( + [Path("S1-fw.ab1"), Path("S12-rv.ab1"), Path("junk.ab1")], + pattern=r"(?PS\d+)-(?P\w+)", + ) + assert list(g.clones()) == ["S1", "S12"] + assert g.clones()["S12"][0].primer == "rv" + assert g.unassigned == [Path("junk.ab1")] + + +def test_bad_regex_raises(): + with pytest.raises(ValueError): + group_reads([Path("a.ab1")], pattern="(unclosed") + with pytest.raises(ValueError): + group_reads([Path("a.ab1")], pattern=r"(\w+)") # no named groups + + +def test_no_clone_goes_to_unassigned(): + g = group_reads([Path("T7.ab1")]) + assert g.reads == [] and g.unassigned == [Path("T7.ab1")] + + +def test_natural_sort(): + paths = [Path(f"cl{n}_T7.ab1") for n in (10, 2, 1)] + assert list(group_reads(paths).clones()) == ["cl1", "cl2", "cl10"] + + +def test_grouping_roundtrip_and_manual_assign(): + g = group_reads([Path("cl1_T7.ab1"), Path("junk.ab1")]) + g.assign(Path("junk.ab1"), "cl1", "SP6") + g.assign(Path("cl1_T7.ab1"), "cl2", "T7") # reassign moves, never duplicates + assert g.unassigned == [] + assert [(r.clone, r.primer) for r in g.reads] == [("cl2", "T7"), ("cl1", "SP6")] + d = g.to_dict() + assert all(isinstance(r["path"], str) for r in d["reads"]) + assert Grouping.from_dict(d) == g + assert ReadRef(Path("a"), "c", "p") in Grouping.from_dict( + {"reads": [{"path": "a", "clone": "c", "primer": "p"}], "unassigned": []} + ).reads + + +def test_config_new_fields_and_old_files(tmp_path): + cfg = AppConfig() + assert cfg.primer_tokens == list(DEFAULT_PRIMER_TOKENS) and cfg.group_pattern == "" + f = tmp_path / "c.json" + f.write_text('{"theme": "dark"}', encoding="utf-8") + assert load_config(f).primer_tokens == list(DEFAULT_PRIMER_TOKENS) + + +def test_natural_sort_with_non_decimal_digits(): + # "²" is a digit to str.isdigit() but not int()-convertible + g = Grouping([ReadRef(Path(f"{c}_F.ab1"), c, "F") for c in ("x1²", "x10", "x2")]) + assert list(g.clones()) == ["x1²", "x2", "x10"] diff --git a/tests/test_peaks.py b/tests/test_peaks.py new file mode 100644 index 0000000..a83b709 --- /dev/null +++ b/tests/test_peaks.py @@ -0,0 +1,136 @@ +import random +from dataclasses import replace + +from chrom_core.align import align_read +from chrom_core.peaks import ( + SecondaryPeak, + channel_baseline, + peak_window, + scan_secondary_peaks, + secondary_peak, +) +from chrom_core.snps import peak_heights +from clone_fixtures import REAL_READS, make_chrom, real_read, reference_for + + +def _random_seq(seed: int, n: int = 120) -> str: + rng = random.Random(seed) + return "".join(rng.choice("ACGT") for _ in range(n)) + + +def test_weak_channel_normalisation_removes_false_het() -> None: + # The G dye is systematically half as bright: every called G peak is 500 + # high, every other called peak 1000. At position 10 (called G) the A + # channel shows 225 — 45% of the raw G height, a false het by raw ratio. + # Normalised, the G peak itself is at its baseline (500/500 = 1.0) while + # A is 225/1000 = 0.225 of its own baseline -> ratio well below 0.40. + seq = list(_random_seq(1, 40)) + seq[10] = "G" + seq = "".join(seq) + chrom = make_chrom(seq, primary={"G": 500}, heights={(10, "A"): 225}) + raw = peak_heights(chrom, 10) + assert raw["A"] / raw["G"] >= 0.40 # the raw ratio would call a het + peak = secondary_peak(chrom, 10) + assert peak.primary_base == "G" + assert peak.base == "A" + assert peak.ratio < 0.40 + + +def test_true_het_detected_after_normalisation() -> None: + seq = list(_random_seq(2, 40)) + seq[10] = "A" + seq = "".join(seq) + chrom = make_chrom(seq, heights={(10, "C"): 500}) # second C peak at 50% + peak = secondary_peak(chrom, 10) + assert peak.base == "C" + assert 0.45 <= peak.ratio <= 0.55 + + +def test_peak_window_tracks_spacing() -> None: + chrom_spacing_10 = make_chrom("ACGTACGTAC", spacing=10) + chrom_spacing_3 = make_chrom("ACGTACGTAC", spacing=3) + assert peak_window(chrom_spacing_10, 5) == 5 + assert peak_window(chrom_spacing_3, 5) == 2 + + +def test_neighbour_shoulder_is_not_a_second_peak() -> None: + # Position 11 is a broad A peak (spacing 10): its rising flank reaches + # 600 at the edge of position 10's window. A plain window maximum would + # read that as a 60% second peak; it is a slope, not a peak. + seq = "CGTACGTACGCAGTCGATCGAT" + chrom = make_chrom(seq) + assert seq[10] == "C" and seq[11] == "A" + a = list(chrom.traces["A"]) + for k, v in zip(range(111, 120), (300, 350, 400, 500, 600, 700, 800, 900, 950)): + a[k] = v + a[105:111] = [100, 120, 150, 180, 220, 260] + chrom = replace(chrom, traces={**chrom.traces, "A": tuple(a)}) + assert peak_heights(chrom, 10)["A"] >= 600 # raw window max sees the shoulder + peak = secondary_peak(chrom, 10) + assert peak.ratio < 0.40 + + +def test_channel_baseline_uses_called_peaks_per_channel() -> None: + seq = "ACGT" * 10 + chrom = make_chrom(seq, primary={"G": 500}) + baseline = channel_baseline(chrom, 20) + assert baseline == {"A": 1000.0, "C": 1000.0, "G": 500.0, "T": 1000.0} + + +def test_channel_baseline_falls_back_when_channel_never_called() -> None: + # no T is called anywhere: T falls back to the median of all called peaks + chrom = make_chrom("ACG" * 15, primary={"G": 400}) + baseline = channel_baseline(chrom, 20) + assert baseline["G"] == 400.0 + assert baseline["T"] == 1000.0 # median of 1000/1000/400 heights + + +def test_secondary_peak_without_outside_channel_is_unmeasurable() -> None: + chrom = make_chrom("ACGTNACGTA") + peak = secondary_peak(chrom, 4) + assert peak == SecondaryPeak(primary_base=peak.primary_base, base="", ratio=None) + + +def test_scan_finds_het_where_reference_base_was_called() -> None: + ref = list(_random_seq(3)) + if ref[30] == "T": + ref[30] = "A" + ref = "".join(ref) + # the read equals the reference, but under position 30 a T peak at 50% + chrom = make_chrom(ref, heights={(30, "T"): 500}) + hits = scan_secondary_peaks(chrom, align_read(chrom.seq, ref), 0.40, 20) + assert [pos for pos, _ in hits] == [30] + assert hits[0][1].base == "T" + + +def test_scan_maps_reverse_strand_to_displayed_read() -> None: + from chrom_core.seqops import reverse_complement + + forward = list(_random_seq(4)) + if forward[30] == "T": + forward[30] = "A" + forward = "".join(forward) + chrom = make_chrom(forward, heights={(30, "T"): 500}) + ref = reverse_complement(forward) + aln = align_read(chrom.seq, ref) + assert aln.strand == "-" + hits = scan_secondary_peaks(chrom, aln, 0.40, 20) + assert [pos for pos, _ in hits] == [len(forward) - 1 - 30] + + +def test_scan_skips_low_quality() -> None: + ref = list(_random_seq(5)) + if ref[30] == "T": + ref[30] = "A" + ref = "".join(ref) + chrom = make_chrom(ref, quality=10, heights={(30, "T"): 500}) + assert scan_secondary_peaks(chrom, align_read(chrom.seq, ref), 0.40, 20) == [] + + +def test_real_reads_have_no_mass_false_hets() -> None: + assert REAL_READS + for path in REAL_READS: + chrom = real_read(path.name) + aln = align_read(chrom.seq, reference_for(path.name)) + hits = scan_secondary_peaks(chrom, aln, 0.40, 20) + assert len(hits) <= 2, (path.name, hits) diff --git a/tests/test_perf_marker.py b/tests/test_perf_marker.py new file mode 100644 index 0000000..9469150 --- /dev/null +++ b/tests/test_perf_marker.py @@ -0,0 +1,31 @@ +"""Timing tests are opt-in (``-m perf``) and their bounds scale with AB1KIT_PERF_FACTOR.""" + +import subprocess +import sys +from pathlib import Path + +from clone_fixtures import perf_factor + +ROOT = Path(__file__).resolve().parent.parent +PERF = {"tests/test_clone_verify.py::test_performance_12_clones_x_4_reads", + "tests/test_construct_ui.py::test_paint_cost_10kb_12_rows"} + + +def _collected(*extra: str) -> set[str]: + out = subprocess.run( + [sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider", + "tests/test_clone_verify.py", "tests/test_construct_ui.py", *extra], + cwd=ROOT, capture_output=True, text=True, check=False).stdout + return {line.strip().replace("\\", "/") for line in out.splitlines() if "::" in line} + + +def test_perf_tests_deselected_by_default_and_selected_with_marker(): + assert not PERF & _collected() + assert _collected("-m", "perf") == PERF + + +def test_perf_factor(monkeypatch): + monkeypatch.delenv("AB1KIT_PERF_FACTOR", raising=False) + assert perf_factor() == 1.0 + monkeypatch.setenv("AB1KIT_PERF_FACTOR", "2.5") + assert perf_factor() == 2.5 diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..26c46bd --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,120 @@ +import json +import shutil +from pathlib import Path + +import pytest + +from chrom_core.clones import Grouping, ReadRef, VerifyParams +from chrom_core.project import PROJECT_VERSION, Project, load_project, save_project + + +def _make(folder: Path, outside: Path | None = None) -> Project: + reads = [ + ReadRef(folder / "reads" / "c1_F.ab1", "c1", "F"), + ReadRef(folder / "reads" / "c1_R.ab1", "c1", "R"), + ] + unassigned = [folder / "x.ab1"] + if outside is not None: + reads.append(ReadRef(outside / "far.ab1", "c2", "F")) + return Project( + construct_path=folder / "plasmid.gb", + circular=True, + region=(900, 100), + grouping=Grouping(reads=reads, unassigned=unassigned), + params=VerifyParams(min_q=25, het_ratio=0.3, mixed_min_positions=4), + ) + + +def test_round_trip(tmp_path): + proj = _make(tmp_path, outside=tmp_path.parent / "elsewhere") + f = tmp_path / "s.ab1proj" + save_project(f, proj) + assert load_project(f) == proj + + +def test_whole_construct_region_none(tmp_path): + proj = _make(tmp_path) + proj.region = None + f = tmp_path / "s.ab1proj" + save_project(f, proj) + assert load_project(f).region is None + + +def test_paths_relative_and_posix_inside_folder(tmp_path): + outside = tmp_path.parent / "elsewhere" + f = tmp_path / "s.ab1proj" + save_project(f, _make(tmp_path, outside)) + data = json.loads(f.read_text(encoding="utf-8")) + assert data["app"] == "ab1kit" + assert data["version"] == PROJECT_VERSION + assert data["construct_path"] == "plasmid.gb" + paths = [r["path"] for r in data["grouping"]["reads"]] + assert paths[0] == "reads/c1_F.ab1" + assert Path(paths[2]).is_absolute() + + +def test_moved_folder_still_resolves(tmp_path): + src = tmp_path / "a" + src.mkdir() + save_project(src / "s.ab1proj", _make(src)) + dst = tmp_path / "b" / "moved" + dst.parent.mkdir() + shutil.move(str(src), str(dst)) + loaded = load_project(dst / "s.ab1proj") + assert loaded == _make(dst) + + +def test_cyrillic_folder_with_space(tmp_path): + folder = tmp_path / "Данные" / "клоны 1" + folder.mkdir(parents=True) + f = folder / "проект.ab1proj" + proj = _make(folder) + save_project(f, proj) + assert "Данные" not in f.read_text(encoding="utf-8") + assert load_project(f) == proj + moved = tmp_path / "Другие данные" / "клоны 1" + moved.parent.mkdir() + shutil.move(str(folder), str(moved)) + assert load_project(moved / "проект.ab1proj") == _make(moved) + + +def test_corrupt_json(tmp_path): + f = tmp_path / "s.ab1proj" + f.write_text("{not json", encoding="utf-8") + with pytest.raises(ValueError, match="JSON"): + load_project(f) + + +def test_foreign_file(tmp_path): + f = tmp_path / "s.ab1proj" + f.write_text(json.dumps({"app": "other", "version": 1}), encoding="utf-8") + with pytest.raises(ValueError, match="ab1kit"): + load_project(f) + + +def test_future_version(tmp_path): + f = tmp_path / "s.ab1proj" + f.write_text( + json.dumps({"app": "ab1kit", "version": PROJECT_VERSION + 1}), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="version"): + load_project(f) + + +def test_missing_fields(tmp_path): + f = tmp_path / "s.ab1proj" + f.write_text(json.dumps({"app": "ab1kit", "version": 1}), encoding="utf-8") + with pytest.raises(ValueError): + load_project(f) + + +@pytest.mark.parametrize("grouping", [None, [], ["x"], {"reads": [None]}]) +def test_malformed_grouping_is_a_value_error(tmp_path, grouping): + f = tmp_path / "s.ab1proj" + save_project(f, _make(tmp_path)) + data = json.loads(f.read_text(encoding="utf-8")) + data["grouping"] = grouping + f.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(ValueError, match="s.ab1proj"): + load_project(f) diff --git a/tests/test_reference.py b/tests/test_reference.py new file mode 100644 index 0000000..0f9317b --- /dev/null +++ b/tests/test_reference.py @@ -0,0 +1,90 @@ +import pytest +from Bio import SeqIO + +from clone_fixtures import COI_GB, PUC19_GB, SNAPGENE_DNA +from chrom_core.reference import Construct, Feature, construct_from_text, load_construct + + +def test_genbank_puc19(): + c = load_construct(PUC19_GB) + assert len(c.seq) == len(SeqIO.read(PUC19_GB, "genbank").seq) and c.circular + # The committed L09137 record annotates only "source" (no CDS); CDS is covered by COI below. + assert [f.kind for f in c.features] == ["source"] + + +def test_coi_has_cds(): + assert any(f.kind == "CDS" for f in load_construct(COI_GB).features) + + +def test_puc19_length_is_2686(): + assert len(load_construct(PUC19_GB).seq) == 2686 + + +def test_snapgene_sample(): + c = load_construct(SNAPGENE_DNA) + assert c.seq and c.features + + +def test_coi_cds_codon_start_and_name(): + c = load_construct(COI_GB) + cds = [f for f in c.features if f.kind == "CDS"] + assert cds and all(f.codon_start in (1, 2, 3) for f in cds) + rec = SeqIO.read(COI_GB, "genbank") + want = int(next(f for f in rec.features if f.type == "CDS").qualifiers.get("codon_start", ["1"])[0]) + assert cds[0].codon_start == want + assert all(f.name for f in c.features) + + +def test_fasta_multi_record_lowercase(tmp_path): + p = tmp_path / "x.fasta" + p.write_text(">a\nacgtn\n>b\nTTTT\n") + c = load_construct(p) + assert c.seq == "ACGTN" and c.circular and c.features == () + + +def test_unknown_extension_sniffed_fasta(tmp_path): + p = tmp_path / "x.seq" + p.write_text(">a\nACGT\n") + assert load_construct(p).seq == "ACGT" + + +def test_unknown_extension_sniffed_genbank(tmp_path): + p = tmp_path / "x.seq" + p.write_bytes(PUC19_GB.read_bytes()) + assert len(load_construct(p).seq) == 2686 + + +def test_garbage_rejected(tmp_path): + p = tmp_path / "x.seq" + p.write_text("hello world") + with pytest.raises(ValueError): + load_construct(p) + + +def test_invalid_chars_rejected(tmp_path): + p = tmp_path / "x.fa" + p.write_text(">a\nAC!GT\n") + with pytest.raises(ValueError): + load_construct(p) + + +def test_with_circular_toggle(): + assert not construct_from_text("ACGT").with_circular(False).circular + + +def test_construct_from_text_defaults(): + c = construct_from_text(">x\nacgt\n") + assert c == Construct("pasted", "ACGT", True, ()) + + +def test_origin_spanning_feature(tmp_path): + p = tmp_path / "o.gb" + p.write_text( + "LOCUS t 12 bp DNA circular SYN 01-JAN-2000\n" + "FEATURES Location/Qualifiers\n" + " misc_feature join(9..12,1..3)\n" + ' /label="wrap"\n' + "ORIGIN\n 1 acgtacgtac gt\n//\n" + ) + f = load_construct(p).features[0] + assert (f.name, f.start, f.end, f.strand, f.codon_start) == ("wrap", 8, 3, 1, 1) diff --git a/tests/test_smoke_gui.py b/tests/test_smoke_gui.py index 9ac345e..d517168 100644 --- a/tests/test_smoke_gui.py +++ b/tests/test_smoke_gui.py @@ -24,11 +24,41 @@ def no_config_writes(monkeypatch, tmp_path): """Keep tests away from the real config and from session sidecars next to the example files (closing a modified window autosaves one).""" monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None) + monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None) + monkeypatch.setattr("ui.construct.workspace.save_config", lambda *a, **k: None) from chrom_core import sessions monkeypatch.setattr(sessions, "session_path", lambda _p: tmp_path / "session.json") +@pytest.fixture(autouse=True) +def delete_windows(): + """Destroy the windows a test created: closed windows otherwise stay alive + and every later theme switch restyles all of them.""" + yield + from PySide6.QtCore import QCoreApplication, QEvent + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + return + for widget in app.topLevelWidgets(): + widget.close() + widget.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value) + app.processEvents() + + +def _wait_for(app, predicate, timeout=5.0): + import time + + deadline = time.monotonic() + timeout + while not predicate() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.005) + assert predicate() + + def test_entry_point_importable(): from chrom_cli import main from ui.main_window import MainWindow, run @@ -68,7 +98,7 @@ def test_open_broken_file_shows_error(qapp, monkeypatch): shown = {} monkeypatch.setattr( - "ui.main_window.QMessageBox.critical", + "ui.read_view.read_view.QMessageBox.critical", lambda *a, **k: shown.setdefault("called", True), ) window = MainWindow(AppConfig(recent_files=[])) @@ -121,7 +151,7 @@ def test_trim_dialog_wiring(qapp, monkeypatch): warned = {} monkeypatch.setattr( - "ui.main_window.QMessageBox.warning", + "ui.read_view.read_view.QMessageBox.warning", lambda *a, **k: warned.setdefault("called", True), ) sample = next(p for p in AB1 if "3730" in p.name) # high-quality read @@ -150,12 +180,13 @@ def test_batch_panel_flow(qapp, monkeypatch, tmp_path): from ui.main_window import MainWindow monkeypatch.setattr( - "ui.main_window.QFileDialog.getExistingDirectory", + "ui.read_view.read_view.QFileDialog.getExistingDirectory", lambda *a, **k: str(tmp_path / "export"), ) window = MainWindow(AppConfig(recent_files=[])) window.show() window._scan_folder(EXAMPLES) + _wait_for(qapp, lambda: window.batch_panel.table.rowCount() > 0) assert window.batch_dock.isVisible() assert window.batch_panel.table.rowCount() == len(AB1) @@ -205,7 +236,7 @@ def test_reference_alignment_flow(qapp, monkeypatch, tmp_path): out = tmp_path / "aligned.fasta" monkeypatch.setattr( - "ui.main_window.QFileDialog.getSaveFileName", + "ui.read_view.read_view.QFileDialog.getSaveFileName", lambda *a, **k: (str(out), "FASTA (*.fasta)"), ) window._export_aligned() @@ -256,7 +287,7 @@ def test_expected_snp_verification_flow(qapp, monkeypatch, tmp_path): out = tmp_path / "report.csv" monkeypatch.setattr( - "ui.main_window.QFileDialog.getSaveFileName", + "ui.read_view.read_view.QFileDialog.getSaveFileName", lambda *a, **k: (str(out), "CSV (*.csv)"), ) window._save_verify_csv() @@ -287,3 +318,365 @@ def test_session_autosave_and_restore(qapp, monkeypatch, tmp_path): assert reopened.read_model.modified reopened.close() assert sidecar.exists() + + +def test_verify_table_note_is_localised(qapp): + import i18n + from chrom_core.config import AppConfig + from i18n import tr + from ui.main_window import MainWindow + + sample = next(p for p in AB1 if "3730" in p.name) + i18n.set_language("ru") + try: + window = MainWindow(AppConfig(recent_files=[], language="ru")) + assert window.open_path(sample) + seq = "".join(window.read_model.seq) + ref = seq[100:400] + window._set_reference_seq(ref) + wrong = "A" if ref[50] != "A" else "C" + window._verify_variants(f"name;pos;ref;alt\nsnp;51;{wrong};T\n") + note_cell_text = window.verify_table.item(0, 7).text() + assert "table ref base" not in note_cell_text + assert note_cell_text == tr("verify.note.ref_differs") + window.close() + finally: + i18n.set_language("ru") + + +def test_reverse_read_reference_overlay_in_displayed_orientation(qapp): + from chrom_core.config import AppConfig + from clone_fixtures import DATA, reference_for + from ui.main_window import MainWindow + + name = "Achl_ACHLO006-09_2_R.ab1" + window = MainWindow(AppConfig(recent_files=[])) + assert window.open_path(DATA / "real" / name) + window._set_reference_seq(reference_for(name)) + assert window.alignment.strand == "-" + + letters, mapping = window.chrom_view.reference + seq = window.read_model.seq + assert mapping and all(0 <= idx < len(seq) for idx in mapping.values()) + matches = sum(letters[pos].upper() == seq[idx].upper() for pos, idx in mapping.items()) + assert matches / len(mapping) > 0.9 + + # the "№ базы" column shows displayed 1-based indices + for row in range(window.diff_table.rowCount()): + shown = window.diff_table.item(row, 4).text() + ref_pos = int(window.diff_table.item(row, 0).text()) - 1 + if shown and ref_pos in mapping: + assert int(shown) - 1 == mapping[ref_pos] + window.close() + + +@pytest.mark.skipif(not AB1, reason="no example .ab1 files") +def test_read_view_emits_read_edited(qapp): + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + window = MainWindow(AppConfig(recent_files=[])) + emitted = [] + window.read_view.read_edited.connect(emitted.append) + view = window.read_view + assert view.open_path(AB1[0]) + assert emitted == [] # opening is not an edit + assert view.current_model() is window.read_model + + expected = [Path(AB1[0])] + steps = [ + lambda: view.apply_base_change(0, "N"), + view.undo_read, + view.redo_read, + lambda: view.apply_trim_range(10, 100), + view.reverse_complement_read, + view._save_session, + ] + for count, step in enumerate(steps, start=1): + step() + assert emitted == expected * count + window.close() + assert emitted == expected * len(steps) # close-time autosave does not emit + + +@pytest.mark.skipif(not AB1, reason="no example .ab1 files") +def test_bare_read_view_works_without_shell_menus(qapp): + from chrom_core.config import AppConfig + from ui.read_view import ReadView + + view = ReadView(AppConfig(recent_files=[]), lambda *a, **k: None) + assert view.open_path(AB1[0]) + view.apply_base_change(0, "N") + assert view.undo_action.isEnabled() + view.close() + + +# ------------------------------------------------------------------ Task 13: theme +def test_toolbar_actions_have_icons(qapp): + from PySide6.QtWidgets import QToolBar + + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + window = MainWindow(AppConfig(recent_files=[])) + bar = window.read_view.findChild(QToolBar) + actions = [a for a in bar.actions() if not a.isSeparator() and a.text()] + assert actions + assert all(not a.icon().isNull() for a in actions), [a.text() for a in actions if a.icon().isNull()] + window.close() + + +def test_quality_colour_thresholds_and_palette(qapp): + from chrom_core.clone_report import STATUS_HEX + from ui.status import quality_color, status_color + + for theme in ("light", "dark"): + assert quality_color(15, theme) != quality_color(35, theme) + assert quality_color(19, theme) != quality_color(20, theme) + assert quality_color(20, theme) == quality_color(29, theme) + assert quality_color(29, theme) != quality_color(30, theme) + assert quality_color(30, theme) == quality_color(60, theme) + for verdict, (light, dark) in STATUS_HEX.items(): + assert status_color(verdict, "light").name() == light + assert status_color(verdict, "dark").name() == dark + + +def test_minimap_and_chrom_view_share_quality_buckets(qapp): + from ui.status import quality_bucket + + assert [quality_bucket(q) for q in (0, 19, 20, 29, 30, 99)] == [ + "low_q", "low_q", "mid_q", "mid_q", "high_q", "high_q"] + + +def test_status_chip_is_label(qapp): + from PySide6.QtWidgets import QLabel + + from ui.status import status_chip + + chip = status_chip("ok", "OK") + assert isinstance(chip, QLabel) and chip.text() == "OK" + assert "border-radius" in chip.styleSheet() + + +def test_stylesheet_applies_in_both_themes(qapp): + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + window = MainWindow(AppConfig(recent_files=[])) + seen = [] + for name in ("light", "dark"): + window.set_theme(name) + assert qapp.styleSheet().strip() + seen.append(qapp.styleSheet()) + assert seen[0] != seen[1] + window.close() + + +def test_tables_stretch_last_column_and_tooltip_notes(qapp): + from PySide6.QtWidgets import QHeaderView + + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + sample = next(p for p in AB1 if "3730" in p.name) + window = MainWindow(AppConfig(recent_files=[])) + assert window.open_path(sample) + seq = "".join(window.read_model.seq) + ref = seq[100:400] + window._set_reference_seq(ref) + wrong = "A" if ref[50] != "A" else "C" + window._verify_variants(f"name;pos;ref;alt\nsnp;51;{wrong};T\n") + for table in (window.verify_table, window.diff_table): + assert table.horizontalHeader().stretchLastSection() + assert table.horizontalHeader().sectionResizeMode(0) != QHeaderView.ResizeMode.Fixed + note = window.verify_table.item(0, 7) + assert len(note.text()) > 40 and note.toolTip() == note.text() + window.close() + + +def test_status_cells_repaint_on_theme_switch(qapp): + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + from ui.status import status_color + + sample = next(p for p in AB1 if "3730" in p.name) + window = MainWindow(AppConfig(recent_files=[], theme="light")) + assert window.open_path(sample) + seq = "".join(window.read_model.seq) + ref = seq[100:400] + window._set_reference_seq(ref) + wrong = "A" if ref[50] != "A" else "C" + window._verify_variants(f"name;pos;ref;alt\nsnp;51;{wrong};T\n") + cell = window.verify_table.item(0, 4) + assert cell.background().color() == status_color("errors", "light") + window.set_theme("dark") + assert cell.background().color() == status_color("errors", "dark") + window.close() + + +def test_icon_pixmap_changes_with_theme(qapp): + from PySide6.QtGui import QAction + + from ui import status + + action = QAction("x") + status.bind_icon(action, "undo-2") + try: + status.set_theme("light") + light = action.icon().pixmap(24, 24).toImage() + status.set_theme("dark") + dark = action.icon().pixmap(24, 24).toImage() + assert light != dark + finally: + status.set_theme("light") + + +def test_channel_checkboxes_use_channel_colours_and_tick(qapp): + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + from ui.theme import theme as palette + + window = MainWindow(AppConfig(recent_files=[])) + for name in ("light", "dark"): + window.set_theme(name) + for base, box in window.read_view._channel_boxes.items(): + sheet = box.styleSheet() + colour = palette(name)["bases"][base] + assert f"QCheckBox::indicator:checked {{ background-color: {colour}" in sheet + assert "image: url(" in sheet + assert "image: url(" in qapp.styleSheet() + window.close() + + +def test_unwritable_temp_falls_back_to_no_tick_image(qapp, monkeypatch, tmp_path): + import tempfile + + from chrom_core.config import AppConfig + from ui import theme + from ui.main_window import MainWindow + + def fail(*_a, **_k): + raise OSError("read-only temp") + + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path / "nowhere")) + monkeypatch.setattr(Path, "mkdir", fail) + assert theme.tick_image("#2f6fed") == "" + for dark in (False, True): + qss = theme.app_qss(dark) + assert "@" not in qss.replace("@media", "") and "url()" not in qss + window = MainWindow(AppConfig(recent_files=[])) + window.set_theme("dark") + assert all("url(" not in box.styleSheet() + for box in window.read_view._channel_boxes.values()) + window.close() + monkeypatch.undo() + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + monkeypatch.setattr(Path, "write_text", fail) + assert theme.tick_image("#2f6fed") == "" + + +def test_batch_table_stretches_file_column(qapp): + from PySide6.QtWidgets import QHeaderView + + from ui.read_view.batch_dock import BatchPanel + + panel = BatchPanel() + header = panel.table.horizontalHeader() + assert header.sectionResizeMode(0) == QHeaderView.ResizeMode.Stretch + assert not header.stretchLastSection() + + +def test_tooltips_only_on_long_cells(qapp): + from PySide6.QtWidgets import QTableWidgetItem + + from ui.tables import set_full_text_tooltip + + short, long_ = QTableWidgetItem("12"), QTableWidgetItem("x" * 60) + set_full_text_tooltip(short) + set_full_text_tooltip(long_) + assert short.toolTip() == "" and long_.toolTip() == "x" * 60 + + +# ------------------------------------------------------------------ Task 16: tabs, projects +def _coi_project(tmp_path, region): + from chrom_core.clones import Grouping, VerifyParams + from chrom_core.project import Project, save_project + + refs = Path(__file__).resolve().parent / "data" / "refs" + project = tmp_path / "p.ab1proj" + save_project(project, Project(refs / "coi_HM417896.gb", False, region, Grouping(), + VerifyParams())) + return project + + +def test_construct_tab_first_with_project_menu(qapp): + from chrom_core.config import AppConfig + from i18n import tr + from ui.main_window import MainWindow + + window = MainWindow(AppConfig(recent_files=[])) + assert window.tabs.count() == 2 + assert window.tabs.currentIndex() == 0 and window.tabs.currentWidget() is window.workspace + assert window.tabs.tabText(0) == tr("tab.construct") + assert window.tabs.tabText(1) == tr("tab.read") + titles = [a.text() for a in window.project_menu.actions() if not a.isSeparator()] + assert titles == [tr("menu.project.new"), tr("menu.project.open"), tr("menu.project.save"), + tr("menu.project.save_as"), tr("menu.project.export")] + window.close() + + +@pytest.mark.skipif(not AB1, reason="no example .ab1 files") +def test_open_ab1_switches_to_read_tab(qapp): + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + window = MainWindow(AppConfig(recent_files=[])) + assert window.open_path(AB1[0]) + assert window.tabs.currentWidget() is window.read_view + window.close() + + +def test_open_project_path_goes_to_construct_tab(qapp, tmp_path): + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + project = _coi_project(tmp_path, (10, 200)) + window = MainWindow(AppConfig(recent_files=[])) + window.tabs.setCurrentWidget(window.read_view) + assert window.open_path(project) + assert window.tabs.currentWidget() is window.workspace + assert window.workspace.region() == (10, 200) + assert window.workspace.construct().name == "HM417896.1" + window.close() + + +def test_run_opens_project_argument(qapp, monkeypatch, tmp_path): + from PySide6.QtWidgets import QApplication + + import ui.main_window as mw + + project = _coi_project(tmp_path, None) + opened = [] + class ExistingApp: # run() must not create a second QApplication or block + instance = staticmethod(QApplication.instance) + + def __init__(self, _args): + pass + + def setStyle(self, _name): # noqa: N802 + pass + + def exec(self): + return 0 + + monkeypatch.setattr(mw, "QApplication", ExistingApp) + from PySide6.QtWidgets import QMessageBox + + for box in ("critical", "warning"): + monkeypatch.setattr(QMessageBox, box, lambda *a, **k: QMessageBox.StandardButton.Ok) + monkeypatch.setattr(mw.MainWindow, "show", lambda self: opened.append(self)) + assert mw.run([str(project)]) == 0 + (window,) = opened + assert window.tabs.currentWidget() is window.workspace + assert window.workspace.construct() is not None + window.close() diff --git a/tests/test_snps.py b/tests/test_snps.py index 51b800a..519b296 100644 --- a/tests/test_snps.py +++ b/tests/test_snps.py @@ -4,44 +4,13 @@ import pytest from chrom_core import read_ab1 -from chrom_core.abif import Chromatogram from chrom_core.align import align_read -from chrom_core.seqops import IUPAC, reverse_complement +from chrom_core.seqops import reverse_complement from chrom_core.snps import alleles_to_iupac, call_snps, peak_heights +from clone_fixtures import make_chrom EXAMPLES = Path(__file__).resolve().parent.parent / "examples" -SPACING = 10 # trace points between called bases - - -def make_chrom(seq: str, quality: int = 40, heights: dict[tuple[int, str], float] | None = None) -> Chromatogram: - """Synthetic chromatogram: clean primary peaks, optional extra heights. - - ``heights`` maps (base index, channel) -> trace value at that peak, e.g. - {(10, "C"): 500} models a secondary C peak at read position 10. - """ - heights = heights or {} - n = len(seq) - total = (n + 1) * SPACING - traces = {base: [80] * total for base in "ACGT"} - peaks = [] - for i, called in enumerate(seq.upper()): - peak = (i + 1) * SPACING - peaks.append(peak) - # an IUPAC called base lights up all of its channels - active = set(IUPAC[called]) if called in IUPAC else set("ACGT") - for base in "ACGT": - traces[base][peak] = 1000 if base in active else 60 - for (index, base), value in heights.items(): - traces[base][(index + 1) * SPACING] = value - return Chromatogram( - sample_id="synthetic", - seq=seq, - quality=(quality,) * n, - traces={b: tuple(t) for b, t in traces.items()}, - peak_indices=tuple(peaks), - ) - def test_alleles_to_iupac() -> None: assert alleles_to_iupac({"A"}) == "A" diff --git a/tests/test_ui_tasks.py b/tests/test_ui_tasks.py new file mode 100644 index 0000000..4f21ce6 --- /dev/null +++ b/tests/test_ui_tasks.py @@ -0,0 +1,245 @@ +"""Background task runner tests (offscreen, no pytest-qt).""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import time +from concurrent.futures import CancelledError + +from pathlib import Path + +import pytest + +AB1 = sorted((Path(__file__).resolve().parent.parent / "examples").glob("*.ab1")) + + +@pytest.fixture(scope="module") +def qapp(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def parent(qapp): + from PySide6.QtCore import QObject + + return QObject() + + +def _wait(qapp, predicate, timeout=5.0): + deadline = time.monotonic() + timeout + while not predicate() and time.monotonic() < deadline: + qapp.processEvents() + time.sleep(0.005) + return predicate() + + +def _collect(task): + events = [] + task.finished.connect(lambda v: events.append(("finished", v))) + task.failed.connect(lambda m: events.append(("failed", m))) + task.cancelled.connect(lambda: events.append(("cancelled", None))) + task.progress.connect(lambda d, t: events.append(("progress", (d, t)))) + return events + + +def test_result_delivered_in_finished(qapp, parent): + from ui.tasks import run_task + + task = run_task(lambda progress, is_cancelled: (progress(1, 2), 42)[1], parent) + events = _collect(task) + assert _wait(qapp, lambda: any(e[0] == "finished" for e in events)) + assert ("finished", 42) in events + assert ("progress", (1, 2)) in events + + +def test_exception_goes_to_failed(qapp, parent): + from ui.tasks import run_task + + def boom(progress, is_cancelled): + raise ValueError("bad thing") + + task = run_task(boom, parent) + events = _collect(task) + assert _wait(qapp, lambda: bool(events)) + assert events == [("failed", "bad thing")] + + +def test_cancel_emits_cancelled_and_drops_result(qapp, parent): + from ui.tasks import run_task + + def loop(progress, is_cancelled): + for _ in range(500): + if is_cancelled(): + raise CancelledError() + time.sleep(0.01) + return "late" + + task = run_task(loop, parent) + events = _collect(task) + task.cancel() + assert task.is_cancelled() + assert events == [("cancelled", None)] # emitted immediately + assert _wait(qapp, lambda: bool(events)) + assert events == [("cancelled", None)] + + +def test_cancel_drops_late_result_of_uninterruptible_fn(qapp, parent): + from ui.tasks import run_task + + task = run_task(lambda p, c: (time.sleep(0.2), "late")[1], parent) + events = _collect(task) + task.cancel() + assert _wait(qapp, lambda: bool(events)) + assert events == [("cancelled", None)] + + +def test_gui_thread_not_blocked(qapp, parent): + from PySide6.QtCore import QTimer + + from ui.tasks import run_task + + ticks = [] + timer = QTimer() + timer.timeout.connect(lambda: ticks.append(1)) + timer.start(20) + task = run_task(lambda p, c: (time.sleep(0.3), "ok")[1], parent) + events = _collect(task) + assert _wait(qapp, lambda: bool(events)) + timer.stop() + assert events == [("finished", "ok")] + assert len(ticks) >= 5 + + +def test_signals_delivered_in_gui_thread(qapp, parent): + from PySide6.QtCore import QThread + + from ui.tasks import run_task + + threads = [] + task = run_task(lambda p, c: 1, parent) + task.finished.connect(lambda v: threads.append(QThread.currentThread())) + assert _wait(qapp, lambda: bool(threads)) + assert threads[0] is qapp.thread() + + +@pytest.mark.skipif(not AB1, reason="no example .ab1 files") +def test_main_window_blast_uses_task_and_cancel_drops_result(qapp, monkeypatch, tmp_path): + from chrom_core import blast + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None) + monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None) + opened = [] + monkeypatch.setattr( + "ui.read_view.read_view.QDesktopServices.openUrl", lambda url: opened.append(url) + ) + monkeypatch.setattr(blast, "submit", lambda params, timeout=20: (time.sleep(0.2), "RID1")[1]) + window = MainWindow(AppConfig(recent_files=[])) + window.show() + assert window.open_path(AB1[0]) + window._blast_submit() + assert window.task_cancel_button.isVisible() + assert window.task_progress.isVisible() + assert _wait(qapp, lambda: bool(opened)) + assert _wait(qapp, lambda: not window.task_progress.isVisible()) + + opened.clear() + window._blast_submit() + window.task_cancel_button.click() + assert _wait(qapp, lambda: not window.task_progress.isVisible()) + time.sleep(0.3) + qapp.processEvents() + assert opened == [] + window.close() + + +def test_base_exception_still_terminal(qapp, parent): + from ui.tasks import run_task + + def bail(progress, is_cancelled): + raise KeyboardInterrupt() + + task = run_task(bail, parent) + events = _collect(task) + assert _wait(qapp, lambda: bool(events)) + assert events == [("failed", "KeyboardInterrupt")] + + +def test_parent_deleted_mid_run_is_clean(qapp, capfd): + import shiboken6 + from PySide6.QtCore import QObject + + from ui import tasks + from ui.tasks import run_task + + def slow(progress, is_cancelled): + time.sleep(0.2) + progress(1, 2) + raise RuntimeError("after delete") + + parent = QObject() + run_task(slow, parent) + shiboken6.delete(parent) + assert _wait(qapp, lambda: not tasks._running) + time.sleep(0.05) + assert "Traceback" not in capfd.readouterr().err + assert not tasks._running + + +@pytest.mark.skipif(not AB1, reason="no example .ab1 files") +def test_scan_last_start_wins(qapp, monkeypatch, tmp_path): + from chrom_core import batch + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None) + monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None) + real = batch.scan_folder + + def fake(folder, *a, **k): + if folder.name == "slow": + time.sleep(0.4) + return real(folder.parent, *a, **k)[:1] + return real(folder.parent, *a, **k)[:2] + + monkeypatch.setattr(batch, "scan_folder", fake) + window = MainWindow(AppConfig(recent_files=[])) + window.show() + ex = AB1[0].parent + window._scan_folder(ex / "slow") + window._scan_folder(ex / "fast") + assert _wait(qapp, lambda: window.batch_panel.table.rowCount() == min(2, len(AB1))) + time.sleep(0.6) + qapp.processEvents() + assert window.batch_panel.table.rowCount() == min(2, len(AB1)) + assert not window.task_progress.isVisible() + window.close() + + +@pytest.mark.skipif(not AB1, reason="no example .ab1 files") +def test_second_blast_cancels_first(qapp, monkeypatch): + from chrom_core import blast + from chrom_core.config import AppConfig + from ui.main_window import MainWindow + + monkeypatch.setattr("ui.main_window.save_config", lambda *a, **k: None) + monkeypatch.setattr("ui.read_view.read_view.save_config", lambda *a, **k: None) + opened = [] + monkeypatch.setattr( + "ui.read_view.read_view.QDesktopServices.openUrl", lambda url: opened.append(url.toString()) + ) + rids = iter(["FIRST", "SECOND"]) + monkeypatch.setattr(blast, "submit", lambda params, timeout=20: (time.sleep(0.2), next(rids))[1]) + window = MainWindow(AppConfig(recent_files=[])) + assert window.open_path(AB1[0]) + window._blast_submit() + window._blast_submit() + assert _wait(qapp, lambda: bool(opened)) + time.sleep(0.4) + qapp.processEvents() + assert len(opened) == 1 + window.close() diff --git a/tests/test_verify.py b/tests/test_verify.py index bdb9ef4..1a2aba6 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -11,35 +11,9 @@ verify_expected, verdicts_csv, ) +from clone_fixtures import make_chrom EXAMPLES = Path(__file__).resolve().parent.parent / "examples" -SPACING = 10 - - -def make_chrom(seq: str, quality: int = 40, heights: dict | None = None) -> object: - from chrom_core.abif import Chromatogram - from chrom_core.seqops import IUPAC - - heights = heights or {} - n = len(seq) - total = (n + 1) * SPACING - traces = {base: [80] * total for base in "ACGT"} - peaks = [] - for i, called in enumerate(seq.upper()): - peak = (i + 1) * SPACING - peaks.append(peak) - active = set(IUPAC[called]) if called in IUPAC else set("ACGT") - for base in "ACGT": - traces[base][peak] = 1000 if base in active else 60 - for (index, base), value in heights.items(): - traces[base][(index + 1) * SPACING] = value - return Chromatogram( - sample_id="synthetic", - seq=seq, - quality=(quality,) * n, - traces={b: tuple(t) for b, t in traces.items()}, - peak_indices=tuple(peaks), - ) # ------------------------------------------------------------------- parsing @@ -196,6 +170,64 @@ def test_ref_mismatch(setup) -> None: assert verdicts[0].observed == reference[50] +def test_ref_mismatch_carries_note_code(setup) -> None: + reference = setup + wrong_ref = "A" if reference[50] != "A" else "C" + verdicts, _ = _verify(reference, reference, [ExpectedVariant(51, wrong_ref, "T")]) + assert verdicts[0].note_code == "ref_differs" + assert verdicts[0].note # English text stays for CSV/XLSX + + +def test_other_note_codes(setup) -> None: + reference = setup + verdicts, _ = _verify(reference, reference, [ExpectedVariant(51, "AT", "GC")]) + assert verdicts[0].note_code == "only_snv" + verdicts, _ = _verify(reference, reference, [ExpectedVariant(5000, ".", "A")]) + assert verdicts[0].note_code == "pos_outside_ref" + read = reference[:40] + reference[60:] + verdicts, _ = _verify(reference, read, [ExpectedVariant(51, reference[50], "A")]) + assert verdicts[0].note_code == "deleted_in_read" + alt = "T" if reference[50] != "T" else "A" + chrom = make_chrom(confirmed_case(reference, 50, alt), quality=10) + verdicts, _ = _verify(reference, confirmed_case(reference, 50, alt), + [ExpectedVariant(51, reference[50], alt)], chrom=chrom) + assert verdicts[0].note_code == "low_q" + assert verdicts[0].note_args == {"q": 10, "min_q": 20} + + +_COMP = {"A": "T", "C": "G", "G": "C", "T": "A"} + + +@pytest.mark.parametrize("name", ["Achl_ACHLO006-09_1_F.ab1", "Achl_ACHLO006-09_2_R.ab1"]) +def test_real_read_confirmed_on_both_strands(name) -> None: + from clone_fixtures import real_read, reference_for + + chrom = real_read(name) + reference = reference_for(name) + alignment = align_read("".join(chrom.seq), reference) + assert alignment.strand == ("-" if name.endswith("_R.ab1") else "+") + n = len(chrom.seq) + checked = 0 + for pos, oriented_idx in sorted(alignment.ref_to_read.items()): + if pos < 100: + continue + idx = oriented_idx if alignment.strand == "+" else n - 1 - oriented_idx + if chrom.quality[idx] < 30 or chrom.seq[idx].upper() not in "ACGT": + continue + base = chrom.seq[idx].upper() + ref_strand = base if alignment.strand == "+" else _COMP[base] + verdict = verify_expected( + alignment, chrom, reference, [ExpectedVariant(pos + 1, ".", ref_strand)] + )[0] + assert verdict.verdict == "confirmed", (pos, verdict) + assert verdict.observed == ref_strand + assert verdict.read_pos == idx + checked += 1 + if checked >= 40: + break + assert checked == 40 + + def test_low_quality(setup) -> None: reference = setup read = confirmed_case(reference, 50, "T" if reference[50] != "T" else "A") diff --git a/translations_en.py b/translations_en.py index 3efc92a..b16b4fc 100644 --- a/translations_en.py +++ b/translations_en.py @@ -72,6 +72,12 @@ "verify.ref_mismatch": "ref mismatch", "verify.low_quality": "low quality", "verify.unsupported": "unsupported", + "verify.note.only_snv": "only SNV rows are supported", + "verify.note.pos_outside_ref": "position outside the reference", + "verify.note.ref_differs": "table ref base differs from the reference", + "verify.note.deleted_in_read": "deleted in the read", + "verify.note.outside_coverage": "outside read coverage", + "verify.note.low_q": "Q{q} < {min_q}", "verify.summary": "{n} positions: {confirmed} confirmed, {het} heterozygous, {notfound} not confirmed, {nodata} no data", "verify.need_reference": "Load a reference and align the read first", "verify.save_csv": "Save CSV…", @@ -81,6 +87,8 @@ "tb.zoom_out": "Zoom out", "tb.fit": "Fit read", "tb.reset": "Reset view", + "tb.quality_legend": "Quality Q:", + "tb.quality_legend.tip": "Quality bar colours: red Q<20, yellow 20–29, green ≥30", "goto.title": "Go to base", "goto.label": "Base number", "search.title": "Find subsequence", @@ -141,8 +149,105 @@ "batch.col.q20": "Q≥20", "batch.col.trim": "After trim", "batch.col.status": "Status", + "task.cancel": "Cancel", + "task.running": "Working…", + "task.cancelled": "Operation cancelled", "batch.scan_done": "Scanned files: {n}", "batch.exported": "Exported: {n}, errors: {err}", "batch.export_dir": "Export to folder", "err.open_failed": "Failed to open file", + "construct.bp": "{n} bp", + "construct.circular": "circular", + "construct.linear": "linear", + "construct.region": "region {start}–{end}", + "construct.region_all": "region: whole construct", + "construct.count.ok": "OK: {n}", + "construct.count.errors": "Errors: {n}", + "construct.count.incomplete": "Incomplete: {n}", + "construct.count.mixed": "Mixed?: {n}", + "construct.count.no_data": "No data: {n}", + "construct.filter_tip": "Show or hide clones with this verdict", + "matrix.reference": "reference", + "matrix.chip.ok": "OK", + "matrix.chip.errors": "{n} diff.", + "matrix.chip.incomplete": "{pct}%", + "matrix.chip.mixed": "mixed?", + "matrix.chip.no_data": "no data", + "matrix.legend.cov_both": "both strands", + "matrix.legend.cov_one": "one strand", + "matrix.legend.cov_none": "none (Q<20)", + "matrix.legend.confirmed": "confirmed", + "matrix.legend.disputed": "disputed", + "matrix.legend.low_quality": "low Q", + "matrix.legend.mixed": "double peak", + "matrix.legend.unchecked": "not checked", + "matrix.legend.outside": "outside region", + "matrix.empty": "No clones to show", + "matrix.hint": "Wheel: zoom, drag: pan, Shift+drag on the reference: set the region", + "detail.col.pos": "Position", + "detail.col.change": "Ref→read", + "detail.col.effect": "Effect", + "detail.col.support": "For / against", + "detail.col.status": "Status", + "effect.synonymous": "synonymous", + "effect.frameshift": "frameshift", + "effect.inframe_ins": "in-frame insertion", + "effect.inframe_del": "in-frame deletion", + "detail.err.no_alignment": "no alignment", + "detail.ambiguous": "ambiguous — not counted in the verdict", + "detail.uncovered": "position not covered", + "tab.construct": "Construct check", + "tab.read": "Read viewer", + "menu.project": "&Project", + "menu.project.new": "&New", + "menu.project.open": "&Open project…", + "menu.project.save": "&Save project", + "menu.project.save_as": "Save project &as…", + "menu.project.export": "&Export report…", + "grouping.title": "Group reads into clones", + "grouping.pattern": "Name pattern:", + "grouping.pattern_placeholder": "empty — automatic, by file name", + "grouping.pattern_tip": "Regular expression on the file name without extension, with groups (?P…) and (?P…)", + "grouping.col.file": "File", + "grouping.col.clone": "Clone", + "grouping.col.primer": "Primer", + "grouping.no_clone": "No clone — the read is not checked", + "grouping.summary": "Clones: {clones} · reads: {reads} · without clone: {unassigned}", + "workspace.empty_title": "Clone verification", + "workspace.empty_hint": "Give a construct and reads — every clone gets its verdict right away", + "workspace.drop_construct": "Drop the construct file (FASTA, GenBank, SnapGene)", + "workspace.drop_reads": "…and a folder or files of .ab1 reads", + "workspace.click_to_choose": "or click to choose", + "workspace.choose_files": "Choose individual files…", + "workspace.reads_loaded": "reads: {n}", + "workspace.region_from": "Region:", + "workspace.region_to": "–", + "workspace.whole": "Whole construct", + "workspace.whole_tip": "Check the whole construct sequence", + "workspace.circular": "Circular", + "workspace.circular_tip": "Circular construct: reads and the region may cross the origin", + "workspace.min_q": "Min Q", + "workspace.min_q_tip": "Bases below this quality neither confirm nor dispute a difference", + "workspace.het_ratio": "Double peak", + "workspace.het_ratio_tip": "Second-to-main peak ratio from which a position counts as mixed", + "workspace.mixed_min": "Mixed from", + "workspace.mixed_min_tip": "How many mixed positions make the clone's verdict “mixed?”", + "workspace.grouping": "Grouping…", + "workspace.add_reads": "Add reads…", + "workspace.construct_button": "Construct…", + "workspace.construct_failed": "Cannot open the construct", + "workspace.construct_too_long": "The construct is too long: {n} bp (limit {limit})", + "workspace.no_new_reads": "No new .ab1 files found", + "workspace.region_invalid": "Invalid region", + "workspace.region_invalid_text": "Region {start}–{end} cannot be checked: {reason}", + "workspace.verify_failed": "Clone verification failed", + "workspace.verified": "Clones checked: {n}", + "workspace.project_failed": "Cannot open the project", + "workspace.project_loaded": "Project opened: {name}", + "workspace.project_save_failed": "Cannot save the project", + "workspace.need_construct": "Choose a construct file first", + "workspace.project_saved": "Project saved: {name}", + "workspace.export_failed": "Cannot save the report", + "workspace.no_results": "No verification results yet", + "workspace.exported": "Report saved: {name}", } diff --git a/ui/chrom_view.py b/ui/chrom_view.py index 0478ac5..3ed01f0 100644 --- a/ui/chrom_view.py +++ b/ui/chrom_view.py @@ -10,6 +10,7 @@ from chrom_core import Chromatogram from i18n import tr +from ui.status import quality_bucket from ui.theme import LIGHT MIN_ZOOM = 0.02 @@ -525,7 +526,7 @@ def _paint_bases(self, painter: QPainter, w: int, h: int) -> None: prev_peak = peak continue prev_peak = peak - bucket = "low_q" if q < 10 else "mid_q" if q < 20 else "high_q" + bucket = quality_bucket(q) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor(self.theme[bucket])) bar_h = int((Q_STRIP_H - 6) * q / max_q) diff --git a/ui/construct/__init__.py b/ui/construct/__init__.py new file mode 100644 index 0000000..7d37761 --- /dev/null +++ b/ui/construct/__init__.py @@ -0,0 +1,4 @@ +"""Construct verification screen: summary bar, clone matrix (and later details, grouping).""" + +# verdict order everywhere on this screen (counters, legend, filters) +VERDICTS = ("ok", "errors", "incomplete", "mixed", "no_data") diff --git a/ui/construct/controls.py b/ui/construct/controls.py new file mode 100644 index 0000000..cfa3fb2 --- /dev/null +++ b/ui/construct/controls.py @@ -0,0 +1,266 @@ +"""Construct-screen controls: drop zones of the empty state and the row of +region / parameter inputs above the matrix.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QLocale, Qt, Signal +from PySide6.QtGui import QDragEnterEvent, QDropEvent, QMouseEvent +from PySide6.QtWidgets import ( + QCheckBox, + QDoubleSpinBox, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from chrom_core.clones import Region, VerifyParams +from i18n import language, tr +from ui.theme import theme as palette + + +def dropped_paths(event: QDropEvent | QDragEnterEvent) -> list[Path]: + return [Path(u.toLocalFile()) for u in event.mimeData().urls() if u.toLocalFile()] + + +class DropZone(QFrame): + """Big dashed area: accepts dropped files/folders and works as a button.""" + + clicked = Signal() + dropped = Signal(list) # list[Path] + + def __init__(self, text: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setAcceptDrops(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumSize(280, 180) + self._prompt = text + self._done = "" + self._hover = False + self._theme = "light" + self._label = QLabel(text) + self._label.setWordWrap(True) + self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._detail = QLabel() + self._detail.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._detail.setWordWrap(True) + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.addStretch(1) + layout.addWidget(self._label) + layout.addWidget(self._detail) + self._extra = QVBoxLayout() + layout.addLayout(self._extra) + layout.addStretch(1) + self._restyle() + + def text(self) -> str: + return self._label.text() + + def add_widget(self, widget: QWidget) -> None: + """Extra control under the texts (e.g. a secondary "choose files" button).""" + self._extra.addWidget(widget, 0, Qt.AlignmentFlag.AlignHCenter) + + def detail_text(self) -> str: + return self._detail.text() + + def set_done(self, text: str) -> None: + """Mark the zone as filled (``text`` names what was loaded); "" resets it.""" + self._done = text + self._detail.setText(f"✓ {text}" if text else tr("workspace.click_to_choose")) + self._restyle() + + def set_theme(self, name: str) -> None: + self._theme = name + self._restyle() + + def _restyle(self) -> None: + colors = palette(self._theme) + accent = colors["region"] + border = accent if (self._hover or self._done) else colors["dim"] + self.setStyleSheet( + f"DropZone {{ border: 2px dashed {border}; border-radius: 12px;" + f" background-color: {colors['panel']}; }}" + ) + self._label.setStyleSheet(f"font-size: 15pt; color: {colors['text']};") + self._detail.setStyleSheet(f"color: {accent if self._done else colors['dim']};") + if not self._detail.text(): + self._detail.setText(tr("workspace.click_to_choose")) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton and self.rect().contains( + event.position().toPoint()): + self.clicked.emit() + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 + if dropped_paths(event): + self._hover = True + self._restyle() + event.acceptProposedAction() + + def dragLeaveEvent(self, event) -> None: # noqa: N802 + self._hover = False + self._restyle() + + def dropEvent(self, event: QDropEvent) -> None: # noqa: N802 + self._hover = False + self._restyle() + paths = dropped_paths(event) + if paths: + event.acceptProposedAction() + self.dropped.emit(paths) + + +class EmptyState(QWidget): + """Shown until both a construct and reads are loaded.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.construct_zone = DropZone(tr("workspace.drop_construct")) + self.reads_zone = DropZone(tr("workspace.drop_reads")) + self.reads_files = QPushButton(tr("workspace.choose_files")) + self.reads_files.setFlat(True) + self.reads_files.setCursor(Qt.CursorShape.PointingHandCursor) + title = QLabel(tr("workspace.empty_title")) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + title.setStyleSheet("font-size: 18pt; font-weight: 600;") + hint = QLabel(tr("workspace.empty_hint")) + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + hint.setWordWrap(True) + self._hint = hint + + zones = QHBoxLayout() + zones.setSpacing(24) + zones.addStretch(1) + zones.addWidget(self.construct_zone, 3) + self.reads_zone.add_widget(self.reads_files) + zones.addWidget(self.reads_zone, 3) + zones.addStretch(1) + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.addStretch(1) + layout.addWidget(title) + layout.addWidget(hint) + layout.addSpacing(16) + layout.addLayout(zones, 2) + layout.addStretch(1) + + def zones(self) -> list[DropZone]: + return [self.construct_zone, self.reads_zone] + + def set_theme(self, name: str) -> None: + for zone in self.zones(): + zone.set_theme(name) + self._hint.setStyleSheet(f"color: {palette(name)['dim']};") + + +class ControlsBar(QWidget): + """Region (1-based, inclusive), circular flag, thresholds and data buttons. + + Spin boxes report only finished input (no keyboard tracking). Programmatic + updates (``set_region`` etc.) emit nothing. + """ + + region_edited = Signal(object) # Region (0-based half-open) + whole_requested = Signal() + params_changed = Signal() + circular_toggled = Signal(bool) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.start_spin = self._spin(1, 1) + self.end_spin = self._spin(1, 1) + self.whole_button = QPushButton(tr("workspace.whole")) + self.whole_button.setToolTip(tr("workspace.whole_tip")) + self.circular = QCheckBox(tr("workspace.circular")) + self.circular.setToolTip(tr("workspace.circular_tip")) + defaults = VerifyParams() + self.min_q = self._spin(0, 60, defaults.min_q) + self.min_q.setToolTip(tr("workspace.min_q_tip")) + self.het_ratio = QDoubleSpinBox() + self.het_ratio.setRange(0.05, 0.95) + self.het_ratio.setSingleStep(0.05) + self.het_ratio.setDecimals(2) + self.het_ratio.setLocale(QLocale( + QLocale.Language.English if language() == "en" else QLocale.Language.Russian)) + self.het_ratio.setValue(defaults.het_ratio) + self.het_ratio.setKeyboardTracking(False) + self.het_ratio.setToolTip(tr("workspace.het_ratio_tip")) + self.mixed_min = self._spin(1, 99, defaults.mixed_min_positions) + self.mixed_min.setToolTip(tr("workspace.mixed_min_tip")) + self.grouping_button = QPushButton(tr("workspace.grouping")) + self.construct_button = QPushButton(tr("workspace.construct_button")) + self.reads_button = QPushButton(tr("workspace.add_reads")) + + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 2, 8, 2) + layout.setSpacing(6) + for widget in (QLabel(tr("workspace.region_from")), self.start_spin, + QLabel(tr("workspace.region_to")), self.end_spin, self.whole_button): + layout.addWidget(widget) + layout.addSpacing(10) + layout.addWidget(self.circular) + layout.addSpacing(10) + for text, widget in ((tr("workspace.min_q"), self.min_q), + (tr("workspace.het_ratio"), self.het_ratio), + (tr("workspace.mixed_min"), self.mixed_min)): + layout.addWidget(QLabel(text)) + layout.addWidget(widget) + layout.addStretch(1) + # data buttons live in their own strip (placed by the workspace next to + # the summary) so the parameter row stays narrow + self.data_buttons = QWidget() + buttons = QHBoxLayout(self.data_buttons) + buttons.setContentsMargins(0, 0, 8, 0) + for button in (self.grouping_button, self.reads_button, self.construct_button): + buttons.addWidget(button) + + self.start_spin.valueChanged.connect(self._region_from_spins) + self.end_spin.valueChanged.connect(self._region_from_spins) + self.whole_button.clicked.connect(self.whole_requested) + self.circular.toggled.connect(self.circular_toggled) + for spin in (self.min_q, self.het_ratio, self.mixed_min): + spin.valueChanged.connect(lambda _v: self.params_changed.emit()) + + @staticmethod + def _spin(lo: int, hi: int, value: int | None = None) -> QSpinBox: + spin = QSpinBox() + spin.setRange(lo, hi) + spin.setKeyboardTracking(False) + if value is not None: + spin.setValue(value) + return spin + + # ------------------------------------------------------------- public API + def set_region(self, region: Region | None, length: int) -> None: + """Show ``region`` (``None``: 1..length) without emitting ``region_edited``.""" + start, end = (0, length) if region is None else region + for spin, value in ((self.start_spin, start + 1), (self.end_spin, end or length)): + spin.blockSignals(True) + spin.setRange(1, max(1, length)) + spin.setValue(value) + spin.blockSignals(False) + + def set_circular(self, flag: bool) -> None: + self.circular.blockSignals(True) + self.circular.setChecked(flag) + self.circular.blockSignals(False) + + def params(self) -> VerifyParams: + return VerifyParams(self.min_q.value(), round(self.het_ratio.value(), 2), + self.mixed_min.value()) + + def set_params(self, params: VerifyParams) -> None: + for spin, value in ((self.min_q, params.min_q), (self.het_ratio, params.het_ratio), + (self.mixed_min, params.mixed_min_positions)): + spin.blockSignals(True) + spin.setValue(value) + spin.blockSignals(False) + + def _region_from_spins(self) -> None: + self.region_edited.emit((self.start_spin.value() - 1, self.end_spin.value())) diff --git a/ui/construct/detail_pane.py b/ui/construct/detail_pane.py new file mode 100644 index 0000000..3f681f9 --- /dev/null +++ b/ui/construct/detail_pane.py @@ -0,0 +1,329 @@ +"""Difference detail: every read of a clone centred on one reference position, +plus the clone's differences table, so the user can judge an error by eye.""" + +from __future__ import annotations + +import struct +from pathlib import Path + +from PySide6.QtCore import QEvent, Qt, Signal +from PySide6.QtGui import QColor, QFont +from PySide6.QtWidgets import ( + QAbstractItemView, QGraphicsOpacityEffect, QLabel, QScrollArea, QSplitter, QTableWidget, QTableWidgetItem, + QVBoxLayout, QWidget, +) + +from chrom_core.align import ReadAlignment +from chrom_core.clones import CloneReport, load_read +from chrom_core.reference import Construct +from i18n import tr +from ui import status +from ui.chrom_view import MAX_ZOOM, MIN_ZOOM, ChromatogramWidget +from ui.read_view.analysis_dock import reference_overlay +from ui.tables import STATUS_ROLE, configure_table +from ui.theme import theme as palette + +CONTEXT = 10 # reference bases shown on each side of the position +VIEW_HEIGHT = 190 # compact height of one chromatogram +ROW_STATUS = {"confirmed": "errors", "disputed": "errors", "low_quality": "no_data"} +STATUS_KEY = {"confirmed": "matrix.legend.confirmed", "disputed": "matrix.legend.disputed", + "low_quality": "matrix.legend.low_quality"} +READ_ERRORS = (OSError, ValueError, KeyError, TypeError, IndexError, AssertionError, struct.error) # AssertionError: BioPython ABIF parser on corrupt files +DISPUTED_TINT = 0.35 # share of the errors red mixed into the background +UNCOVERED_OPACITY = 0.35 +EFFECT_CODES = ("synonymous", "frameshift", "inframe_ins", "inframe_del") + + +def displayed_index(alignment: ReadAlignment, ref_pos: int, read_length: int) -> int | None: + """Index in the *displayed* read of the base aligned to ``ref_pos``. + + ``ref_to_read`` indexes the aligned orientation (the reverse complement on + '-'), so the displayed index is ``n-1-idx`` there. A position deleted in the + read falls back to the nearest mapped neighbour; ``None`` when not covered, + including an index outside ``0..read_length-1`` (the read was edited after + the alignment was stored). + """ + if not alignment.covers(ref_pos): + return None + mapping, length = alignment.ref_to_read, alignment.ref_length + idx = None + for step in range(len(mapping) + (length or 0) + 1): + for pos in ((ref_pos + step, ref_pos - step) if step else (ref_pos,)): + if length: + pos %= length + if pos in mapping: + idx = mapping[pos] + break + if idx is not None: + break + if idx is None: + return None + idx = idx if alignment.strand == "+" else read_length - 1 - idx + return idx if 0 <= idx < read_length else None + + +class DetailPane(QWidget): + open_read = Signal(Path) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._report: CloneReport | None = None + self._construct: Construct | None = None + self._views: list[ChromatogramWidget] = [] + self._results: list = [] + self._rows: list[QWidget] = [] # one container per read (view or placeholder) + self._notes: list[QLabel] = [] # 'position not covered' label per view + self._paths: dict[ChromatogramWidget, Path] = {} + self._ref_pos: int | None = None + self._zoom: float | None = None + self._zoom_chosen = False # the user zoomed: keep it across resizes + self._syncing = False + self._theme_name = status.current_theme() + + self._ref_label = QLabel() + mono = QFont("Consolas") + mono.setStyleHint(QFont.StyleHint.Monospace) + self._ref_label.setFont(mono) + self._ref_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + self._stack = QWidget() + self._stack_layout = QVBoxLayout(self._stack) + self._stack_layout.setContentsMargins(0, 0, 0, 0) + self._stack_layout.setSpacing(2) + self._stack_layout.addStretch(1) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self._stack) + + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.addWidget(self._ref_label) + left_layout.addWidget(scroll, 1) + + self.table = QTableWidget(0, 5) + self.table.setHorizontalHeaderLabels([ + tr("detail.col.pos"), tr("detail.col.change"), tr("detail.col.effect"), + tr("detail.col.support"), tr("detail.col.status"), + ]) + configure_table(self.table, stretch_column=2) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.itemSelectionChanged.connect(self._row_selected) + + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(left) + splitter.addWidget(self.table) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 2) + self._splitter = splitter + self._split_set = False # first real width: chromatograms 3/5, table 2/5 + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(splitter) + + # ---------------------------------------------------------------- API + def views(self) -> list[ChromatogramWidget]: + return list(self._views) + + def reference_text(self) -> str: + return self._ref_label.text() + + def set_theme(self, name: str) -> None: + self._theme_name = name + for view in self._views: + view.set_theme(palette(name)) + self._paint_table() + + def show_clone(self, report: CloneReport, construct: Construct) -> None: + self._report, self._construct = report, construct + self._ref_pos, self._zoom, self._zoom_chosen = None, None, False + self._ref_label.clear() + for row in self._rows: + row.hide() + row.deleteLater() + self._views, self._results, self._paths, self._rows, self._notes = [], [], {}, [], [] + for result in report.reads: + name = result.ref.path.name + if result.alignment is None: + error = tr("detail.err.no_alignment") if result.error == "no_alignment" else result.error + self._add_row(QLabel(f"{result.ref.primer} · {name}: {error}")) + continue + try: + chrom = load_read(result.ref.path) + except READ_ERRORS as exc: + self._add_row(QLabel(f"{result.ref.primer} · {name}: {exc}")) + continue + view = ChromatogramWidget() + view.setMinimumSize(0, VIEW_HEIGHT) + view.setMaximumHeight(VIEW_HEIGHT) + view.set_theme(palette(self._theme_name)) + view.set_chromatogram(chrom) + letters, mapping = reference_overlay(result.alignment, construct.seq, len(chrom.seq)) + view.set_reference(letters, mapping) + title = f"{result.ref.primer} · {name} ({result.alignment.strand})" + if result.ambiguous: + title += " — " + tr("detail.ambiguous") + view.setToolTip(title) + view.installEventFilter(self) + view.view_changed.connect(lambda v=view: self._view_changed(v)) + note = QLabel(tr("detail.uncovered")) + note.hide() + container = QWidget() + box = QVBoxLayout(container) + box.setContentsMargins(0, 0, 0, 0) + box.setSpacing(0) + box.addWidget(QLabel(title)) + box.addWidget(note) + box.addWidget(view) + self._paths[view] = result.ref.path + self._views.append(view) + self._results.append(result) + self._notes.append(note) + self._add_row(container) + self._reset_default_zoom() + self._fill_table(report) + + def _add_row(self, widget: QWidget) -> None: + self._rows.append(widget) + self._stack_layout.insertWidget(self._stack_layout.count() - 1, widget) + + def show_position(self, ref_pos: int) -> None: + if self._report is None or self._construct is None: + return + self._ref_pos = ref_pos + self._select_row(ref_pos) + self._ref_label.setText(self._reference_context(ref_pos)) + for view, result, note in zip(self._views, self._results, self._notes): + exact = ref_pos in result.alignment.ref_to_read + idx = displayed_index(result.alignment, ref_pos, len(view.chrom.seq)) + view.set_highlights([(idx, idx + 1)] if exact and idx is not None else []) + note.setVisible(idx is None) + effect = None + if idx is None: + effect = QGraphicsOpacityEffect(view) + effect.setOpacity(UNCOVERED_OPACITY) + view.setGraphicsEffect(effect) + self._apply_view() + + # ----------------------------------------------------------- internals + def _reference_context(self, ref_pos: int) -> str: + seq, n = self._construct.seq, len(self._construct.seq) + + def around(a: int, b: int) -> str: + if self._construct.circular: + return "".join(seq[i % n] for i in range(a, b)) + return seq[max(0, a):max(0, b)] + + return (f"{ref_pos + 1}: {around(ref_pos - CONTEXT, ref_pos)}[{seq[ref_pos % n]}]" + f"{around(ref_pos + 1, ref_pos + CONTEXT + 1)}") + + def _reset_default_zoom(self) -> None: + """Default scale (about 21 bases visible), unless the user chose one.""" + if not self._views or self._zoom_chosen: + return + spacing = self._views[0]._avg_spacing + width = max(self._views[0].width(), 200) + self._zoom = min(MAX_ZOOM, max(MIN_ZOOM, width / ((2 * CONTEXT + 1) * spacing))) + self._apply_view() + + def _apply_view(self) -> None: + """Give every view the shared zoom and centre it on the position.""" + if self._zoom is None: + return + self._syncing = True + try: + for view, result in zip(self._views, self._results): + view.zoom = self._zoom + if self._ref_pos is not None: + idx = displayed_index(result.alignment, self._ref_pos, len(view.chrom.seq)) + if idx is not None: + view.center_on_point(view.chrom.peak_indices[idx]) + view.update() + finally: + self._syncing = False + + def _view_changed(self, source: ChromatogramWidget) -> None: + """A zoom change in one view is applied to all (shared scale).""" + if self._syncing or source.zoom == self._zoom: + return + self._zoom, self._zoom_chosen = source.zoom, True + self._apply_view() + + def resizeEvent(self, event) -> None: # noqa: N802 + super().resizeEvent(event) + if not self._split_set and self.isVisible() and self.width() > 100: + self._split_set = True + self._splitter.setSizes([self.width() * 3 // 5, self.width() * 2 // 5]) + self._reset_default_zoom() + if self._zoom_chosen: + self._apply_view() + + def eventFilter(self, obj, event) -> bool: # noqa: N802 + if event.type() == QEvent.Type.MouseButtonDblClick and obj in self._paths: + self.open_read.emit(self._paths[obj]) + return True + return super().eventFilter(obj, event) + + def _fill_table(self, report: CloneReport) -> None: + self.table.blockSignals(True) + self.table.setRowCount(len(report.differences)) + for row, d in enumerate(report.differences): + effect = tr(f"effect.{d.effect}") if d.effect in EFFECT_CODES else d.effect + cells = [str(d.ref_pos + 1), f"{d.ref_base or '-'}→{d.read_base or '-'}", effect, + f"{d.support} / {d.against}", tr(STATUS_KEY[d.status])] + for col, text in enumerate(cells): + item = QTableWidgetItem(text) + if col == 0: + item.setData(Qt.ItemDataRole.UserRole, d.ref_pos) + if col == 4: + item.setData(STATUS_ROLE, d.status) + if not d.in_region: + item.setToolTip(tr("matrix.legend.outside")) + font = item.font() + font.setItalic(True) + item.setFont(font) + self.table.setItem(row, col, item) + self.table.blockSignals(False) + self._paint_table() + + def _paint_table(self) -> None: + """Status cells: confirmed solid red, disputed a weak red tint, low quality + grey (shared palette); rows outside the region dimmed. Current theme.""" + theme, colours = self._theme_name, palette(self._theme_name) + base = QColor(colours["bg"]) + red = status.status_color("errors", theme) + + def mix(a: int, b: int) -> int: + return round(a * DISPUTED_TINT + b * (1 - DISPUTED_TINT)) + + for row, d in enumerate(self._report.differences if self._report else ()): + for col in range(self.table.columnCount()): + item = self.table.item(row, col) + if item is None: + continue + if col == 4 and d.status == "disputed": + item.setBackground(QColor(mix(red.red(), base.red()), mix(red.green(), base.green()), + mix(red.blue(), base.blue()))) + item.setForeground(QColor(colours["text"])) + elif col == 4: + verdict = ROW_STATUS[d.status] + item.setBackground(status.status_color(verdict, theme)) + item.setForeground(status.status_text_color(verdict, theme)) + elif not d.in_region: + item.setForeground(QColor(colours["dim"])) + + def _row_selected(self) -> None: + rows = self.table.selectionModel().selectedRows() + if rows: + self.show_position(self.table.item(rows[0].row(), 0).data(Qt.ItemDataRole.UserRole)) + + def _select_row(self, ref_pos: int) -> None: + self.table.blockSignals(True) + self.table.clearSelection() + for row in range(self.table.rowCount()): + if self.table.item(row, 0).data(Qt.ItemDataRole.UserRole) == ref_pos: + self.table.selectRow(row) + break + self.table.blockSignals(False) diff --git a/ui/construct/grouping_dialog.py b/ui/construct/grouping_dialog.py new file mode 100644 index 0000000..339e67b --- /dev/null +++ b/ui/construct/grouping_dialog.py @@ -0,0 +1,165 @@ +"""Grouping dialog: which read belongs to which clone (and primer). + +A table "file / clone / primer" with editable clone and primer cells; reads +without a clone are highlighted. A custom regex (named groups ``clone`` and +``primer``, matched against the file stem) regroups all reads at once as a +live preview; an invalid pattern gets a red frame and disables OK. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QBrush, QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QLineEdit, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from chrom_core.clones import DEFAULT_PRIMER_TOKENS, Grouping, ReadRef, group_reads +from i18n import tr +from ui import status +from ui.tables import configure_table +from ui.theme import theme as palette + +COL_FILE, COL_CLONE, COL_PRIMER = range(3) +_PATH_ROLE = Qt.ItemDataRole.UserRole + 2 +_INVALID_STYLE = "QLineEdit { border: 2px solid #d32f2f; }" + + +class GroupingDialog(QDialog): + def __init__(self, grouping: Grouping, pattern: str, parent: QWidget | None = None, + primer_tokens: Iterable[str] = DEFAULT_PRIMER_TOKENS) -> None: + super().__init__(parent) + self.setWindowTitle(tr("grouping.title")) + self.resize(640, 480) + self._tokens = tuple(primer_tokens) + self._paths = [r.path for r in grouping.reads] + list(grouping.unassigned) + self._invalid = False + + self.pattern_edit = QLineEdit(pattern) + self.pattern_edit.setPlaceholderText(tr("grouping.pattern_placeholder")) + self.pattern_edit.setToolTip(tr("grouping.pattern_tip")) + self._error = QLabel() + self._error.setStyleSheet("color: #d32f2f;") + self._summary = QLabel() + + self.table = QTableWidget(0, 3) + self.table.setHorizontalHeaderLabels( + [tr("grouping.col.file"), tr("grouping.col.clone"), tr("grouping.col.primer")]) + configure_table(self.table, stretch_column=COL_FILE) + self.table.setAlternatingRowColors(False) # the highlight must stay readable + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.itemChanged.connect(self._item_changed) + + self.buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + row = QHBoxLayout() + row.addWidget(QLabel(tr("grouping.pattern"))) + row.addWidget(self.pattern_edit, 1) + layout = QVBoxLayout(self) + layout.addLayout(row) + layout.addWidget(self._error) + layout.addWidget(self.table, 1) + layout.addWidget(self._summary) + layout.addWidget(self.buttons) + + self._fill(grouping) + self._error.hide() + try: # a stale pattern from the config: flag it, keep the given grouping + group_reads([], pattern or None, self._tokens) + except ValueError as exc: + self._set_invalid(str(exc)) + self.pattern_edit.textChanged.connect(self._pattern_changed) + + # ------------------------------------------------------------- public API + def result_grouping(self) -> Grouping: + grouping = Grouping() + for row in range(self.table.rowCount()): + path = Path(self.table.item(row, COL_FILE).data(_PATH_ROLE)) + clone = self.table.item(row, COL_CLONE).text().strip() + primer = self.table.item(row, COL_PRIMER).text().strip() + if clone: + grouping.reads.append(ReadRef(path, clone, primer)) + else: + grouping.unassigned.append(path) + return grouping + + def result_pattern(self) -> str: + return self.pattern_edit.text() + + def pattern_invalid(self) -> bool: + return self._invalid + + def unassigned_rows(self) -> list[int]: + return [row for row in range(self.table.rowCount()) + if not self.table.item(row, COL_CLONE).text().strip()] + + # --------------------------------------------------------------- private + def _pattern_changed(self, text: str) -> None: + try: + grouping = group_reads(self._paths, text or None, self._tokens) + except ValueError as exc: + self._set_invalid(str(exc)) + return + self._set_invalid("") + self._fill(grouping) + + def _set_invalid(self, message: str) -> None: + self._invalid = bool(message) + self.pattern_edit.setStyleSheet(_INVALID_STYLE if message else "") + self._error.setText(message) + self._error.setVisible(bool(message)) + self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not message) + + def _fill(self, grouping: Grouping) -> None: + refs = {r.path: r for r in grouping.reads} + self.table.blockSignals(True) + self.table.setRowCount(len(self._paths)) + for row, path in enumerate(self._paths): + ref = refs.get(path) + name = QTableWidgetItem(path.name) + name.setData(_PATH_ROLE, str(path)) + name.setToolTip(str(path)) + name.setFlags(name.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.table.setItem(row, COL_FILE, name) + self.table.setItem(row, COL_CLONE, QTableWidgetItem(ref.clone if ref else "")) + self.table.setItem(row, COL_PRIMER, QTableWidgetItem(ref.primer if ref else "")) + self.table.blockSignals(False) + self._highlight() + + def _item_changed(self, _item: QTableWidgetItem) -> None: + self._highlight() + + def _highlight(self) -> None: + """Rows without a clone get the theme highlight; the summary counts them.""" + colors = palette(status.current_theme()) + mark = QBrush(QColor(colors["highlight"])) + unassigned = set(self.unassigned_rows()) + self.table.blockSignals(True) + for row in range(self.table.rowCount()): + for col in range(3): + item = self.table.item(row, col) + item.setBackground(mark if row in unassigned else QBrush()) + if row in unassigned and col == COL_CLONE: + item.setToolTip(tr("grouping.no_clone")) + elif col == COL_CLONE: + item.setToolTip("") + self.table.blockSignals(False) + clones = {self.table.item(r, COL_CLONE).text().strip() + for r in range(self.table.rowCount())} - {""} + self._summary.setText(tr("grouping.summary", clones=len(clones), + reads=self.table.rowCount(), unassigned=len(unassigned))) diff --git a/ui/construct/matrix.py b/ui/construct/matrix.py new file mode 100644 index 0000000..17587a3 --- /dev/null +++ b/ui/construct/matrix.py @@ -0,0 +1,623 @@ +"""Clone matrix: every clone as a row on one shared coordinate axis under the +reference track (layout "B" of the construct screen). + +Colours +------- +- Coverage (per region position, reads with Q >= min_q): both strands -> theme + ``cov_both`` (saturated), one strand -> ``cov_one`` (light), none -> the empty + ``track`` background. Outside the region the core computes no coverage, so + the track there is hatched as "not checked". +- Difference marks use the saturated variant of the status palette + (``STATUS_HEX`` dark tone) in both themes, so a thin mark stays visible on + light coverage: ``confirmed`` -> solid errors red, ``disputed`` -> hollow + errors-red outline (at least 5 px wide), ``low_quality`` -> solid no-data + grey; mixed (double-peak) positions -> solid mixed purple. Differences + outside the region (they do not affect the verdict) keep their style but are + shorter and translucent. +- Verdict chips use ``status_color`` of the current theme. + +The axis is linear 0..L also for circular constructs; anything crossing the +origin (``start > end``) is drawn as two segments. +""" + +from __future__ import annotations + +import math + +from PySide6.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QFont, QFontMetrics, QMouseEvent, QPainter, QPen, QWheelEvent +from PySide6.QtWidgets import QWidget + +from chrom_core.clones import CloneReport, Region +from chrom_core.reference import Construct +from i18n import tr +from ui import status +from ui.construct import VERDICTS +from ui.theme import theme as palette + +MARGIN = 6 +RULER_H = 16 +REF_ROW_H = 28 +ROW_H = 24 +TRACK_H = 10 # clone coverage bar +REF_TRACK_H = 14 # reference / feature bar +MARK_H = 16 +OUT_MARK_H = 10 # differences outside the region: shorter ... +OUT_ALPHA = 180 # ... and translucent +MARK_MIN_W = 3 +OUTLINE_MIN_W = 5 # hollow (disputed) marks need room for an inside +LEGEND_H = 20 +CHIP_W = 84 +CHIP_H = 16 +GAP = 6 +NAME_MIN, NAME_MAX = 44, 160 +ZOOM_STEP = 1.25 # per wheel notch +MIN_SPAN = 20.0 # bp visible at most zoom +DRAG_SLOP = 3 # px before a press becomes a pan +HIT_SLOP = 4 # px around a mark that still hits it + +_STATUS_ORDER = ("low_quality", "disputed", "confirmed") # drawn bottom -> top + + +def segments(start: int, end: int, length: int) -> list[tuple[int, int]]: + """Linear pieces of a half-open interval; ``start > end`` wraps the origin.""" + if start <= end: + return [(start, end)] + return [(start, length), (0, end)] + + +def mark_colors() -> dict[str, QColor]: + """Mark colour per difference status, plus ``mixed``. The same saturated + tones are used in both themes (see the module docstring).""" + return { + "confirmed": status.status_color("errors", "dark"), + "disputed": status.status_color("errors", "dark"), + "low_quality": status.status_color("no_data", "dark"), + "mixed": status.status_color("mixed", "dark"), + } + + +def outside(region: tuple[int, int] | None, length: int) -> list[tuple[int, int]]: + """Linear pieces of the construct outside ``region`` (none for ``None``).""" + if region is None: + return [] + start, end = region + return [(a, b) for a, b in segments(end, start, length) if b > a] + + +def _coverage_runs(report: CloneReport) -> list[tuple[int, int, int]]: + """``(start, end, level)`` runs of consecutive positions; level 2 = both + strands, 1 = one strand (positions without coverage are left out).""" + runs: list[list[int]] = [] + for pos in sorted(report.coverage): + plus, minus = report.coverage[pos] + level = 2 if plus and minus else 1 if plus + minus else 0 + if level == 0: + continue + if runs and runs[-1][1] == pos and runs[-1][2] == level: + runs[-1][1] = pos + 1 + else: + runs.append([pos, pos + 1, level]) + return [tuple(r) for r in runs] + + +class _Row: + def __init__(self, report: CloneReport) -> None: + self.report = report + self.runs = _coverage_runs(report) + self.differences = sorted( # in-region and stronger statuses painted last, on top + report.differences, + key=lambda d: (d.in_region, + _STATUS_ORDER.index(d.status) if d.status in _STATUS_ORDER else 0)) + self.mixed = report.mixed_positions + + +class CloneMatrix(QWidget): + """Reference track plus one row per clone; custom painted. + + Wheel zooms the shared axis around the cursor, dragging pans it, a Shift+drag + on the reference track picks a region. A click on a mark emits + ``difference_clicked(clone, ref_pos)``, elsewhere on a row ``clone_clicked``. + """ + + difference_clicked = Signal(str, int) + clone_clicked = Signal(str) + region_changed = Signal(object) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._theme = status.current_theme() + self._construct: Construct | None = None + self._region: Region | None = None + self._rows: list[_Row] = [] + self._filter = set(VERDICTS) + self._view_start = 0.0 + self._view_span = 1.0 + self._press: QPoint | None = None + self._press_view = 0.0 + self._mode = "" # "", "click", "pan", "region" + self._band: tuple[float, float] | None = None + self._name_w = NAME_MIN + self.setToolTip(tr("matrix.hint")) + self.setMinimumWidth(320) + + # ------------------------------------------------------------- public API + def set_data(self, construct: Construct, region: Region | None, + reports: list[CloneReport]) -> None: + self._construct = construct + self._region = region + self._rows = [_Row(r) for r in reports] + self._update_name_width() + self._view_start, self._view_span = 0.0, float(max(1, len(construct.seq))) + self.updateGeometry() + self.update() + + def clear(self) -> None: + """No construct, no rows (the empty placeholder is painted).""" + self._construct, self._region, self._rows = None, None, [] + self._band, self._press, self._mode = None, None, "" + self.updateGeometry() + self.update() + + def set_filter(self, verdicts: set[str]) -> None: + self._filter = set(verdicts) + self.updateGeometry() + self.update() + + def set_theme(self, name: str) -> None: + self._theme = "dark" if name == "dark" else "light" + self.update() + + def visible_clones(self) -> list[str]: + return [row.report.clone for row in self._visible()] + + def view_range(self) -> tuple[float, float]: + """``(first position, number of positions)`` shown on the axis.""" + return (self._view_start, self._view_span) + + def x_for(self, pos: float) -> float: + left, width = self._track_left(), self._track_width() + return left + (pos - self._view_start) / self._view_span * width + + def pos_at(self, x: float) -> float: + return self._view_start + (x - self._track_left()) / self._track_width() * self._view_span + + def ruler_ticks(self) -> list[tuple[int, float]]: + """``(label, x)`` of the ruler ticks: 1-based base numbers at base centres, + so they read like the summary's region text.""" + fm = QFontMetrics(self._small_font()) + label_w = fm.horizontalAdvance(f"{self._length():,}") + 16 + raw = self._view_span * label_w / self._track_width() + magnitude = 10 ** math.floor(math.log10(max(raw, 1))) + step = next(m * magnitude for m in (1, 2, 5, 10) if m * magnitude >= raw) + end = self._view_start + self._view_span + v = max(step, math.ceil((self._view_start + 0.5) / step) * step) + ticks = [] + while v - 0.5 <= end and v <= self._length(): + ticks.append((int(v), self.x_for(v - 0.5))) + v += step + return ticks + + def reference_rect(self) -> QRect: + return QRect(round(self._track_left()), MARGIN + RULER_H, + round(self._track_width()), REF_ROW_H) + + def row_rect(self, clone: str) -> QRect: + """Full-width rectangle of a shown clone's row; null when hidden or unknown.""" + for i, row in enumerate(self._visible()): + if row.report.clone == clone: + return QRect(0, self._rows_top() + i * ROW_H, self.width(), ROW_H) + return QRect() + + def chip_rect(self, clone: str) -> QRect: + row = self.row_rect(clone) + if row.isNull(): + return QRect() + return QRect(self.width() - MARGIN - CHIP_W, row.center().y() - CHIP_H // 2 + 1, + CHIP_W, CHIP_H) + + def mark_point(self, clone: str, ref_pos: int) -> QPoint: + """Centre of the mark drawn for ``ref_pos`` in ``clone``'s row.""" + data = self._row(clone) + diffs = data.differences if data is not None else [] + kind = next((d.kind for d in diffs if d.ref_pos == ref_pos), "substitution") + return QPoint(round(self._mark_x(ref_pos, kind)), self.row_rect(clone).center().y()) + + def hit_test(self, pos: QPoint) -> tuple[str, int] | None: + """``(clone, ref_pos)`` of the difference or mixed mark under ``pos``.""" + index = self._row_index_at(pos.y()) + if index is None or not self._track_left() - HIT_SLOP <= pos.x() <= self._track_right() + HIT_SLOP: + return None + row = self._visible()[index] + slop = max(HIT_SLOP, self._px_per_base() / 2 + 1) + best: tuple[float, int, int] | None = None # (distance, priority, ref_pos) + candidates = [(d.ref_pos, d.kind, 0) for d in row.differences] + candidates += [(p, "substitution", 1) for p in row.mixed] + for ref_pos, kind, priority in candidates: + distance = abs(self._mark_x(ref_pos, kind) - pos.x()) + if distance <= slop and (best is None or (distance, priority) < best[:2]): + best = (distance, priority, ref_pos) + return (row.report.clone, best[2]) if best else None + + def sizeHint(self) -> QSize: # noqa: N802 + return QSize(720, self._content_height()) + + def minimumSizeHint(self) -> QSize: # noqa: N802 + return QSize(320, self._content_height()) + + # --------------------------------------------------------------- geometry + def _visible(self) -> list[_Row]: + return [r for r in self._rows if r.report.verdict in self._filter] + + def _row(self, clone: str) -> _Row | None: + return next((r for r in self._rows if r.report.clone == clone), None) + + def _length(self) -> int: + return len(self._construct.seq) if self._construct else 1 + + def _name_width(self) -> int: + return self._name_w + + def _update_name_width(self) -> None: + """Measure the clone names once (on new data or a font/style change).""" + fm = QFontMetrics(self._bold_font()) + widest = max((fm.horizontalAdvance(r.report.clone) for r in self._rows), default=0) + self._name_w = min(NAME_MAX, max(NAME_MIN, widest + GAP)) + + def changeEvent(self, event: QEvent) -> None: # noqa: N802 + if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange): + self._update_name_width() + self.update() + super().changeEvent(event) + + def _track_left(self) -> float: + return float(MARGIN + self._name_width()) + + def _track_right(self) -> float: + return float(self.width() - MARGIN - CHIP_W - GAP) + + def _track_width(self) -> float: + return max(1.0, self._track_right() - self._track_left()) + + def _px_per_base(self) -> float: + return self._track_width() / self._view_span + + def _rows_top(self) -> int: + return MARGIN + RULER_H + REF_ROW_H + + def _content_height(self) -> int: + rows = max(1, len(self._visible())) + return self._rows_top() + rows * ROW_H + GAP + LEGEND_H + MARGIN + + def _row_index_at(self, y: int) -> int | None: + offset = y - self._rows_top() + if offset < 0: + return None + index = offset // ROW_H + return index if index < len(self._visible()) else None + + def _mark_x(self, ref_pos: int, kind: str) -> float: + # an insertion sits before ``ref_pos``: on the boundary, not the base centre + return self.x_for(ref_pos if kind == "insertion" else ref_pos + 0.5) + + def _bold_font(self) -> QFont: + font = QFont(self.font()) + font.setBold(True) + return font + + def _small_font(self) -> QFont: + font = QFont(self.font()) + font.setPointSizeF(max(6.0, font.pointSizeF() - 1.5)) + return font + + def _set_view(self, start: float, span: float) -> None: + length = float(self._length()) + span = min(length, max(min(MIN_SPAN, length), span)) + start = min(max(0.0, start), length - span) + if (start, span) != (self._view_start, self._view_span): + self._view_start, self._view_span = start, span + self.update() + + # ------------------------------------------------------------------ input + def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802 + if self._construct is None: + return + steps = event.angleDelta().y() / 120.0 + if not steps: + return + x = event.position().x() + if not self._track_left() <= x <= self._track_right(): + x = (self._track_left() + self._track_right()) / 2 + anchor = self.pos_at(x) + fraction = (x - self._track_left()) / self._track_width() + span = self._view_span / ZOOM_STEP ** steps + length = float(self._length()) + span = min(length, max(min(MIN_SPAN, length), span)) + self._set_view(anchor - fraction * span, span) + event.accept() + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if event.button() != Qt.MouseButton.LeftButton or self._construct is None: + return + point = event.position().toPoint() + self._press, self._press_view = point, self._view_start + on_reference = self.reference_rect().adjusted(-HIT_SLOP, 0, HIT_SLOP, 0).contains(point) + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier and on_reference: + self._mode = "region" + x = self._clamp_x(point.x()) + self._band = (x, x) + self.update() + else: + self._mode = "click" + + def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if self._press is None: + return + x = event.position().x() + if self._mode == "region": + self._band = (self._band[0], self._clamp_x(x)) + self.update() + return + dx = x - self._press.x() + if self._mode == "click" and abs(dx) > DRAG_SLOP: + self._mode = "pan" + if self._mode == "pan": + self._set_view(self._press_view - dx / self._track_width() * self._view_span, + self._view_span) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if self._press is None or event.button() != Qt.MouseButton.LeftButton: + return + mode, press = self._mode, self._press + self._press, self._mode = None, "" + if mode == "region": + a, b = sorted(self._band or (0.0, 0.0)) + self._band = None + self.update() + start, end = round(self.pos_at(a)), round(self.pos_at(b)) + start, end = max(0, start), min(self._length(), end) + if end > start: + self.region_changed.emit((start, end)) + elif mode == "click": + hit = self.hit_test(press) + if hit is not None: + self.difference_clicked.emit(*hit) + return + index = self._row_index_at(press.y()) + if index is not None: + self.clone_clicked.emit(self._visible()[index].report.clone) + + def _clamp_x(self, x: float) -> float: + lo, hi = self.x_for(0), self.x_for(self._length()) + return min(max(x, max(lo, self._track_left())), min(hi, self._track_right())) + + # --------------------------------------------------------------- painting + def paintEvent(self, event) -> None: # noqa: N802, ARG002 + colors = palette(self._theme) + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(colors["bg"])) + if self._construct is None: + self._paint_empty(painter, colors) + painter.end() + return + self._paint_ruler(painter, colors) + self._paint_reference(painter, colors) + rows = self._visible() + for i, row in enumerate(rows): + self._paint_row(painter, colors, row, self._rows_top() + i * ROW_H) + if not rows: + self._paint_empty(painter, colors) + self._paint_region_lines(painter, colors, len(rows)) + self._paint_legend(painter, colors, max(1, len(rows))) + painter.end() + + def _track_clip(self) -> QRectF: + return QRectF(self._track_left(), 0, self._track_width(), self.height()) + + def _spans(self, start: int, end: int) -> list[QRectF]: + """Pixel x-extents (as zero-height rects) of an interval, split at the origin.""" + return [QRectF(self.x_for(a), 0, self.x_for(b) - self.x_for(a), 0) + for a, b in segments(start, end, self._length())] + + def _paint_empty(self, painter: QPainter, colors: dict) -> None: + painter.setPen(QColor(colors["dim"])) + rect = QRect(0, self._rows_top(), self.width(), ROW_H) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, tr("matrix.empty")) + + def _paint_ruler(self, painter: QPainter, colors: dict) -> None: + painter.setFont(self._small_font()) + label_w = painter.fontMetrics().horizontalAdvance(f"{self._length():,}") + 16 + y = MARGIN + RULER_H + painter.setPen(QPen(QColor(colors["dim"]), 1)) + painter.save() + painter.setClipRect(self._track_clip().adjusted(-label_w, 0, label_w, 0)) + for v, x in self.ruler_ticks(): + painter.drawLine(QPointF(x, y - 4), QPointF(x, y)) + painter.drawText(QRectF(x - label_w / 2, MARGIN - 2, label_w, RULER_H - 3), + Qt.AlignmentFlag.AlignCenter, f"{v:,}".replace(",", "\u202f")) + painter.restore() + painter.drawLine(QPointF(self._track_left(), y), QPointF(self._track_right(), y)) + + def _paint_reference(self, painter: QPainter, colors: dict) -> None: + top = MARGIN + RULER_H + cy = top + REF_ROW_H / 2 + track = QRectF(self._track_left(), cy - REF_TRACK_H / 2, self._track_width(), REF_TRACK_H) + painter.setFont(self._small_font()) + painter.setPen(QColor(colors["dim"])) + painter.drawText(QRectF(self.width() - MARGIN - CHIP_W, top, CHIP_W, REF_ROW_H), + Qt.AlignmentFlag.AlignCenter, tr("matrix.reference")) + painter.save() + painter.setClipRect(self._track_clip()) + self._fill_axis(painter, track, 0, self._length(), QColor(colors["track"])) + seen = set() + for feature in self._construct.features: + key = (feature.name, feature.start, feature.end) + if feature.kind == "source" or key in seen: + continue + seen.add(key) + colour = QColor(colors["feature_cds" if feature.kind in ("CDS", "gene") else "feature"]) + for span in self._spans(feature.start, feature.end): + rect = QRectF(span.left(), track.top(), span.width(), track.height()) + painter.fillRect(rect, colour) + if rect.width() > 24: + painter.setPen(QColor("#ffffff")) + text = painter.fontMetrics().elidedText( + feature.name, Qt.TextElideMode.ElideRight, int(rect.width()) - 6) + visible = rect.intersected(self._track_clip()) + painter.drawText(visible.adjusted(6, 0, -3, 0), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text) + start, end = self._region if self._region is not None else (0, self._length()) + painter.setPen(QPen(QColor(colors["region"]), 2)) + painter.setBrush(Qt.BrushStyle.NoBrush) + for span in self._spans(start, end): + painter.drawRoundedRect(QRectF(span.left(), track.top() - 3, span.width(), + track.height() + 6), 3, 3) + if self._band is not None: + a, b = sorted(self._band) + band = QColor(colors["region"]) + band.setAlpha(80) + painter.fillRect(QRectF(a, track.top() - 3, b - a, track.height() + 6), band) + painter.restore() + + def _fill_axis(self, painter: QPainter, track: QRectF, start: int, end: int, + colour: QColor) -> None: + for span in self._spans(start, end): + painter.fillRect(QRectF(span.left(), track.top(), span.width(), track.height()), colour) + + def _paint_row(self, painter: QPainter, colors: dict, row: _Row, top: int) -> None: + report = row.report + cy = top + ROW_H / 2 + painter.setFont(self._bold_font()) + painter.setPen(QColor(colors["text"])) + name = painter.fontMetrics().elidedText(report.clone, Qt.TextElideMode.ElideRight, + self._name_width() - GAP) + painter.drawText(QRectF(MARGIN, top, self._name_width() - GAP, ROW_H), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, name) + + track = QRectF(self._track_left(), cy - TRACK_H / 2, self._track_width(), TRACK_H) + painter.save() + painter.setClipRect(self._track_clip()) + self._fill_axis(painter, track, 0, self._length(), QColor(colors["track"])) + self._paint_unchecked(painter, colors, track) + shades = {2: QColor(colors["cov_both"]), 1: QColor(colors["cov_one"])} + first, last = self._view_start, self._view_start + self._view_span + for start, end, level in row.runs: # runs are linear (never wrap) + if end <= first or start >= last: + continue + x0 = self.x_for(start) + painter.fillRect(QRectF(x0, track.top(), self.x_for(end) - x0, track.height()), + shades[level]) + marks = mark_colors() + for pos in row.mixed: + self._paint_mark(painter, self._mark_x(pos, "substitution"), cy, marks["mixed"]) + for d in row.differences: + if not first - 1 <= d.ref_pos <= last: + continue + colour = QColor(marks.get(d.status, marks["low_quality"])) + if not d.in_region: + colour.setAlpha(OUT_ALPHA) + self._paint_mark(painter, self._mark_x(d.ref_pos, d.kind), cy, colour, + hollow=d.status == "disputed", + height=MARK_H if d.in_region else OUT_MARK_H) + painter.restore() + self._paint_chip(painter, report) + + def _paint_unchecked(self, painter: QPainter, colors: dict, track: QRectF) -> None: + """Hatch the track outside the region: not checked, not "no coverage".""" + hatch = QColor(colors["dim"]) + hatch.setAlpha(140) + for a, b in outside(self._region, self._length()): + x0 = self.x_for(a) + painter.fillRect(QRectF(x0, track.top(), self.x_for(b) - x0, track.height()), + QBrush(hatch, Qt.BrushStyle.BDiagPattern)) + + def _paint_mark(self, painter: QPainter, x: float, cy: float, colour: QColor, + hollow: bool = False, height: int = MARK_H) -> None: + """A whole-pixel mark centred on ``x``; ``hollow`` draws a 1 px outline.""" + width = max(OUTLINE_MIN_W if hollow else MARK_MIN_W, round(self._px_per_base())) + left, top = round(x - width / 2), round(cy - height / 2) + if not hollow: + painter.fillRect(QRect(left, top, width, height), colour) + return + for rect in (QRect(left, top, width, 1), QRect(left, top + height - 1, width, 1), + QRect(left, top, 1, height), QRect(left + width - 1, top, 1, height)): + painter.fillRect(rect, colour) + + def _chip_text(self, report: CloneReport) -> str: + if report.verdict == "errors": + n = sum(1 for d in report.region_differences if d.status != "low_quality") + return tr("matrix.chip.errors", n=n) + if report.verdict == "incomplete": + return tr("matrix.chip.incomplete", pct=math.floor(report.region_coverage_pct)) + return tr(f"matrix.chip.{report.verdict}") if report.verdict in VERDICTS else report.verdict + + def _paint_chip(self, painter: QPainter, report: CloneReport) -> None: + rect = QRectF(self.chip_rect(report.clone)) + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(status.status_color(report.verdict, self._theme)) + painter.drawRoundedRect(rect, CHIP_H / 2, CHIP_H / 2) + painter.setPen(status.status_text_color(report.verdict, self._theme)) + painter.setFont(self._small_font()) + text = painter.fontMetrics().elidedText(self._chip_text(report), + Qt.TextElideMode.ElideRight, CHIP_W - 8) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) + painter.restore() + + def _paint_region_lines(self, painter: QPainter, colors: dict, n_rows: int) -> None: + """Thin dashed region boundaries through the clone rows.""" + if self._region is None or n_rows == 0: + return + top, bottom = self._rows_top(), self._rows_top() + n_rows * ROW_H + pen = QPen(QColor(colors["region"]), 1, Qt.PenStyle.DashLine) + painter.save() + painter.setClipRect(self._track_clip()) + painter.setPen(pen) + for x in {self.x_for(self._region[0]), self.x_for(self._region[1])}: + painter.drawLine(QPointF(x, top), QPointF(x, bottom)) + painter.restore() + + def _paint_legend(self, painter: QPainter, colors: dict, n_rows: int) -> None: + top = self._rows_top() + n_rows * ROW_H + GAP + marks = mark_colors() + dim_red = QColor(marks["confirmed"]) + dim_red.setAlpha(OUT_ALPHA) + items = [ + ("block", QColor(colors["cov_both"]), "matrix.legend.cov_both"), + ("block", QColor(colors["cov_one"]), "matrix.legend.cov_one"), + ("block", QColor(colors["track"]), "matrix.legend.cov_none"), + ("hatch", QColor(colors["track"]), "matrix.legend.unchecked"), + ("mark", marks["confirmed"], "matrix.legend.confirmed"), + ("hollow", marks["disputed"], "matrix.legend.disputed"), + ("mark", marks["low_quality"], "matrix.legend.low_quality"), + ("mark", marks["mixed"], "matrix.legend.mixed"), + ("short", dim_red, "matrix.legend.outside"), + ] + painter.setFont(self._small_font()) + fm = painter.fontMetrics() + hatch = QColor(colors["dim"]) + hatch.setAlpha(140) + x, cy = float(self._track_left()), top + LEGEND_H / 2 + for kind, colour, key in items: + if kind in ("block", "hatch"): + painter.fillRect(QRectF(x, cy - 4, 14, 8), colour) + if kind == "hatch": + painter.fillRect(QRectF(x, cy - 4, 14, 8), + QBrush(hatch, Qt.BrushStyle.BDiagPattern)) + x += 18 + else: + rect = QRect(round(x), round(cy) - 6, 5 if kind == "hollow" else 4, 12) + if kind == "short": + rect = QRect(rect.left(), round(cy) - 4, 4, 8) + if kind == "hollow": + painter.setPen(colour) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(rect.adjusted(0, 0, -1, -1)) + else: + painter.fillRect(rect, colour) + x += 9 + text = tr(key) + painter.setPen(QColor(colors["dim"])) + painter.drawText(QPointF(x, cy + fm.ascent() / 2 - 1), text) + x += fm.horizontalAdvance(text) + 12 diff --git a/ui/construct/session_files.py b/ui/construct/session_files.py new file mode 100644 index 0000000..f298b8e --- /dev/null +++ b/ui/construct/session_files.py @@ -0,0 +1,41 @@ +"""File helpers of the construct workspace: read discovery, path identity, reports.""" + +from __future__ import annotations + +import os +from collections.abc import Iterable +from pathlib import Path + +from chrom_core.clone_report import clone_report_xlsx, clones_csv, differences_csv +from chrom_core.clones import CloneReport + +PROJECT_SUFFIX = ".ab1proj" +CONSTRUCT_FILTER = "Constructs (*.fa *.fasta *.fna *.txt *.gb *.gbk *.genbank *.dna);;All files (*)" + + +def path_key(path: Path | str) -> str: + """Comparable identity of a file path (absolute, case-normalised on Windows).""" + return os.path.normcase(os.path.abspath(path)) + + +def ab1_files(paths: Iterable[Path | str]) -> list[Path]: + """``.ab1`` files among ``paths``; a folder contributes its own ``.ab1`` files.""" + out: list[Path] = [] + for p in map(Path, paths): + if p.is_dir(): + out += sorted(x for x in p.iterdir() if x.suffix.lower() == ".ab1" and x.is_file()) + elif p.suffix.lower() == ".ab1": + out.append(p) + return out + + +def write_report(path: Path, reports: list[CloneReport], construct_name: str) -> None: + """``.xlsx``: one workbook; otherwise ``path`` gets the per-clone CSV and + ``_differences.csv`` next to it the per-difference CSV.""" + path = Path(path) + if path.suffix.lower() == ".xlsx": + clone_report_xlsx(path, reports, construct_name) + return + path.write_text(clones_csv(reports), encoding="utf-8") + path.with_name(f"{path.stem}_differences.csv").write_text( + differences_csv(reports), encoding="utf-8") diff --git a/ui/construct/summary_bar.py b/ui/construct/summary_bar.py new file mode 100644 index 0000000..315887a --- /dev/null +++ b/ui/construct/summary_bar.py @@ -0,0 +1,124 @@ +"""One-line summary of a construct check: name, length, topology, region and +per-verdict clone counters that double as row filters for the matrix.""" + +from __future__ import annotations + +from collections import Counter + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget + +from chrom_core.clones import CloneReport, Region +from chrom_core.reference import Construct +from i18n import tr +from ui import status +from ui.construct import VERDICTS +from ui.theme import theme as palette + + +def region_text(region: Region | None) -> str: + """``region 7–647`` (1-based, inclusive); ``None`` is the whole construct.""" + if region is None: + return tr("construct.region_all") + start, end = region + return tr("construct.region", start=start + 1, end=end) + + +def _thousands(n: int) -> str: + return f"{n:,}".replace(",", " ") + + +class SummaryBar(QWidget): + """Construct name and facts, then a counter chip per verdict. + + Clicking a counter toggles that verdict in the shown set and emits + ``filter_changed`` with the verdicts still shown. + """ + + filter_changed = Signal(set) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._theme = status.current_theme() + self._shown = set(VERDICTS) + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 4, 8, 4) + layout.setSpacing(8) + self._title = QLabel() + self._title.setStyleSheet("font-weight: 600;") + self._info = QLabel() + layout.addWidget(self._title) + layout.addWidget(self._info) + self._counters: dict[str, QPushButton] = {} + for verdict in VERDICTS: + button = QPushButton() + button.setCheckable(True) + button.setChecked(True) + button.setFlat(True) + button.setToolTip(tr("construct.filter_tip")) + button.toggled.connect(lambda on, v=verdict: self._toggle(v, on)) + button.hide() + layout.addWidget(button) + self._counters[verdict] = button + layout.addStretch(1) + self._restyle() + + # ------------------------------------------------------------- public API + def set_data(self, construct: Construct, region: Region | None, + reports: list[CloneReport]) -> None: + self._title.setText(construct.name) + topology = tr("construct.circular" if construct.circular else "construct.linear") + self._info.setText(" · ".join(( + f"{tr('construct.bp', n=_thousands(len(construct.seq)))}, {topology}", + region_text(region), + ))) + counts = Counter(r.verdict for r in reports) + for verdict, button in self._counters.items(): + n = counts.get(verdict, 0) + button.setText(tr(f"construct.count.{verdict}", n=n)) + button.setVisible(n > 0) + + def clear(self) -> None: + self._title.clear() + self._info.clear() + for button in self._counters.values(): + button.hide() + + def counter(self, verdict: str) -> QPushButton: + return self._counters[verdict] + + def shown_verdicts(self) -> set[str]: + return set(self._shown) + + def title_text(self) -> str: + return self._title.text() + + def info_text(self) -> str: + return self._info.text() + + def set_theme(self, name: str) -> None: + self._theme = "dark" if name == "dark" else "light" + self._restyle() + + # --------------------------------------------------------------- private + def _toggle(self, verdict: str, on: bool) -> None: + if on: + self._shown.add(verdict) + else: + self._shown.discard(verdict) + self._restyle() + self.filter_changed.emit(set(self._shown)) + + def _restyle(self) -> None: + dim = palette(self._theme)["dim"] + for verdict, button in self._counters.items(): + bg = status.status_color(verdict, self._theme).name() + fg = status.status_text_color(verdict, self._theme).name() + # shown: filled chip; hidden: outline only, struck through + button.setStyleSheet( + "QPushButton { border-radius: 9px; padding: 1px 10px; font-weight: 600;" + f" border: 1px solid {bg}; background-color: transparent; color: {dim};" + " text-decoration: line-through; }" + f"QPushButton:checked {{ background-color: {bg}; color: {fg};" + " text-decoration: none; }" + ) diff --git a/ui/construct/workspace.py b/ui/construct/workspace.py new file mode 100644 index 0000000..816c208 --- /dev/null +++ b/ui/construct/workspace.py @@ -0,0 +1,482 @@ +"""The "construct check" workspace: construct + reads in, per-clone verdicts out. + +Holds the session state (construct file, circular flag, region, grouping, +thresholds), re-runs ``verify_all`` in the background on every change (key +``"verify"``: a newer run cancels the older one; a cancelled or failed run +leaves the previous verdicts on screen) and reads/writes ``.ab1proj`` files. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from pathlib import Path + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QDragEnterEvent, QDropEvent +from PySide6.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QMessageBox, + QScrollArea, + QSplitter, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from chrom_core import project as project_io +from chrom_core.align import MAX_REFERENCE_LENGTH +from chrom_core.clones import ( + CloneReport, + Grouping, + Region, + VerifyParams, + check_region, + group_reads, + verify_all, +) +from chrom_core.config import AppConfig, save_config +from chrom_core.reference import Construct, load_construct +from i18n import tr +from ui import status +from ui.construct.controls import ControlsBar, EmptyState, dropped_paths +from ui.construct.detail_pane import DetailPane +from ui.construct.grouping_dialog import GroupingDialog +from ui.construct.matrix import CloneMatrix +from ui.construct.session_files import ( + CONSTRUCT_FILTER, + PROJECT_SUFFIX, + ab1_files, + path_key, + write_report, +) +from ui.construct.summary_bar import SummaryBar + + + +class ConstructWorkspace(QWidget): + open_read = Signal(Path) + status_message = Signal(str, int) + + def __init__(self, config: AppConfig, start_task: Callable, + parent: QWidget | None = None) -> None: + super().__init__(parent) + self.config = config + self._start_task = start_task + self._construct_path: Path | None = None + self._base: Construct | None = None + self._circular = False + self._region: Region | None = None + self._grouping = Grouping() + self._pattern = config.group_pattern + self._reports: list[CloneReport] = [] + self._shown_construct: Construct | None = None + self._task = None + self._generation = 0 + self._loading = False + self._project_path: Path | None = None + self._selected: tuple[str, int | None] | None = None # (clone, ref_pos) + + self.empty_state = EmptyState() + self.summary = SummaryBar() + self.controls = ControlsBar() + self.matrix = CloneMatrix() + self.detail = DetailPane() + self.detail.hide() + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + scroll.setWidget(self.matrix) + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(scroll) + splitter.addWidget(self.detail) + splitter.setStretchFactor(0, 2) + splitter.setStretchFactor(1, 3) + main = QWidget() + box = QVBoxLayout(main) + box.setContentsMargins(0, 0, 0, 0) + box.setSpacing(2) + top = QHBoxLayout() + top.addWidget(self.summary, 1) + top.addWidget(self.controls.data_buttons) + box.addLayout(top) + box.addWidget(self.controls) + box.addWidget(splitter, 1) + self._main_page = main + self._pages = QStackedWidget() + self._pages.addWidget(self.empty_state) + self._pages.addWidget(main) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._pages) + self.setAcceptDrops(True) + self._connect() + self.set_theme(status.current_theme()) + self._refresh_zones() + + def _connect(self) -> None: + empty = self.empty_state + empty.construct_zone.clicked.connect(self.choose_construct) + empty.reads_zone.clicked.connect(self.choose_reads_folder) + empty.reads_files.clicked.connect(self.choose_read_files) + for zone in empty.zones(): + zone.dropped.connect(self.handle_drop) + c = self.controls + c.region_edited.connect(self.set_region) + c.whole_requested.connect(lambda: self.set_region(None)) + c.circular_toggled.connect(self.set_circular) + c.params_changed.connect(lambda: self.set_params(c.params())) + c.grouping_button.clicked.connect(self.edit_grouping) + c.reads_button.clicked.connect(self.choose_reads_folder) + c.construct_button.clicked.connect(self.choose_construct) + self.summary.filter_changed.connect(self.matrix.set_filter) + self.matrix.region_changed.connect(self.set_region) + self.matrix.difference_clicked.connect(self._show_difference) + self.matrix.clone_clicked.connect(lambda clone: self._show_difference(clone, None)) + self.detail.open_read.connect(self.open_read) + + # ------------------------------------------------------------- state API + def construct(self) -> Construct | None: + return self._base.with_circular(self._circular) if self._base else None + + def region(self) -> Region | None: + return self._region + + def grouping(self) -> Grouping: + return Grouping(list(self._grouping.reads), list(self._grouping.unassigned)) + + def params(self) -> VerifyParams: + return self.controls.params() + + def reports(self) -> list[CloneReport]: + return self._reports + + def is_verifying(self) -> bool: + return self._task is not None + + def project_path(self) -> Path | None: + return self._project_path + + def contains_read(self, path: Path | str) -> bool: + key = path_key(path) + return any(path_key(p) == key for p in self._all_paths()) + + # -------------------------------------------------------------- changes + def set_construct(self, path: Path | str) -> bool: + path = Path(path) + try: + construct = load_construct(path) + except (ValueError, OSError) as exc: + self._warn(tr("workspace.construct_failed"), f"{path}:\n{exc}") + return False + if len(construct.seq) > MAX_REFERENCE_LENGTH: + self._warn(tr("workspace.construct_failed"), + tr("workspace.construct_too_long", n=len(construct.seq), + limit=MAX_REFERENCE_LENGTH)) + return False + self._construct_path, self._base, self._circular = path, construct, construct.circular + if not self._region_ok(self._region, construct.circular): + self._region = None + self._sync_controls() + self._reverify() + return True + + def add_reads(self, paths: Iterable[Path | str]) -> int: + known = {path_key(p) for p in self._all_paths()} + new = [p for p in ab1_files(paths) if path_key(p) not in known] + if not new: + self.status_message.emit(tr("workspace.no_new_reads"), 5000) + return 0 + try: + added = group_reads(new, self._pattern or None, self.config.primer_tokens) + except ValueError as exc: # stale custom pattern in the config + self._warn(tr("grouping.title"), str(exc)) + added = group_reads(new, None, self.config.primer_tokens) + self._grouping.reads += added.reads + self._grouping.unassigned += added.unassigned + self._reverify() + return len(new) + + def set_grouping(self, grouping: Grouping) -> None: + self._grouping = Grouping(list(grouping.reads), list(grouping.unassigned)) + self._reverify() + + def set_region(self, region: Region | None) -> bool: + if region is not None: + region = (int(region[0]), int(region[1])) + if not self._region_ok(region, self._circular, warn=True): + self._sync_controls() + return False + if region == self._region: + self._sync_controls() + return True + self._region = region + self._sync_controls() + self._reverify() + return True + + def set_circular(self, flag: bool) -> bool: + if not self._region_ok(self._region, flag, warn=True): + self._sync_controls() + return False + self._circular = bool(flag) + self._sync_controls() + self._reverify() + return True + + def set_params(self, params: VerifyParams) -> None: + self.controls.set_params(params) + self._reverify() + + def read_edited(self, path: Path | str) -> bool: + """A read was edited elsewhere: re-run the check if it is part of this session.""" + if not self.contains_read(path): + return False + self._reverify() + return True + + def clear(self) -> None: + self._cancel_run() + self._construct_path = self._base = None + self._circular, self._region = False, None + self._grouping, self._reports = Grouping(), [] + self._project_path = None + self._selected = None + self._shown_construct = None + self.matrix.clear() + self.summary.clear() + self.detail.hide() + self.controls.set_params(VerifyParams()) + self._sync_controls() + self._refresh_zones() + + # --------------------------------------------------------------- project + def load_project(self, path: Path | str) -> bool: + path = Path(path) + try: + project = project_io.load_project(path) + except ValueError as exc: + self.clear() + self._warn(tr("workspace.project_failed"), str(exc)) + return False + self.clear() + self._loading = True + try: + ok = self.set_construct(project.construct_path) + self._grouping = project.grouping + self.controls.set_params(project.params) + if ok: + self._circular = project.circular + if self._region_ok(project.region, project.circular, warn=True): + self._region = project.region + self._project_path = path + finally: + self._loading = False + self._sync_controls() + self._reverify() + if ok: # without its construct the project is not open: Save asks for a file + self.status_message.emit(tr("workspace.project_loaded", name=path.name), 5000) + return ok + + def save_project(self, path: Path | str) -> bool: + path = Path(path) + if self._construct_path is None: + self._warn(tr("workspace.project_save_failed"), tr("workspace.need_construct")) + return False + project = project_io.Project(self._construct_path, self._circular, self._region, + self.grouping(), self.params()) + try: + project_io.save_project(path, project) + except OSError as exc: + self._warn(tr("workspace.project_save_failed"), f"{path}:\n{exc}") + return False + self._project_path = path + self.status_message.emit(tr("workspace.project_saved", name=path.name), 5000) + return True + + def export_report(self, path: Path | str) -> bool: + path = Path(path) + if not self._reports or self._shown_construct is None: + self._warn(tr("workspace.export_failed"), tr("workspace.no_results")) + return False + try: + write_report(path, self._reports, self._shown_construct.name) + except (OSError, RuntimeError) as exc: + self._warn(tr("workspace.export_failed"), f"{path}:\n{exc}") + return False + self.status_message.emit(tr("workspace.exported", name=path.name), 5000) + return True + + # --------------------------------------------------------------- dialogs + def choose_construct(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, tr("workspace.construct_button"), self._start_dir(), CONSTRUCT_FILTER) + if path: + self.set_construct(path) + + def choose_reads_folder(self) -> None: + folder = QFileDialog.getExistingDirectory(self, tr("workspace.add_reads"), + self._start_dir()) + if folder: + self.add_reads([folder]) + + def choose_read_files(self) -> None: + paths, _ = QFileDialog.getOpenFileNames( + self, tr("workspace.add_reads"), self._start_dir(), + "Sanger chromatograms (*.ab1);;All files (*)") + if paths: + self.add_reads(paths) + + def edit_grouping(self) -> None: + dialog = GroupingDialog(self._grouping, self._pattern, self, self.config.primer_tokens) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + if dialog.result_pattern() != self._pattern: + self._pattern = self.config.group_pattern = dialog.result_pattern() + save_config(self.config) + self.set_grouping(dialog.result_grouping()) + + def handle_drop(self, paths: list[Path]) -> None: + """Projects open, ``.ab1`` files and folders become reads, anything else + is taken as the construct.""" + paths = [Path(p) for p in paths] + project = next((p for p in paths if p.suffix.lower() == PROJECT_SUFFIX), None) + if project is not None: + self.load_project(project) + return + constructs = [p for p in paths if p.is_file() and p.suffix.lower() != ".ab1"] + reads = [p for p in paths if p.is_dir() or p.suffix.lower() == ".ab1"] + changed = False + self._loading = True + try: + if constructs: + changed = self.set_construct(constructs[0]) + if reads: + changed = self.add_reads(reads) > 0 or changed + finally: + self._loading = False + if changed: + self._reverify() + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 + if dropped_paths(event): + event.acceptProposedAction() + + def dropEvent(self, event: QDropEvent) -> None: # noqa: N802 + paths = dropped_paths(event) + if paths: + event.acceptProposedAction() + self.handle_drop(paths) + + def set_theme(self, name: str) -> None: + self.summary.set_theme(name) + self.matrix.set_theme(name) + self.detail.set_theme(name) + self.empty_state.set_theme(name) + + # ------------------------------------------------------------ verification + def _reverify(self) -> None: + if self._loading: + return + self._refresh_zones() + construct = self.construct() + if construct is None: + return + self._cancel_run() + if not self._grouping.reads: + self._show([], construct, self._region) + return + region, grouping, params = self._region, self.grouping(), self.params() + generation = self._generation + + def work(progress, cancelled): + return verify_all(construct, region, grouping, params, progress, cancelled) + + def done(reports): + if generation == self._generation: + self._task = None + self._show(reports, construct, region) + + task = self._start_task(work, done, tr("workspace.verify_failed"), key="verify") + self._task = task + task.cancelled.connect(lambda: self._task_ended(task)) + task.failed.connect(lambda _m: self._task_ended(task)) + + def _task_ended(self, task) -> None: + if self._task is task: + self._task = None + + def _cancel_run(self) -> None: + self._generation += 1 + task, self._task = self._task, None + if task is not None: + task.cancel() + + def _show(self, reports: list[CloneReport], construct: Construct, + region: Region | None) -> None: + self._reports, self._shown_construct = reports, construct + self.summary.set_data(construct, region, reports) + self.matrix.set_data(construct, region, reports) + self.matrix.set_filter(self.summary.shown_verdicts()) + if self._selected is not None: + clone, pos = self._selected + if any(r.clone == clone for r in reports): + self._show_difference(clone, pos) + else: + self._selected = None + self.detail.hide() + if reports: + self.status_message.emit(tr("workspace.verified", n=len(reports)), 5000) + + def _show_difference(self, clone: str, ref_pos: int | None) -> None: + report = next((r for r in self._reports if r.clone == clone), None) + if report is None or self._shown_construct is None: + return + self._selected = (clone, ref_pos) + self.detail.show() + self.detail.show_clone(report, self._shown_construct) + if ref_pos is not None: + self.detail.show_position(ref_pos) + + # --------------------------------------------------------------- helpers + def _region_ok(self, region: Region | None, circular: bool, warn: bool = False) -> bool: + if region is None or self._base is None: + return True + try: + check_region(region, len(self._base.seq), circular) + except ValueError as exc: + if warn: + start, end = region + self._warn(tr("workspace.region_invalid"), + tr("workspace.region_invalid_text", start=start + 1, end=end, + reason=exc)) + return False + return True + + def _sync_controls(self) -> None: + length = len(self._base.seq) if self._base else 1 + self.controls.set_region(self._region, length) + self.controls.set_circular(self._circular) + + def _refresh_zones(self) -> None: + empty = self.empty_state + empty.construct_zone.set_done( + f"{self._base.name} · {self._construct_path.name}" if self._base else "") + n = len(self._grouping.reads) + len(self._grouping.unassigned) + empty.reads_zone.set_done(tr("workspace.reads_loaded", n=n) if n else "") + ready = self._base is not None and n > 0 + self._pages.setCurrentWidget(self._main_page if ready else self.empty_state) + + def _all_paths(self) -> list[Path]: + return [r.path for r in self._grouping.reads] + list(self._grouping.unassigned) + + def _start_dir(self) -> str: + if self._project_path is not None: + return str(self._project_path.parent) + if self._construct_path is not None: + return str(self._construct_path.parent) + return "" + + def _warn(self, title: str, text: str) -> None: + QMessageBox.warning(self, title, text) diff --git a/ui/icons/LICENSE b/ui/icons/LICENSE new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/ui/icons/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/icons/README.md b/ui/icons/README.md new file mode 100644 index 0000000..9869754 --- /dev/null +++ b/ui/icons/README.md @@ -0,0 +1,3 @@ +Icons are from Lucide (https://lucide.dev), release 1.65.0, downloaded from +https://raw.githubusercontent.com/lucide-icons/lucide/1.65.0/icons/.svg +License: ISC (some icons MIT, Feather) - see LICENSE in this folder. Only the icons used by ab1kit are included. diff --git a/ui/icons/app/ab1kit.ico b/ui/icons/app/ab1kit.ico new file mode 100644 index 0000000..ad1ef34 Binary files /dev/null and b/ui/icons/app/ab1kit.ico differ diff --git a/ui/icons/app/ab1kit.png b/ui/icons/app/ab1kit.png new file mode 100644 index 0000000..a772ebb Binary files /dev/null and b/ui/icons/app/ab1kit.png differ diff --git a/ui/icons/app/ab1kit.svg b/ui/icons/app/ab1kit.svg new file mode 100644 index 0000000..29aa796 --- /dev/null +++ b/ui/icons/app/ab1kit.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/icons/check.svg b/ui/icons/check.svg new file mode 100644 index 0000000..5abe152 --- /dev/null +++ b/ui/icons/check.svg @@ -0,0 +1,13 @@ + + + diff --git a/ui/icons/chevron-down.svg b/ui/icons/chevron-down.svg new file mode 100644 index 0000000..e70d30b --- /dev/null +++ b/ui/icons/chevron-down.svg @@ -0,0 +1,13 @@ + + + diff --git a/ui/icons/chevron-up.svg b/ui/icons/chevron-up.svg new file mode 100644 index 0000000..b011f77 --- /dev/null +++ b/ui/icons/chevron-up.svg @@ -0,0 +1,13 @@ + + + diff --git a/ui/icons/copy.svg b/ui/icons/copy.svg new file mode 100644 index 0000000..f62ce99 --- /dev/null +++ b/ui/icons/copy.svg @@ -0,0 +1,14 @@ + + + + diff --git a/ui/icons/dna.svg b/ui/icons/dna.svg new file mode 100644 index 0000000..f2e63c0 --- /dev/null +++ b/ui/icons/dna.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/ui/icons/flask-conical.svg b/ui/icons/flask-conical.svg new file mode 100644 index 0000000..5917608 --- /dev/null +++ b/ui/icons/flask-conical.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/icons/folder-open.svg b/ui/icons/folder-open.svg new file mode 100644 index 0000000..b01f60f --- /dev/null +++ b/ui/icons/folder-open.svg @@ -0,0 +1,13 @@ + + + diff --git a/ui/icons/globe.svg b/ui/icons/globe.svg new file mode 100644 index 0000000..53c80ee --- /dev/null +++ b/ui/icons/globe.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/icons/info.svg b/ui/icons/info.svg new file mode 100644 index 0000000..2a46eac --- /dev/null +++ b/ui/icons/info.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/icons/maximize.svg b/ui/icons/maximize.svg new file mode 100644 index 0000000..f78ca32 --- /dev/null +++ b/ui/icons/maximize.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/ui/icons/moon.svg b/ui/icons/moon.svg new file mode 100644 index 0000000..3c4bc31 --- /dev/null +++ b/ui/icons/moon.svg @@ -0,0 +1,13 @@ + + + diff --git a/ui/icons/redo-2.svg b/ui/icons/redo-2.svg new file mode 100644 index 0000000..72371c8 --- /dev/null +++ b/ui/icons/redo-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/ui/icons/rotate-ccw.svg b/ui/icons/rotate-ccw.svg new file mode 100644 index 0000000..eb0fe34 --- /dev/null +++ b/ui/icons/rotate-ccw.svg @@ -0,0 +1,14 @@ + + + + diff --git a/ui/icons/save.svg b/ui/icons/save.svg new file mode 100644 index 0000000..07af070 --- /dev/null +++ b/ui/icons/save.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/icons/scissors.svg b/ui/icons/scissors.svg new file mode 100644 index 0000000..f87bf20 --- /dev/null +++ b/ui/icons/scissors.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/ui/icons/search.svg b/ui/icons/search.svg new file mode 100644 index 0000000..03e442a --- /dev/null +++ b/ui/icons/search.svg @@ -0,0 +1,14 @@ + + + + diff --git a/ui/icons/table-2.svg b/ui/icons/table-2.svg new file mode 100644 index 0000000..fa89be6 --- /dev/null +++ b/ui/icons/table-2.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/icons/undo-2.svg b/ui/icons/undo-2.svg new file mode 100644 index 0000000..3e93594 --- /dev/null +++ b/ui/icons/undo-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/ui/icons/zoom-in.svg b/ui/icons/zoom-in.svg new file mode 100644 index 0000000..11173c0 --- /dev/null +++ b/ui/icons/zoom-in.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/ui/icons/zoom-out.svg b/ui/icons/zoom-out.svg new file mode 100644 index 0000000..70abec4 --- /dev/null +++ b/ui/icons/zoom-out.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/main_window.py b/ui/main_window.py index 7fc29cf..8446515 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,4 +1,7 @@ -"""Main ab1kit window: toolbar, menus, overview map, chromatogram, docks.""" +"""ab1kit shell window: tabs "construct check" / "read viewer", File/Project/ +View/Help menus, language, theme, recent files, background-task status bar. +The single-read viewer lives in ``ui.read_view``, the construct check in +``ui.construct.workspace``.""" from __future__ import annotations @@ -6,47 +9,25 @@ from importlib.metadata import PackageNotFoundError, version as pkg_version from pathlib import Path -from PySide6.QtCore import QMarginsF, QSizeF, Qt, QUrl, Signal -from PySide6.QtGui import QAction, QColor, QDesktopServices, QDragEnterEvent, QDropEvent, QPageSize, QPainter, QPdfWriter +from PySide6.QtGui import QAction, QDragEnterEvent, QDropEvent from PySide6.QtWidgets import ( QApplication, - QCheckBox, - QDialog, - QDockWidget, QFileDialog, - QFormLayout, - QHBoxLayout, - QInputDialog, - QLabel, - QLineEdit, QMainWindow, - QMenu, QMessageBox, + QProgressBar, QPushButton, - QScrollBar, - QSpinBox, - QPlainTextEdit, - QTableWidget, - QTableWidgetItem, - QToolBar, - QVBoxLayout, - QWidget, + QTabWidget, ) -from chrom_core import Chromatogram, EditableRead, batch, read_ab1, trim_window -from chrom_core import blast as blast_api -from chrom_core import export, sessions -from chrom_core.align import ReadAlignment, align_read, gapped_sequences, parse_sequence from chrom_core.config import AppConfig, load_config, save_config -from chrom_core.editing import INSERT_LETTERS, IUPAC_LETTERS -from chrom_core.snps import call_snps -from chrom_core.verify import parse_expected, verify_expected, verdicts_csv, verdicts_xlsx -from chrom_core.seqops import find_matches from i18n import set_language, tr -from ui.batch_panel import BatchPanel -from ui.chrom_view import ChromatogramWidget -from ui.minimap import MiniMap -from ui.theme import app_qss, theme as palette +from ui import status +from ui.construct.workspace import PROJECT_SUFFIX, ConstructWorkspace +from ui.read_view import ReadView +from ui.status import bind_icon +from ui.tasks import Task, run_task +from ui.theme import app_icon, app_qss try: VERSION = pkg_version("ab1kit") @@ -54,265 +35,163 @@ VERSION = "dev" -class SearchDialog(QDialog): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setWindowTitle(tr("search.title")) - layout = QVBoxLayout(self) - self.edit = QLineEdit() - self.edit.setPlaceholderText(tr("search.pattern")) - self.reverse_box = QCheckBox(tr("search.reverse")) - button = QPushButton(tr("search.button")) - button.setDefault(True) - button.clicked.connect(self.accept) - layout.addWidget(self.edit) - layout.addWidget(self.reverse_box) - layout.addWidget(button) - - def pattern(self) -> str: - return self.edit.text().strip().upper() - - def want_reverse(self) -> bool: - return self.reverse_box.isChecked() - - -class TrimDialog(QDialog): - """Non-modal quality-trim dialog; preview streams into the chromatogram.""" - - region_changed = Signal(int, int) - apply_requested = Signal(int, int) - - def __init__(self, quality_fn, window: int, min_q: int, total: int, parent=None) -> None: - super().__init__(parent) - self.setWindowTitle(tr("trim.title")) - self.quality_fn = quality_fn - self.total = total - self._region = (0, total) - - layout = QVBoxLayout(self) - form = QFormLayout() - self.window_spin = QSpinBox() - self.window_spin.setRange(2, 100) - self.window_spin.setValue(window) - self.minq_spin = QSpinBox() - self.minq_spin.setRange(0, 60) - self.minq_spin.setValue(min_q) - form.addRow(QLabel(tr("trim.window")), self.window_spin) - form.addRow(QLabel(tr("trim.minq")), self.minq_spin) - layout.addLayout(form) - self.preview = QLabel() - layout.addWidget(self.preview) - buttons = QWidget() - row = QVBoxLayout(buttons) - apply_button = QPushButton(tr("trim.apply")) - apply_button.setDefault(True) - apply_button.clicked.connect(self._apply) - close_button = QPushButton(tr("dialog.close")) - close_button.clicked.connect(self.close) - row.addWidget(apply_button) - row.addWidget(close_button) - layout.addWidget(buttons) - - self.window_spin.valueChanged.connect(self._recompute) - self.minq_spin.valueChanged.connect(self._recompute) - self._recompute() - - def window_value(self) -> int: - return self.window_spin.value() - - def minq_value(self) -> int: - return self.minq_spin.value() - - def _recompute(self) -> None: - start, end = trim_window(self.quality_fn(), self.window_spin.value(), self.minq_spin.value()) - self._set_region(start, end) - self.region_changed.emit(start, end) - - def _set_region(self, start: int, end: int) -> None: - self._region = (start, end) - self.preview.setText(tr("trim.preview", n=end - start, total=self.total)) - - def set_region(self, start: int, end: int) -> None: - """Sync preview when the user drags trim handles in the view.""" - self._set_region(start, end) - - def _apply(self) -> None: - start, end = self._region - self.apply_requested.emit(start, end) - self.close() +def _delegate(name: str) -> property: + """Read-only view onto ``ReadView.`` (keeps the old MainWindow API).""" + return property(lambda self: getattr(self.read_view, name)) class MainWindow(QMainWindow): + # State and widgets that used to live on the window itself. + read_model = _delegate("read_model") + chrom_view = _delegate("chrom_view") + minimap = _delegate("minimap") + trim_dialog = _delegate("trim_dialog") + reference_seq = _delegate("reference_seq") + alignment = _delegate("alignment") + snp_calls = _delegate("snp_calls") + variant_verdicts = _delegate("variant_verdicts") + batch_reports = _delegate("batch_reports") + batch_panel = _delegate("batch_panel") + batch_dock = _delegate("batch_dock") + diff_dock = _delegate("diff_dock") + diff_table = _delegate("diff_table") + verify_dock = _delegate("verify_dock") + verify_table = _delegate("verify_table") + undo_action = _delegate("undo_action") + redo_action = _delegate("redo_action") + def __init__(self, config: AppConfig | None = None) -> None: super().__init__() self.config = config or load_config() set_language(self.config.language) + # App-wide and idempotent; here (not only in run()) so directly constructed + # windows, e.g. in tests, carry the icon too. + QApplication.instance().setWindowIcon(app_icon()) self.setWindowTitle("ab1kit") self.resize(1200, 780) - self._path: Path | None = None - self.read_model: EditableRead | None = None - self.trim_dialog: TrimDialog | None = None - self.batch_reports: list[batch.FileReport] = [] - self.reference_seq: str = "" - self.alignment: ReadAlignment | None = None - self.snp_calls: list = [] - self.variant_verdicts: list = [] - self._search_ranges: list[tuple[int, int]] = [] - self._search_pos = -1 - - self.chrom_view = ChromatogramWidget() - self.minimap = MiniMap() - self.scrollbar = QScrollBar(Qt.Orientation.Horizontal) - - central = QWidget() - layout = QVBoxLayout(central) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(self.minimap) - layout.addWidget(self.chrom_view, 1) - layout.addWidget(self.scrollbar) - self.setCentralWidget(central) - - self._channel_boxes: dict[str, QCheckBox] = {} - self._build_toolbar() - self._build_info_dock() - self._build_batch_dock() - self._build_analysis_dock() - self._build_verify_dock() + self.read_view = ReadView(self.config, self._start_task) + self.workspace = ConstructWorkspace(self.config, self._start_task) + self.tabs = QTabWidget() + self.tabs.setDocumentMode(True) + self.tabs.addTab(self.workspace, tr("tab.construct")) + self.tabs.addTab(self.read_view, tr("tab.read")) + self.setCentralWidget(self.tabs) + self.tabs.currentChanged.connect( + lambda _i: self.read_view.set_active(self.tabs.currentWidget() is self.read_view)) + self.read_view.file_opened.connect(self._push_recent) + self.read_view.file_opened.connect(lambda _p: self.tabs.setCurrentWidget(self.read_view)) + self.read_view.status_message.connect(self.statusBar().showMessage) + self.read_view.title_changed.connect(self.setWindowTitle) + self.read_view.read_edited.connect(self._read_edited) + self.workspace.status_message.connect(self.statusBar().showMessage) + self.workspace.open_read.connect(self._open_read) + self._build_menus() - self._connect() self.setAcceptDrops(True) self.set_theme(self.config.theme) self.statusBar().showMessage(tr("status.ready")) + self._build_task_status() self._fill_recent_menu() - - # ------------------------------------------------------------------ setup - def _build_toolbar(self) -> None: - bar = QToolBar() - bar.setMovable(False) - self.addToolBar(bar) - - def add(text: str, slot, shortcut: str | None = None) -> None: - action = QAction(text, self) - if shortcut: - action.setShortcut(shortcut) - action.triggered.connect(slot) - bar.addAction(action) - - add(tr("tb.open"), self._open_dialog) - bar.addSeparator() - add(tr("tb.zoom_in"), self.chrom_view.zoom_in, "Ctrl++") - add(tr("tb.zoom_out"), self.chrom_view.zoom_out, "Ctrl+-") - add(tr("tb.fit"), self.chrom_view.zoom_fit) - add(tr("tb.reset"), self.chrom_view.zoom_reset) - bar.addSeparator() - for base in "ACGT": - box = QCheckBox(base) - box.setChecked(True) - box.toggled.connect( - lambda on, b=base: self.chrom_view.set_visible_channel(b, on) - ) - bar.addWidget(box) - self._channel_boxes[base] = box - + self.read_view.set_active(self.tabs.currentWidget() is self.read_view) + + # ------------------------------------------------------------------ tasks + def _build_task_status(self) -> None: + self._active_tasks: set[Task] = set() + self._keyed_tasks: dict[str, Task] = {} + self.task_progress = QProgressBar() + self.task_progress.setMaximumWidth(180) + self.task_progress.setFormat(tr("task.running")) + self.task_cancel_button = QPushButton(tr("task.cancel")) + self.task_cancel_button.clicked.connect(self._cancel_tasks) + for widget in (self.task_progress, self.task_cancel_button): + self.statusBar().addPermanentWidget(widget) + widget.hide() + + def _cancel_tasks(self) -> None: + for task in list(self._active_tasks): + task.cancel() + + def _start_task(self, fn, on_finished, error_title: str, key: str = "") -> Task: + """Run ``fn`` in the background; ``on_finished(result)`` runs in the GUI + thread unless the task was cancelled or failed. A new task with the + same ``key`` cancels the previous one (last start wins).""" + if key and key in self._keyed_tasks: + self._keyed_tasks.pop(key).cancel() + task = run_task(fn, self) + if key: + self._keyed_tasks[key] = task + self._active_tasks.add(task) + self.task_progress.setRange(0, 0) # busy until progress arrives + self.task_progress.show() + self.task_cancel_button.show() + + def release() -> None: + self._active_tasks.discard(task) + if key and self._keyed_tasks.get(key) is task: + del self._keyed_tasks[key] + if not self._active_tasks: + self.task_progress.hide() + self.task_cancel_button.hide() + + def on_progress(done: int, total: int) -> None: + if total > 0: + self.task_progress.setRange(0, total) + self.task_progress.setValue(done) + + def on_failed(message: str) -> None: + release() + QMessageBox.warning(self, error_title, message) + + def on_cancelled() -> None: + release() + self.statusBar().showMessage(tr("task.cancelled"), 5000) + + def on_done(result: object) -> None: + release() + on_finished(result) + + task.progress.connect(on_progress) + task.failed.connect(on_failed) + task.cancelled.connect(on_cancelled) + task.finished.connect(on_done) + return task + + # ------------------------------------------------------------------ menus def _build_menus(self) -> None: file_menu = self.menuBar().addMenu(tr("menu.file")) self._file_menu = file_menu open_action = QAction(tr("menu.file.open"), self) + bind_icon(open_action, "folder-open") open_action.setShortcut("Ctrl+O") - open_action.triggered.connect(self._open_dialog) + open_action.triggered.connect(lambda: self._in_read_tab(self.read_view.open_dialog)) file_menu.addAction(open_action) open_folder = QAction(tr("menu.file.open_folder"), self) open_folder.setShortcut("Ctrl+Shift+O") - open_folder.triggered.connect(self._open_folder_dialog) + open_folder.triggered.connect( + lambda: self._in_read_tab(self.read_view.open_folder_dialog)) file_menu.addAction(open_folder) self.recent_menu = file_menu.addMenu(tr("menu.file.recent")) - save_session = QAction(tr("menu.file.save_session"), self) - save_session.setShortcut("Ctrl+S") - save_session.triggered.connect(self._save_session) - file_menu.addAction(save_session) - export_menu = file_menu.addMenu(tr("menu.file.export")) - for text, slot in ( - (tr("menu.file.export_fasta"), self._export_fasta), - (tr("menu.file.export_fastq"), self._export_fastq), - (tr("menu.file.export_png"), self._export_png), - (tr("menu.file.export_pdf"), self._export_pdf), - ): - action = QAction(text, self) - action.triggered.connect(slot) - export_menu.addAction(action) - file_menu.addSeparator() - copy_action = QAction(tr("menu.file.copy_seq"), self) - copy_action.setShortcut("Ctrl+Shift+C") - copy_action.triggered.connect(self._copy_sequence) - file_menu.addAction(copy_action) + self.read_view.add_file_actions(file_menu) file_menu.addSeparator() exit_action = QAction(tr("menu.file.exit"), self) exit_action.setShortcut("Ctrl+Q") exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) - edit_menu = self.menuBar().addMenu(tr("menu.edit")) - self.undo_action = QAction(tr("menu.edit.undo"), self) - self.undo_action.setShortcut("Ctrl+Z") - self.undo_action.triggered.connect(self.undo_read) - self.undo_action.setEnabled(False) - edit_menu.addAction(self.undo_action) - self.redo_action = QAction(tr("menu.edit.redo"), self) - self.redo_action.setShortcut("Ctrl+Y") - self.redo_action.triggered.connect(self.redo_read) - self.redo_action.setEnabled(False) - edit_menu.addAction(self.redo_action) - edit_menu.addSeparator() - find_action = QAction(tr("menu.edit.find"), self) - find_action.setShortcut("Ctrl+F") - find_action.triggered.connect(self._search) - edit_menu.addAction(find_action) - find_next = QAction(tr("menu.edit.find_next"), self) - find_next.setShortcut("F3") - find_next.triggered.connect(self._find_next) - edit_menu.addAction(find_next) - goto_action = QAction(tr("menu.edit.goto"), self) - goto_action.setShortcut("Ctrl+G") - goto_action.triggered.connect(self._goto) - edit_menu.addAction(goto_action) - edit_menu.addSeparator() - revcomp_action = QAction(tr("menu.edit.revcomp"), self) - revcomp_action.setShortcut("Ctrl+R") - revcomp_action.triggered.connect(self.reverse_complement_read) - edit_menu.addAction(revcomp_action) - trim_action = QAction(tr("menu.edit.trim"), self) - trim_action.setShortcut("Ctrl+T") - trim_action.triggered.connect(self._open_trim_dialog) - edit_menu.addAction(trim_action) + self._build_project_menu(self.menuBar().addMenu(tr("menu.project"))) + self.read_view.add_edit_menu(self.menuBar().addMenu(tr("menu.edit"))) view_menu = self.menuBar().addMenu(tr("menu.view")) self.dark_action = QAction(tr("menu.view.dark_theme"), self) + bind_icon(self.dark_action, "moon") self.dark_action.setCheckable(True) self.dark_action.setChecked(self.config.theme == "dark") self.dark_action.toggled.connect( lambda on: self.set_theme("dark" if on else "light") ) view_menu.addAction(self.dark_action) - smooth_action = QAction(tr("menu.view.smoothing"), self) - smooth_action.setCheckable(True) - smooth_action.toggled.connect(self.chrom_view.set_smoothing) - view_menu.addAction(smooth_action) - profile_action = QAction(tr("menu.view.quality_profile"), self) - profile_action.setCheckable(True) - profile_action.toggled.connect(self.minimap.set_profile) - view_menu.addAction(profile_action) - info_action = QAction(tr("menu.view.file_info"), self) - info_action.setCheckable(True) - info_action.toggled.connect(self.info_dock.setVisible) - self.info_dock.visibilityChanged.connect(info_action.setChecked) - view_menu.addAction(info_action) - batch_action = QAction(tr("menu.view.batch_panel"), self) - batch_action.setCheckable(True) - batch_action.toggled.connect(self.batch_dock.setVisible) - self.batch_dock.visibilityChanged.connect(batch_action.setChecked) - view_menu.addAction(batch_action) + self.read_view.add_view_actions(view_menu) lang_menu = view_menu.addMenu(tr("menu.view.language")) self._lang_actions = {} for code, label in (("ru", tr("lang.ru")), ("en", tr("lang.en"))): @@ -323,27 +202,7 @@ def _build_menus(self) -> None: lang_menu.addAction(action) self._lang_actions[code] = action - analysis_menu = self.menuBar().addMenu(tr("menu.analysis")) - reference_action = QAction(tr("menu.analysis.reference"), self) - reference_action.setShortcut("Ctrl+L") - reference_action.triggered.connect(self._reference_dialog) - analysis_menu.addAction(reference_action) - clear_ref = QAction(tr("menu.analysis.clear_reference"), self) - clear_ref.triggered.connect(self._clear_reference) - analysis_menu.addAction(clear_ref) - analysis_menu.addSeparator() - export_aligned = QAction(tr("menu.analysis.export_aligned"), self) - export_aligned.triggered.connect(self._export_aligned) - analysis_menu.addAction(export_aligned) - analysis_menu.addSeparator() - verify_action = QAction(tr("menu.analysis.verify"), self) - verify_action.setShortcut("Ctrl+Shift+V") - verify_action.triggered.connect(self._verify_dialog) - analysis_menu.addAction(verify_action) - analysis_menu.addSeparator() - blast_action = QAction(tr("menu.analysis.blast"), self) - blast_action.triggered.connect(self._blast_submit) - analysis_menu.addAction(blast_action) + self.read_view.add_analysis_menu(self.menuBar().addMenu(tr("menu.analysis"))) help_menu = self.menuBar().addMenu(tr("menu.help")) about_action = QAction(tr("menu.help.about"), self) @@ -352,309 +211,80 @@ def _build_menus(self) -> None: ) help_menu.addAction(about_action) - def _build_info_dock(self) -> None: - self.info_dock = QDockWidget(tr("info.title"), self) - self.info_dock.setWidget(QWidget()) - self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.info_dock) - self.info_dock.hide() - - def _build_batch_dock(self) -> None: - self.batch_dock = QDockWidget(tr("batch.title"), self) - self.batch_panel = BatchPanel() - self.batch_dock.setWidget(self.batch_panel) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.batch_dock) - self.batch_dock.hide() - self.batch_panel.open_requested.connect(self.open_path) - self.batch_panel.export_button.clicked.connect(self._batch_export) - self.batch_panel.csv_button.clicked.connect(self._batch_save_csv) - - def _build_analysis_dock(self) -> None: - self.diff_dock = QDockWidget(tr("analysis.diffs.title"), self) - self.diff_table = QTableWidget(0, 8) - self.diff_table.setHorizontalHeaderLabels( - [ - tr("analysis.col.pos"), - tr("analysis.col.kind"), - tr("analysis.col.ref"), - tr("analysis.col.read"), - tr("analysis.col.readpos"), - tr("snp.col.verdict"), - tr("snp.col.q"), - tr("snp.col.ratio"), - ] - ) - self.diff_table.verticalHeader().setVisible(False) - self.diff_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.diff_table.setSortingEnabled(True) - self.diff_dock.setWidget(self.diff_table) - self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.diff_dock) - self.diff_dock.hide() - - def _build_verify_dock(self) -> None: - self.verify_dock = QDockWidget(tr("verify.title"), self) - self.verify_table = QTableWidget(0, 8) - self.verify_table.setHorizontalHeaderLabels( - [ - tr("verify.col.name"), - tr("verify.col.pos"), - tr("verify.col.ref"), - tr("verify.col.alt"), - tr("verify.col.verdict"), - tr("verify.col.observed"), - tr("verify.col.q"), - tr("verify.col.note"), - ] - ) - self.verify_table.verticalHeader().setVisible(False) - self.verify_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.verify_table.setSortingEnabled(True) - buttons = QWidget() - row = QHBoxLayout(buttons) - csv_button = QPushButton(tr("verify.save_csv")) - csv_button.clicked.connect(self._save_verify_csv) - xlsx_button = QPushButton(tr("verify.save_xlsx")) - xlsx_button.clicked.connect(self._save_verify_xlsx) - row.addWidget(csv_button) - row.addWidget(xlsx_button) - row.addStretch(1) - container = QWidget() - layout = QVBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.verify_table) - layout.addWidget(buttons) - self.verify_dock.setWidget(container) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.verify_dock) - self.verify_dock.hide() - - def _connect(self) -> None: - self.chrom_view.view_changed.connect(self._sync_from_view) - self.chrom_view.cursor_info.connect(self.statusBar().showMessage) - self.chrom_view.base_clicked.connect(self._on_base_clicked) - self.minimap.request_center.connect(self.chrom_view.center_on_point) - self.scrollbar.valueChanged.connect( - lambda value: self.chrom_view.set_offset(float(value)) - ) + def _build_project_menu(self, menu) -> None: + self.project_menu = menu + for key, shortcut, slot in ( + ("menu.project.new", "", self._project_new), + ("menu.project.open", "Ctrl+Shift+P", self._project_open), + ("menu.project.save", "Ctrl+Shift+S", self._project_save), + ("menu.project.save_as", "", self._project_save_as), + (None, "", None), + ("menu.project.export", "Ctrl+Shift+E", self._project_export), + ): + if key is None: + menu.addSeparator() + continue + action = QAction(tr(key), self) + if shortcut: + action.setShortcut(shortcut) + action.triggered.connect(slot) + menu.addAction(action) - # ---------------------------------------------------------------- opening - def open_path(self, path: str | Path) -> bool: - try: - chrom = read_ab1(path) - except Exception as exc: # user-facing: any parse failure becomes a dialog - QMessageBox.critical(self, tr("err.open_failed"), f"{path}:\n{exc}") - return False - self._path = Path(path) - model = EditableRead(chrom) - restored = self._try_restore_session(model) - self.read_model = model - self._search_ranges = [] - self._search_pos = -1 - self.chrom_view.set_chromatogram(model.current()) - self.chrom_view.set_edited(model.edited) - self.minimap.set_chromatogram(model.current()) - self.minimap.set_highlights([]) - self._push_recent(str(self._path)) - self._refresh_info(model.current()) - self._sync_actions() - if self.reference_seq: - self._realign() - n = len(model.seq) - mean_q = sum(model.quality) / max(1, n) - message = tr("status.file", n=n, q=mean_q) - if restored: - message += " · " + tr("session.loaded") - self.statusBar().showMessage(message, 8000) - self.setWindowTitle(f"ab1kit — {self._path.name}" + ("*" if model.modified else "")) - return True - - def _try_restore_session(self, model: EditableRead) -> bool: - sidecar = sessions.session_path(self._path) - data = sessions.load_session(sidecar) - if not data or data.get("source") != self._path.name: - return False - try: - model.restore_state(data) - except (KeyError, TypeError, ValueError): - return False - return True - - def _open_dialog(self) -> None: + # --------------------------------------------------------------- projects + def _show_workspace(self) -> None: + self.tabs.setCurrentWidget(self.workspace) + + def _project_new(self) -> None: + self.workspace.clear() + self._show_workspace() + + def _project_open(self) -> None: path, _ = QFileDialog.getOpenFileName( - self, - tr("menu.file.open"), - str(self._path.parent) if self._path else "", - "Sanger chromatograms (*.ab1);;All files (*)", - ) + self, tr("menu.project.open"), "", f"ab1kit project (*{PROJECT_SUFFIX})") if path: self.open_path(path) - # --------------------------------------------------------------- analysis - def _reference_dialog(self) -> None: - dialog = QDialog(self) - dialog.setWindowTitle(tr("analysis.ref.title")) - dialog.resize(480, 240) - layout = QVBoxLayout(dialog) - edit = QPlainTextEdit() - edit.setPlaceholderText(tr("analysis.ref.paste")) - layout.addWidget(edit) - - def from_file() -> None: - path, _ = QFileDialog.getOpenFileName( - dialog, tr("analysis.ref.from_file"), "", "Sequence (*.fasta *.fa *.txt);;All files (*)" - ) - if path: - edit.setPlainText(Path(path).read_text(encoding="utf-8", errors="replace")) - - file_button = QPushButton(tr("analysis.ref.from_file")) - file_button.clicked.connect(from_file) - layout.addWidget(file_button) - - ok = QPushButton("OK") - ok.clicked.connect(dialog.accept) - cancel = QPushButton(tr("dialog.close")) - cancel.clicked.connect(dialog.reject) - row = QWidget() - row_layout = QHBoxLayout(row) - row_layout.addWidget(ok) - row_layout.addWidget(cancel) - layout.addWidget(row) - - if dialog.exec() != QDialog.DialogCode.Accepted: - return - try: - sequence = parse_sequence(edit.toPlainText()) - except ValueError as exc: - QMessageBox.warning(self, tr("analysis.ref.title"), str(exc)) - return - if not sequence: - return - self._set_reference_seq(sequence) - - def _set_reference_seq(self, sequence: str) -> None: - self.reference_seq = sequence - if self.read_model is not None: - self._realign() - else: - self.statusBar().showMessage(tr("analysis.need_read"), 5000) + def _project_save(self) -> None: + path = self.workspace.project_path() + if path is None: + self._project_save_as() + elif self.workspace.save_project(path): + self._push_recent(str(path)) - def _clear_reference(self) -> None: - self.reference_seq = "" - self.alignment = None - self.chrom_view.set_reference(None) - self.diff_table.setRowCount(0) - self.diff_dock.hide() - - def _realign(self) -> None: - if not (self.read_model and self.reference_seq): - return - try: - self.alignment = align_read("".join(self.read_model.seq), self.reference_seq) - except ValueError as exc: - QMessageBox.warning(self, tr("analysis.ref.title"), str(exc)) - self._clear_reference() + def _project_save_as(self) -> None: + self._show_workspace() + path, _ = QFileDialog.getSaveFileName( + self, tr("menu.project.save_as"), "", f"ab1kit project (*{PROJECT_SUFFIX})") + if not path: return - self.chrom_view.set_reference(self.reference_seq, self.alignment.ref_to_read) - chrom = self.read_model.current() - self.snp_calls = call_snps( - self.alignment, chrom, - min_q=self.config.snp_min_q, het_ratio=self.config.het_ratio, - ) - self._fill_diff_table() - self.diff_dock.setVisible(bool(self.alignment.differences)) - pct = f"{self.alignment.identity * 100:.1f}" - if self.alignment.differences: - message = tr( - "analysis.status", - n=len(self.alignment.differences), - pct=pct, - rstart=self.alignment.ref_start + 1, - rend=self.alignment.ref_end, - ) - else: - message = tr("analysis.no_diffs") - if self.snp_calls: - counts = {k: sum(1 for c in self.snp_calls if c.kind == k) - for k in ("homozygous", "heterozygous", "low_quality")} - message += " · " + tr( - "snp.summary", - homo=counts["homozygous"], - het=counts["heterozygous"], - low=counts["low_quality"], - ) - if self.alignment.alternatives > 1: - message += " · " + tr("analysis.ambiguous", n=self.alignment.alternatives) - self.statusBar().showMessage(message, 8000) - - def _fill_diff_table(self) -> None: - kind_labels = { - "substitution": tr("analysis.kind.substitution"), - "deletion": tr("analysis.kind.deletion"), - "insertion": tr("analysis.kind.insertion"), - } - verdict_labels = { - "homozygous": tr("snp.homozygous"), - "heterozygous": tr("snp.heterozygous"), - "low_quality": tr("snp.low_quality"), - } - calls_by_ref = {c.ref_pos: c for c in getattr(self, "snp_calls", [])} - diffs = self.alignment.differences if self.alignment else [] - self.diff_table.setSortingEnabled(False) - self.diff_table.setRowCount(len(diffs)) - for row, diff in enumerate(diffs): - call = calls_by_ref.get(diff.ref_pos) - verdict = "" - if call is not None: - verdict = verdict_labels.get(call.kind, call.kind) - if call.kind == "heterozygous": - verdict += f" ({call.iupac})" - values = [ - diff.ref_pos + 1, - kind_labels.get(diff.kind, diff.kind), - diff.ref_base, - diff.read_base, - (call.read_pos + 1 if call is not None - else diff.read_pos + 1 if diff.read_pos is not None else ""), - verdict, - call.quality if call is not None else "", - f"{call.ratio:.2f}" if call is not None and call.ratio is not None else "", - ] - for col, value in enumerate(values): - item = QTableWidgetItem() - item.setData(Qt.ItemDataRole.DisplayRole, value) - if call is not None and col == 5 and call.kind == "heterozygous": - item.setForeground(QColor("#a05000")) - self.diff_table.setItem(row, col, item) - self.diff_table.setSortingEnabled(True) + if not path.lower().endswith(PROJECT_SUFFIX): + path += PROJECT_SUFFIX + if self.workspace.save_project(path): + self._push_recent(path) - def _export_aligned(self) -> None: - if not (self.alignment and self.reference_seq and self.read_model): - return - path = self._export_target( - tr("menu.analysis.export_aligned"), ".aligned.fasta", "FASTA (*.fasta)" - ) + def _project_export(self) -> None: + self._show_workspace() + path, chosen = QFileDialog.getSaveFileName( + self, tr("menu.project.export"), "", "Excel (*.xlsx);;CSV (*.csv)") if not path: return - ref_aligned, read_aligned = gapped_sequences(self.alignment, self.reference_seq) - sample = self._export_id() - strand = "reverse" if self.alignment.strand == "-" else "forward" - content = ( - export.to_fasta(f"{sample}_reference[{self.alignment.ref_start + 1}-{self.alignment.ref_end}]", ref_aligned) - + export.to_fasta(f"{sample}_read_{strand}", read_aligned) - ) - export.write_text(path, content) - self.statusBar().showMessage(tr("export.done", path=path), 5000) + if Path(path).suffix.lower() not in (".xlsx", ".csv"): + path += ".csv" if "csv" in chosen.lower() else ".xlsx" + self.workspace.export_report(path) - # ------------------------------------------------------------------ BLAST - def _blast_submit(self) -> None: - if not self.read_model: - return - seq = "".join(self.read_model.seq) - try: - rid = blast_api.submit(blast_api.build_request(seq)) - except (OSError, RuntimeError, ValueError) as exc: - QMessageBox.warning(self, tr("menu.analysis.blast"), str(exc)) - return - QDesktopServices.openUrl(QUrl(blast_api.result_url(rid))) - self.statusBar().showMessage(tr("blast.sent", rid=rid), 10000) + def _in_read_tab(self, action) -> None: + self.tabs.setCurrentWidget(self.read_view) + action() + + def _open_read(self, path: Path) -> None: + self.read_view.open_path(path) # file_opened switches to the viewer tab + + def _read_edited(self, path: Path) -> None: + """Edits reach the check through the read's session sidecar: write it + now (or drop it after undoing everything), then re-run the check.""" + if self.workspace.contains_read(path): + self.read_view.autosave_session() + self.workspace.read_edited(path) def _switch_language(self, code: str) -> None: self.config.language = code @@ -663,169 +293,18 @@ def _switch_language(self, code: str) -> None: action.setChecked(other == code) QMessageBox.information(self, tr("menu.view.language"), tr("lang.restart")) - # ------------------------------------------------- expected SNP verification - def _verify_dialog(self) -> None: - if not (self.read_model and self.alignment and self.reference_seq): - self.statusBar().showMessage(tr("verify.need_reference"), 6000) - return - path, _ = QFileDialog.getOpenFileName( - self, - tr("menu.analysis.verify"), - str(self._path.parent) if self._path else "", - "Variants (*.vcf *.csv *.tsv *.txt);;All files (*)", - ) - if not path: - return - try: - text = Path(path).read_text(encoding="utf-8", errors="replace") - except OSError as exc: - QMessageBox.warning(self, tr("verify.title"), str(exc)) - return - self._verify_variants(text) - - def _verify_variants(self, text: str) -> None: - try: - variants, warnings = parse_expected(text) - except ValueError as exc: - QMessageBox.warning(self, tr("verify.title"), str(exc)) - return - if not variants: - QMessageBox.warning(self, tr("verify.title"), tr("search.none")) - return - self.variant_verdicts = verify_expected( - self.alignment, - self.read_model.current(), - self.reference_seq, - variants, - min_q=self.config.snp_min_q, - het_ratio=self.config.het_ratio, - ) - self._fill_verify_table() - self.verify_dock.show() - self.chrom_view.set_snp_marks( - [(v.read_pos, v.verdict) for v in self.variant_verdicts if v.read_pos is not None] - ) - counts = {k: sum(1 for v in self.variant_verdicts if v.verdict == k) - for k in ("confirmed", "heterozygous", "not_found", "no_data")} - message = tr( - "verify.summary", - n=len(self.variant_verdicts), - confirmed=counts["confirmed"], - het=counts["heterozygous"], - notfound=counts["not_found"], - nodata=counts["no_data"], - ) - if warnings: - message += f" · {len(warnings)} ⚠" - self.statusBar().showMessage(message, 10000) - - def _fill_verify_table(self) -> None: - labels = { - "confirmed": tr("verify.confirmed"), - "heterozygous": tr("verify.heterozygous"), - "not_found": tr("verify.not_found"), - "no_data": tr("verify.no_data"), - "ref_mismatch": tr("verify.ref_mismatch"), - "low_quality": tr("verify.low_quality"), - "unsupported": tr("verify.unsupported"), - } - colors = { - "confirmed": "#1e9e1e", - "heterozygous": "#e0a000", - "not_found": "#e02020", - } - self.verify_table.setSortingEnabled(False) - self.verify_table.setRowCount(len(self.variant_verdicts)) - for row, v in enumerate(self.variant_verdicts): - values = [ - v.variant.name, - v.variant.position, - v.variant.ref, - v.variant.alt, - labels.get(v.verdict, v.verdict), - v.observed, - v.quality if v.quality is not None else "", - v.note, - ] - for col, value in enumerate(values): - item = QTableWidgetItem() - item.setData(Qt.ItemDataRole.DisplayRole, value) - if col == 4 and v.verdict in colors: - item.setForeground(QColor(colors[v.verdict])) - self.verify_table.setItem(row, col, item) - self.verify_table.setSortingEnabled(True) - - def _save_verify_csv(self) -> None: - if not self.variant_verdicts: - return - path, _ = QFileDialog.getSaveFileName( - self, tr("verify.save_csv"), "snp_report.csv", "CSV (*.csv)" - ) - if path: - export.write_text(path, verdicts_csv(self.variant_verdicts)) - self.statusBar().showMessage(tr("export.done", path=path), 5000) - - def _save_verify_xlsx(self) -> None: - if not self.variant_verdicts: - return - path, _ = QFileDialog.getSaveFileName( - self, tr("verify.save_xlsx"), "snp_report.xlsx", "Excel (*.xlsx)" - ) - if not path: - return - try: - verdicts_xlsx(path, self.variant_verdicts, sample_id=self._export_id()) - except RuntimeError as exc: - QMessageBox.warning(self, tr("verify.save_xlsx"), str(exc)) - return - self.statusBar().showMessage(tr("export.done", path=path), 5000) - - # ------------------------------------------------------------------ batch - def _open_folder_dialog(self) -> None: - start = str(self._path.parent) if self._path else "" - folder = QFileDialog.getExistingDirectory(self, tr("menu.file.open_folder"), start) - if folder: - self._scan_folder(Path(folder)) - - def _scan_folder(self, folder: Path) -> None: - self.batch_reports = batch.scan_folder( - folder, self.config.trim_window, self.config.trim_min_q - ) - self.batch_panel.show_reports(self.batch_reports) - self.batch_dock.show() - self.statusBar().showMessage(tr("batch.scan_done", n=len(self.batch_reports)), 5000) - - def _batch_export(self) -> None: - if not self.batch_reports: - return - out_dir = QFileDialog.getExistingDirectory(self, tr("batch.export_dir")) - if not out_dir: - return - results = batch.batch_export( - self.batch_reports, - Path(out_dir), - window=self.config.trim_window, - min_q=self.config.trim_min_q, - ) - exported = sum(1 for _, dest, error in results if dest and not error) - errors = sum(1 for _, _, error in results if error) - self.statusBar().showMessage( - tr("batch.exported", n=exported, err=errors), 8000 - ) - - def _batch_save_csv(self) -> None: - if not self.batch_reports: - return - path, _ = QFileDialog.getSaveFileName( - self, tr("batch.csv"), "batch_summary.csv", "CSV (*.csv)" - ) - if path: - export.write_text(path, batch.summary_csv(self.batch_reports)) - self.statusBar().showMessage(tr("export.done", path=path), 5000) + def set_theme(self, name: str) -> None: + self.config.theme = name + status.set_theme(name) + self.read_view.apply_theme(name) + self.workspace.set_theme(name) + QApplication.instance().setStyleSheet(app_qss(name == "dark")) + save_config(self.config) + # ----------------------------------------------------------- recent, drops def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 urls = event.mimeData().urls() - if urls and urls[0].toLocalFile().lower().endswith(".ab1"): + if urls and urls[0].toLocalFile().lower().endswith((".ab1", PROJECT_SUFFIX)): event.acceptProposedAction() def dropEvent(self, event: QDropEvent) -> None: # noqa: N802 @@ -851,301 +330,77 @@ def _fill_recent_menu(self) -> None: ) self.recent_menu.addAction(action) - # ------------------------------------------------------------------ view - def _sync_from_view(self) -> None: - total = self.chrom_view._total_points() - visible = self.chrom_view._visible_points() - self.scrollbar.blockSignals(True) - self.scrollbar.setRange(0, max(0, int(total - visible))) - self.scrollbar.setPageStep(max(1, int(visible))) - self.scrollbar.setValue(int(self.chrom_view.offset)) - self.scrollbar.blockSignals(False) - self.minimap.set_view(self.chrom_view.offset, visible, total) - - def set_theme(self, name: str) -> None: - self.config.theme = name - colors = palette(name) - self.chrom_view.set_theme(colors) - self.minimap.set_theme(colors) - for base, box in self._channel_boxes.items(): - box.setStyleSheet( - f"QCheckBox {{ color: {colors['bases'][base]}; font-weight: bold; padding: 0 4px; }}" - ) - QApplication.instance().setStyleSheet(app_qss(name == "dark")) - save_config(self.config) - - # --------------------------------------------------------------- editing - def _sync_actions(self) -> None: - model = self.read_model - self.undo_action.setEnabled(bool(model and model.can_undo)) - self.redo_action.setEnabled(bool(model and model.can_redo)) + # ------------------------------------------------- delegates to the read view + def open_path(self, path: str | Path) -> bool: + """A ``.ab1proj`` opens in the construct check, anything else in the viewer.""" + if str(path).lower().endswith(PROJECT_SUFFIX): + self._show_workspace() + opened = self.workspace.load_project(path) + if opened: + self._push_recent(str(path)) + return opened + return self.read_view.open_path(path) # file_opened switches to the viewer tab def refresh_read(self) -> None: - if self.read_model is None: - return - chrom = self.read_model.current() - self.chrom_view.set_chromatogram(chrom, keep_view=True) - self.chrom_view.set_edited(self.read_model.edited) - self.minimap.set_chromatogram(chrom) - self.minimap.set_highlights([]) - self._search_ranges = [] - self._search_pos = -1 - self._refresh_info(chrom) - self._sync_actions() - self.setWindowTitle( - f"ab1kit — {self._path.name}" + ("*" if self.read_model.modified else "") - ) - if self.reference_seq: - self._realign() + self.read_view.refresh_read() def apply_base_change(self, index: int, base: str) -> None: - if self.read_model: - self.read_model.set_base(index, base) - self.refresh_read() + self.read_view.apply_base_change(index, base) def apply_base_insert(self, index: int, base: str) -> None: - if self.read_model: - self.read_model.insert_base(index, base) - self.refresh_read() + self.read_view.apply_base_insert(index, base) def apply_base_delete(self, index: int) -> None: - if self.read_model: - self.read_model.delete_base(index) - self.refresh_read() + self.read_view.apply_base_delete(index) def apply_trim_range(self, start: int, end: int) -> None: - if not self.read_model: - return - try: - self.read_model.apply_trim(start, end) - except ValueError as exc: - QMessageBox.warning(self, tr("trim.title"), str(exc)) - return - self.chrom_view.set_trim_region(None) - self.refresh_read() - self.statusBar().showMessage(tr("trim.status", n=len(self.read_model.seq))) + self.read_view.apply_trim_range(start, end) def undo_read(self) -> None: - if self.read_model and self.read_model.undo(): - self.refresh_read() + self.read_view.undo_read() def redo_read(self) -> None: - if self.read_model and self.read_model.redo(): - self.refresh_read() + self.read_view.redo_read() def reverse_complement_read(self) -> None: - if self.read_model: - self.read_model.reverse_complement() - self.refresh_read() + self.read_view.reverse_complement_read() - def _on_base_clicked(self, index: int, global_pos) -> None: - if self.read_model is None: - return - menu = QMenu(self) - change = menu.addMenu(tr("edit.change")) - for letter in IUPAC_LETTERS: - change.addAction(letter, lambda l=letter: self.apply_base_change(index, l)) - insert_left = menu.addMenu(tr("edit.insert_left")) - for letter in INSERT_LETTERS: - insert_left.addAction(letter, lambda l=letter: self.apply_base_insert(index, l)) - insert_right = menu.addMenu(tr("edit.insert_right")) - for letter in INSERT_LETTERS: - insert_right.addAction(letter, lambda l=letter: self.apply_base_insert(index + 1, l)) - menu.addSeparator() - menu.addAction(tr("edit.delete"), lambda: self.apply_base_delete(index)) - menu.exec(global_pos) - - # ------------------------------------------------------------------ trim - def _open_trim_dialog(self) -> None: - if self.read_model is None or self.trim_dialog is not None: - return - start, end = trim_window( - self.read_model.quality, self.config.trim_window, self.config.trim_min_q - ) - self.chrom_view.set_trim_region((start, end)) - dialog = TrimDialog( - lambda: self.read_model.quality, - self.config.trim_window, - self.config.trim_min_q, - len(self.read_model.seq), - self, - ) - self.trim_dialog = dialog - dialog.region_changed.connect( - lambda start, end: self.chrom_view.set_trim_region((start, end)) - ) - self.chrom_view.trim_range_changed.connect(dialog.set_region) - dialog.apply_requested.connect(self._apply_trim_from_dialog) - dialog.finished.connect(self._trim_dialog_closed) - dialog.show() - - def _apply_trim_from_dialog(self, start: int, end: int) -> None: - if self.trim_dialog is not None: - self.config.trim_window = self.trim_dialog.window_value() - self.config.trim_min_q = self.trim_dialog.minq_value() - save_config(self.config) - self.apply_trim_range(start, end) - - def _trim_dialog_closed(self) -> None: - dialog = self.trim_dialog - self.trim_dialog = None - if dialog is not None: - try: - self.chrom_view.trim_range_changed.disconnect(dialog.set_region) - except RuntimeError: - pass - self.chrom_view.set_trim_region(None) - - # ---------------------------------------------------------------- search - def _search(self) -> None: - if self.chrom_view.chrom is None: - return - dialog = SearchDialog(self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - pattern = dialog.pattern() - if not pattern: - return - seq = self.chrom_view.chrom.seq - try: - fwd = find_matches(pattern, seq) - rev = find_matches(pattern, seq, reverse=True) if dialog.want_reverse() else [] - except ValueError as exc: - QMessageBox.warning(self, tr("search.title"), str(exc)) - return - ranges = [(i, i + len(pattern)) for i in fwd + rev] - self.chrom_view.set_highlights(ranges) - self.minimap.set_highlights(ranges) - self._search_ranges = ranges - self._search_pos = -1 - if ranges: - self.statusBar().showMessage( - tr("search.found", n=len(ranges), f=len(fwd), r=len(rev)) - ) - self._find_next() - else: - self.statusBar().showMessage(tr("search.none")) + def _set_reference_seq(self, sequence: str) -> None: + self.read_view._set_reference_seq(sequence) - def _find_next(self) -> None: - if not self._search_ranges: - return - self._search_pos = (self._search_pos + 1) % len(self._search_ranges) - self.chrom_view.goto_base(self._search_ranges[self._search_pos][0] + 1) + def _reference_overlay(self) -> tuple[str, dict[int, int]]: + return self.read_view._reference_overlay() - # ----------------------------------------------------------- goto and info - def _goto(self) -> None: - chrom = self.chrom_view.chrom - if chrom is None: - return - number, ok = QInputDialog.getInt( - self, tr("goto.title"), tr("goto.label"), 1, 1, len(chrom.seq) - ) - if ok: - self.chrom_view.goto_base(number) - - def _refresh_info(self, chrom: Chromatogram) -> None: - page = QWidget() - form = QFormLayout(page) - form.setContentsMargins(8, 8, 8, 8) - - def row(label: str, value) -> None: - widget = QLabel(str(value)) - widget.setWordWrap(True) - form.addRow(QLabel(label), widget) - - row(tr("info.file"), self._path) - row(tr("info.length"), len(chrom.seq)) - row(tr("info.mean_q"), f"{sum(chrom.quality) / max(1, len(chrom.quality)):.1f}") - row(tr("info.q20"), sum(1 for q in chrom.quality if q >= 20)) - meta_keys = ( - ("sample", "meta.sample"), - ("instrument", "meta.instrument"), - ("run_date", "meta.run_date"), - ("run_time", "meta.run_time"), - ("project", "meta.project"), - ("capillary", "meta.capillary"), - ("mobility", "meta.mobility"), - ("comment", "meta.comment"), - ) - for key, label_key in meta_keys: - if key in chrom.metadata: - row(tr(label_key), chrom.metadata[key]) - self.info_dock.setWidget(page) - - # ------------------------------------------------------- export and sessions - def _export_target(self, caption: str, suffix: str, pattern: str) -> str | None: - default = (self._path.stem if self._path else "read") + suffix - path, _ = QFileDialog.getSaveFileName(self, caption, default, pattern) - return path or None - - def _export_fasta(self) -> None: - if not self.read_model: - return - path = self._export_target(tr("menu.file.export_fasta"), ".fasta", "FASTA (*.fasta)") - if path: - export.write_text(path, export.to_fasta(self._export_id(), "".join(self.read_model.seq))) - self.statusBar().showMessage(tr("export.done", path=path), 5000) + def _clear_reference(self) -> None: + self.read_view._clear_reference() - def _export_fastq(self) -> None: - if not self.read_model: - return - path = self._export_target(tr("menu.file.export_fastq"), ".fastq", "FASTQ (*.fastq)") - if path: - content = export.to_fastq( - self._export_id(), "".join(self.read_model.seq), self.read_model.quality - ) - export.write_text(path, content) - self.statusBar().showMessage(tr("export.done", path=path), 5000) + def _export_aligned(self) -> None: + self.read_view._export_aligned() - def _export_id(self) -> str: - return self.read_model.current().sample_id if self.read_model else "read" + def _blast_submit(self) -> None: + self.read_view._blast_submit() - def _export_png(self) -> None: - path = self._export_target(tr("menu.file.export_png"), ".png", "PNG (*.png)") - if path and self.chrom_view.grab().save(path): - self.statusBar().showMessage(tr("export.done", path=path), 5000) + def _verify_variants(self, text: str) -> None: + self.read_view._verify_variants(text) - def _export_pdf(self) -> None: - path = self._export_target(tr("menu.file.export_pdf"), ".pdf", "PDF (*.pdf)") - if not path: - return - pixmap = self.chrom_view.grab() - writer = QPdfWriter(path) - writer.setPageSize(QPageSize(QSizeF(pixmap.width(), pixmap.height()), QPageSize.Unit.Point)) - writer.setPageMargins(QMarginsF(0, 0, 0, 0)) - painter = QPainter(writer) - painter.drawPixmap(0, 0, writer.width(), writer.height(), pixmap) - painter.end() - self.statusBar().showMessage(tr("export.done", path=path), 5000) - - def _copy_sequence(self) -> None: - if self.read_model: - QApplication.clipboard().setText("".join(self.read_model.seq)) - self.statusBar().showMessage( - tr("copy.done", n=len(self.read_model.seq)), 3000 - ) + def _fill_verify_table(self) -> None: + self.read_view._fill_verify_table() - def _save_session(self) -> None: - if self.read_model is None or self._path is None: - return - sessions.save_session( - sessions.session_path(self._path), - {"source": self._path.name, **self.read_model.to_dict()}, - ) - self.statusBar().showMessage(tr("session.saved"), 3000) + def _save_verify_csv(self) -> None: + self.read_view._save_verify_csv() + + def _scan_folder(self, folder: Path) -> None: + self._in_read_tab(lambda: self.read_view._scan_folder(folder)) + + def _batch_export(self) -> None: + self.batch_dock.export_trimmed() + + def _open_trim_dialog(self) -> None: + self.read_view._open_trim_dialog() def closeEvent(self, event) -> None: # noqa: N802 - if self.read_model is not None and self._path is not None: - sidecar = sessions.session_path(self._path) - try: - if self.read_model.modified: - sessions.save_session( - sidecar, {"source": self._path.name, **self.read_model.to_dict()} - ) - elif sidecar.exists(): - sidecar.unlink() - except OSError: - pass + self._cancel_tasks() + self.read_view.autosave_session() super().closeEvent(event) diff --git a/ui/minimap.py b/ui/minimap.py index 264576f..3acc067 100644 --- a/ui/minimap.py +++ b/ui/minimap.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QWidget from chrom_core import Chromatogram +from ui.status import quality_bucket from ui.theme import LIGHT STRIP_H = 18 @@ -107,7 +108,7 @@ def _paint_strip(self, painter: QPainter) -> None: y = self._strip_y() peaks = list(chrom.peak_indices) + [self._total] for i, quality in enumerate(chrom.quality): - bucket = "low_q" if quality < 10 else "mid_q" if quality < 20 else "high_q" + bucket = quality_bucket(quality) painter.setBrush(QColor(self.theme[bucket])) x0 = self._x(peaks[i]) x1 = self._x(peaks[i + 1]) diff --git a/ui/read_view/__init__.py b/ui/read_view/__init__.py new file mode 100644 index 0000000..dfb6a74 --- /dev/null +++ b/ui/read_view/__init__.py @@ -0,0 +1,5 @@ +"""Single-read viewer package.""" + +from ui.read_view.read_view import ReadView + +__all__ = ["ReadView"] diff --git a/ui/read_view/analysis_dock.py b/ui/read_view/analysis_dock.py new file mode 100644 index 0000000..7fdb71b --- /dev/null +++ b/ui/read_view/analysis_dock.py @@ -0,0 +1,108 @@ +"""Reference-comparison dock: table of differences with SNP verdicts.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDockWidget, QTableWidget, QTableWidgetItem, QWidget + +from chrom_core.align import ReadAlignment +from chrom_core.seqops import reverse_complement +from i18n import tr +from ui.tables import configure_table, paint_status, set_full_text_tooltip + + +def reference_overlay( + alignment: ReadAlignment, reference_seq: str, read_length: int +) -> tuple[str, dict[int, int]]: + """Reference letters and ref -> index mapping in the *displayed* read's + orientation (the alignment indexes the reverse complement on '-').""" + if alignment.strand == "+": + return reference_seq, alignment.ref_to_read + n = read_length + return ( + reverse_complement(reference_seq)[::-1], # complement, same ref indexing + {pos: n - 1 - idx for pos, idx in alignment.ref_to_read.items()}, + ) + + +def displayed_diff_index(alignment: ReadAlignment, diff, read_length: int) -> int | None: + """0-based index of ``diff`` in the displayed read (None for deletions).""" + if diff.read_pos is None: + return None + if alignment.strand == "+": + return diff.read_pos + n = read_length + span = len(diff.read_base) if diff.kind == "insertion" else 1 + return n - diff.read_pos - span + + +DIFF_VERDICT_STATUS = {"homozygous": "ok", "heterozygous": "mixed", "low_quality": "incomplete"} + + +class DiffDock(QDockWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(tr("analysis.diffs.title"), parent) + self.diff_table = QTableWidget(0, 8) + self.diff_table.setHorizontalHeaderLabels( + [ + tr("analysis.col.pos"), + tr("analysis.col.kind"), + tr("analysis.col.ref"), + tr("analysis.col.read"), + tr("analysis.col.readpos"), + tr("snp.col.verdict"), + tr("snp.col.q"), + tr("snp.col.ratio"), + ] + ) + configure_table(self.diff_table) + self.diff_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.diff_table.setSortingEnabled(True) + self.setWidget(self.diff_table) + + def fill(self, alignment: ReadAlignment | None, snp_calls: list, read_length: int) -> None: + kind_labels = { + "substitution": tr("analysis.kind.substitution"), + "deletion": tr("analysis.kind.deletion"), + "insertion": tr("analysis.kind.insertion"), + } + verdict_labels = { + "homozygous": tr("snp.homozygous"), + "heterozygous": tr("snp.heterozygous"), + "low_quality": tr("snp.low_quality"), + } + calls_by_ref = {c.ref_pos: c for c in snp_calls} + diffs = alignment.differences if alignment else [] + self.diff_table.setSortingEnabled(False) + self.diff_table.setRowCount(len(diffs)) + for row, diff in enumerate(diffs): + call = calls_by_ref.get(diff.ref_pos) + verdict = "" + if call is not None: + verdict = verdict_labels.get(call.kind, call.kind) + if call.kind == "heterozygous": + verdict += f" ({call.iupac})" + values = [ + diff.ref_pos + 1, + kind_labels.get(diff.kind, diff.kind), + diff.ref_base, + diff.read_base, + (call.read_pos + 1 if call is not None + else shown + 1 if (shown := displayed_diff_index(alignment, diff, read_length)) is not None + else ""), + verdict, + call.quality if call is not None else "", + f"{call.ratio:.2f}" if call is not None and call.ratio is not None else "", + ] + for col, value in enumerate(values): + item = QTableWidgetItem() + item.setData(Qt.ItemDataRole.DisplayRole, value) + set_full_text_tooltip(item) + if call is not None and col == 5: + paint_status(item, DIFF_VERDICT_STATUS.get(call.kind, "no_data")) + self.diff_table.setItem(row, col, item) + self.diff_table.setSortingEnabled(True) + + def clear(self) -> None: + self.diff_table.setRowCount(0) + self.hide() diff --git a/ui/batch_panel.py b/ui/read_view/batch_dock.py similarity index 51% rename from ui/batch_panel.py rename to ui/read_view/batch_dock.py index c9cd77c..8818bf7 100644 --- a/ui/batch_panel.py +++ b/ui/read_view/batch_dock.py @@ -1,13 +1,16 @@ -"""Batch panel: a QC table over a folder of .ab1 files.""" +"""Batch dock: a QC table over a folder of .ab1 files, with scan and export.""" from __future__ import annotations +from collections.abc import Callable +from pathlib import Path + from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QCheckBox, + QDockWidget, + QFileDialog, QHBoxLayout, - QHeaderView, QPushButton, QTableWidget, QTableWidgetItem, @@ -15,11 +18,14 @@ QWidget, ) +from chrom_core import batch, export from chrom_core.batch import FileReport +from chrom_core.config import AppConfig from i18n import tr +from ui.tables import configure_table, paint_status, set_full_text_tooltip STATUS_TEXT = {"ok": "✓", "warn": "⚠", "bad": "✗", "error": "✗"} -STATUS_COLOR = {"ok": "#1e9e1e", "warn": "#e0a000", "bad": "#e02020", "error": "#e02020"} +STATUS_VERDICT = {"ok": "ok", "warn": "incomplete", "bad": "errors", "error": "errors"} class BatchPanel(QWidget): @@ -44,10 +50,7 @@ def __init__(self, parent: QWidget | None = None) -> None: tr("batch.col.status"), ] ) - self.table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.Stretch - ) - self.table.verticalHeader().setVisible(False) + configure_table(self.table, stretch_column=0) self.table.setSortingEnabled(True) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) @@ -73,11 +76,15 @@ def show_reports(self, reports: list[FileReport]) -> None: for row, report in enumerate(reports): self._set_item(row, 0, report.path.name, data=str(report.path)) self._set_item(row, 1, report.length if not report.error else "") - self._set_item(row, 2, report.mean_q if not report.error else "") + self._set_item( + row, 2, + "" if report.error else "—" if report.mean_q is None else report.mean_q, + ) self._set_item(row, 3, report.q20 if not report.error else "") self._set_item(row, 4, report.trimmed_length if not report.error else "") status = QTableWidgetItem(STATUS_TEXT[report.status]) - status.setForeground(QColor(STATUS_COLOR[report.status])) + status.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + paint_status(status, STATUS_VERDICT[report.status]) if report.error: status.setToolTip(report.error) self.table.setItem(row, 5, status) @@ -89,6 +96,7 @@ def _set_item(self, row: int, col: int, value, data=None) -> None: item.setData(Qt.ItemDataRole.DisplayRole, value) if data is not None: item.setData(Qt.ItemDataRole.UserRole, data) + set_full_text_tooltip(item) self.table.setItem(row, col, item) def _apply_filter(self) -> None: @@ -104,3 +112,69 @@ def _double_clicked(self, item: QTableWidgetItem) -> None: path = path_item.data(Qt.ItemDataRole.UserRole) if path: self.open_requested.emit(str(path)) + + +class BatchDock(QDockWidget): + """Dock around :class:`BatchPanel`; owns folder scanning and batch export. + + ``start_task`` is the shell's background-task runner; ``message`` is + emitted for status-bar text (text, timeout in ms). + """ + + open_requested = Signal(str) + message = Signal(str, int) + + def __init__(self, config: AppConfig, start_task: Callable, parent: QWidget | None = None) -> None: + super().__init__(tr("batch.title"), parent) + self.config = config + self._start_task = start_task + self.reports: list[batch.FileReport] = [] + self.batch_panel = BatchPanel() + self.setWidget(self.batch_panel) + self.batch_panel.open_requested.connect(self.open_requested) + self.batch_panel.export_button.clicked.connect(self.export_trimmed) + self.batch_panel.csv_button.clicked.connect(self.save_csv) + + def scan_folder(self, folder: Path) -> None: + window, min_q = self.config.trim_window, self.config.trim_min_q + + def done(reports: object) -> None: + self.reports = reports # type: ignore[assignment] + self.batch_panel.show_reports(self.reports) + self.show() + self.message.emit(tr("batch.scan_done", n=len(self.reports)), 5000) + + # scan_folder reports no progress and is not interruptible: on cancel + # its late result is dropped and the table stays as it was. + self._start_task( + lambda progress, is_cancelled: batch.scan_folder(folder, window, min_q), + done, + tr("menu.file.open_folder"), + key="scan", + ) + + def export_trimmed(self) -> None: + if not self.reports: + return + out_dir = QFileDialog.getExistingDirectory(self, tr("batch.export_dir")) + if not out_dir: + return + results = batch.batch_export( + self.reports, + Path(out_dir), + window=self.config.trim_window, + min_q=self.config.trim_min_q, + ) + exported = sum(1 for _, dest, error in results if dest and not error) + errors = sum(1 for _, _, error in results if error) + self.message.emit(tr("batch.exported", n=exported, err=errors), 8000) + + def save_csv(self) -> None: + if not self.reports: + return + path, _ = QFileDialog.getSaveFileName( + self, tr("batch.csv"), "batch_summary.csv", "CSV (*.csv)" + ) + if path: + export.write_text(path, batch.summary_csv(self.reports)) + self.message.emit(tr("export.done", path=path), 5000) diff --git a/ui/read_view/dialogs.py b/ui/read_view/dialogs.py new file mode 100644 index 0000000..4d0524f --- /dev/null +++ b/ui/read_view/dialogs.py @@ -0,0 +1,149 @@ +"""Dialogs used by the single-read view.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPlainTextEdit, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from chrom_core import trim_window +from i18n import tr + + +class SearchDialog(QDialog): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle(tr("search.title")) + layout = QVBoxLayout(self) + self.edit = QLineEdit() + self.edit.setPlaceholderText(tr("search.pattern")) + self.reverse_box = QCheckBox(tr("search.reverse")) + button = QPushButton(tr("search.button")) + button.setDefault(True) + button.clicked.connect(self.accept) + layout.addWidget(self.edit) + layout.addWidget(self.reverse_box) + layout.addWidget(button) + + def pattern(self) -> str: + return self.edit.text().strip().upper() + + def want_reverse(self) -> bool: + return self.reverse_box.isChecked() + + +class TrimDialog(QDialog): + """Non-modal quality-trim dialog; preview streams into the chromatogram.""" + + region_changed = Signal(int, int) + apply_requested = Signal(int, int) + + def __init__(self, quality_fn, window: int, min_q: int, total: int, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle(tr("trim.title")) + self.quality_fn = quality_fn + self.total = total + self._region = (0, total) + + layout = QVBoxLayout(self) + form = QFormLayout() + self.window_spin = QSpinBox() + self.window_spin.setRange(2, 100) + self.window_spin.setValue(window) + self.minq_spin = QSpinBox() + self.minq_spin.setRange(0, 60) + self.minq_spin.setValue(min_q) + form.addRow(QLabel(tr("trim.window")), self.window_spin) + form.addRow(QLabel(tr("trim.minq")), self.minq_spin) + layout.addLayout(form) + self.preview = QLabel() + layout.addWidget(self.preview) + buttons = QWidget() + row = QVBoxLayout(buttons) + apply_button = QPushButton(tr("trim.apply")) + apply_button.setDefault(True) + apply_button.clicked.connect(self._apply) + close_button = QPushButton(tr("dialog.close")) + close_button.clicked.connect(self.close) + row.addWidget(apply_button) + row.addWidget(close_button) + layout.addWidget(buttons) + + self.window_spin.valueChanged.connect(self._recompute) + self.minq_spin.valueChanged.connect(self._recompute) + self._recompute() + + def window_value(self) -> int: + return self.window_spin.value() + + def minq_value(self) -> int: + return self.minq_spin.value() + + def _recompute(self) -> None: + start, end = trim_window(self.quality_fn(), self.window_spin.value(), self.minq_spin.value()) + self._set_region(start, end) + self.region_changed.emit(start, end) + + def _set_region(self, start: int, end: int) -> None: + self._region = (start, end) + self.preview.setText(tr("trim.preview", n=end - start, total=self.total)) + + def set_region(self, start: int, end: int) -> None: + """Sync preview when the user drags trim handles in the view.""" + self._set_region(start, end) + + def _apply(self) -> None: + start, end = self._region + self.apply_requested.emit(start, end) + self.close() + + +def ask_reference_text(parent: QWidget) -> str | None: + """Modal dialog for pasting/loading a reference; returns the raw text + when accepted, ``None`` otherwise.""" + dialog = QDialog(parent) + dialog.setWindowTitle(tr("analysis.ref.title")) + dialog.resize(480, 240) + layout = QVBoxLayout(dialog) + edit = QPlainTextEdit() + edit.setPlaceholderText(tr("analysis.ref.paste")) + layout.addWidget(edit) + + def from_file() -> None: + path, _ = QFileDialog.getOpenFileName( + dialog, tr("analysis.ref.from_file"), "", "Sequence (*.fasta *.fa *.txt);;All files (*)" + ) + if path: + edit.setPlainText(Path(path).read_text(encoding="utf-8", errors="replace")) + + file_button = QPushButton(tr("analysis.ref.from_file")) + file_button.clicked.connect(from_file) + layout.addWidget(file_button) + + ok = QPushButton("OK") + ok.clicked.connect(dialog.accept) + cancel = QPushButton(tr("dialog.close")) + cancel.clicked.connect(dialog.reject) + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.addWidget(ok) + row_layout.addWidget(cancel) + layout.addWidget(row) + + if dialog.exec() != QDialog.DialogCode.Accepted: + return None + return edit.toPlainText() diff --git a/ui/read_view/menus.py b/ui/read_view/menus.py new file mode 100644 index 0000000..ba432e9 --- /dev/null +++ b/ui/read_view/menus.py @@ -0,0 +1,117 @@ +"""Read-specific menu items, appended by the shell into its own menus. + +Shortcuts, order and enabled-state handling are the ones the single window +used to have; the actions are parented to the view so they live with it. +""" + +from __future__ import annotations + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QMenu + +from i18n import tr +from ui.status import bind_icon + + +def add_file_actions(view, file_menu: QMenu) -> None: + """Save session, export submenu, copy sequence (between 'Recent' and 'Exit').""" + save_session = QAction(tr("menu.file.save_session"), view) + bind_icon(save_session, "save") + save_session.setShortcut("Ctrl+S") + save_session.triggered.connect(view._save_session) + file_menu.addAction(save_session) + export_menu = file_menu.addMenu(tr("menu.file.export")) + for text, slot in ( + (tr("menu.file.export_fasta"), view._export_fasta), + (tr("menu.file.export_fastq"), view._export_fastq), + (tr("menu.file.export_png"), view._export_png), + (tr("menu.file.export_pdf"), view._export_pdf), + ): + action = QAction(text, view) + action.triggered.connect(slot) + export_menu.addAction(action) + file_menu.addSeparator() + copy_action = QAction(tr("menu.file.copy_seq"), view) + bind_icon(copy_action, "copy") + copy_action.setShortcut("Ctrl+Shift+C") + copy_action.triggered.connect(view._copy_sequence) + file_menu.addAction(copy_action) + + +def add_edit_actions(view, edit_menu: QMenu) -> None: + edit_menu.addAction(view.undo_action) + edit_menu.addAction(view.redo_action) + edit_menu.addSeparator() + find_action = QAction(tr("menu.edit.find"), view) + bind_icon(find_action, "search") + find_action.setShortcut("Ctrl+F") + find_action.triggered.connect(view._search) + edit_menu.addAction(find_action) + find_next = QAction(tr("menu.edit.find_next"), view) + find_next.setShortcut("F3") + find_next.triggered.connect(view._find_next) + edit_menu.addAction(find_next) + goto_action = QAction(tr("menu.edit.goto"), view) + goto_action.setShortcut("Ctrl+G") + goto_action.triggered.connect(view._goto) + edit_menu.addAction(goto_action) + edit_menu.addSeparator() + revcomp_action = QAction(tr("menu.edit.revcomp"), view) + revcomp_action.setShortcut("Ctrl+R") + revcomp_action.triggered.connect(view.reverse_complement_read) + edit_menu.addAction(revcomp_action) + trim_action = QAction(tr("menu.edit.trim"), view) + bind_icon(trim_action, "scissors") + trim_action.setShortcut("Ctrl+T") + trim_action.triggered.connect(view._open_trim_dialog) + edit_menu.addAction(trim_action) + + +def add_view_actions(view, view_menu: QMenu) -> None: + """Smoothing, quality profile, info and batch docks (after 'Dark theme').""" + smooth_action = QAction(tr("menu.view.smoothing"), view) + smooth_action.setCheckable(True) + smooth_action.toggled.connect(view.chrom_view.set_smoothing) + view_menu.addAction(smooth_action) + profile_action = QAction(tr("menu.view.quality_profile"), view) + profile_action.setCheckable(True) + profile_action.toggled.connect(view.minimap.set_profile) + view_menu.addAction(profile_action) + info_action = QAction(tr("menu.view.file_info"), view) + bind_icon(info_action, "info") + info_action.setCheckable(True) + info_action.toggled.connect(view.info_dock.setVisible) + view.info_dock.visibilityChanged.connect(info_action.setChecked) + view_menu.addAction(info_action) + batch_action = QAction(tr("menu.view.batch_panel"), view) + bind_icon(batch_action, "table-2") + batch_action.setCheckable(True) + batch_action.toggled.connect(view.batch_dock.setVisible) + view.batch_dock.visibilityChanged.connect(batch_action.setChecked) + view_menu.addAction(batch_action) + + +def add_analysis_actions(view, analysis_menu: QMenu) -> None: + reference_action = QAction(tr("menu.analysis.reference"), view) + bind_icon(reference_action, "dna") + reference_action.setShortcut("Ctrl+L") + reference_action.triggered.connect(view._reference_dialog) + analysis_menu.addAction(reference_action) + clear_ref = QAction(tr("menu.analysis.clear_reference"), view) + clear_ref.triggered.connect(view._clear_reference) + analysis_menu.addAction(clear_ref) + analysis_menu.addSeparator() + export_aligned = QAction(tr("menu.analysis.export_aligned"), view) + export_aligned.triggered.connect(view._export_aligned) + analysis_menu.addAction(export_aligned) + analysis_menu.addSeparator() + verify_action = QAction(tr("menu.analysis.verify"), view) + bind_icon(verify_action, "flask-conical") + verify_action.setShortcut("Ctrl+Shift+V") + verify_action.triggered.connect(view._verify_dialog) + analysis_menu.addAction(verify_action) + analysis_menu.addSeparator() + blast_action = QAction(tr("menu.analysis.blast"), view) + bind_icon(blast_action, "globe") + blast_action.triggered.connect(view._blast_submit) + analysis_menu.addAction(blast_action) diff --git a/ui/read_view/read_view.py b/ui/read_view/read_view.py new file mode 100644 index 0000000..3479429 --- /dev/null +++ b/ui/read_view/read_view.py @@ -0,0 +1,791 @@ +"""Single-read viewer: chromatogram, minimap, editing, trim, search, analysis docks.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from PySide6.QtCore import QMarginsF, QSizeF, Qt, QUrl, Signal +from PySide6.QtGui import QAction, QDesktopServices, QPageSize, QPainter, QPdfWriter +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QDialog, + QDockWidget, + QFileDialog, + QFormLayout, + QInputDialog, + QLabel, + QMainWindow, + QMenu, + QMessageBox, + QScrollBar, + QSizePolicy, + QToolBar, + QVBoxLayout, + QWidget, +) + +from chrom_core import Chromatogram, EditableRead, read_ab1, trim_window +from chrom_core import blast as blast_api +from chrom_core import export, sessions +from chrom_core.align import ReadAlignment, align_read, gapped_sequences, parse_sequence +from chrom_core.config import AppConfig, save_config +from chrom_core.editing import INSERT_LETTERS, IUPAC_LETTERS +from chrom_core.snps import call_snps +from chrom_core.verify import parse_expected, verify_expected, verdicts_csv, verdicts_xlsx +from chrom_core.seqops import find_matches +from i18n import tr +from ui import status +from ui.chrom_view import ChromatogramWidget +from ui.minimap import MiniMap +from ui.read_view import menus +from ui.read_view.analysis_dock import DiffDock, displayed_diff_index, reference_overlay +from ui.read_view.batch_dock import BatchDock +from ui.read_view.dialogs import SearchDialog, TrimDialog, ask_reference_text +from ui.read_view.verify_dock import VerifyDock +from ui.status import bind_icon, quality_bucket +from ui.tables import repaint_statuses +from ui.theme import image_rule, theme as palette, tick_image + + +class ReadView(QMainWindow): + """Everything that belongs to one open read. Embedded by the shell window. + + The shell supplies ``start_task`` (background-task runner, see + ``MainWindow._start_task``) and listens to the signals below; the view + never touches its parent. + """ + + read_edited = Signal(Path) # after a base edit, undo/redo, trim, revcomp, session save + file_opened = Signal(str) + status_message = Signal(str, int) # text, timeout in ms (0 = until replaced) + title_changed = Signal(str) + + def __init__(self, config: AppConfig, start_task: Callable, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.config = config + self._start_task = start_task + self._path: Path | None = None + self._active = True # False while the shell shows another tab + self.read_model: EditableRead | None = None + self.trim_dialog: TrimDialog | None = None + self.reference_seq: str = "" + self.alignment: ReadAlignment | None = None + self.snp_calls: list = [] + self.variant_verdicts: list = [] + self._search_ranges: list[tuple[int, int]] = [] + self._search_pos = -1 + + self.chrom_view = ChromatogramWidget() + self.minimap = MiniMap() + self.scrollbar = QScrollBar(Qt.Orientation.Horizontal) + + central = QWidget() + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.minimap) + layout.addWidget(self.chrom_view, 1) + layout.addWidget(self.scrollbar) + self.setCentralWidget(central) + + self.undo_action = QAction(tr("menu.edit.undo"), self) + self.undo_action.setShortcut("Ctrl+Z") + self.undo_action.triggered.connect(self.undo_read) + self.undo_action.setEnabled(False) + self.redo_action = QAction(tr("menu.edit.redo"), self) + self.redo_action.setShortcut("Ctrl+Y") + self.redo_action.triggered.connect(self.redo_read) + self.redo_action.setEnabled(False) + + self._channel_boxes: dict[str, QCheckBox] = {} + self._build_toolbar() + self._build_docks() + self._connect() + + def _say(self, text: str, timeout: int = 0) -> None: + self.status_message.emit(text, timeout) + + # ------------------------------------------------------------------ setup + def _build_toolbar(self) -> None: + bar = QToolBar() + bar.setMovable(False) + bar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.addToolBar(bar) + + def add(text: str, icon_name: str, slot, shortcut: str | None = None) -> None: + action = QAction(text, self) + bind_icon(action, icon_name) + if shortcut: + action.setShortcut(shortcut) + action.triggered.connect(slot) + bar.addAction(action) + + add(tr("tb.open"), "folder-open", self.open_dialog) + bar.addSeparator() + add(tr("tb.zoom_in"), "zoom-in", self.chrom_view.zoom_in, "Ctrl++") + add(tr("tb.zoom_out"), "zoom-out", self.chrom_view.zoom_out, "Ctrl+-") + add(tr("tb.fit"), "maximize", self.chrom_view.zoom_fit) + add(tr("tb.reset"), "rotate-ccw", self.chrom_view.zoom_reset) + bar.addSeparator() + bind_icon(self.undo_action, "undo-2") + bind_icon(self.redo_action, "redo-2") + bar.addAction(self.undo_action) + bar.addAction(self.redo_action) + bar.addSeparator() + for base in "ACGT": + box = QCheckBox(base) + box.setChecked(True) + box.toggled.connect( + lambda on, b=base: self.chrom_view.set_visible_channel(b, on) + ) + bar.addWidget(box) + self._channel_boxes[base] = box + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + bar.addWidget(spacer) + self.quality_legend = QLabel() + self.quality_legend.setToolTip(tr("tb.quality_legend.tip")) + bar.addWidget(self.quality_legend) + self._refresh_legend("light") + + def _build_docks(self) -> None: + self.info_dock = QDockWidget(tr("info.title"), self) + self.info_dock.setWidget(QWidget()) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.info_dock) + self.info_dock.hide() + + self.batch_dock = BatchDock(self.config, self._start_task) + self.batch_panel = self.batch_dock.batch_panel + self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.batch_dock) + self.batch_dock.hide() + self.batch_dock.open_requested.connect(self.open_path) + self.batch_dock.message.connect(self._say) + + self.diff_dock = DiffDock() + self.diff_table = self.diff_dock.diff_table + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.diff_dock) + self.diff_dock.hide() + + self.verify_dock = VerifyDock() + self.verify_table = self.verify_dock.verify_table + self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.verify_dock) + self.verify_dock.hide() + self.verify_dock.csv_button.clicked.connect(self._save_verify_csv) + self.verify_dock.xlsx_button.clicked.connect(self._save_verify_xlsx) + + @property + def batch_reports(self) -> list: + return self.batch_dock.reports + + def current_model(self) -> EditableRead | None: + return self.read_model + + # ------------------------------------------------------------------ menus + # The shell owns the menu bar and its order; it asks the view to append + # the read-specific items (see ``menus.py``) at the right places. + def add_file_actions(self, menu: QMenu) -> None: + menus.add_file_actions(self, menu) + + def add_edit_menu(self, menu: QMenu) -> None: + menus.add_edit_actions(self, menu) + + def add_view_actions(self, menu: QMenu) -> None: + menus.add_view_actions(self, menu) + + def add_analysis_menu(self, menu: QMenu) -> None: + menus.add_analysis_actions(self, menu) + + def _connect(self) -> None: + self.chrom_view.view_changed.connect(self._sync_from_view) + self.chrom_view.cursor_info.connect(self._say) + self.chrom_view.base_clicked.connect(self._on_base_clicked) + self.minimap.request_center.connect(self.chrom_view.center_on_point) + self.scrollbar.valueChanged.connect( + lambda value: self.chrom_view.set_offset(float(value)) + ) + + def open_path(self, path: str | Path) -> bool: + try: + chrom = read_ab1(path) + except Exception as exc: # user-facing: any parse failure becomes a dialog + QMessageBox.critical(self, tr("err.open_failed"), f"{path}:\n{exc}") + return False + self._path = Path(path) + model = EditableRead(chrom) + restored = self._try_restore_session(model) + self.read_model = model + self._search_ranges = [] + self._search_pos = -1 + self.chrom_view.set_chromatogram(model.current()) + self.chrom_view.set_edited(model.edited) + self.minimap.set_chromatogram(model.current()) + self.minimap.set_highlights([]) + self.file_opened.emit(str(self._path)) + self._refresh_info(model.current()) + self._sync_actions() + if self.reference_seq: + self._realign() + n = len(model.seq) + mean_q = sum(model.quality) / max(1, n) + message = tr("status.file", n=n, q=mean_q) + if restored: + message += " · " + tr("session.loaded") + self._say(message, 8000) + self.title_changed.emit(f"ab1kit — {self._path.name}" + ("*" if model.modified else "")) + return True + + def _try_restore_session(self, model: EditableRead) -> bool: + sidecar = sessions.session_path(self._path) + data = sessions.load_session(sidecar) + if not data or data.get("source") != self._path.name: + return False + try: + model.restore_state(data) + except (KeyError, TypeError, ValueError): + return False + return True + + def open_dialog(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + tr("menu.file.open"), + str(self._path.parent) if self._path else "", + "Sanger chromatograms (*.ab1);;All files (*)", + ) + if path: + self.open_path(path) + + # --------------------------------------------------------------- analysis + + # --------------------------------------------------------------- analysis + def _reference_dialog(self) -> None: + text = ask_reference_text(self) + if text is None: + return + try: + sequence = parse_sequence(text) + except ValueError as exc: + QMessageBox.warning(self, tr("analysis.ref.title"), str(exc)) + return + if not sequence: + return + self._set_reference_seq(sequence) + + def _set_reference_seq(self, sequence: str) -> None: + self.reference_seq = sequence + if self.read_model is not None: + self._realign() + else: + self._say(tr("analysis.need_read"), 5000) + + def _reference_overlay(self) -> tuple[str, dict[int, int]]: + return reference_overlay(self.alignment, self.reference_seq, len(self.read_model.seq)) + + def _displayed_diff_index(self, diff) -> int | None: + return displayed_diff_index(self.alignment, diff, len(self.read_model.seq)) + + def _clear_reference(self) -> None: + self.reference_seq = "" + self.alignment = None + self.chrom_view.set_reference(None) + self.diff_dock.clear() + + def _realign(self) -> None: + if not (self.read_model and self.reference_seq): + return + try: + self.alignment = align_read("".join(self.read_model.seq), self.reference_seq) + except ValueError as exc: + QMessageBox.warning(self, tr("analysis.ref.title"), str(exc)) + self._clear_reference() + return + letters, ref_to_read = self._reference_overlay() + self.chrom_view.set_reference(letters, ref_to_read) + chrom = self.read_model.current() + self.snp_calls = call_snps( + self.alignment, chrom, + min_q=self.config.snp_min_q, het_ratio=self.config.het_ratio, + ) + self.diff_dock.fill(self.alignment, self.snp_calls, len(self.read_model.seq)) + self.diff_dock.setVisible(bool(self.alignment.differences)) + pct = f"{self.alignment.identity * 100:.1f}" + if self.alignment.differences: + message = tr( + "analysis.status", + n=len(self.alignment.differences), + pct=pct, + rstart=self.alignment.ref_start + 1, + rend=self.alignment.ref_end, + ) + else: + message = tr("analysis.no_diffs") + if self.snp_calls: + counts = {k: sum(1 for c in self.snp_calls if c.kind == k) + for k in ("homozygous", "heterozygous", "low_quality")} + message += " · " + tr( + "snp.summary", + homo=counts["homozygous"], + het=counts["heterozygous"], + low=counts["low_quality"], + ) + if self.alignment.alternatives > 1: + message += " · " + tr("analysis.ambiguous", n=self.alignment.alternatives) + self._say(message, 8000) + + def _export_aligned(self) -> None: + if not (self.alignment and self.reference_seq and self.read_model): + return + path = self._export_target( + tr("menu.analysis.export_aligned"), ".aligned.fasta", "FASTA (*.fasta)" + ) + if not path: + return + ref_aligned, read_aligned = gapped_sequences(self.alignment, self.reference_seq) + sample = self._export_id() + strand = "reverse" if self.alignment.strand == "-" else "forward" + content = ( + export.to_fasta(f"{sample}_reference[{self.alignment.ref_start + 1}-{self.alignment.ref_end}]", ref_aligned) + + export.to_fasta(f"{sample}_read_{strand}", read_aligned) + ) + export.write_text(path, content) + self._say(tr("export.done", path=path), 5000) + + def _blast_submit(self) -> None: + if not self.read_model: + return + seq = "".join(self.read_model.seq) + try: + params = blast_api.build_request(seq) + except (OSError, RuntimeError, ValueError) as exc: + QMessageBox.warning(self, tr("menu.analysis.blast"), str(exc)) + return + + def done(rid: object) -> None: + QDesktopServices.openUrl(QUrl(blast_api.result_url(str(rid)))) + self._say(tr("blast.sent", rid=rid), 10000) + + # urllib cannot be interrupted: on cancel the late RID is dropped. + self._start_task( + lambda progress, is_cancelled: blast_api.submit(params, timeout=20), + done, + tr("menu.analysis.blast"), + key="blast", + ) + + def _verify_dialog(self) -> None: + if not (self.read_model and self.alignment and self.reference_seq): + self._say(tr("verify.need_reference"), 6000) + return + path, _ = QFileDialog.getOpenFileName( + self, + tr("menu.analysis.verify"), + str(self._path.parent) if self._path else "", + "Variants (*.vcf *.csv *.tsv *.txt);;All files (*)", + ) + if not path: + return + try: + text = Path(path).read_text(encoding="utf-8", errors="replace") + except OSError as exc: + QMessageBox.warning(self, tr("verify.title"), str(exc)) + return + self._verify_variants(text) + + def _verify_variants(self, text: str) -> None: + try: + variants, warnings = parse_expected(text) + except ValueError as exc: + QMessageBox.warning(self, tr("verify.title"), str(exc)) + return + if not variants: + QMessageBox.warning(self, tr("verify.title"), tr("search.none")) + return + self.variant_verdicts = verify_expected( + self.alignment, + self.read_model.current(), + self.reference_seq, + variants, + min_q=self.config.snp_min_q, + het_ratio=self.config.het_ratio, + ) + self._fill_verify_table() + self.verify_dock.show() + self.chrom_view.set_snp_marks( + [(v.read_pos, v.verdict) for v in self.variant_verdicts if v.read_pos is not None] + ) + counts = {k: sum(1 for v in self.variant_verdicts if v.verdict == k) + for k in ("confirmed", "heterozygous", "not_found", "no_data")} + message = tr( + "verify.summary", + n=len(self.variant_verdicts), + confirmed=counts["confirmed"], + het=counts["heterozygous"], + notfound=counts["not_found"], + nodata=counts["no_data"], + ) + if warnings: + message += f" · {len(warnings)} ⚠" + self._say(message, 10000) + + def _fill_verify_table(self) -> None: + self.verify_dock.fill(self.variant_verdicts) + + def _save_verify_csv(self) -> None: + if not self.variant_verdicts: + return + path, _ = QFileDialog.getSaveFileName( + self, tr("verify.save_csv"), "snp_report.csv", "CSV (*.csv)" + ) + if path: + export.write_text(path, verdicts_csv(self.variant_verdicts)) + self._say(tr("export.done", path=path), 5000) + + def _save_verify_xlsx(self) -> None: + if not self.variant_verdicts: + return + path, _ = QFileDialog.getSaveFileName( + self, tr("verify.save_xlsx"), "snp_report.xlsx", "Excel (*.xlsx)" + ) + if not path: + return + try: + verdicts_xlsx(path, self.variant_verdicts, sample_id=self._export_id()) + except RuntimeError as exc: + QMessageBox.warning(self, tr("verify.save_xlsx"), str(exc)) + return + self._say(tr("export.done", path=path), 5000) + + # ------------------------------------------------------------------ batch + def open_folder_dialog(self) -> None: + start = str(self._path.parent) if self._path else "" + folder = QFileDialog.getExistingDirectory(self, tr("menu.file.open_folder"), start) + if folder: + self._scan_folder(Path(folder)) + + def _scan_folder(self, folder: Path) -> None: + self.batch_dock.scan_folder(folder) + + # ------------------------------------------------------------------ view + def _sync_from_view(self) -> None: + total = self.chrom_view._total_points() + visible = self.chrom_view._visible_points() + self.scrollbar.blockSignals(True) + self.scrollbar.setRange(0, max(0, int(total - visible))) + self.scrollbar.setPageStep(max(1, int(visible))) + self.scrollbar.setValue(int(self.chrom_view.offset)) + self.scrollbar.blockSignals(False) + self.minimap.set_view(self.chrom_view.offset, visible, total) + + def _refresh_legend(self, name: str) -> None: + """Small colour key for the quality bars (same thresholds everywhere).""" + colors = palette(name) + parts = [tr("tb.quality_legend")] + for q, text in ((0, "<20"), (20, "20–29"), (30, "≥30")): + parts.append(f'■ {text}') + self.quality_legend.setText("  ".join(parts)) + + def apply_theme(self, name: str) -> None: + status.set_theme(name) + colors = palette(name) + self._refresh_legend(name) + self.chrom_view.set_theme(colors) + self.minimap.set_theme(colors) + for base, box in self._channel_boxes.items(): + c = colors["bases"][base] + box.setStyleSheet( + f"QCheckBox {{ color: {c}; font-weight: bold; padding: 0 4px; }}" + f"QCheckBox::indicator {{ border: 1px solid {c}; }}" + f"QCheckBox::indicator:checked {{ background-color: {c}; border-color: {c};" + f" {image_rule(tick_image(c))} }}" + ) + for table in (self.diff_table, self.verify_table, self.batch_panel.table): + repaint_statuses(table) + + # --------------------------------------------------------------- editing + def set_active(self, active: bool) -> None: + """Enable or disable all of the view's own actions (menus, toolbar, + shortcuts), so a hidden view cannot be edited by a stray shortcut.""" + self._active = bool(active) + for action in self.findChildren(QAction, options=Qt.FindChildOption.FindDirectChildrenOnly): + action.setEnabled(self._active) + self._sync_actions() + + def _sync_actions(self) -> None: + model = self.read_model + self.undo_action.setEnabled(self._active and bool(model and model.can_undo)) + self.redo_action.setEnabled(self._active and bool(model and model.can_redo)) + + def refresh_read(self) -> None: + if self.read_model is None: + return + chrom = self.read_model.current() + self.chrom_view.set_chromatogram(chrom, keep_view=True) + self.chrom_view.set_edited(self.read_model.edited) + self.minimap.set_chromatogram(chrom) + self.minimap.set_highlights([]) + self._search_ranges = [] + self._search_pos = -1 + self._refresh_info(chrom) + self._sync_actions() + self.title_changed.emit( + f"ab1kit — {self._path.name}" + ("*" if self.read_model.modified else "") + ) + if self.reference_seq: + self._realign() + + def _after_edit(self) -> None: + self.refresh_read() + self.read_edited.emit(self._path) + + def apply_base_change(self, index: int, base: str) -> None: + if self.read_model: + self.read_model.set_base(index, base) + self._after_edit() + + def apply_base_insert(self, index: int, base: str) -> None: + if self.read_model: + self.read_model.insert_base(index, base) + self._after_edit() + + def apply_base_delete(self, index: int) -> None: + if self.read_model: + self.read_model.delete_base(index) + self._after_edit() + + def apply_trim_range(self, start: int, end: int) -> None: + if not self.read_model: + return + try: + self.read_model.apply_trim(start, end) + except ValueError as exc: + QMessageBox.warning(self, tr("trim.title"), str(exc)) + return + self.chrom_view.set_trim_region(None) + self._after_edit() + self._say(tr("trim.status", n=len(self.read_model.seq))) + + def undo_read(self) -> None: + if self.read_model and self.read_model.undo(): + self._after_edit() + + def redo_read(self) -> None: + if self.read_model and self.read_model.redo(): + self._after_edit() + + def reverse_complement_read(self) -> None: + if self.read_model: + self.read_model.reverse_complement() + self._after_edit() + + def _on_base_clicked(self, index: int, global_pos) -> None: + if self.read_model is None: + return + menu = QMenu(self) + change = menu.addMenu(tr("edit.change")) + for letter in IUPAC_LETTERS: + change.addAction(letter, lambda l=letter: self.apply_base_change(index, l)) + insert_left = menu.addMenu(tr("edit.insert_left")) + for letter in INSERT_LETTERS: + insert_left.addAction(letter, lambda l=letter: self.apply_base_insert(index, l)) + insert_right = menu.addMenu(tr("edit.insert_right")) + for letter in INSERT_LETTERS: + insert_right.addAction(letter, lambda l=letter: self.apply_base_insert(index + 1, l)) + menu.addSeparator() + menu.addAction(tr("edit.delete"), lambda: self.apply_base_delete(index)) + menu.exec(global_pos) + + # ------------------------------------------------------------------ trim + def _open_trim_dialog(self) -> None: + if self.read_model is None or self.trim_dialog is not None: + return + start, end = trim_window( + self.read_model.quality, self.config.trim_window, self.config.trim_min_q + ) + self.chrom_view.set_trim_region((start, end)) + dialog = TrimDialog( + lambda: self.read_model.quality, + self.config.trim_window, + self.config.trim_min_q, + len(self.read_model.seq), + self, + ) + self.trim_dialog = dialog + dialog.region_changed.connect( + lambda start, end: self.chrom_view.set_trim_region((start, end)) + ) + self.chrom_view.trim_range_changed.connect(dialog.set_region) + dialog.apply_requested.connect(self._apply_trim_from_dialog) + dialog.finished.connect(self._trim_dialog_closed) + dialog.show() + + def _apply_trim_from_dialog(self, start: int, end: int) -> None: + if self.trim_dialog is not None: + self.config.trim_window = self.trim_dialog.window_value() + self.config.trim_min_q = self.trim_dialog.minq_value() + save_config(self.config) + self.apply_trim_range(start, end) + + def _trim_dialog_closed(self) -> None: + dialog = self.trim_dialog + self.trim_dialog = None + if dialog is not None: + try: + self.chrom_view.trim_range_changed.disconnect(dialog.set_region) + except RuntimeError: + pass + self.chrom_view.set_trim_region(None) + + # ---------------------------------------------------------------- search + def _search(self) -> None: + if self.chrom_view.chrom is None: + return + dialog = SearchDialog(self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + pattern = dialog.pattern() + if not pattern: + return + seq = self.chrom_view.chrom.seq + try: + fwd = find_matches(pattern, seq) + rev = find_matches(pattern, seq, reverse=True) if dialog.want_reverse() else [] + except ValueError as exc: + QMessageBox.warning(self, tr("search.title"), str(exc)) + return + ranges = [(i, i + len(pattern)) for i in fwd + rev] + self.chrom_view.set_highlights(ranges) + self.minimap.set_highlights(ranges) + self._search_ranges = ranges + self._search_pos = -1 + if ranges: + self._say( + tr("search.found", n=len(ranges), f=len(fwd), r=len(rev)) + ) + self._find_next() + else: + self._say(tr("search.none")) + + def _find_next(self) -> None: + if not self._search_ranges: + return + self._search_pos = (self._search_pos + 1) % len(self._search_ranges) + self.chrom_view.goto_base(self._search_ranges[self._search_pos][0] + 1) + + # ----------------------------------------------------------- goto and info + def _goto(self) -> None: + chrom = self.chrom_view.chrom + if chrom is None: + return + number, ok = QInputDialog.getInt( + self, tr("goto.title"), tr("goto.label"), 1, 1, len(chrom.seq) + ) + if ok: + self.chrom_view.goto_base(number) + + def _refresh_info(self, chrom: Chromatogram) -> None: + page = QWidget() + form = QFormLayout(page) + form.setContentsMargins(8, 8, 8, 8) + + def row(label: str, value) -> None: + widget = QLabel(str(value)) + widget.setWordWrap(True) + form.addRow(QLabel(label), widget) + + row(tr("info.file"), self._path) + row(tr("info.length"), len(chrom.seq)) + row(tr("info.mean_q"), f"{sum(chrom.quality) / max(1, len(chrom.quality)):.1f}") + row(tr("info.q20"), sum(1 for q in chrom.quality if q >= 20)) + meta_keys = ( + ("sample", "meta.sample"), + ("instrument", "meta.instrument"), + ("run_date", "meta.run_date"), + ("run_time", "meta.run_time"), + ("project", "meta.project"), + ("capillary", "meta.capillary"), + ("mobility", "meta.mobility"), + ("comment", "meta.comment"), + ) + for key, label_key in meta_keys: + if key in chrom.metadata: + row(tr(label_key), chrom.metadata[key]) + self.info_dock.setWidget(page) + + # ------------------------------------------------------- export and sessions + def _export_target(self, caption: str, suffix: str, pattern: str) -> str | None: + default = (self._path.stem if self._path else "read") + suffix + path, _ = QFileDialog.getSaveFileName(self, caption, default, pattern) + return path or None + + def _export_fasta(self) -> None: + if not self.read_model: + return + path = self._export_target(tr("menu.file.export_fasta"), ".fasta", "FASTA (*.fasta)") + if path: + export.write_text(path, export.to_fasta(self._export_id(), "".join(self.read_model.seq))) + self._say(tr("export.done", path=path), 5000) + + def _export_fastq(self) -> None: + if not self.read_model: + return + path = self._export_target(tr("menu.file.export_fastq"), ".fastq", "FASTQ (*.fastq)") + if path: + content = export.to_fastq( + self._export_id(), "".join(self.read_model.seq), self.read_model.quality + ) + export.write_text(path, content) + self._say(tr("export.done", path=path), 5000) + + def _export_id(self) -> str: + return self.read_model.current().sample_id if self.read_model else "read" + + def _export_png(self) -> None: + path = self._export_target(tr("menu.file.export_png"), ".png", "PNG (*.png)") + if path and self.chrom_view.grab().save(path): + self._say(tr("export.done", path=path), 5000) + + def _export_pdf(self) -> None: + path = self._export_target(tr("menu.file.export_pdf"), ".pdf", "PDF (*.pdf)") + if not path: + return + pixmap = self.chrom_view.grab() + writer = QPdfWriter(path) + writer.setPageSize(QPageSize(QSizeF(pixmap.width(), pixmap.height()), QPageSize.Unit.Point)) + writer.setPageMargins(QMarginsF(0, 0, 0, 0)) + painter = QPainter(writer) + painter.drawPixmap(0, 0, writer.width(), writer.height(), pixmap) + painter.end() + self._say(tr("export.done", path=path), 5000) + + def _copy_sequence(self) -> None: + if self.read_model: + QApplication.clipboard().setText("".join(self.read_model.seq)) + self._say( + tr("copy.done", n=len(self.read_model.seq)), 3000 + ) + + def _save_session(self) -> None: + if self.read_model is None or self._path is None: + return + sessions.save_session( + sessions.session_path(self._path), + {"source": self._path.name, **self.read_model.to_dict()}, + ) + self._say(tr("session.saved"), 3000) + self.read_edited.emit(self._path) + + def autosave_session(self) -> None: + """Called by the shell on close: save the sidecar if modified, else delete a stale one.""" + if self.read_model is not None and self._path is not None: + sidecar = sessions.session_path(self._path) + try: + if self.read_model.modified: + sessions.save_session( + sidecar, {"source": self._path.name, **self.read_model.to_dict()} + ) + elif sidecar.exists(): + sidecar.unlink() + except OSError: + pass diff --git a/ui/read_view/verify_dock.py b/ui/read_view/verify_dock.py new file mode 100644 index 0000000..584b57c --- /dev/null +++ b/ui/read_view/verify_dock.py @@ -0,0 +1,94 @@ +"""Expected-SNP verification dock: verdict table with CSV/XLSX buttons.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDockWidget, + QHBoxLayout, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from i18n import tr +from ui.tables import configure_table, paint_status, set_full_text_tooltip + + +VERIFY_VERDICT_STATUS = { + "confirmed": "ok", + "heterozygous": "mixed", + "not_found": "errors", + "ref_mismatch": "errors", + "low_quality": "incomplete", + "no_data": "no_data", + "unsupported": "no_data", +} + + +class VerifyDock(QDockWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(tr("verify.title"), parent) + self.verify_table = QTableWidget(0, 8) + self.verify_table.setHorizontalHeaderLabels( + [ + tr("verify.col.name"), + tr("verify.col.pos"), + tr("verify.col.ref"), + tr("verify.col.alt"), + tr("verify.col.verdict"), + tr("verify.col.observed"), + tr("verify.col.q"), + tr("verify.col.note"), + ] + ) + configure_table(self.verify_table) + self.verify_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.verify_table.setSortingEnabled(True) + buttons = QWidget() + row = QHBoxLayout(buttons) + self.csv_button = QPushButton(tr("verify.save_csv")) + self.xlsx_button = QPushButton(tr("verify.save_xlsx")) + row.addWidget(self.csv_button) + row.addWidget(self.xlsx_button) + row.addStretch(1) + container = QWidget() + layout = QVBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.verify_table) + layout.addWidget(buttons) + self.setWidget(container) + + def fill(self, verdicts: list) -> None: + labels = { + "confirmed": tr("verify.confirmed"), + "heterozygous": tr("verify.heterozygous"), + "not_found": tr("verify.not_found"), + "no_data": tr("verify.no_data"), + "ref_mismatch": tr("verify.ref_mismatch"), + "low_quality": tr("verify.low_quality"), + "unsupported": tr("verify.unsupported"), + } + self.verify_table.setSortingEnabled(False) + self.verify_table.setRowCount(len(verdicts)) + for row, v in enumerate(verdicts): + values = [ + v.variant.name, + v.variant.position, + v.variant.ref, + v.variant.alt, + labels.get(v.verdict, v.verdict), + v.observed, + v.quality if v.quality is not None else "", + tr(f"verify.note.{v.note_code}", **v.note_args) if v.note_code else v.note, + ] + for col, value in enumerate(values): + item = QTableWidgetItem() + item.setData(Qt.ItemDataRole.DisplayRole, value) + set_full_text_tooltip(item) + if col == 4: + paint_status(item, VERIFY_VERDICT_STATUS.get(v.verdict, "no_data")) + self.verify_table.setItem(row, col, item) + self.verify_table.setSortingEnabled(True) diff --git a/ui/status.py b/ui/status.py new file mode 100644 index 0000000..53a43aa --- /dev/null +++ b/ui/status.py @@ -0,0 +1,109 @@ +"""Shared visual language: status palette, quality colours, chips and icons. + +Status colours come from ``chrom_core.clone_report.STATUS_HEX`` (single source of +truth, also used by the XLSX report). Quality colours come from the theme dicts; +``quality_bucket`` is the one place that defines the Q thresholds. +""" + +from __future__ import annotations + +import weakref + +from PySide6.QtCore import QRectF, Qt +from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtWidgets import QLabel + +from chrom_core.clone_report import STATUS_HEX +from ui.theme import resource_dir, theme as palette + +Q_LOW = 20 # Q below this is red +Q_HIGH = 30 # Q at or above this is green; in between is yellow + +_ICON_SIZES = (16, 20, 24, 32, 48) +_current_theme = "light" +_icon_cache: dict[tuple[str, str], QIcon] = {} +_bound: list[tuple[weakref.ref, str]] = [] + + +def quality_bucket(q: int) -> str: + """Theme key for a phred score: ``low_q`` (<20), ``mid_q`` (20-29), ``high_q`` (>=30).""" + return "low_q" if q < Q_LOW else "mid_q" if q < Q_HIGH else "high_q" + + +def quality_color(q: int, theme: str) -> QColor: + return QColor(palette(theme)[quality_bucket(q)]) + + +def status_color(verdict: str, theme: str) -> QColor: + """Fill colour of a verdict (``ok/errors/incomplete/mixed/no_data``); unknown -> no_data.""" + light, dark = STATUS_HEX.get(verdict, STATUS_HEX["no_data"]) + return QColor(dark if theme == "dark" else light) + + +def status_text_color(verdict: str, theme: str) -> QColor: + """Readable text colour on top of ``status_color``.""" + bg = status_color(verdict, theme) + luminance = 0.299 * bg.red() + 0.587 * bg.green() + 0.114 * bg.blue() + return QColor("#1a1a1a" if luminance > 150 else "#ffffff") + + +def current_theme() -> str: + return _current_theme + + +def status_chip(verdict: str, text: str) -> QLabel: + """Rounded pill in the current theme's status colours.""" + chip = QLabel(text) + bg = status_color(verdict, _current_theme).name() + fg = status_text_color(verdict, _current_theme).name() + chip.setStyleSheet( + f"QLabel {{ background-color: {bg}; color: {fg}; border-radius: 9px;" + " padding: 1px 10px; font-weight: 600; }" + ) + chip.setAlignment(Qt.AlignmentFlag.AlignCenter) + return chip + + +def icon(name: str) -> QIcon: + """Lucide icon ``ui/icons/.svg`` tinted with the current theme's text colour.""" + key = (name, _current_theme) + cached = _icon_cache.get(key) + if cached is not None: + return cached + svg = (resource_dir() / "icons" / f"{name}.svg").read_text(encoding="utf-8") + svg = svg.replace("currentColor", palette(_current_theme)["text"]) + renderer = QSvgRenderer(svg.encode("utf-8")) + result = QIcon() + for size in _ICON_SIZES: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + renderer.render(painter, QRectF(0, 0, size, size)) + painter.end() + result.addPixmap(pixmap) + _icon_cache[key] = result + return result + + +def bind_icon(action: QAction, name: str) -> None: + """Give ``action`` the icon and keep it tinted when the theme changes.""" + action.setIcon(icon(name)) + _bound.append((weakref.ref(action), name)) + + +def set_theme(name: str) -> None: + """Switch the icon tint and re-icon every bound action.""" + global _current_theme + _current_theme = "dark" if name == "dark" else "light" + alive = [] + for ref, icon_name in _bound: + action = ref() + if action is None: + continue + try: + action.setIcon(icon(icon_name)) + except RuntimeError: # underlying QAction already deleted + continue + alive.append((ref, icon_name)) + _bound[:] = alive diff --git a/ui/style.qss b/ui/style.qss new file mode 100644 index 0000000..de122dd --- /dev/null +++ b/ui/style.qss @@ -0,0 +1,66 @@ +/* ab1kit light theme. Explicit colours so the chrome never follows the OS mode. */ +QWidget { background-color: #f6f7f9; color: #1a1a1a; font-size: 13px; } +QMainWindow, QDialog { background-color: #f6f7f9; } +QToolTip { background-color: #ffffff; color: #1a1a1a; border: 1px solid #d0d5dd; padding: 4px 6px; } + +QMenuBar { background-color: #eceef2; border-bottom: 1px solid #d0d5dd; } +QMenuBar::item { padding: 5px 10px; background: transparent; } +QMenuBar::item:selected { background-color: #e1e5eb; border-radius: 4px; } +QMenu { background-color: #ffffff; border: 1px solid #d0d5dd; padding: 4px; } +QMenu::item { padding: 5px 24px 5px 24px; border-radius: 4px; } +QMenu::item:selected { background-color: #2f6fed; color: #ffffff; } +QMenu::item:disabled { color: #8a8f98; } +QMenu::separator { height: 1px; background: #d0d5dd; margin: 4px 6px; } + +QToolBar { background-color: #eceef2; border: none; border-bottom: 1px solid #d0d5dd; padding: 3px; spacing: 2px; } +QToolBar > QWidget { background: transparent; } +QToolBar::separator { background-color: #d0d5dd; width: 1px; margin: 4px 6px; } +QToolButton { background: transparent; border: 1px solid transparent; border-radius: 5px; padding: 4px 6px; } +QToolButton:hover { background-color: #e1e5eb; border-color: #d0d5dd; } +QToolButton:pressed { background-color: #cfd5de; } +QToolButton:disabled { color: #8a8f98; } + +QPushButton { background-color: #ffffff; border: 1px solid #d0d5dd; border-radius: 5px; padding: 5px 14px; } +QPushButton:hover { background-color: #e1e5eb; } +QPushButton:pressed { background-color: #cfd5de; } +QPushButton:default { border-color: #2f6fed; } +QPushButton:disabled { color: #8a8f98; } + +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit, QTextEdit { + background-color: #ffffff; border: 1px solid #d0d5dd; border-radius: 4px; padding: 3px 6px; + selection-background-color: #2f6fed; selection-color: #ffffff; } +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { border-color: #2f6fed; } +QSpinBox, QDoubleSpinBox { padding-right: 18px; } +QSpinBox::up-button, QDoubleSpinBox::up-button { subcontrol-origin: border; subcontrol-position: top right; + width: 16px; border: none; border-left: 1px solid #d0d5dd; border-top-right-radius: 4px; } +QSpinBox::down-button, QDoubleSpinBox::down-button { subcontrol-origin: border; subcontrol-position: bottom right; + width: 16px; border: none; border-left: 1px solid #d0d5dd; border-bottom-right-radius: 4px; } +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background-color: #e1e5eb; } +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url(@UP@); width: 9px; height: 9px; } +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url(@DOWN@); width: 9px; height: 9px; } + +QDockWidget { titlebar-close-icon: none; } +QDockWidget::title { background-color: #eceef2; border-bottom: 1px solid #d0d5dd; padding: 5px 8px; font-weight: 600; } + +QTableView, QTableWidget { background-color: #ffffff; alternate-background-color: #f6f7f9; + gridline-color: #d0d5dd; border: 1px solid #d0d5dd; selection-background-color: #2f6fed; selection-color: #ffffff; } +QHeaderView::section { background-color: #eceef2; color: #1a1a1a; padding: 4px 8px; border: none; + border-right: 1px solid #d0d5dd; border-bottom: 1px solid #d0d5dd; font-weight: 600; } + +QScrollBar:vertical { background: transparent; width: 12px; margin: 0; } +QScrollBar:horizontal { background: transparent; height: 12px; margin: 0; } +QScrollBar::handle:vertical, QScrollBar::handle:horizontal { background: #c3c8d1; border-radius: 5px; min-height: 24px; min-width: 24px; margin: 2px; } +QScrollBar::handle:hover { background: #8a8f98; } +QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; } +QScrollBar::add-page, QScrollBar::sub-page { background: transparent; } + +QProgressBar { background-color: #ffffff; border: 1px solid #d0d5dd; border-radius: 4px; text-align: center; } +QProgressBar::chunk { background-color: #2f6fed; border-radius: 3px; } +QStatusBar { background-color: #eceef2; border-top: 1px solid #d0d5dd; } +QStatusBar::item { border: none; } +QCheckBox { spacing: 6px; background: transparent; } +QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #8a8f98; border-radius: 3px; background-color: #ffffff; } +QCheckBox::indicator:checked { background-color: #2f6fed; border-color: #2f6fed; image: url(@TICK@); } +QCheckBox::indicator:disabled { border-color: #d0d5dd; } +QLabel { background: transparent; } diff --git a/ui/style_dark.qss b/ui/style_dark.qss new file mode 100644 index 0000000..bdf680a --- /dev/null +++ b/ui/style_dark.qss @@ -0,0 +1,66 @@ +/* ab1kit dark theme. Explicit colours so the chrome never follows the OS mode. */ +QWidget { background-color: #252526; color: #d4d4d4; font-size: 13px; } +QMainWindow, QDialog { background-color: #252526; } +QToolTip { background-color: #2d2d30; color: #d4d4d4; border: 1px solid #3f3f46; padding: 4px 6px; } + +QMenuBar { background-color: #2d2d30; border-bottom: 1px solid #3f3f46; } +QMenuBar::item { padding: 5px 10px; background: transparent; } +QMenuBar::item:selected { background-color: #3a3d41; border-radius: 4px; } +QMenu { background-color: #2d2d30; border: 1px solid #3f3f46; padding: 4px; } +QMenu::item { padding: 5px 24px 5px 24px; border-radius: 4px; } +QMenu::item:selected { background-color: #2f6fed; color: #ffffff; } +QMenu::item:disabled { color: #8a8a8a; } +QMenu::separator { height: 1px; background: #3f3f46; margin: 4px 6px; } + +QToolBar { background-color: #2d2d30; border: none; border-bottom: 1px solid #3f3f46; padding: 3px; spacing: 2px; } +QToolBar > QWidget { background: transparent; } +QToolBar::separator { background-color: #3f3f46; width: 1px; margin: 4px 6px; } +QToolButton { background: transparent; border: 1px solid transparent; border-radius: 5px; padding: 4px 6px; } +QToolButton:hover { background-color: #3a3d41; border-color: #3f3f46; } +QToolButton:pressed { background-color: #4a4d52; } +QToolButton:disabled { color: #8a8a8a; } + +QPushButton { background-color: #2d2d30; border: 1px solid #3f3f46; border-radius: 5px; padding: 5px 14px; } +QPushButton:hover { background-color: #3a3d41; } +QPushButton:pressed { background-color: #4a4d52; } +QPushButton:default { border-color: #2f6fed; } +QPushButton:disabled { color: #8a8a8a; } + +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit, QTextEdit { + background-color: #1e1e1e; border: 1px solid #3f3f46; border-radius: 4px; padding: 3px 6px; + selection-background-color: #2f6fed; selection-color: #ffffff; } +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { border-color: #2f6fed; } +QSpinBox, QDoubleSpinBox { padding-right: 18px; } +QSpinBox::up-button, QDoubleSpinBox::up-button { subcontrol-origin: border; subcontrol-position: top right; + width: 16px; border: none; border-left: 1px solid #3f3f46; border-top-right-radius: 4px; } +QSpinBox::down-button, QDoubleSpinBox::down-button { subcontrol-origin: border; subcontrol-position: bottom right; + width: 16px; border: none; border-left: 1px solid #3f3f46; border-bottom-right-radius: 4px; } +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background-color: #3a3d41; } +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url(@UP@); width: 9px; height: 9px; } +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url(@DOWN@); width: 9px; height: 9px; } + +QDockWidget { titlebar-close-icon: none; } +QDockWidget::title { background-color: #2d2d30; border-bottom: 1px solid #3f3f46; padding: 5px 8px; font-weight: 600; } + +QTableView, QTableWidget { background-color: #1e1e1e; alternate-background-color: #232324; + gridline-color: #3f3f46; border: 1px solid #3f3f46; selection-background-color: #2f6fed; selection-color: #ffffff; } +QHeaderView::section { background-color: #2d2d30; color: #d4d4d4; padding: 4px 8px; border: none; + border-right: 1px solid #3f3f46; border-bottom: 1px solid #3f3f46; font-weight: 600; } + +QScrollBar:vertical { background: transparent; width: 12px; margin: 0; } +QScrollBar:horizontal { background: transparent; height: 12px; margin: 0; } +QScrollBar::handle:vertical, QScrollBar::handle:horizontal { background: #4a4a50; border-radius: 5px; min-height: 24px; min-width: 24px; margin: 2px; } +QScrollBar::handle:hover { background: #8a8a8a; } +QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; } +QScrollBar::add-page, QScrollBar::sub-page { background: transparent; } + +QProgressBar { background-color: #1e1e1e; border: 1px solid #3f3f46; border-radius: 4px; text-align: center; } +QProgressBar::chunk { background-color: #2f6fed; border-radius: 3px; } +QStatusBar { background-color: #2d2d30; border-top: 1px solid #3f3f46; } +QStatusBar::item { border: none; } +QCheckBox { spacing: 6px; background: transparent; } +QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #8a8f98; border-radius: 3px; background-color: #1e1e1e; } +QCheckBox::indicator:checked { background-color: #2f6fed; border-color: #2f6fed; image: url(@TICK@); } +QCheckBox::indicator:disabled { border-color: #3f3f46; } +QLabel { background: transparent; } diff --git a/ui/tables.py b/ui/tables.py new file mode 100644 index 0000000..9c891a0 --- /dev/null +++ b/ui/tables.py @@ -0,0 +1,52 @@ +"""Shared table behaviour: content-sized columns, stretching last column, tooltips.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem + +from ui.status import current_theme, status_color, status_text_color + + +STATUS_ROLE = Qt.ItemDataRole.UserRole + 1 +TOOLTIP_MIN_CHARS = 40 + + +def configure_table(table: QTableWidget, stretch_column: int | None = None) -> None: + """Columns fit their content; ``stretch_column`` (default: the last) takes the rest.""" + header = table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + if stretch_column is None: + header.setStretchLastSection(True) + else: + header.setStretchLastSection(False) + header.setSectionResizeMode(stretch_column, QHeaderView.ResizeMode.Stretch) + header.setMinimumSectionSize(40) + table.verticalHeader().setVisible(False) + table.setAlternatingRowColors(True) + table.setWordWrap(False) + + +def set_full_text_tooltip(item: QTableWidgetItem) -> None: + """Show the whole text on hover for long cells (a narrow column may elide them).""" + text = item.text() + if len(text) > TOOLTIP_MIN_CHARS: + item.setToolTip(text) + + +def paint_status(item: QTableWidgetItem, verdict: str) -> None: + """Colour a cell with the shared status palette (current theme).""" + item.setData(STATUS_ROLE, verdict) + theme = current_theme() + item.setBackground(status_color(verdict, theme)) + item.setForeground(status_text_color(verdict, theme)) + + +def repaint_statuses(table: QTableWidget) -> None: + """Re-apply the status palette (after a theme switch) to every status cell.""" + for row in range(table.rowCount()): + for col in range(table.columnCount()): + item = table.item(row, col) + verdict = item.data(STATUS_ROLE) if item is not None else None + if verdict: + paint_status(item, verdict) diff --git a/ui/tasks.py b/ui/tasks.py new file mode 100644 index 0000000..78630fb --- /dev/null +++ b/ui/tasks.py @@ -0,0 +1,113 @@ +"""Background tasks: run a function in the global thread pool with progress, +cancellation and GUI-thread signal delivery.""" + +from __future__ import annotations + +from collections.abc import Callable +from concurrent.futures import CancelledError +import threading + +from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal, Slot + +ProgressFn = Callable[[int, int], None] +CancelFn = Callable[[], bool] +TaskFn = Callable[[ProgressFn, CancelFn], object] + +# Strong references to running tasks (and, through them, their runnables) so +# they are not garbage-collected mid-run. +_running: set[Task] = set() + + +class Task(QObject): + """Handle of a background task. Public signals are emitted in the GUI thread. + + Exactly one of ``finished`` / ``failed`` / ``cancelled`` is emitted. After + ``cancel()`` the task always ends with ``cancelled``: a result that arrives + late (an uninterruptible call such as urllib) is dropped. + """ + + progress = Signal(int, int) + finished = Signal(object) + failed = Signal(str) + cancelled = Signal() + + # Worker -> GUI hand-off. Connected to a slot of this QObject (which lives + # in the GUI thread), so emission from the worker is queued. + _event = Signal(str, object) + + def __init__(self, fn: TaskFn, parent: QObject | None = None) -> None: + super().__init__(parent) + self._fn = fn + self._cancel_event = threading.Event() + self._done = False + self._event.connect(self._dispatch) + self._runnable = _Runnable(self) + + def cancel(self) -> None: + """Request cancellation (call from the GUI thread). ``cancelled`` is + emitted at once; whatever the worker produces later is ignored.""" + self._cancel_event.set() + if not self._done: + self._done = True + _running.discard(self) + self.cancelled.emit() + + def is_cancelled(self) -> bool: + return self._cancel_event.is_set() + + def _start(self) -> None: + _running.add(self) + QThreadPool.globalInstance().start(self._runnable) + + @Slot(str, object) + def _dispatch(self, kind: str, payload: object) -> None: + if self._done: + return + if kind == "progress": + if not self.is_cancelled(): + self.progress.emit(*payload) # type: ignore[misc] + return + self._done = True + _running.discard(self) + if kind == "cancelled": + self.cancelled.emit() + elif kind == "failed": + self.failed.emit(str(payload)) + else: + self.finished.emit(payload) + + +class _Runnable(QRunnable): + def __init__(self, task: Task) -> None: + super().__init__() + self.setAutoDelete(False) + self._task = task + + def run(self) -> None: + task = self._task + def emit(kind: str, payload: object) -> None: + try: + task._event.emit(kind, payload) + except RuntimeError: # Task was destroyed with its parent + _running.discard(task) + + try: + result = task._fn( + lambda done, total: emit("progress", (int(done), int(total))), + task.is_cancelled, + ) + except CancelledError: + emit("cancelled", None) + except BaseException as exc: # noqa: BLE001 - reported through ``failed`` + emit("failed", str(exc) or type(exc).__name__) + else: + emit("finished", result) + finally: + _running.discard(task) + + +def run_task(fn: TaskFn, parent: QObject) -> Task: + """Run ``fn(progress, is_cancelled)`` in the global thread pool.""" + task = Task(fn, parent) + task._start() + return task diff --git a/ui/theme.py b/ui/theme.py index 0cb7602..6977b86 100644 --- a/ui/theme.py +++ b/ui/theme.py @@ -2,6 +2,13 @@ from __future__ import annotations +import sys +import tempfile +from functools import lru_cache +from pathlib import Path + +from PySide6.QtGui import QIcon + LIGHT = { "bg": "#ffffff", "panel": "#f3f3f3", @@ -15,6 +22,14 @@ "low_q": "#e02020", "mid_q": "#e0a000", "high_q": "#1e9e1e", + # construct matrix: coverage on both strands / one strand, empty track, + # region frame, features (CDS/gene and others) + "cov_both": "#4caf50", + "cov_one": "#b5e0b7", + "track": "#e8e8e8", + "region": "#2563eb", + "feature_cds": "#2e8b57", + "feature": "#7a8290", } DARK = { @@ -30,6 +45,12 @@ "low_q": "#ff5252", "mid_q": "#e0a000", "high_q": "#37d437", + "cov_both": "#43a047", + "cov_one": "#3d6e41", + "track": "#333333", + "region": "#5b8def", + "feature_cds": "#2e7d4f", + "feature": "#5c6370", } @@ -37,20 +58,60 @@ def theme(name: str) -> dict: return DARK if name == "dark" else LIGHT +def resource_dir() -> Path: + """Folder holding style*.qss and icons/: next to this module, or under + ``sys._MEIPASS/ui`` in a PyInstaller bundle (see ab1kit.spec datas).""" + frozen = getattr(sys, "_MEIPASS", None) + return Path(frozen) / "ui" if frozen else Path(__file__).resolve().parent + + +def app_icon() -> QIcon: + """The application icon (``ui/icons/app/ab1kit.png``), frozen-safe.""" + return QIcon(str(resource_dir() / "icons" / "app" / "ab1kit.png")) + + +@lru_cache(maxsize=2) +def _read_qss(dark: bool) -> str: + name = "style_dark.qss" if dark else "style.qss" + return (resource_dir() / name).read_text(encoding="utf-8") + + +def tinted_icon(name: str, color: str, stroke: str = "2") -> str: + """Path of icon ``name`` (``ui/icons/.svg``) drawn in ``color``, for QSS + ``image: url(...)``. Written once per icon and colour to the temp folder; + ``""`` when that folder cannot be written (see ``image_rule``).""" + folder = Path(tempfile.gettempdir()) / "ab1kit_icons" + target = folder / f"{name}_{color.lstrip('#')}_{stroke}.svg" + try: + folder.mkdir(exist_ok=True) + if not target.exists(): + svg = (resource_dir() / "icons" / f"{name}.svg").read_text(encoding="utf-8") + target.write_text(svg.replace("currentColor", color) + .replace('stroke-width="2"', f'stroke-width="{stroke}"'), + encoding="utf-8") + except OSError: + return "" + return target.as_posix() + + +def image_rule(path: str) -> str: + """QSS ``image: url(path);``, or nothing for a missing (``""``) image.""" + return f"image: url({path});" if path else "" + + +def tick_image(background: str) -> str: + """Path of a tick (Lucide ``check``) SVG readable on ``background``.""" + r, g, b = (int(background.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)) + tick = "#1a1a1a" if 0.299 * r + 0.587 * g + 0.114 * b > 150 else "#ffffff" + return tinted_icon("check", tick, "3") + + def app_qss(dark: bool) -> str: - """Application stylesheet. Both themes set explicit colors so the chrome - never depends on the OS light/dark mode (native dark Windows makes our - fixed light-theme text colors unreadable).""" - if dark: - return ( - "QWidget { background-color: #252526; color: #d4d4d4; }" - "QMenuBar, QMenu, QToolBar { background-color: #2d2d30; }" - "QLineEdit, QSpinBox { background-color: #1e1e1e; }" - "QDockWidget::title { background-color: #2d2d30; }" - ) - return ( - "QWidget { background-color: #f3f3f3; color: #1a1a1a; }" - "QMenuBar, QMenu, QToolBar { background-color: #e9e9e9; }" - "QLineEdit, QSpinBox { background-color: #ffffff; }" - "QDockWidget::title { background-color: #e9e9e9; }" - ) + """Application stylesheet (ui/style.qss or ui/style_dark.qss). Both themes set + explicit colours so the chrome never depends on the OS light/dark mode.""" + arrow = theme("dark" if dark else "light")["text"] + return (_read_qss(dark) + .replace("image: url(@TICK@);", image_rule(tick_image("#2f6fed"))) + .replace("image: url(@UP@);", image_rule(tinted_icon("chevron-up", arrow, "3"))) + .replace("image: url(@DOWN@);", + image_rule(tinted_icon("chevron-down", arrow, "3"))))