lisp/sav.lisp @ 97e37a4c1138 default tip
More
| author | Steve Losh <steve@stevelosh.com> |
|---|---|
| date | Tue, 13 Oct 2026 10:31:54 -0400 |
| parents | (none) |
| children | (none) |
(eval-when (:compile-toplevel :load-toplevel :execute) (ql:quickload '(:adopt :str :alexandria :losh :iterate :boots) :silent t)) (defpackage :sav (:use :cl :iterate :losh) (:export :toplevel :*ui*)) (in-package :sav) ;;;; Configuration ------------------------------------------------------------ (defparameter *score-match* 1) (defparameter *score-mismatch* -1.5) (defparameter *score-gap* -2) ;;;; Utilities ---------------------------------------------------------------- (defclass* seq () (label bases (annotations :initform nil))) (defun make-buffer (&key initial-contents (element-type t) (initial-capacity (max 64 (length initial-contents)))) (let ((buffer (make-array initial-capacity :element-type element-type :adjustable t :fill-pointer (length initial-contents)))) (when initial-contents (replace buffer initial-contents)) buffer)) (defun buffer-push (buffer element) (vector-push-extend element buffer) element) (defun buffer-append (buffer sequence) (let* ((l1 (length buffer)) (l2 (length sequence)) (needed (+ l1 l2))) (when (< (array-total-size buffer) needed) (adjust-array buffer (max needed (* l1 2)))) (setf (fill-pointer buffer) needed) (replace buffer sequence :start1 l1) sequence)) (defmacro-clause (BUFFERING expr &optional APPEND (append nil) INTO (var iterate::*result-var*) INITIAL-CONTENTS (initial-contents '()) ELEMENT-TYPE (element-type t)) `(progn (with ,var = (make-buffer :initial-contents ,initial-contents :element-type ,element-type)) (,(if append 'buffer-append 'buffer-push) ,var ,expr))) (defun read-fasta (stream) (iterate (with label = nil) (case (peek-char nil stream nil :eof) (:eof (finish)) (#\Newline (read-char stream)) ((#\> #\@) (if label (finish) (setf label (subseq (read-line stream) 1)))) (t (buffering (read-line stream) :into data :append t :element-type 'character))) (finally (return (make-instance 'seq :label label :bases data))))) (defun read-fastq (stream) (let ((header-line (read-line stream)) (sequence-line (read-line stream)) (sep-line (read-line stream)) (quality-line (read-line stream))) (declare (ignore sep-line quality-line)) (make-instance 'seq :label (subseq header-line 1) :bases sequence-line))) (defun read-bare (stream) (make-instance 'seq :label "Query" :bases (read-line stream))) (defun read-fast* (stream) (ecase (peek-char nil stream nil :eof) (:eof nil) ((#\A #\C #\T #\G #\n #\a #\c #\t #\g #\n) (read-bare stream)) (#\> (read-fasta stream)) (#\@ (read-fastq stream)))) (defun parse-file (path) (with-open-file (s path) (iterate (for seq = (read-fast* s)) (while seq) (collect seq)))) (defun read-data (path &key default-name) ;; todo handle - (declare (ignore default-name)) (parse-file path)) (defun dna-reverse (seq) (reverse seq)) (defun dna-complement (seq) (map (type-of seq) (lambda (ch) (ecase ch (#\A #\T) (#\T #\A) (#\C #\G) (#\G #\C) (#\N #\N))) seq)) (defun dna-reverse-complement (seq) (dna-reverse (dna-complement seq))) ;;;; Alignment ---------------------------------------------------------------- (defun make-smith-waterman-grid (rows cols) (make-array (list rows cols) :initial-element 0)) (defun extract-smith-waterman-path (grid prev best-row best-col) (iterate (for coord :first (cons best-row best-col) :then (aref prev (car coord) (cdr coord))) (until (< (aref grid (car coord) (cdr coord)) 0.1f0)) (collect (cons (1- (car coord)) (1- (cdr coord))) :at start))) (defun print-grid (seq1 seq2 grid &key (pad 4)) (flet ((print-padded (seq) (map nil (lambda (el) (format t "~V@A" pad el)) seq)) (row-of (array row) ; ugly hack, but just for debugging… (iterate (for col :below (array-dimension array 1)) (collect (aref array row col))))) (print-padded " ") (print-padded seq1) (terpri) (iterate (for row :below (array-dimension grid 0)) (print-padded (list (if (plusp row) (aref seq2 (1- row)) #\Space))) (print-padded (row-of grid row)) (terpri)))) (defun render-annotations (seq pad gaps anns) (if (null anns) (list (list) "") (let ((chars "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") (n 0) (legend (list))) (flet ((annotate (text) (let ((ch (aref chars n))) (incf n) (push (format nil "~A - ~A" ch text) legend) ch))) (iterate (for i :from 0 :below (length seq)) (collect (if-found (text (gethash i anns)) (annotate text) #\space) :into line :result-type 'string) (loop :while (eql i (car gaps)) :do (progn (collect #\space :into line :result-type 'string) (pop gaps))) (returning (list (nreverse legend) (str:concat (str:repeat pad " ") line)))))))) (defclass* alignment () (lab1 seq1 pad1 leg1 ann1 m padm lab2 seq2 pad2 leg2 ann2)) (defun render-alignment (rseq qseq path) (iterate (with seq1 = (bases rseq)) (with seq2 = (bases qseq)) (with (lo2 . lo1) = (first path)) (with (hi2 . hi1) = (car (last path))) (with gaps1 = (list)) (with gaps2 = (list)) (with pad1 = (if (>= lo1 lo2) 0 (- lo2 lo1))) (with pad2 = (if (>= lo2 lo1) 0 (- lo1 lo2))) (with padM = (max lo1 lo2)) (with p1 = -1) (with p2 = -1) (for (i2 . i1) :in path) (for ch1 = (aref seq1 i1)) (for ch2 = (aref seq2 i2)) (collect (cond ((or (= i1 p1) (= i2 p2)) #\g) ((char= ch1 ch2) #\|) (t #\x)) :into m :result-type 'string) (collect (if (> i1 p1) (progn (setf p1 i1) ch1) (progn (push i1 gaps1) #\-)) :into l1 :result-type 'string) (collect (if (> i2 p2) (progn (setf p2 i2) ch2) (progn (push i2 gaps2) #\-)) :into l2 :result-type 'string) (returning (destructuring-bind (leg1 ann1) (render-annotations seq1 pad1 gaps1 (annotations rseq)) (destructuring-bind (leg2 ann2) (render-annotations seq2 pad2 gaps2 (annotations qseq)) (make-instance 'alignment :lab1 (label rseq) :seq1 (str:concat (str:repeat pad1 " ") (subseq seq1 0 lo1) l1 (subseq seq1 (1+ hi1) nil)) :pad1 pad1 :leg1 leg1 :ann1 ann1 :m (str:concat (str:repeat padM " ") m) :padm padm :lab2 (label qseq) :seq2 (str:concat (str:repeat pad2 " ") (subseq seq2 0 lo2) l2 (subseq seq2 (1+ hi2) nil)) :pad2 pad2 :leg2 leg2 :ann2 ann2)))))) ;; (defun print-alignment (seq1 seq2 path &optional ann1 ann2) ;; (let ((a (render-alignment seq1 seq2 path ann1 ann2))) ;; (map nil #'write-line (leg1 a)) ;; (when (leg1 a) (write-line "")) ;; (write-line (ann1 a)) ;; (write-line (seq1 a)) ;; (write-line (m a)) ;; (write-line (seq2 a)) ;; (write-line (ann2 a)) ;; (when (leg2 a) (write-line "")) ;; (map nil #'write-line (leg2 a)))) (defun smith-waterman (rseq qseq) (iterate (with seq1 = (coerce (bases rseq) 'simple-base-string)) (with seq2 = (coerce (bases qseq) 'simple-base-string)) (with best-row = 0) (with best-col = 0) (with best-score = 0) (with rows = (1+ (length seq2))) (with cols = (1+ (length seq1))) (with grid = (make-smith-waterman-grid rows cols)) (with prev = (make-array (list rows cols) :initial-element nil)) (for row :from 1 :below rows) (for ch2 :in-string seq2) (iterate (for col :from 1 :below cols) (for ch1 :in-string seq1) (for row-1 = (1- row)) (for col-1 = (1- col)) (for match = (+ (aref grid row-1 col-1) (if (char= ch1 ch2) *score-match* *score-mismatch*))) (for gap-1 = (+ (aref grid row col-1) *score-gap*)) (for gap-2 = (+ (aref grid row-1 col) *score-gap*)) (for best = (max match gap-1 gap-2 0)) (setf (aref grid row col) best (aref prev row col) (cond ((= best 0) nil) ((= best match) (cons row-1 col-1)) ((= best gap-1) (cons row col-1)) ((= best gap-2) (cons row-1 col)))) (if (> best best-score) (setf best-score best best-row row best-col col))) (returning (let ((path (extract-smith-waterman-path grid prev best-row best-col))) (render-alignment rseq qseq path))))) ;;;; UI ----------------------------------------------------------------------- (defparameter *selected-index* 0) (defparameter *r-seqs* nil) (defparameter *q-seq* nil) (defparameter *x-offset* 5) (defparameter *alignments* nil) (defconstant +attr/match+ (boots:attr :bold t :fg (boots:rgb 50 255 50))) (defconstant +attr/mismatch+ (boots:attr :bold t :fg (boots:rgb 255 50 50))) (defconstant +attr/not-selected+ (boots:attr :bold t)) (defconstant +attr/selected+ (boots:attr :bold t :fg (boots:rgb 0 0 0) :bg (boots:rgb 255 255 255))) (defun draw/status (pad) (boots:draw pad 0 0 "SAV v1")) (defun draw/alignment (index pad &aux (alignment (nth index *alignments*))) (boots:draw pad 0 0 (format nil "~A vs ~A" (lab1 alignment) (lab2 alignment)) (if (= index *selected-index*) +attr/selected+ +attr/not-selected+)) (boots:draw pad *x-offset* 2 (seq1 alignment)) (boots:draw pad *x-offset* 4 (seq2 alignment)) (loop :for i :from 0 :for ch :across (m alignment) :do (multiple-value-bind (c a) (case ch (#\| (values #\| +attr/match+)) (#\g (values #\space +attr/mismatch+)) (#\x (values #\x +attr/mismatch+)) (#\space (values nil nil))) (when c (boots:draw pad (+ *x-offset* i) 2 (aref (seq1 alignment) i) a) (boots:draw pad (+ *x-offset* i) 3 c a) (boots:draw pad (+ *x-offset* i) 4 (aref (seq2 alignment) i) a))))) (defun press-any-key () (boots:read-event) nil) (defun recompute-alignments () (setf *alignments* (loop :for r-seq :in *r-seqs* :collect (smith-waterman r-seq *q-seq*)))) (defun incf-selected (delta) (zapf *selected-index* (mod (+ % delta) (length *alignments*)))) (defun swap-alignments (a b) (let ((ai (mod a (length *alignments*))) (bi (mod b (length *alignments*)))) (rotatef (nth ai *alignments*) (nth bi *alignments*)) (setf *selected-index* bi))) (defun ui/main () (setf (boots:root boots:*screen*) (boots:make-stack :children (list* (boots:make-canvas :height 1 :draw #'draw/status) (loop :for a :in *alignments* :for i :from 0 :collect (boots:make-canvas :border-top t :fill-char #\space :draw (curry #'draw/alignment i)))))) (loop (boots:redraw) (boots:event-case (boots:read-event) ((#\k :up) (incf-selected -1)) ((#\j :down) (incf-selected 1)) (#\K (swap-alignments *selected-index* (1- *selected-index*))) (#\J (swap-alignments *selected-index* (1+ *selected-index*))) ((#\h :left) (incf *x-offset* 10)) ((#\l :right) (decf *x-offset* 10)) ((#\escape #\q) (return))))) ;;;; Entry Point -------------------------------------------------------------- (defun run% (r-seqs q-seq) (boots/terminals/ansi:with-ansi-terminal (terminal :truecolor t) (boots:with-screen (boots:*screen* terminal) (boots:with-light-borders (let* ((*r-seqs* r-seqs) (*q-seq* q-seq)) (recompute-alignments) (ui/main)))))) (defun run (reference-path query-path) (let ((r-seqs (read-data reference-path :default-name "Reference")) (q-seqs (read-data query-path :default-name "Query"))) (run% r-seqs (first q-seqs)))) (defun r () (run "/home/sjl/scratch/sav-foo.fasta" "/home/sjl/scratch/sav-bar.fasta")) ;;;; User Interface ----------------------------------------------------------- (defparameter *option/help* (adopt:make-option 'help :help "Display help and exit." :long "help" :short #\h :reduce (constantly t))) (defparameter *option/version* (adopt:make-option 'version :help "Display version information and exit." :long "version" :reduce (constantly t))) (adopt:define-string *help-text* "sav is a Sequence Alignment Visualizer") (defparameter *examples* '(("Align the clipboard to the L1HS consensus sequence:" . "sav path/to/l1.fa <(pbpaste)"))) (defparameter *ui* (adopt:make-interface :name "sav" :usage "[OPTIONS] REF QUERY" :summary "sequence alignment visualizer" :help *help-text* :examples *examples* :contents (list *option/help* *option/version*))) (defun toplevel () (adopt::quit-on-ctrl-c () (multiple-value-bind (arguments options) (adopt:parse-options-or-exit *ui*) (when (gethash 'help options) (adopt:print-help-and-exit *ui*)) (let () ; todo options (handler-case (progn (assert (= (length arguments) 2) () "REF and QUERY arguments are both required.") (run (first arguments) (second arguments))) (error (e) (adopt:print-error-and-exit e))))))) #; Scratch -------------------------------------------------------------------- ;; (run "~/scratch/sav-foo.fasta" "~/scratch/sav-bar.fasta" ;; (alexandria:alist-hash-table '()) ;; (alexandria:alist-hash-table '()))