oddSNP 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oddSNP/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """oddSNP is an open-source framework for the calculation of SNP-Information
2
+ Content (SNP-IC), a quantitative metric computable from simple unpooled pilot
3
+ data that accurately predicts the success of genotype-based demultiplexing;
4
+ and its equivalent for genotype-free approaches, cell-paired SNP-Information
5
+ Content (cpSNP-IC)."""
6
+
7
+ __version__ = '0.1.0'
8
+ __author__ = 'Rodolfo Allendes'
9
+ __email__ = 'rodolfo.allendes.prime@osaka-u.ac.jp'
10
+
11
+ # make oddSNP available if someone imports
12
+ from .__main__ import oddSNP as main
13
+
14
+ __all__ = ['main']
oddSNP/__main__.py ADDED
@@ -0,0 +1,33 @@
1
+ # Copyright (C) 2025 Nemoto-lab, Osaka University
2
+ # email: rodolfo.allendes.prime@osaka-u.ac.jp
3
+ # email: nemoto.takahiro.prime@osaka-u.ac.jp
4
+ #
5
+ # This program is free software; you can redistribute it and/or modify it under
6
+ # the terms of the GNU General Public License as published by the Free Software
7
+ # Foundation; either version 2 of the License, or (at your option) any later
8
+ # version.
9
+ # This program is distributed in the hope that it will be useful, but WITHOUT
10
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License along with
14
+ # this program; if not, write to the Free Software Foundation, Inc., 59 Temple
15
+ # Place, Suite 330, Boston, MA 02111-1307 USA
16
+ import click
17
+
18
+ from . import utils
19
+ from . import downsampling
20
+ from . import genotype
21
+ from . import snpic
22
+ from . import cpsnpic
23
+
24
+ @click.group()
25
+ @click.version_option(version='0.1.0', prog_name='oddSNP')
26
+ def oddSNP():
27
+ pass
28
+
29
+ oddSNP.add_command(utils.utils)
30
+ oddSNP.add_command(downsampling.downsample)
31
+ oddSNP.add_command(genotype.genotype)
32
+ oddSNP.add_command(snpic.snpic)
33
+ oddSNP.add_command(cpsnpic.cpsnpic)
oddSNP/cpsnpic.py ADDED
@@ -0,0 +1,332 @@
1
+ # Copyright 2025-2026 Nemoto-lab, The University of Osaka
2
+ # email: rodolfo.allendes.prime@osaka-u.ac.jp
3
+ # email: nemoto.takahiro.prime@osaka-u.ac.jp
4
+
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ # ---
18
+
19
+ import click
20
+ import traceback
21
+ import math
22
+ import pickle, gzip
23
+ import itertools, more_itertools
24
+ import joblib
25
+ import tqdm
26
+
27
+ from pathlib import Path
28
+ import pandas as pd
29
+ import numpy as np
30
+
31
+ from . import genotype
32
+ from . import plotting
33
+
34
+ @click.group()
35
+ def cpsnpic():
36
+ pass
37
+
38
+ def matchingSNPs(snps1, snps2):
39
+ """ Calculate the sum of min OTH+DP for matching SNPs between two cells.
40
+
41
+ Args:
42
+ snps1: DataFrame with SNPs for cell 1
43
+ snps2: DataFrame with SNPs for cell 2
44
+
45
+ Returns:
46
+ A list with [cell1_bcode, cell2_bcode, sum_min_OTH+DP]
47
+ """
48
+ tmp = pd.concat([snps1,snps2], axis=1, keys=[1,2]).dropna()
49
+ if tmp.shape[0]==0: #empty intersection means no matching SNPs
50
+ return None
51
+
52
+ # flat the column names and calculate the min OTH+DP for each SNP in the intersection
53
+ tmp.columns = [ '{}_{}'.format(b,a) for (a,b) in tmp.columns.to_flat_index() ]
54
+ tmp['sum_1'] = tmp['DP_1']+tmp['OTH_1']
55
+ tmp['sum_2'] = tmp['DP_2']+tmp['OTH_2']
56
+ tmp['min_sum'] = tmp[['sum_1','sum_2']].min(axis=1)
57
+
58
+ return [
59
+ snps1.bcode.iat[0],
60
+ snps2.bcode.iat[0],
61
+ tmp.min_sum.sum() # sum of minimum across matching SNPs
62
+ ]
63
+
64
+ @cpsnpic.command
65
+ @click.argument('inpath', type=click.Path(exists=True))
66
+ @click.argument('oupath', type=str)
67
+ @click.option('--nproc', type=int, default=20, help='Number of parallel processes to use')
68
+ @click.option('--batch_size', type=int, default=100000, help='Number of cell pairs to process in each batch.')
69
+ @click.option('--force', is_flag=True, help='Override target file.')
70
+ def calculate_cpsnpic(inpath, oupath, nproc, batch_size, force):
71
+ """ Calculate the cpSNP-IC for pairs of cells. Returns the name of the file
72
+ where the DataFrame with the cpSNP-IC counts per cell pair is stored.
73
+
74
+ Arguments:
75
+
76
+ INPATH: Path to the folder containing pileup related files. The folder should
77
+ contain at least the following files: ``cellSNP.samples.tsv``,
78
+ ``cellSNP.tag.AD/DP`` and ``OTH`` matrices
79
+
80
+ OUPATH: Path to the output folder where results will be saved.
81
+ """
82
+ # only regenerate if the file does not exist or if the force flag is set
83
+ tgt = Path('{}/cpsnpic_counts.pkl.gz'.format(oupath))
84
+ if tgt.is_file() and not force:
85
+ print('cpSNP-IC counts found at: {}'.format(tgt))
86
+ return tgt
87
+
88
+ # calculate or fetch the aggregated genotype file
89
+ ag = genotype.call_aggregate_pileup(
90
+ inpath=inpath,
91
+ oupath=oupath,
92
+ force=force
93
+ )
94
+ # remove cellid
95
+ ag.reset_index('Cell', drop=True, inplace=True)
96
+
97
+ # calculate or fetch the groups of SNPs by cell barcode
98
+ # as this is not a DF, we need to save/load it using pickle + gzip
99
+ spc = Path('{}/snps_per_cell.pkl.gz'.format(oupath))
100
+ if not spc.is_file() or force:
101
+ grps = ag.groupby('bcode')
102
+ if not Path(oupath).exists(): Path(oupath).mkdir(parents=True)
103
+ print('Saving SNPs per cell to {}'.format(spc))
104
+ with gzip.open(spc, 'wb') as f:
105
+ pickle.dump(grps, f)
106
+ else:
107
+ print('Loading SNPs per cell from {}'.format(spc))
108
+ with gzip.open(spc, 'rb') as f:
109
+ grps = pickle.load(f)
110
+
111
+ # generate all cell pairs
112
+ cells = list(grps.groups.keys())
113
+ print('Generating cell pairs for {} cells'.format(len(cells)))
114
+ pairs = itertools.combinations(cells, 2)
115
+ npairs = (len(cells) * (len(cells) - 1)) // 2
116
+
117
+ # Perform the acutal matching of snps and counts calculation
118
+ # Given that the cells share SNPs, we can obtain a series of statistics
119
+ # associated to them, in particular, what is the minimum Allele frequency
120
+ # or the number of times (DP+DP) that the SNP was found for each cell
121
+ full_results = []
122
+ total_batches= npairs // batch_size
123
+ print('total pairs {}, total batches {}'.format(npairs, total_batches))
124
+
125
+ for i,batch in enumerate(tqdm.tqdm(
126
+ more_itertools.ichunked(pairs, batch_size),
127
+ total=total_batches+1,
128
+ desc="Batch progress")):
129
+
130
+ results = joblib.Parallel(
131
+ n_jobs=nproc
132
+ )(
133
+ tqdm.tqdm([
134
+ joblib.delayed(matchingSNPs)(grps.get_group(a), grps.get_group(b)
135
+ ) for a,b in batch
136
+ ],
137
+ total=batch_size if i!=total_batches else npairs % batch_size,
138
+ desc="Parallel jobs"
139
+ )
140
+ )
141
+ results = [r for r in results if r is not None] # filter out None results
142
+ full_results.extend(results)
143
+
144
+ # Create a DataFrame from the list of results
145
+ df = pd.DataFrame(results, columns=['cell1','cell2','min_sum'])
146
+ # save the resulting file
147
+ print('Saving results to {}'.format(tgt))
148
+ df.to_pickle(tgt, compression='gzip')
149
+ return tgt
150
+
151
+ def call_calculate_cpsnpic(inpath,oupath,nproc,batch_size,force):
152
+ """Python API wrapper for :func:`calculate_cpsnpic`.
153
+
154
+ Args:
155
+ inpath: Path to the folder containing pileup related files. The folder should
156
+ contain at least the following files: ``cellSNP.samples.tsv``,
157
+ ``cellSNP.tag.AD/DP`` and ``OTH`` matrices
158
+ oupath: Path to the output folder where results will be saved.
159
+ nproc: Number of parallel processes to use
160
+ batch_size: Number of cell pairs to process in each batch.
161
+ force: If ``True``, override the target file.
162
+ """
163
+ return calculate_cpsnpic.callback(
164
+ inpath=inpath,
165
+ oupath=oupath,
166
+ nproc=nproc,
167
+ batch_size=batch_size,
168
+ force=force
169
+ )
170
+
171
+ @cpsnpic.command()
172
+ @click.argument('cpfile', type=str)
173
+ @click.argument('oupath', type=str)
174
+ @click.option('--force', is_flag=True, help='Override target file.')
175
+ def generate_histogram(cpfile, oupath, force):
176
+ """ Generate a histogram of cpSNP-IC values from a cpSNP-IC counts file.
177
+ Returns the name of the file where the histogram DataFrame is stored.
178
+
179
+ Arguments:
180
+
181
+ CPFILE: Path to the cpSNP-IC counts file (pkl.gz) generated by calculate_cpsnpic.
182
+
183
+ OUPATH: Path to the folder where to store the histogram file.
184
+ """
185
+ # only regenerate if the file does not exist or if the force flag is set
186
+ tgt = Path('{}/cpsnpic_histogram.pkl'.format(oupath))
187
+ if tgt.is_file() and not force:
188
+ print('cpSNP-IC histogram file found at: {}'.format(tgt))
189
+ return tgt
190
+
191
+ # load the cpSNP-IC counts file
192
+ print('Loading cpSNP-IC counts from: {}'.format(cpfile))
193
+ agdf = pd.read_pickle(cpfile)
194
+
195
+ # determine the maximum value to set the histogram bins
196
+ m = math.ceil(agdf.min_sum.max())
197
+ nb = 100 if m>100 else m # fix the maximum number of bins
198
+ step = m//nb
199
+ bin_edges = np.arange(start=0,stop=m+step, step=step) # make sure the bins have int-edges
200
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 # midpoint of each bin
201
+
202
+ bin_counts, _ = np.histogram(
203
+ agdf.min_sum.dropna().values,
204
+ bins=bin_edges,
205
+ )
206
+
207
+ hist = pd.DataFrame({
208
+ 'centers': bin_centers,
209
+ 'counts': bin_counts
210
+ })
211
+
212
+ # save the resulting file
213
+ print('Saving cpSNP-IC histogram to: {}'.format(tgt))
214
+ Path(oupath).mkdir(parents=True, exist_ok=True)
215
+ hist.to_pickle(tgt)
216
+
217
+ # return the name of the file where the histogram is stored
218
+ return tgt
219
+
220
+ def call_generate_histogram(cpfile, oupath, force):
221
+ """ Python API wrapper for :func:`generate_histogram`.
222
+
223
+ Args:
224
+ cpfile: Path to the cpSNP-IC counts file (pkl.gz)
225
+ oupath: Path to the folder where to store the histogram file
226
+ force: If ``True``, override the target file.
227
+ """
228
+ return generate_histogram.callback(
229
+ cpfile=cpfile,
230
+ oupath=oupath,
231
+ force=force
232
+ )
233
+
234
+ @cpsnpic.command()
235
+ @click.argument('histofile', type=str)
236
+ @click.argument('output', type=str)
237
+ @click.option('--force', is_flag=True, help='Override target file.')
238
+ def save_cpsnpic_plot(histofile, output, force=False):
239
+ """ Save a cpSNP-IC histogram figure
240
+
241
+ Arguments:
242
+
243
+ HISTOFILE The file where histogram data is stored
244
+
245
+ OUTPUT The output file where to save the figure (with extension .html)
246
+ """
247
+ tgt = Path(output)
248
+ if tgt.is_file() and not force:
249
+ print('Figure file {} already exists, skipping...'.format(tgt))
250
+ return
251
+
252
+ fig = plotting.generate_cpsnpic_plot(histofile)
253
+ print('Saving cpSNP-IC figure to: {}'.format(tgt))
254
+ plotting.save_plot(
255
+ figure=fig,
256
+ output=output,
257
+ force=force
258
+ )
259
+
260
+ def call_save_cpsnpic_plot(histofile, output, force):
261
+ """ Python API wrapper for :func:`save_cpsnpic_plot`.
262
+
263
+ Args:
264
+ histofile: The file where histogram data is stored
265
+ output: The output file where to save the figure (with extension .html)
266
+ force: If ``True``, override the target file.
267
+ """
268
+ return save_cpsnpic_plot.callback(
269
+ histofile=histofile,
270
+ output=output,
271
+ force=force
272
+ )
273
+
274
+ @cpsnpic.command()
275
+ @click.argument('inpath', type=str)
276
+ @click.argument('oupath', type=str)
277
+ @click.option('--nproc', type=int, default=20, help='Number of parallel processes to use')
278
+ @click.option('--batch_size', type=int, default=100000, help='Number of cell pairs to process in each batch.')
279
+ @click.option('--force', is_flag=True, help='Override target files.')
280
+ def run_all(inpath, oupath, nproc, batch_size, force):
281
+ """ Run all cpSNP-IC calculation steps.
282
+
283
+ Arguments:
284
+
285
+ INPATH: Path to the folder containing pileup related files. The folder should
286
+ contain at least the following files: cellSNP.samples.tsv,
287
+ cellSNP.tag.AD/DP and OTH matrices
288
+
289
+ OUPATH: Path to the output folder where results will be saved.
290
+ """
291
+ # step 1: calculate cpSNP-IC counts
292
+ cpfile = calculate_cpsnpic.callback(
293
+ inpath=inpath,
294
+ oupath=oupath,
295
+ nproc=nproc,
296
+ batch_size=batch_size,
297
+ force=force
298
+ )
299
+ # step 2: generate histogram
300
+ histfile = generate_histogram.callback(
301
+ cpfile=cpfile,
302
+ oupath=oupath,
303
+ force=force
304
+ )
305
+ # step 3: plot histogram
306
+ fig = plotting.generate_cpsnpic_plot(histfile)
307
+ plotting.save_plot(
308
+ figure=fig,
309
+ output='{}/cpsnpic_figure.html'.format(oupath),
310
+ force=force
311
+ )
312
+
313
+ def call_run_all(inpath, oupath, nproc, batch_size, force):
314
+ """ Python API wrapper for :func:`run_all`.
315
+
316
+ Args:
317
+ inpath: Path to the folder containing pileup related files.
318
+ oupath: Path to the output folder where results will be saved.
319
+ nproc: Number of parallel processes to use.
320
+ batch_size: Number of cell pairs to process in each batch.
321
+ force: If ``True``, override target files.
322
+ """
323
+ return run_all.callback(
324
+ inpath=inpath,
325
+ oupath=oupath,
326
+ nproc=nproc,
327
+ batch_size=batch_size,
328
+ force=force
329
+ )
330
+
331
+ if __name__ == '__main__':
332
+ cpsnpic()
oddSNP/downsampling.py ADDED
@@ -0,0 +1,261 @@
1
+ # Copyright 2025-2026 Nemoto-lab, The University of Osaka
2
+ # email: rodolfo.allendes.prime@osaka-u.ac.jp
3
+ # email: nemoto.takahiro.prime@osaka-u.ac.jp
4
+
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ # ---
18
+ import click
19
+ import traceback
20
+ import subprocess
21
+ import sys
22
+ import os
23
+ import pysam, pysam.bcftools
24
+
25
+ from pathlib import Path
26
+ from tqdm import tqdm
27
+ import pandas as pd
28
+ import numpy as np
29
+
30
+ from . import utils
31
+
32
+ @click.group()
33
+ def downsample():
34
+ pass
35
+
36
+ @downsample.command()
37
+ @click.argument('bam', type=click.Path(exists=True, dir_okay=False))
38
+ @click.argument('reads', type=float)
39
+ @click.argument('oupath', type=str)
40
+ @click.option('--celltag', default='XC', help='Tag used inside the bam file for cell barcodes')
41
+ @click.option('--seed', type=int, default=1234, help='A seed to make the selection of reads deterministic')
42
+ @click.option('--nproc', type=int, default=1, help='Number of processes to use')
43
+ @click.option('--force', is_flag=True, help='Override previous results')
44
+ def reads_downsampling(bam, reads, oupath, celltag, seed, nproc, force):
45
+ """ Downsample BAM file by extracting a given percentage of reads.
46
+ It saves both the filtered BAM file and a TSV file with the cell barcodes to
47
+ the output directory.
48
+
49
+ Arguments:
50
+
51
+ BAM: The input BAM file
52
+
53
+ READS: The fraction of reads to keep (between 0 and 1)
54
+
55
+ OUTDIR: The path to the directory where to store the filtered BAM file
56
+ """
57
+ # remove possible trailing slash
58
+ oupath = oupath.rstrip('/') if len(oupath)>1 else oupath
59
+
60
+ # only perform calculation if file doesnt exist or if force flag is set
61
+ tgt = Path('{}/{}_{:.2f}.bam'.format(oupath, Path(bam).stem, reads)) # resulting file
62
+ if tgt.is_file() and not force:
63
+ print('Filtered file {} already exists, skipping...'.format(tgt))
64
+ return tgt
65
+
66
+ os.makedirs(oupath, exist_ok=True)
67
+ # get total number of lines in the file
68
+ totallines= utils.call_count_lines(bam=bam, nproc=nproc)
69
+ print('Total number of reads in {}: {:,}'.format(bam, totallines))
70
+
71
+ # generate a list of as many true/false values as needed
72
+ keep = round(totallines*reads)
73
+ arr = np.concatenate((
74
+ np.ones(keep, dtype=bool),
75
+ np.zeros(totallines-keep, dtype=bool)
76
+ ))
77
+ # and shuffle the values to randoly determine the lines to keep
78
+ rng = np.random.default_rng(seed)
79
+ rng.shuffle(arr)
80
+
81
+ # process the file, keeping the predetermined lines and dropping the
82
+ # remaining ones
83
+ inp = pysam.AlignmentFile('{}'.format(bam), 'rb')
84
+ out = pysam.AlignmentFile(
85
+ '{}'.format(tgt),
86
+ 'wb', template=inp)
87
+
88
+ # re-map all reads in the original file
89
+ bcodes = set()
90
+ for i,read in tqdm(enumerate(inp.fetch(until_eof=True)),
91
+ total=totallines,
92
+ desc='Filtering reads'):
93
+ if arr[i]:
94
+ try:
95
+ out.write(read) # make sure to copy the read first
96
+ bcodes.add(read.get_tag(tag=celltag)) # and then try to fetch the cell bcode
97
+ except KeyError:
98
+ continue
99
+ # close the files after finishing
100
+ inp.close()
101
+ out.close()
102
+
103
+ #convert bcodes to DataFrame and save to file
104
+ bcodes_df = pd.DataFrame.from_dict(data={'cells': list(bcodes)})
105
+ bname = '{}/{}_{:.2f}_barcodes.tsv'.format(oupath, Path(bam).stem, reads)
106
+ bcodes_df.to_csv(bname,
107
+ index=False,
108
+ header=False
109
+ )
110
+
111
+ return tgt
112
+
113
+ def call_reads_downsampling(bam, reads, oupath, celltag, seed, nproc, force):
114
+ """ Python wrapper for :func:`reads_downsampling`.
115
+
116
+ Args:
117
+ bam: The input BAM file
118
+ reads: The percentage of reads to keep (between 0 and 1)
119
+ oupath: The path to the directory where to store the filtered BAM file
120
+ celltag: The tag used inside the bam file for cell barcodes
121
+ seed: A seed to make the selection of reads deterministic
122
+ nproc: Number of processes to use
123
+ force: Override previous results
124
+
125
+ Returns:
126
+ The path to the filtered BAM file
127
+ """
128
+ return reads_downsampling.callback(
129
+ bam=bam,
130
+ reads=reads,
131
+ oupath=oupath,
132
+ celltag=celltag,
133
+ seed=seed,
134
+ nproc=nproc,
135
+ force=force
136
+ )
137
+
138
+ @downsample.command()
139
+ @click.argument('bam', type=click.File('rb'))
140
+ @click.argument('barcodes', type=click.File('r'))
141
+ @click.argument('oupath', type=str)
142
+ @click.option('--celltag', default='XC', help='Tag used inside the bam file for cell barcodes')
143
+ @click.option('--nproc', type=int, default=1, help='Number of processes to use')
144
+ @click.option('--force', is_flag=True, help='Override previous results')
145
+ def barcode_downsampling(bam, barcodes, oupath, celltag, nproc, force):
146
+ """ Downsample BAM file by extracting selected barcodes.
147
+ Given a list of cell barcodes, filter a BAM file in order to only include
148
+ reads associated to those barcodes.
149
+
150
+ Arguments:
151
+
152
+ BAM: Path to the BAM file to filter.
153
+
154
+ BARCODES: A file with the list of cell barcodes to include.
155
+
156
+ OUPATH: The path to the directory where to store the filtered BAM file.
157
+ """
158
+ # remove possible trailing slash
159
+ oupath = oupath.rstrip('/') if len(oupath)>1 else oupath
160
+
161
+ # only perform filtering if the output file doesnt exist or if force flag is set
162
+ tgt = Path('{}/{}_filtered.bam'.format(oupath, Path(bam.name).stem)) # resulting file
163
+ if tgt.is_file() and not force:
164
+ print('Filtered file {} already exists, skipping...'.format(tgt))
165
+ return tgt
166
+
167
+ # create output directory if it does not exist
168
+ os.makedirs(oupath, exist_ok=True)
169
+
170
+ # process the files in parallel for read extraction
171
+ pysam.samtools.view(
172
+ '-bh',
173
+ '-D', '{}:{}'.format(celltag, barcodes.name),
174
+ '-o', str(tgt),
175
+ '--threads', str(nproc),
176
+ bam.name,
177
+ catch_stdout=False
178
+ )
179
+
180
+ return tgt
181
+
182
+ def call_barcode_downsampling(bam, barcodes, oupath, celltag, nproc, force):
183
+ """ Python wrapper for :func:`barcode_downsampling`.
184
+
185
+ Args:
186
+ bam: Path to the BAM file to filter.
187
+ barcodes: A file with the list of cell barcodes to include.
188
+ oupath: The path to the directory where to store the filtered BAM file.
189
+ celltag: The tag used inside the bam file for cell barcodes
190
+ nproc: Number of processes to use
191
+ force: If `True` override previous results
192
+ """
193
+ return barcode_downsampling.callback(
194
+ bam=bam,
195
+ barcodes=barcodes,
196
+ oupath=oupath,
197
+ celltag=celltag,
198
+ nproc=nproc,
199
+ force=force
200
+ )
201
+
202
+ @downsample.command()
203
+ @click.argument('vcf', type=click.Path(exists=True, dir_okay=False))
204
+ @click.argument('regions', type=click.Path(exists=True, dir_okay=False))
205
+ @click.argument('out', type=str)
206
+ @click.option('--nproc', type=int, default=1, help='Number of processes to use')
207
+ @click.option('--force', is_flag=True, help='Override previous results')
208
+ def vcf_downsampling(vcf, regions, out, nproc, force):
209
+ """ Filter a reference VCF file to only include variants in given regions.
210
+
211
+ Arguments:
212
+ VCF: Path to the VCF file to filter.
213
+
214
+ REGIONS: A file with the list of regions to include.
215
+
216
+ OUT: The path to the directory where to store the filtered VCF file.
217
+ """
218
+ # only perform calculation if file doesnt exist or if force flag is set
219
+ tgt = Path(out) # resulting file
220
+ if tgt.is_file() and not force:
221
+ print('Filtered file {} already exists, skipping...'.format(tgt))
222
+ return tgt
223
+
224
+ # make sure the vcf file has an index
225
+ if not os.path.isfile(f'{vcf}.csi'):
226
+ print(f"Index file for {vcf} not found. Generating index.")
227
+ utils.generate_vcf_index(vcf, nproc)
228
+
229
+ # create output directory if it does not exist
230
+ os.makedirs(Path(out).parent, exist_ok=True)
231
+
232
+ pysam.bcftools.view(
233
+ "-R", regions,
234
+ "-Oz", "-o", out,
235
+ "--threads", str(nproc),
236
+ vcf,
237
+ catch_stdout=False
238
+ )
239
+
240
+ return tgt
241
+
242
+ def call_vcf_downsampling(vcf, regions, out, nproc, force):
243
+ """ Python wrapper for :func:`vcf_downsampling`.
244
+
245
+ Args:
246
+ vcf: Path to the VCF file to filter.
247
+ regions: A file with the list of regions to include.
248
+ out: The path to the directory where to store the filtered VCF file.
249
+ nproc: Number of processes to use
250
+ force: If `True` override previous results
251
+ """
252
+ return vcf_downsampling.callback(
253
+ vcf=vcf,
254
+ regions=regions,
255
+ out=out,
256
+ nproc=nproc,
257
+ force=force
258
+ )
259
+
260
+ if __name__ == '__main__':
261
+ downsample()