packages feed

sequence-formats-1.12.1.0: src/SequenceFormats/Plink.hs

{-# LANGUAGE BinaryLiterals    #-}
{-# LANGUAGE OverloadedStrings #-}
module SequenceFormats.Plink (readBimStdIn,
                              readBimFile,
                              writeBim,
                              readFamFile,
                              readPlinkBedFile,
                              readPlink,
                              writePlink,
                              PlinkFamEntry(..),
                              plinkFam2EigenstratInd,
                              eigenstratInd2PlinkFam,
                              PlinkPopNameMode(..)) where

import           SequenceFormats.Eigenstrat       (EigenstratIndEntry (..),
                                                   EigenstratSnpEntry (..),
                                                   GenoEntry (..), GenoLine,
                                                   Sex (..))
import           SequenceFormats.Utils            (Chrom (..),
                                                   SeqFormatException (..),
                                                   consumeProducer,
                                                   deflateFinaliser,
                                                   gzipConsumer,
                                                   readFileProdCheckCompress,
                                                   word, writeFromPopper)

import           Control.Applicative              ((<|>))
import           Control.Monad                    (forM_, void, when)
import           Control.Monad.Catch              (MonadThrow, throwM)
import           Control.Monad.IO.Class           (MonadIO, liftIO)
import           Control.Monad.Trans.Class        (lift)
import qualified Data.Attoparsec.ByteString       as AB
import qualified Data.Attoparsec.ByteString.Char8 as A
import           Data.Bits                        (shiftL, shiftR, (.&.), (.|.))
import qualified Data.ByteString                  as BB
import qualified Data.ByteString.Char8            as B
import           Data.Char                        (isSpace)
import           Data.List                        (isSuffixOf)
import qualified Data.Streaming.Zlib              as Z
import           Data.Vector                      (fromList, toList)
import           Data.Word                        (Word8)
import           Pipes                            (Consumer, Producer, (>->))
import           Lens.Family2                     (view)
import           Pipes.Attoparsec                 (parsed)
import qualified Pipes.ByteString                 as PB
import qualified Pipes.Prelude                    as P
import           Pipes.Safe                       (MonadSafe, register)
import qualified Pipes.Safe.Prelude               as PS
import           System.IO                        (IOMode (..),
                                                   withFile)
import           Text.Printf                      (printf)

-- see https://www.cog-genomics.org/plink/2.0/formats#fam
data PlinkFamEntry = PlinkFamEntry {
    _famFamilyID     :: B.ByteString,
    _famIndividualID :: B.ByteString,
    _famFatherID     :: B.ByteString,
    _famMotherID     :: B.ByteString,
    _famSexCode      :: Sex,
    _famPhenotype    :: B.ByteString
} deriving (Eq, Show)

data PlinkPopNameMode = PlinkPopNameAsFamily | PlinkPopNameAsPhenotype | PlinkPopNameAsBoth deriving (Eq, Show)

bimParser :: A.Parser EigenstratSnpEntry
bimParser = do
    chrom      <- word
    snpId_     <- A.skipMany1 A.space >> word
    geneticPos <- A.skipMany1 A.space >> A.double
    pos        <- A.skipMany1 A.space >> A.decimal
    ref        <- A.skipMany1 A.space >> A.satisfy (A.inClass "ACTGNX01234.")
    alt        <- A.skipMany1 A.space >> A.satisfy (A.inClass "ACTGNX01234.")
    void A.endOfLine
    let refConvert = convertChar ref
        altConvert = convertChar alt
    return $ EigenstratSnpEntry (Chrom chrom) pos geneticPos snpId_ refConvert altConvert
  where
    convertChar '0' = 'N'
    convertChar '1' = 'A'
    convertChar '2' = 'C'
    convertChar '3' = 'G'
    convertChar '4' = 'T'
    convertChar 'X' = 'N'
    convertChar '.' = 'N'
    convertChar x   = x

famParser :: A.Parser PlinkFamEntry
famParser = do
    A.skipMany A.space
    famID    <- word
    indID    <- A.skipMany1 A.space >> word
    fatherID <- A.skipMany1 A.space >> word
    motherID <- A.skipMany1 A.space >> word
    sex      <- A.skipMany1 A.space >> parseSex
    phen     <- A.skipMany1 A.space >> word
    void A.endOfLine
    return $ PlinkFamEntry famID indID fatherID motherID sex phen
  where
    parseSex = parseMale <|> parseFemale <|> parseUnknown
    parseMale = A.char '1' >> return Male
    parseFemale = A.char '2' >> return Female
    parseUnknown = A.anyChar >> return Unknown

plinkFam2EigenstratInd :: PlinkPopNameMode -> PlinkFamEntry -> EigenstratIndEntry
plinkFam2EigenstratInd plinkPopNameMode (PlinkFamEntry famId indId _ _ sex phen) =
    let popName = case plinkPopNameMode of
            PlinkPopNameAsFamily    -> famId
            PlinkPopNameAsPhenotype -> phen
            -- If the two differ but you want both, then merge them somehow.
            PlinkPopNameAsBoth -> if famId == phen then famId else famId <> ":" <> phen
    in  EigenstratIndEntry indId sex popName

eigenstratInd2PlinkFam :: PlinkPopNameMode -> EigenstratIndEntry -> PlinkFamEntry
eigenstratInd2PlinkFam plinkPopNameMode (EigenstratIndEntry indId sex popName)=
    case plinkPopNameMode of
        PlinkPopNameAsFamily    -> PlinkFamEntry popName indId "0" "0" sex "0"
        PlinkPopNameAsPhenotype -> PlinkFamEntry "DummyFamily" indId "0" "0" sex popName
        PlinkPopNameAsBoth      -> PlinkFamEntry popName indId "0" "0" sex popName

-- |Checks the three magic bytes at the start of a bed file, see
-- https://www.cog-genomics.org/plink/1.9/formats#bed
checkBedHeader :: (MonadThrow m) => FilePath -> B.ByteString -> m ()
checkBedHeader file header
    | BB.length header < 3 = throwM . SeqFormatException $
        "Plink bed file " ++ file ++ " is too short (" ++ show (BB.length header) ++
        " bytes) to contain the 3-byte bed header. The file seems to be empty or truncated"
    | BB.take 2 header /= BB.pack [0b01101100, 0b00011011] = throwM . SeqFormatException $
        "Plink bed file " ++ file ++ " does not start with the magic bytes 0x6c 0x1b (found " ++
        showBytes (BB.take 2 header) ++ "). It does not seem to be a Plink bed file" ++ gzipHint
    | BB.index header 2 /= 0b00000001 = throwM . SeqFormatException $
        "Plink bed file " ++ file ++ " is not in SNP-major mode (third byte is " ++
        showBytes (BB.drop 2 header) ++ " instead of 0x01). Only SNP-major bed files are supported"
    | otherwise = return ()
  where
    showBytes = unwords . map (\b -> printf "0x%02x" b) . BB.unpack
    gzipHint = if BB.take 2 header == BB.pack [0x1f, 0x8b] && not (".gz" `isSuffixOf` file)
        then ". It looks gzip-compressed, but its name does not end in .gz"
        else ""

-- |The number of bytes per SNP in a bed file, with 2 bits per individual.
bedRecordBytes :: Int -> Int
bedRecordBytes nrInds = (nrInds + 3) `quot` 4

bedGenotypeParser :: Int -> AB.Parser GenoLine
bedGenotypeParser nrInds = do
    bytes <- BB.unpack <$> AB.take (bedRecordBytes nrInds)
    let indBitPairs = concatMap getBitPairs bytes
    return . fromList . take nrInds . map bitPairToGenotype $ indBitPairs
  where
    getBitPairs byte = map (0b00000011 .&.) [byte, shiftR byte 2, shiftR byte 4, shiftR byte 6]
    bitPairToGenotype 0b00000000 = HomRef
    bitPairToGenotype 0b00000010 = Het
    bitPairToGenotype 0b00000011 = HomAlt
    bitPairToGenotype 0b00000001 = Missing
    bitPairToGenotype _          = error "This should never happen"

readPlinkBedProd :: (MonadThrow m) => FilePath -> Int -> Producer B.ByteString m () -> m (Producer GenoLine m ())
readPlinkBedProd file nrInds prod = do
    (headerChunks, rest) <- P.toListM' $ view (PB.splitAt (3 :: Int)) prod
    checkBedHeader file (BB.concat headerChunks)
    return $ parsed (bedGenotypeParser nrInds) rest >>= either reportTruncated return
  where
    -- the genotype parser can only fail if the input ends in the middle of a SNP record
    reportTruncated (_, leftovers) = do
        nrLeftoverBytes <- lift $ P.fold (\n c -> n + BB.length c) 0 id leftovers
        throwM . SeqFormatException $ "Plink bed file " ++ file ++ " ends with an incomplete SNP " ++
            "record (" ++ show nrLeftoverBytes ++ " bytes left over, but each SNP takes " ++
            show (bedRecordBytes nrInds) ++ " bytes for " ++ show nrInds ++ " individuals). " ++
            "Either the bed file is truncated or it does not match the number of individuals in the fam file"

-- |A function to read a bed file from a file. Returns a Producer over all lines.
readPlinkBedFile :: (MonadSafe m) => FilePath -> Int -> m (Producer GenoLine m ())
readPlinkBedFile file nrInds = readPlinkBedProd file nrInds . readFileProdCheckCompress $ file

-- |Function to read a Bim File from StdIn. Returns a Pipes-Producer over the EigenstratSnpEntries.
readBimStdIn :: (MonadThrow m, MonadIO m) => Producer EigenstratSnpEntry m ()
readBimStdIn = consumeProducer bimParser PB.stdin

-- |Function to read a Bim File from a file. Returns a Pipes-Producer over the EigenstratSnpEntries.
readBimFile :: (MonadSafe m) => FilePath -> Producer EigenstratSnpEntry m ()
readBimFile = consumeProducer bimParser . readFileProdCheckCompress

-- |Function to read a Plink fam file. Returns the Eigenstrat Individual Entries as list.
readFamFile :: (MonadIO m) => FilePath -> m [PlinkFamEntry]
readFamFile fn =
    liftIO . withFile fn ReadMode $ \handle ->
        P.toListM $ consumeProducer famParser (PB.fromHandle handle)

-- |Function to read a full Plink dataset from files. Returns a pair of the Plink Individual Entries, and a joint Producer over the snp entries and the genotypes.
readPlink :: (MonadSafe m) => FilePath -- ^The Bed file
               -> FilePath -- ^The Bim File
               -> FilePath -- ^The Fam file
               -> m ([PlinkFamEntry], Producer (EigenstratSnpEntry, GenoLine) m ()) -- The return pair of individual entries and a joint Snp/Geno Producer.
readPlink bedFile bimFile famFile = do
    indEntries <- readFamFile famFile
    let nrInds = length indEntries
        snpProd = readBimFile bimFile
    genoProd <- readPlinkBedFile bedFile nrInds
    return (indEntries, P.zip snpProd genoProd)

-- |Function to write a Bim file. Returns a consumer expecting EigenstratSnpEntries.
writeBim :: (MonadSafe m) => FilePath -- ^The Plink Bim File.
    -> Consumer EigenstratSnpEntry m () -- ^A consumer to read EigenstratSnpEntries
writeBim bimFile = do
    (_, bimFileH) <- lift $ PS.openFile bimFile WriteMode
    bimOutTextConsumer <- if ".gz" `isSuffixOf` bimFile then do
            def <- liftIO $ Z.initDeflate 6 (Z.WindowBits 31)
            _ <- register (deflateFinaliser def bimFileH)
            return $ gzipConsumer def bimFileH
        else
            return $ PB.toHandle bimFileH
    let toTextPipe = P.mapM (\(EigenstratSnpEntry chrom pos gpos gid ref alt) -> do
            when (B.null gid || B.any isSpace gid) . throwM . SeqFormatException $
                "invalid SNP ID " ++ show gid ++ " at " ++ show chrom ++ ":" ++ show pos ++
                ". SNP IDs must be non-empty and must not contain whitespace"
            let bimLine = B.intercalate "\t" [unChrom chrom, gid, B.pack (show gpos),
                    B.pack (show pos), B.singleton ref, B.singleton alt]
            return $ bimLine <> "\n")
    toTextPipe >-> bimOutTextConsumer

-- |Function to write a Plink Fam file.
writeFam :: (MonadIO m) => FilePath -> [PlinkFamEntry] -> m ()
writeFam f indEntries =
    liftIO . withFile f WriteMode $ \h ->
        forM_ indEntries $ \(PlinkFamEntry famId indId fatherId motherId sex phen) ->
            B.hPutStrLn h . B.intercalate "\t" $ [famId, indId, fatherId, motherId, sexToStr sex, phen]
  where
    sexToStr sex = case sex of
        Male    -> "1"
        Female  -> "2"
        Unknown -> "0"

-- |Function to write a Plink Bed File. Returns a consumer expecting Eigenstrat Genolines.
writeBed :: (MonadSafe m) => FilePath -- ^The Bed file handle
  -> Consumer GenoLine m () -- ^A consumer to read Genotype entries.
writeBed bedFile = do
    (_, bedFileH) <- lift $ PS.openFile bedFile WriteMode
    let stickyBytes = BB.pack [0b01101100, 0b00011011, 0b00000001]
    bedOutConsumer <- if ".gz" `isSuffixOf` bedFile then do
            def <- liftIO $ Z.initDeflate 6 (Z.WindowBits 31)
            _ <- register (deflateFinaliser def bedFileH)
            pop <- liftIO (Z.feedDeflate def stickyBytes)
            liftIO (writeFromPopper pop bedFileH)
            return $ gzipConsumer def bedFileH
        else do
            liftIO $ BB.hPut bedFileH stickyBytes
            return $ PB.toHandle bedFileH
    let toPlinkPipe = P.map (BB.pack . genoLineToBytes)
    toPlinkPipe >-> bedOutConsumer
  where
    genoLineToBytes :: GenoLine -> [Word8]
    genoLineToBytes genoLine = go (toList genoLine)
      where
        go :: [GenoEntry] -> [Word8]
        go []                         = [] -- empty list for recursion stop
        go (g1 : g2 : g3 : g4 : rest) = constructByte [g1, g2, g3, g4] : go rest -- at least 5 entries -> more than 1 byte
        go genoEntries                = [constructByte genoEntries] -- four or less entries -> 1 byte
        constructByte :: [GenoEntry] -> Word8
        constructByte []     = error "constructByte - should never happen"
        constructByte [g]    = genoEntryToByte g
        constructByte (g:gs) = shiftL (constructByte gs) 2 .|. genoEntryToByte g

genoEntryToByte :: GenoEntry -> Word8
genoEntryToByte HomRef  = 0b00000000
genoEntryToByte HomAlt  = 0b00000011
genoEntryToByte Het     = 0b00000010
genoEntryToByte Missing = 0b00000001

-- |Function to write a Plink Database. Returns a consumer expecting joint Snp- and Genotype lines.
writePlink :: (MonadSafe m) => FilePath -- ^The Bed file
                -> FilePath -- ^The Bim File
                -> FilePath -- ^The Fam file
                -> [PlinkFamEntry] -- ^The list of individual entries
                -> Consumer (EigenstratSnpEntry, GenoLine) m () -- ^A consumer to read joint Snp/Genotype entries.
writePlink bedFile bimFile famFile indEntries = do
    liftIO $ writeFam famFile indEntries
    let bimOutConsumer = writeBim bimFile
        bedOutConsumer = writeBed bedFile
    P.tee (P.map fst >-> bimOutConsumer) >-> P.map snd >-> bedOutConsumer