NGLess-1.4.0: NGLess/Interpretation/Map.hs
{- Copyright 2013-2019 NGLess Authors
- License: MIT
-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RankNTypes #-}
module Interpretation.Map
( executeMap
, executeMapStats
, executeMergeSams
) where
import qualified Data.Text as T
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Builder as BB
import Control.Monad
import Control.Monad.Except
import qualified Data.Conduit.List as CL
import qualified Data.Conduit.Binary as CB
import qualified Data.Conduit.Combinators as CC
import qualified Data.Conduit as C
import qualified Data.Conduit.Algorithms.Async as CAlg
import Data.Conduit ((.|))
import Data.Conduit.Algorithms.Utils (awaitJust)
import Data.Conduit.Algorithms.Async (conduitPossiblyCompressedFile)
import Control.Monad.Extra (unlessM)
import Data.List (sort)
import System.IO (hClose, hPutStr, hPutStrLn)
import System.IO.Error (catchIOError, isAlreadyExistsError)
import System.FilePath.Glob (namesMatching)
import qualified System.Directory as SD
import System.FilePath ((</>), (<.>))
import qualified System.FilePath as FilePath
import System.PosixCompat.Files (createSymbolicLink)
import Data.Maybe (fromMaybe)
import Language
import FileManagement
import ReferenceDatabases
import Output
import NGLess
import NGLess.NGLEnvironment
import qualified StandardModules.Mappers.Bwa as Bwa
import qualified StandardModules.Mappers.Soap as Soap
import qualified StandardModules.Mappers.Minimap2 as Minimap2
import Data.Sam
import Data.Fasta
import FileOrStream
import Utils.Utils
import Utils.Conduit
import Configuration
import Utils.LockFile
fromRight d (Left _) = d
fromRight _ (Right v) = v
-- | internal type
data ReferenceInfo = PackagedReference T.Text | FaFile FilePath
data MergeStrategy = MSBestOnly
-- | An object which represents a mapper
data Mapper = Mapper
{ createIndex :: FilePath -> NGLessIO ()
, hasValidIndex :: FilePath -> NGLessIO Bool
, callMapper :: forall a. FilePath -> ReadSet -> [String] -> C.ConduitT B.ByteString C.Void NGLessIO a -> NGLessIO a
}
bwa = Mapper Bwa.createIndex Bwa.hasValidIndex Bwa.callMapper
soap = Mapper Soap.createIndex Soap.hasValidIndex Soap.callMapper
minimap2 = Mapper Minimap2.createIndex Minimap2.hasValidIndex Minimap2.callMapper
getMapper :: T.Text -> NGLessIO Mapper
getMapper request = do
mappers <- ngleMappersActive <$> nglEnvironment
if request `elem` mappers
then return $! case request of
"minimap2" -> minimap2
"soap" -> soap
"bwa" -> bwa
_ -> error "should not be possible map:getMapper"
else throwScriptError ("Requested mapper '"++T.unpack request ++"' is not active.")
isSubPath path base = let
path' = FilePath.splitPath path
base' = FilePath.splitPath base
isSubPath' _ [] = True
isSubPath' [] _ = False
isSubPath' (p:ps) (b:bs) = p == b && isSubPath' ps bs
in isSubPath' path' base'
getIndexOutput createLink fafile = do
indexDir <- nConfIndexStorePath <$> nglConfiguration
case indexDir of
Just d
| not (fafile `isSubPath` d) -> liftIO $ do
let dropSlash "" = ""
dropSlash ('/':r) = dropSlash r
dropSlash p = p
afafile <- SD.makeAbsolute fafile
let fafile' = d </> dropSlash afafile
SD.createDirectoryIfMissing True (FilePath.takeDirectory fafile')
when createLink $
createSymbolicLink afafile fafile'
`catchIOError` (\e -> unless (isAlreadyExistsError e) (ioError e))
return fafile'
_ -> return fafile
-- | lazy index creation
ensureIndexExists :: Int -> Mapper -> FilePath -> NGLessIO [FilePath]
ensureIndexExists 0 mapper fafile = do
hasIndex <- hasValidIndex mapper fafile
if hasIndex
then do
outputListLno' DebugOutput ["Index for ", fafile, " already exists."]
return [fafile]
else do
fafile' <- getIndexOutput True fafile
withLockFile LockParameters
{ lockFname = fafile' ++ ".ngless-index.lock"
, maxAge = hoursToDiffTime 36
, whenExistsStrategy = IfLockedRetry { nrLockRetries = 37*60, timeBetweenRetries = 60 }
, mtimeUpdate = True
} $
-- recheck if index exists with the lock in place
-- it may have been created in the meanwhile (especially if we slept waiting for the lock)
unlessM (hasValidIndex mapper fafile') $
createIndex mapper fafile'
return [fafile']
where
hoursToDiffTime h = fromInteger (h * 3600)
ensureIndexExists blockSize mapper fafile = do
blocks <- ensureSplitsExist blockSize fafile
forM_ blocks (ensureIndexExists 0 mapper)
return blocks
ensureSplitsExist blockSize fafile = do
fafile' <- getIndexOutput False fafile
let ofafile = FilePath.takeDirectory fafile' </> FilePath.takeBaseName fafile <.> "splits_" ++ show blockSize ++ "m"
receipt = ofafile <.> "done"
done <- liftIO $ SD.doesFileExist receipt
if done
then do
outputListLno' TraceOutput ["Splits for FASTA file '", fafile, "' found"]
liftIO $ sort <$> namesMatching (ofafile ++ ".*.fna")
else do
outputListLno' DebugOutput ["Splitting FASTA file '", fafile, "'"]
splits <- splitFASTA blockSize fafile ofafile
liftIO $ withOutputFile receipt $ \hout ->
hPutStrLn hout ("FASTA file '" ++ fafile ++ "' split into blocks of " ++ show blockSize ++ " megabases.")
outputListLno' InfoOutput ["Split FASTA file '", fafile, "' into ", show (length splits), " chunks."]
return splits
-- | parse map() args to return a reference
lookupReference :: KwArgsValues -> NGLessIO ReferenceInfo
lookupReference args = do
let reference = lookup "reference" args
fafile = lookup "fafile" args
case (reference, fafile) of
(Nothing, Nothing) -> throwScriptError "Either reference or fafile must be passed"
(Just _, Just _) -> throwScriptError "Reference and fafile cannot be used simmultaneously"
(Just r, Nothing) -> PackagedReference <$> stringOrTypeError "reference in map argument" r
(Nothing, Just fa) -> (FaFile . T.unpack) <$> stringOrTypeError "fafile in map argument" fa
mapToReference :: Mapper -> FilePath -> ReadSet -> [String] -> NGLessIO (FilePath, (Int, Int, Int))
mapToReference mapper refIndex rs extraArgs = do
(newfp, hout) <- openNGLTempFile refIndex "mapped_" "sam.zstd"
statsp <- callMapper mapper refIndex rs extraArgs (zipToStats $ CAlg.asyncZstdTo 3 hout)
liftIO $ hClose hout
return (newfp, statsp)
zipToStats :: C.ConduitT B.ByteString C.Void NGLessIO () -> C.ConduitT B.ByteString C.Void NGLessIO (Int, Int, Int)
zipToStats out = snd <$> C.toConsumer (zipSink2 out (linesVC 4096 .| readSamGroupsC' 1 True .| samStatsC'))
splitFASTA :: Int -> FilePath -> FilePath -> NGLessIO [FilePath]
splitFASTA megaBPS ifile ofileBase =
withLockFile LockParameters
{ lockFname = ifile ++ "." ++ show megaBPS ++ "m.split.lock"
, maxAge = 36 * 3000
, whenExistsStrategy = IfLockedRetry { nrLockRetries = 120, timeBetweenRetries = 60 }
, mtimeUpdate = True
} $ C.runConduit $
conduitPossiblyCompressedFile ifile
.| faConduit
.| splitWriter
where
maxBPS = 1000 * 1000 * megaBPS
splitWriter = splitWriter' [] (0 :: Int)
splitWriter' fs n = do
let f = ofileBase ++ "." ++ show n ++ ".fna"
getNbps
.| faWriteC
.| CB.sinkFileCautious f
finished <- CC.null
if finished
then return $ reverse (f:fs) -- reversing is done just so that chunks are indexed "in order"
else splitWriter' (f:fs) (n + 1)
getNbps = awaitJust $ \fa -> do
C.yield fa
if faseqLength fa > maxBPS
then do
lift $ outputListLno' WarningOutput
["While splitting file '", ifile, ": Sequence ", B8.unpack (seqheader fa), " is ", show (faseqLength fa)
," bases long (which is longer than the block size). Note that NGLess does not split sequences."]
return ()
else getNbps' (faseqLength fa)
getNbps' sofar = awaitJust $ \fa ->
if faseqLength fa + sofar > maxBPS
then C.leftover fa
else do
C.yield fa
getNbps' (faseqLength fa + sofar)
performMap :: Mapper -> Int -> ReferenceInfo -> T.Text -> ReadSet -> [String] -> NGLessIO NGLessObject
performMap mapper blockSize ref name rs extraArgs = do
(ref', mappedRef) <- indexReference ref
case ref' of
[single] -> do
(samPath', (total, aligned, unique)) <- mapToReference mapper single rs extraArgs
outputMappedSetStatistics (MappingInfo undefined samPath' single total aligned unique)
return $ NGOMappedReadSet name (File samPath') mappedRef
blocks -> do
(sam, hout) <- openNGLTempFile "merging" "merged_" "sam.zstd"
partials <- forM blocks (\block -> fst <$> mapToReference mapper block rs extraArgs)
((total, aligned, unique), ()) <- C.runConduit $
mergeSamFiles partials
.| zipSink2 (CC.conduitVector 4096 .| samStatsC')
(CL.concat
.| CL.map ((`mappend` BB.char7 '\n') . encodeSamLine)
.| CC.builderToByteString
.| CAlg.asyncZstdTo 3 hout)
liftIO $ hClose hout
let refname = case ref of
FaFile fa -> fa
PackagedReference r -> T.unpack r
outputMappedSetStatistics (MappingInfo undefined sam refname total aligned unique)
return $! NGOMappedReadSet name (File sam) mappedRef
where
indexReference :: ReferenceInfo -> NGLessIO ([FilePath], Maybe T.Text)
indexReference (FaFile fa) =
expandPath fa >>= \case
Just fa' -> (,Nothing) <$> ensureIndexExists blockSize mapper fa'
Nothing -> throwDataError ("Could not find FASTA file: "++fa)
indexReference (PackagedReference r) = do
ReferenceFilePaths fafile _ _ <- ensureDataPresent r
case fafile of
Just fp -> (, Just r) <$> ensureIndexExists blockSize mapper fp
Nothing -> throwScriptError ("Could not find reference '" ++ T.unpack r ++ "'.")
executeMap :: NGLessObject -> KwArgsValues -> NGLessIO NGLessObject
executeMap fqs args = do
ref <- lookupReference args
oAll <- lookupBoolOrScriptErrorDef (return False) "map() call" "mode_all" args
extraArgs <- map T.unpack <$> lookupStringListOrScriptErrorDef (return []) "extra mapper arguments" "__extra_args" args
mapperName <- lookupStringOrScriptErrorDef (return "bwa") "map() call" "mapper" args
blockSize <- lookupIntegerOrScriptErrorDef (return 0) "map() call" "block_size_megabases" args
mapper <- getMapper mapperName
let bwaArgs = extraArgs ++ ["-a" | oAll]
executeMap' r (NGOList es) = NGOList <$> forM es (executeMap' r)
executeMap' r (NGOReadSet name rs) = performMap mapper (fromInteger blockSize) r name rs bwaArgs
executeMap' _ v = throwShouldNotOccur ("map expects ReadSet, got " ++ show v ++ "")
executeMap' ref fqs
executeMapStats :: NGLessObject -> KwArgsValues -> NGLessIO NGLessObject
executeMapStats (NGOMappedReadSet name sami _) _ = do
outputListLno' TraceOutput ["Computing mapstats on ", show sami]
let (samfp, stream) = asSamStream sami
(t, al, u) <- C.runConduit (stream .| samStatsC) >>= runNGLess
countfp <- makeNGLTempFile samfp "sam_stats_" "stats" $ \hout ->
liftIO . hPutStr hout . concat $
[ "\t", T.unpack name, "\n"
,"total\t", show t, "\n"
,"aligned\t", show al, "\n"
,"unique\t", show u, "\n"
]
return $! NGOCounts (File countfp)
executeMapStats other _ = throwScriptError ("Wrong argument for mapstats: "++show other)
executeMergeSams :: NGLessObject -> KwArgsValues -> NGLessIO NGLessObject
executeMergeSams (NGOList ifnames) _ = do
outputListLno' WarningOutput ["Calling internal function __merge_samfiles"]
partials <- mapM (fmap T.unpack . stringOrTypeError "__merge_samfiles") ifnames
(sam, hout) <- openNGLTempFile "merging" "merged_" "sam.zstd"
((total, aligned, unique), ()) <- C.runConduit $
mergeSamFiles partials
.| zipSink2 (CC.conduitVector 4096 .| samStatsC')
(CL.concat
.| CL.map ((`mappend` BB.char7 '\n') . encodeSamLine)
.| CC.builderToByteString
.| CAlg.asyncZstdTo 3 hout)
outputMappedSetStatistics (MappingInfo undefined sam "no-ref" total aligned unique)
liftIO $ hClose hout
return $! NGOMappedReadSet "test" (File sam) Nothing
executeMergeSams _ _ = throwScriptError "Wrong argument for internal function __merge_samfiles"
mergeSamFiles :: [FilePath] -> C.ConduitT () SamGroup NGLessIO ()
mergeSamFiles [] = lift $ throwShouldNotOccur "empty input to mergeSamFiles"
mergeSamFiles inputs = do
lift $ outputListLno' TraceOutput ["Merging SAM files: ", show inputs]
forM_ inputs $ \f ->
CB.sourceFile f
.| linesC
.| readSamHeaders
-- This is sub-optimal as we reparse the file.
-- There are also obvious opportunities to make this code take advantage of parallelism
C.sequenceSources
[CB.sourceFile f
.| linesVC 4096
.| readSamGroupsC' 1 True
.| CC.concat -- TODO: Remove this and adapt `mergeSAMGroups` to work directly on vectors
| f <- inputs]
.| CL.mapM (mergeSAMGroups MSBestOnly)
readSamHeaders :: C.ConduitT ByteLine SamGroup NGLessIO ()
readSamHeaders =
CC.takeWhile (\(ByteLine line) -> B.null line || B.head line == 64)
.| CL.map (\(ByteLine line) -> [SamHeader line])
mergeSAMGroups :: MergeStrategy -> [SamGroup] -> NGLessIO SamGroup
mergeSAMGroups strategy groups
| not (allSame . fmap samQName $ concat groups) = throwDataError "Merging unsynced SAM files (not implemented yet)"
| otherwise = do
useNewer <- do
v <- ngleVersion <$> nglEnvironment
if v < NGLVersion 1 1
then do
outputListLno' WarningOutput ["SAM merging changed behaviour (for the better) in ngless 1.1. Using the old method for backwards-compatibility."]
return False
else return True
return $ group useNewer group1 ++ group useNewer group2 ++ group useNewer groupS
where
(group1, group2, groupS) = foldl (\(g1,g2,gS) s ->
(if isFirstInPair s
then (s:g1, g2, gS)
else if isSecondInPair s
then (g1, s:g2, gS)
else (g1, g2, s:gS))) ([], [], []) $ concat groups
group :: Bool -> [SamLine] -> [SamLine]
group _ [] = []
group useNewer gs = case filter isAligned gs of
[] -> [head gs]
gs' -> pick useNewer strategy gs'
pick :: Bool -> MergeStrategy -> [SamLine] -> [SamLine]
pick useNewer MSBestOnly gs =
let matchValue :: SamLine -> Int
matchValue samline = fromRight 0 (matchSize useNewer samline) - fromMaybe 0 (samIntTag samline "NM")
bestMatch = maximum (map matchValue gs)
in filter (\samline -> matchValue samline == bestMatch) gs