hca-schema-validator 0.2.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.
- hca_schema_validator/__init__.py +11 -0
- hca_schema_validator/_vendored/__init__.py +18 -0
- hca_schema_validator/_vendored/cellxgene_schema/LICENSE +20 -0
- hca_schema_validator/_vendored/cellxgene_schema/__init__.py +1 -0
- hca_schema_validator/_vendored/cellxgene_schema/atac_seq.py +675 -0
- hca_schema_validator/_vendored/cellxgene_schema/cli.py +206 -0
- hca_schema_validator/_vendored/cellxgene_schema/env.py +9 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode.py +163 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/.gitignore +3 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/drosophila_melanogaster_diff.txt +42 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/gene_info.md +29 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/gene_info.yml +51 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_caenorhabditis_elegans.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_callithrix_jacchus.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_danio_rerio.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_drosophila_melanogaster.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_ercc.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_gorilla_gorilla.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_homo_sapiens.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_macaca_fascicularis.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_macaca_mulatta.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_microcebus_murinus.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_mus_musculus.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_oryctolagus_cuniculus.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_pan_troglodytes.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_rattus_norvegicus.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_sars_cov_2.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_sus_scrofa.csv.gz +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/homo_sapiens_diff.txt +1237 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/mus_musculus_diff.txt +549 -0
- hca_schema_validator/_vendored/cellxgene_schema/gencode_files/rattus_norvegicus_diff.txt +7306 -0
- hca_schema_validator/_vendored/cellxgene_schema/map_species.py +40 -0
- hca_schema_validator/_vendored/cellxgene_schema/matrix_utils.py +134 -0
- hca_schema_validator/_vendored/cellxgene_schema/migrate.py +145 -0
- hca_schema_validator/_vendored/cellxgene_schema/migrate_files/automigrate_terms.json +2 -0
- hca_schema_validator/_vendored/cellxgene_schema/migrate_files/donor_updates.json +2 -0
- hca_schema_validator/_vendored/cellxgene_schema/migrate_files/non_csr_list.txt +54 -0
- hca_schema_validator/_vendored/cellxgene_schema/migrate_files/private_non_csr.json +1 -0
- hca_schema_validator/_vendored/cellxgene_schema/migrate_files/title_donor_updates.json +2 -0
- hca_schema_validator/_vendored/cellxgene_schema/ontology_files/.gitignore +1 -0
- hca_schema_validator/_vendored/cellxgene_schema/ontology_parser.py +3 -0
- hca_schema_validator/_vendored/cellxgene_schema/remove_labels.py +74 -0
- hca_schema_validator/_vendored/cellxgene_schema/schema.py +21 -0
- hca_schema_validator/_vendored/cellxgene_schema/schema_definitions/schema_definition.yaml +841 -0
- hca_schema_validator/_vendored/cellxgene_schema/utils.py +242 -0
- hca_schema_validator/_vendored/cellxgene_schema/validate.py +2389 -0
- hca_schema_validator/_vendored/cellxgene_schema/validation_internals/__init__.py +0 -0
- hca_schema_validator/_vendored/cellxgene_schema/validation_internals/check_duplicates.py +87 -0
- hca_schema_validator/_vendored/cellxgene_schema/write_labels.py +403 -0
- hca_schema_validator/schema_definitions/hca_schema_definition.yaml +839 -0
- hca_schema_validator/validator.py +47 -0
- hca_schema_validator-0.2.0.dist-info/METADATA +116 -0
- hca_schema_validator-0.2.0.dist-info/RECORD +55 -0
- hca_schema_validator-0.2.0.dist-info/WHEEL +4 -0
- hca_schema_validator-0.2.0.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import gzip
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess as sp
|
|
7
|
+
import tempfile
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
import anndata as ad
|
|
13
|
+
import h5py
|
|
14
|
+
import ibis
|
|
15
|
+
import pyarrow as pa
|
|
16
|
+
import pyarrow.csv
|
|
17
|
+
import pyarrow.dataset
|
|
18
|
+
import pysam
|
|
19
|
+
|
|
20
|
+
from .ontology_parser import ONTOLOGY_PARSER
|
|
21
|
+
from .utils import GB, is_ontological_descendant_of
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# TODO: these chromosome tables should be calculated from the fasta file?
|
|
26
|
+
# location of fasta https://www.gencodegenes.org/human/release_44.html and file name GRCh38.primary_assembly.genome.fa
|
|
27
|
+
human_chromosome_by_length = {
|
|
28
|
+
"chr1": 248956422,
|
|
29
|
+
"chr2": 242193529,
|
|
30
|
+
"chr3": 198295559,
|
|
31
|
+
"chr4": 190214555,
|
|
32
|
+
"chr5": 181538259,
|
|
33
|
+
"chr6": 170805979,
|
|
34
|
+
"chr7": 159345973,
|
|
35
|
+
"chr8": 145138636,
|
|
36
|
+
"chr9": 138394717,
|
|
37
|
+
"chr10": 133797422,
|
|
38
|
+
"chr11": 135086622,
|
|
39
|
+
"chr12": 133275309,
|
|
40
|
+
"chr13": 114364328,
|
|
41
|
+
"chr14": 107043718,
|
|
42
|
+
"chr15": 101991189,
|
|
43
|
+
"chr16": 90338345,
|
|
44
|
+
"chr17": 83257441,
|
|
45
|
+
"chr18": 80373285,
|
|
46
|
+
"chr19": 58617616,
|
|
47
|
+
"chr20": 64444167,
|
|
48
|
+
"chr21": 46709983,
|
|
49
|
+
"chr22": 50818468,
|
|
50
|
+
"chrX": 156040895,
|
|
51
|
+
"chrY": 57227415,
|
|
52
|
+
"chrM": 16569,
|
|
53
|
+
"GL000009.2": 201709,
|
|
54
|
+
"GL000194.1": 191469,
|
|
55
|
+
"GL000195.1": 182896,
|
|
56
|
+
"GL000205.2": 185591,
|
|
57
|
+
"GL000213.1": 164239,
|
|
58
|
+
"GL000216.2": 176608,
|
|
59
|
+
"GL000218.1": 161147,
|
|
60
|
+
"GL000219.1": 179198,
|
|
61
|
+
"GL000220.1": 161802,
|
|
62
|
+
"GL000225.1": 211173,
|
|
63
|
+
"KI270442.1": 392061,
|
|
64
|
+
"KI270711.1": 42210,
|
|
65
|
+
"KI270713.1": 40745,
|
|
66
|
+
"KI270721.1": 100316,
|
|
67
|
+
"KI270726.1": 43739,
|
|
68
|
+
"KI270727.1": 448248,
|
|
69
|
+
"KI270728.1": 1872759,
|
|
70
|
+
"KI270731.1": 150754,
|
|
71
|
+
"KI270733.1": 179772,
|
|
72
|
+
"KI270734.1": 165050,
|
|
73
|
+
"KI270744.1": 168472,
|
|
74
|
+
"KI270750.1": 148850,
|
|
75
|
+
}
|
|
76
|
+
mouse_chromosome_by_length = {
|
|
77
|
+
"chr1": 195154279,
|
|
78
|
+
"chr2": 181755017,
|
|
79
|
+
"chr3": 159745316,
|
|
80
|
+
"chr4": 156860686,
|
|
81
|
+
"chr5": 151758149,
|
|
82
|
+
"chr6": 149588044,
|
|
83
|
+
"chr7": 144995196,
|
|
84
|
+
"chr8": 130127694,
|
|
85
|
+
"chr9": 124359700,
|
|
86
|
+
"chr10": 130530862,
|
|
87
|
+
"chr11": 121973369,
|
|
88
|
+
"chr12": 120092757,
|
|
89
|
+
"chr13": 120883175,
|
|
90
|
+
"chr14": 125139656,
|
|
91
|
+
"chr15": 104073951,
|
|
92
|
+
"chr16": 98008968,
|
|
93
|
+
"chr17": 95294699,
|
|
94
|
+
"chr18": 90720763,
|
|
95
|
+
"chr19": 61420004,
|
|
96
|
+
"chrX": 169476592,
|
|
97
|
+
"chrY": 91455967,
|
|
98
|
+
"chrM": 16299,
|
|
99
|
+
"GL456210.1": 169725,
|
|
100
|
+
"GL456211.1": 241735,
|
|
101
|
+
"GL456212.1": 153618,
|
|
102
|
+
"GL456219.1": 175968,
|
|
103
|
+
"GL456221.1": 206961,
|
|
104
|
+
"GL456239.1": 40056,
|
|
105
|
+
"GL456354.1": 195993,
|
|
106
|
+
"GL456372.1": 28664,
|
|
107
|
+
"GL456381.1": 25871,
|
|
108
|
+
"GL456385.1": 35240,
|
|
109
|
+
"JH584295.1": 1976,
|
|
110
|
+
"JH584296.1": 199368,
|
|
111
|
+
"JH584297.1": 205776,
|
|
112
|
+
"JH584298.1": 184189,
|
|
113
|
+
"JH584299.1": 953012,
|
|
114
|
+
"JH584303.1": 158099,
|
|
115
|
+
"JH584304.1": 114452,
|
|
116
|
+
}
|
|
117
|
+
organism_ontology_term_id_by_chromosome_length_table = {
|
|
118
|
+
"NCBITaxon:9606": human_chromosome_by_length,
|
|
119
|
+
"NCBITaxon:10090": mouse_chromosome_by_length,
|
|
120
|
+
}
|
|
121
|
+
column_ordering = ["chromosome", "start coordinate", "stop coordinate", "barcode", "read support"]
|
|
122
|
+
schema = pa.schema(
|
|
123
|
+
[
|
|
124
|
+
pa.field("chromosome", pa.string()),
|
|
125
|
+
pa.field("start coordinate", pa.int64()),
|
|
126
|
+
pa.field("stop coordinate", pa.int64()),
|
|
127
|
+
pa.field("barcode", pa.string()),
|
|
128
|
+
pa.field("read support", pa.int64()),
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def log_calls(func):
|
|
134
|
+
def wrapper(*args, **kwargs):
|
|
135
|
+
logging.info("starting: %s", func.__name__)
|
|
136
|
+
return func(*args, **kwargs)
|
|
137
|
+
|
|
138
|
+
return wrapper
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def count_lines_in_compressed_file(file_path: str) -> int:
|
|
142
|
+
"""
|
|
143
|
+
Count lines in a compressed file (gzip or bgzip).
|
|
144
|
+
|
|
145
|
+
:param file_path: Path to the compressed file
|
|
146
|
+
:return: Number of lines in the file
|
|
147
|
+
"""
|
|
148
|
+
line_count = 0
|
|
149
|
+
with gzip.open(file_path, "rt") as f:
|
|
150
|
+
for _ in f:
|
|
151
|
+
line_count += 1
|
|
152
|
+
return line_count
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def is_atac(x: str) -> str:
|
|
156
|
+
if is_ontological_descendant_of(ONTOLOGY_PARSER, x, "EFO:0010891"):
|
|
157
|
+
if is_ontological_descendant_of(ONTOLOGY_PARSER, x, "EFO:0008913"):
|
|
158
|
+
return "p" # paired
|
|
159
|
+
else:
|
|
160
|
+
return "u" # unpaired
|
|
161
|
+
else:
|
|
162
|
+
return "n" # not atac seq
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_anndata_requires_fragment(anndata_file: str) -> bool:
|
|
166
|
+
"""
|
|
167
|
+
Check if an anndata file requires a fragment file to be valid. The anndata file requires a fragment file if the
|
|
168
|
+
assay_ontology_term_id are all descendants of EFO:0010891 and EFO:0008913. If the assay_ontology_term_id are all
|
|
169
|
+
descendants of EFO:0010891 and not EFO:0008913, the anndata file does not require a fragment file. If the
|
|
170
|
+
assay_ontology_term_id are not all descendants of EFO:0010891, an error is raised and a fragment file is not allowed
|
|
171
|
+
because the anndata is not a valid ATAC-Seq file.
|
|
172
|
+
|
|
173
|
+
:param anndata_file: The anndata file to validate.
|
|
174
|
+
:return:
|
|
175
|
+
"""
|
|
176
|
+
with h5py.File(anndata_file) as f:
|
|
177
|
+
assay_ontology_term_ids = ad.io.read_elem(f["obs"])["assay_ontology_term_id"]
|
|
178
|
+
df = assay_ontology_term_ids.map(is_atac)
|
|
179
|
+
|
|
180
|
+
if (df == "p").all():
|
|
181
|
+
return False
|
|
182
|
+
elif (df == "u").all():
|
|
183
|
+
return True
|
|
184
|
+
if (df == "n").any():
|
|
185
|
+
raise ValueError("Anndata.obs.assay_ontology_term_id are not all descendants of EFO:0010891.")
|
|
186
|
+
else:
|
|
187
|
+
raise ValueError("Anndata.obs.assay_ontology_term_id has mixed paired and unpaired assay terms.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def process_fragment(
|
|
191
|
+
fragment_file: str,
|
|
192
|
+
anndata_file: str,
|
|
193
|
+
generate_index: bool = False,
|
|
194
|
+
output_file: Optional[str] = None,
|
|
195
|
+
) -> list[str]:
|
|
196
|
+
"""
|
|
197
|
+
Validate the fragment against the anndata file and generate the index if the fragment is valid.
|
|
198
|
+
|
|
199
|
+
:param str fragment_file: The fragment file to process
|
|
200
|
+
:param str anndata_file: The anndata file to validate against
|
|
201
|
+
:param bool generate_index: Whether to generate the index for the fragment
|
|
202
|
+
:param override_write_algorithm: Override the write algorithm used to write the bgzip file. Options are "pysam"
|
|
203
|
+
and "cli"
|
|
204
|
+
:param output_file: The output file to write the bgzip file to. If not provided, the output file will be the same
|
|
205
|
+
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
209
|
+
# quick checks
|
|
210
|
+
errors = validate_anndata(anndata_file)
|
|
211
|
+
if errors:
|
|
212
|
+
return errors
|
|
213
|
+
|
|
214
|
+
# convert the fragment to a parquet file for faster processing
|
|
215
|
+
try:
|
|
216
|
+
parquet_file = convert_to_parquet(fragment_file, tempdir)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
msg = "Error Parsing the fragment file. Check that columns match schema definition. Error: " + str(e)
|
|
219
|
+
logger.exception(msg)
|
|
220
|
+
return [msg]
|
|
221
|
+
|
|
222
|
+
organism_ontology_term_id = None
|
|
223
|
+
with h5py.File(anndata_file) as f:
|
|
224
|
+
organism_ontology_term_id = ad.io.read_elem(f["uns"]).get("organism_ontology_term_id")
|
|
225
|
+
|
|
226
|
+
# slow checks
|
|
227
|
+
errors = validate_anndata_with_fragment(parquet_file, anndata_file, organism_ontology_term_id)
|
|
228
|
+
if errors:
|
|
229
|
+
return errors
|
|
230
|
+
else:
|
|
231
|
+
logger.info("Fragment and Anndata file are valid")
|
|
232
|
+
|
|
233
|
+
# generate the index
|
|
234
|
+
if generate_index:
|
|
235
|
+
logger.info(f"Sorting fragment and generating index for {fragment_file}")
|
|
236
|
+
index_fragment(fragment_file, output_file)
|
|
237
|
+
|
|
238
|
+
logger.debug("cleaning up")
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def convert_to_parquet(fragment_file: str, tempdir: str) -> str:
|
|
243
|
+
"""
|
|
244
|
+
Convert the fragment file to a parquet dataset for faster processing.
|
|
245
|
+
|
|
246
|
+
:param fragment_file: A gzipped compressed fragment file
|
|
247
|
+
:param tempdir: The temporary directory to write the parquet file to. Name of the written file is derived from
|
|
248
|
+
the input.
|
|
249
|
+
"""
|
|
250
|
+
logger.info(f"Converting {fragment_file} to parquet")
|
|
251
|
+
parquet_file_path = Path(tempdir) / Path(fragment_file).name.replace(".gz", ".parquet").replace(".bgz", ".parquet")
|
|
252
|
+
pa.dataset.write_dataset(
|
|
253
|
+
data=pa.csv.open_csv(
|
|
254
|
+
pa.input_stream(fragment_file, compression="gzip", buffer_size=GB),
|
|
255
|
+
read_options=pa.csv.ReadOptions(column_names=schema.names),
|
|
256
|
+
parse_options=pa.csv.ParseOptions(delimiter="\t"),
|
|
257
|
+
convert_options=pa.csv.ConvertOptions(column_types=schema),
|
|
258
|
+
),
|
|
259
|
+
base_dir=parquet_file_path,
|
|
260
|
+
format="parquet",
|
|
261
|
+
# Using hive partitioning for best compatibility with dask to_parquet and read_parquet functions
|
|
262
|
+
partitioning=pa.dataset.partitioning(pa.schema([pa.field("chromosome", pa.string())]), flavor="hive"),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return str(parquet_file_path)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def report_errors(header: str, errors: list[str]) -> list[str]:
|
|
269
|
+
if any(errors):
|
|
270
|
+
errors = [e for e in errors if e is not None]
|
|
271
|
+
errors = [header] + errors
|
|
272
|
+
logger.error("\n\t".join(errors))
|
|
273
|
+
return errors
|
|
274
|
+
else:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def validate_anndata(anndata_file: str) -> list[str]:
|
|
279
|
+
errors = [validate_anndata_organism_ontology_term_id(anndata_file), validate_anndata_is_primary_data(anndata_file)]
|
|
280
|
+
return report_errors("Errors found in Anndata file. Skipping fragment validation.", errors)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def validate_anndata_with_fragment(parquet_file: str, anndata_file: str, organism_ontology_term_id: str) -> list[str]:
|
|
284
|
+
errors = [
|
|
285
|
+
validate_fragment_start_coordinate_greater_than_0(parquet_file),
|
|
286
|
+
validate_fragment_barcode_in_adata_index(parquet_file, anndata_file),
|
|
287
|
+
validate_fragment_stop_coordinate_within_chromosome(parquet_file, organism_ontology_term_id),
|
|
288
|
+
validate_fragment_stop_greater_than_start_coordinate(parquet_file),
|
|
289
|
+
validate_fragment_read_support(parquet_file),
|
|
290
|
+
validate_fragment_no_duplicate_rows(parquet_file),
|
|
291
|
+
]
|
|
292
|
+
return report_errors("Errors found in Fragment and/or Anndata file", errors)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@log_calls
|
|
296
|
+
def validate_fragment_no_duplicate_rows(parquet_file: str) -> Optional[str]:
|
|
297
|
+
t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
|
|
298
|
+
rows_per_chromosome = t["chromosome"].value_counts().execute()
|
|
299
|
+
msg = ""
|
|
300
|
+
# Checking number of unique rows per chromosome is more memory efficient than checking all rows at once
|
|
301
|
+
for chromosome, count in rows_per_chromosome.itertuples(index=False):
|
|
302
|
+
n_unique = t.filter(t["chromosome"] == chromosome).distinct().count().execute()
|
|
303
|
+
if n_unique != count:
|
|
304
|
+
if not msg:
|
|
305
|
+
msg = "Fragment file has duplicate rows.\n"
|
|
306
|
+
msg += f"Chromosome {chromosome} has {count} rows but only {n_unique} are unique\n"
|
|
307
|
+
if msg:
|
|
308
|
+
return msg.strip() # remove trailing newline
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@log_calls
|
|
312
|
+
def validate_fragment_start_coordinate_greater_than_0(parquet_file: str) -> Optional[str]:
|
|
313
|
+
df = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
|
|
314
|
+
if not (df["start coordinate"] > 0).all().execute():
|
|
315
|
+
return "Start coordinate must be greater than 0."
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@log_calls
|
|
319
|
+
def validate_fragment_barcode_in_adata_index(parquet_file: str, anndata_file: str) -> Optional[str]:
|
|
320
|
+
df = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
|
|
321
|
+
barcode = set(df.select("barcode").distinct().execute()["barcode"])
|
|
322
|
+
with h5py.File(anndata_file) as f:
|
|
323
|
+
obs = ad.io.read_elem(f["obs"])
|
|
324
|
+
if set(obs.index) != barcode:
|
|
325
|
+
return "Barcodes don't match anndata.obs.index"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@log_calls
|
|
329
|
+
def validate_fragment_stop_greater_than_start_coordinate(parquet_file: str) -> Optional[str]:
|
|
330
|
+
df = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
|
|
331
|
+
if not (df["stop coordinate"] > df["start coordinate"]).all().execute():
|
|
332
|
+
return "Stop coordinate must be greater than start coordinate."
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@log_calls
|
|
336
|
+
def validate_fragment_stop_coordinate_within_chromosome(
|
|
337
|
+
parquet_file: str, organism_ontology_term_id: str
|
|
338
|
+
) -> Optional[str]:
|
|
339
|
+
chromosome_length_table = organism_ontology_term_id_by_chromosome_length_table.get(organism_ontology_term_id)
|
|
340
|
+
t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
|
|
341
|
+
df = t.group_by("chromosome").aggregate(max_stop_coordinate=t["stop coordinate"].max()).execute()
|
|
342
|
+
|
|
343
|
+
mismatched_chromosomes = set(df["chromosome"].unique()) - chromosome_length_table.keys()
|
|
344
|
+
if mismatched_chromosomes:
|
|
345
|
+
return f"Chromosomes in the fragment do not match the organism({organism_ontology_term_id}).\n" + "\t\n".join(
|
|
346
|
+
mismatched_chromosomes
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
df["chromosome_length"] = df["chromosome"].map(chromosome_length_table)
|
|
350
|
+
if not (df["max_stop_coordinate"] <= df["chromosome_length"]).all():
|
|
351
|
+
return "Stop coordinate must be less than the chromosome length."
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@log_calls
|
|
355
|
+
def validate_fragment_read_support(parquet_file: str) -> Optional[str]:
|
|
356
|
+
t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
|
|
357
|
+
if (t["read support"] <= 0).any().execute():
|
|
358
|
+
return "Read support must be greater than 0."
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@log_calls
|
|
362
|
+
def validate_anndata_is_primary_data(anndata_file: str) -> Optional[str]:
|
|
363
|
+
with h5py.File(anndata_file) as f:
|
|
364
|
+
is_primary_data = ad.io.read_elem(f["obs"])["is_primary_data"]
|
|
365
|
+
if not is_primary_data.all():
|
|
366
|
+
return "Anndata.obs.is_primary_data must all be True."
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@log_calls
|
|
370
|
+
def validate_anndata_organism_ontology_term_id(anndata_file: str) -> Optional[str]:
|
|
371
|
+
organism_ontology_term_id = None
|
|
372
|
+
with h5py.File(anndata_file) as f:
|
|
373
|
+
organism_ontology_term_id = ad.io.read_elem(f["uns"]).get("organism_ontology_term_id")
|
|
374
|
+
allowed_terms = [*organism_ontology_term_id_by_chromosome_length_table.keys()]
|
|
375
|
+
if organism_ontology_term_id not in allowed_terms:
|
|
376
|
+
return f"Anndata.obs.organism_ontology_term_id must be one of {allowed_terms}. Got {organism_ontology_term_id}."
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@log_calls
|
|
380
|
+
def detect_chromosomes(parquet_file: str) -> list[str]:
|
|
381
|
+
t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
|
|
382
|
+
chromosomes = list(t.select(["chromosome"]).distinct().execute()["chromosome"])
|
|
383
|
+
chromosomes.sort() # sort chromosomes to ensure consistent order
|
|
384
|
+
return chromosomes
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def get_output_file(fragment_file: str, output_file: Optional[str]) -> str:
|
|
388
|
+
if not output_file:
|
|
389
|
+
bgzip_output_file = fragment_file.replace(".gz", ".bgz")
|
|
390
|
+
elif not output_file.endswith(".bgz"):
|
|
391
|
+
bgzip_output_file = output_file + ".bgz"
|
|
392
|
+
else:
|
|
393
|
+
bgzip_output_file = output_file
|
|
394
|
+
return bgzip_output_file
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def index_fragment(
|
|
398
|
+
fragment_file: str,
|
|
399
|
+
output_file: Optional[str] = None,
|
|
400
|
+
):
|
|
401
|
+
# sort the fragment by chromosome, start coordinate, and stop coordinate, then compress it with bgzip
|
|
402
|
+
bgzip_output_file = get_output_file(fragment_file, output_file)
|
|
403
|
+
bgzip_output_path = Path(bgzip_output_file)
|
|
404
|
+
bgzip_output_path.unlink(missing_ok=True)
|
|
405
|
+
|
|
406
|
+
prepare_fragment(fragment_file, bgzip_output_file)
|
|
407
|
+
logger.info(f"Fragment sorted and compressed: {bgzip_output_file}")
|
|
408
|
+
pysam.tabix_index(bgzip_output_file, preset="bed", force=True)
|
|
409
|
+
tabix_output_file = bgzip_output_file + ".tbi"
|
|
410
|
+
logger.info(f"Index file generated: {tabix_output_file}")
|
|
411
|
+
|
|
412
|
+
# Validate line count between original and output files
|
|
413
|
+
logger.info("Validating line count between original and output files")
|
|
414
|
+
|
|
415
|
+
# Count lines in parallel to reduce processing time for large files
|
|
416
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
417
|
+
original_future = executor.submit(count_lines_in_compressed_file, fragment_file)
|
|
418
|
+
output_future = executor.submit(count_lines_in_compressed_file, bgzip_output_file)
|
|
419
|
+
|
|
420
|
+
original_line_count = original_future.result()
|
|
421
|
+
output_line_count = output_future.result()
|
|
422
|
+
|
|
423
|
+
if original_line_count != output_line_count:
|
|
424
|
+
error_msg = f"Line count validation failed: original file has {original_line_count} lines, output file has {output_line_count} lines"
|
|
425
|
+
logger.error(error_msg)
|
|
426
|
+
raise ValueError(error_msg)
|
|
427
|
+
|
|
428
|
+
logger.info(f"Line count validation passed: {original_line_count} lines in both original and output files")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
SORT_MEMORY_PERCENTAGE = 50
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _calculate_sort_memory(num_cores: int, sort_memory_percent: int) -> int:
|
|
435
|
+
"""
|
|
436
|
+
Calculate the memory percentage to allocate per sort thread.
|
|
437
|
+
|
|
438
|
+
:param num_cores: Number of CPU cores available.
|
|
439
|
+
:param sort_memory_percent: Total percentage of system memory to allocate for sorting.
|
|
440
|
+
:return: Percentage of memory to allocate per sort thread.
|
|
441
|
+
"""
|
|
442
|
+
effective_memory_pct = min(sort_memory_percent, 50) if num_cores > 1 else sort_memory_percent
|
|
443
|
+
return max(effective_memory_pct // num_cores, 1) # ensure at least 1% memory per core
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _ensure_tools(tools: Iterable[Tuple[str, str]]) -> None:
|
|
447
|
+
"""Ensure required CLI tools are present in PATH."""
|
|
448
|
+
missing = [name for name, _ in tools if shutil.which(name) is None]
|
|
449
|
+
if missing:
|
|
450
|
+
msgs = dict(tools)
|
|
451
|
+
raise RuntimeError(" | ".join(f"Missing '{name}' in PATH; {msgs[name]}." for name in missing))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _default_cores() -> int:
|
|
455
|
+
"""Get CPU count, respecting container limits."""
|
|
456
|
+
# Try to read cgroup CPU limit (Docker/Kubernetes) - most reliable method
|
|
457
|
+
try:
|
|
458
|
+
# cgroup v1
|
|
459
|
+
with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") as f:
|
|
460
|
+
quota = int(f.read().strip())
|
|
461
|
+
with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us") as f:
|
|
462
|
+
period = int(f.read().strip())
|
|
463
|
+
if quota > 0 and period > 0:
|
|
464
|
+
return max(1, quota // period)
|
|
465
|
+
except (FileNotFoundError, ValueError, PermissionError):
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
# cgroup v2
|
|
470
|
+
with open("/sys/fs/cgroup/cpu.max") as f:
|
|
471
|
+
content = f.read().strip()
|
|
472
|
+
if content != "max":
|
|
473
|
+
parts = content.split()
|
|
474
|
+
if len(parts) == 2:
|
|
475
|
+
quota, period = int(parts[0]), int(parts[1])
|
|
476
|
+
return max(1, quota // period)
|
|
477
|
+
except (FileNotFoundError, ValueError, PermissionError):
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
# Fallback to system CPU count
|
|
481
|
+
return max(1, (os.cpu_count() or 1))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _deterministic_env() -> dict:
|
|
485
|
+
# GNU coreutils behaviors (sort/uniq) are locale-sensitive; pin for determinism.
|
|
486
|
+
return {**os.environ, "LC_ALL": "C"}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _sort_command(num_cores: int, sort_mem_pct: int) -> List[str]:
|
|
490
|
+
"""Tab-delimited sort by (chrom, start, end, strand), numeric where needed, parallel, compressed temps via pigz."""
|
|
491
|
+
return [
|
|
492
|
+
"sort",
|
|
493
|
+
"--parallel",
|
|
494
|
+
str(num_cores),
|
|
495
|
+
"-t",
|
|
496
|
+
"\t",
|
|
497
|
+
"-k1,1",
|
|
498
|
+
"-k2,2n",
|
|
499
|
+
"-k3,3n",
|
|
500
|
+
"-k4,4",
|
|
501
|
+
"-S",
|
|
502
|
+
f"{sort_mem_pct}%",
|
|
503
|
+
"--compress-program",
|
|
504
|
+
"pigz",
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _bgzip_command(num_cores: int) -> List[str]:
|
|
509
|
+
# Prefer long form; widely supported on recent htslib/bgzip.
|
|
510
|
+
return ["bgzip", f"--threads={num_cores}", "-c"]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _pigz_decompress_command(path: Path) -> List[str]:
|
|
514
|
+
return ["pigz", "-dc", str(path)]
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _pipeline_run(
|
|
518
|
+
stages: Sequence[Tuple[str, List[str]]],
|
|
519
|
+
output_file: Path,
|
|
520
|
+
*,
|
|
521
|
+
env: Optional[dict] = None,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""
|
|
524
|
+
Run a left-to-right streaming pipeline and write the last stage's stdout to output_file.
|
|
525
|
+
Each stage is (name, argv). Captures stderr per stage for error reporting.
|
|
526
|
+
"""
|
|
527
|
+
if not stages:
|
|
528
|
+
raise ValueError("pipeline must have at least one stage")
|
|
529
|
+
|
|
530
|
+
procs: List[sp.Popen] = []
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
# open sink
|
|
534
|
+
with open(output_file, "wb") as out_f:
|
|
535
|
+
# launch first
|
|
536
|
+
name0, cmd0 = stages[0]
|
|
537
|
+
logger.info(f"Starting pipeline stage: {name0}")
|
|
538
|
+
p0 = sp.Popen(cmd0, stdout=sp.PIPE, stderr=sp.PIPE, env=env)
|
|
539
|
+
procs.append(p0)
|
|
540
|
+
|
|
541
|
+
# chain middle (if any)
|
|
542
|
+
prev_stdout = p0.stdout
|
|
543
|
+
for stage_name, stage_cmd in stages[1:-1]:
|
|
544
|
+
logger.info(f"Starting pipeline stage: {stage_name}")
|
|
545
|
+
p = sp.Popen(stage_cmd, stdin=prev_stdout, stdout=sp.PIPE, stderr=sp.PIPE, env=env)
|
|
546
|
+
procs.append(p)
|
|
547
|
+
if prev_stdout is not None:
|
|
548
|
+
prev_stdout.close() # allow upstream SIGPIPE if we fail downstream
|
|
549
|
+
prev_stdout = p.stdout
|
|
550
|
+
|
|
551
|
+
# last -> file
|
|
552
|
+
last_name, last_cmd = stages[-1]
|
|
553
|
+
logger.info(f"Starting pipeline stage: {last_name}")
|
|
554
|
+
plast = sp.Popen(last_cmd, stdin=prev_stdout, stdout=out_f, stderr=sp.PIPE, env=env)
|
|
555
|
+
procs.append(plast)
|
|
556
|
+
if prev_stdout is not None:
|
|
557
|
+
prev_stdout.close()
|
|
558
|
+
|
|
559
|
+
plast.wait()
|
|
560
|
+
|
|
561
|
+
# collect return codes (downstream-first)
|
|
562
|
+
returncodes = []
|
|
563
|
+
for proc in reversed(procs):
|
|
564
|
+
rc = proc.returncode if proc is plast else proc.wait()
|
|
565
|
+
returncodes.append(rc)
|
|
566
|
+
|
|
567
|
+
# error aggregation
|
|
568
|
+
if any(rc != 0 for rc in returncodes):
|
|
569
|
+
errs = []
|
|
570
|
+
|
|
571
|
+
for (stage_name, _), proc in zip(stages, procs):
|
|
572
|
+
if proc.returncode != 0:
|
|
573
|
+
try:
|
|
574
|
+
err = proc.stderr.read().decode(errors="replace").strip() if proc.stderr else ""
|
|
575
|
+
except Exception:
|
|
576
|
+
err = ""
|
|
577
|
+
|
|
578
|
+
errs.append(f"{stage_name} failed (rc={proc.returncode}): {err}")
|
|
579
|
+
|
|
580
|
+
# Provide AWS Batch specific guidance
|
|
581
|
+
error_msg = " | ".join(errs)
|
|
582
|
+
|
|
583
|
+
raise RuntimeError(error_msg)
|
|
584
|
+
|
|
585
|
+
finally:
|
|
586
|
+
# harden against leaked children in edge cases
|
|
587
|
+
for p in procs:
|
|
588
|
+
if p.poll() is None:
|
|
589
|
+
with contextlib.suppress(Exception):
|
|
590
|
+
p.terminate()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def prepare_fragment(fragment_file: str, bgzip_output_file: str) -> None:
|
|
594
|
+
"""
|
|
595
|
+
Decompress -> sort -> bgzip.
|
|
596
|
+
Sorts by (chrom, start, end, strand). Output is bgzipped; chromosomes may appear in non-input order,
|
|
597
|
+
but are internally sorted within chromosome.
|
|
598
|
+
"""
|
|
599
|
+
_ensure_tools(
|
|
600
|
+
[
|
|
601
|
+
("sort", "required to sort the fragment file"),
|
|
602
|
+
("bgzip", "required to bgzip-compress the result"),
|
|
603
|
+
("pigz", "required for parallel gzip (de)compression"),
|
|
604
|
+
]
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
in_path = Path(fragment_file)
|
|
608
|
+
if not in_path.exists():
|
|
609
|
+
raise FileNotFoundError(f"Input file not found: {in_path}")
|
|
610
|
+
|
|
611
|
+
out_path = Path(bgzip_output_file)
|
|
612
|
+
ncores = _default_cores()
|
|
613
|
+
sort_mem_pct = _calculate_sort_memory(ncores, SORT_MEMORY_PERCENTAGE)
|
|
614
|
+
env = _deterministic_env()
|
|
615
|
+
|
|
616
|
+
stages = [
|
|
617
|
+
("pigz -dc", _pigz_decompress_command(in_path)),
|
|
618
|
+
("sort", _sort_command(ncores, sort_mem_pct)),
|
|
619
|
+
("bgzip", _bgzip_command(ncores)),
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
_pipeline_run(stages, out_path, env=env)
|
|
623
|
+
logger.info(f"bgzip compression completed successfully for {out_path}")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def deduplicate_fragment_rows(
|
|
627
|
+
fragment_file_name: str,
|
|
628
|
+
output_file_name: Optional[str] = None,
|
|
629
|
+
sort_memory_percent: int = SORT_MEMORY_PERCENTAGE,
|
|
630
|
+
) -> str:
|
|
631
|
+
"""
|
|
632
|
+
Decompress -> sort -> uniq -> bgzip.
|
|
633
|
+
Removes duplicate lines after sorting by (chrom, start, end, strand).
|
|
634
|
+
|
|
635
|
+
:param fragment_file_name: Path to the input gzipped fragment file.
|
|
636
|
+
:param output_file_name: Path for the output deduplicated bgzipped file. If None, a default name is generated.
|
|
637
|
+
:param sort_memory_percent: Percentage of system memory to allocate per sort thread.
|
|
638
|
+
:return: Path to the deduplicated bgzipped output file.
|
|
639
|
+
:raises RuntimeError: If the operation fails due to resource constraints.
|
|
640
|
+
"""
|
|
641
|
+
_ensure_tools(
|
|
642
|
+
[
|
|
643
|
+
("sort", "required to sort the fragment file"),
|
|
644
|
+
("bgzip", "required to bgzip-compress the result"),
|
|
645
|
+
("pigz", "required for parallel gzip (de)compression"),
|
|
646
|
+
("uniq", "required to remove duplicate rows"),
|
|
647
|
+
]
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
in_path = Path(fragment_file_name)
|
|
651
|
+
if not in_path.exists():
|
|
652
|
+
raise FileNotFoundError(f"Input file not found: {in_path}")
|
|
653
|
+
|
|
654
|
+
if output_file_name is None:
|
|
655
|
+
# <input minus .tsv[.gz]>_dedup.tsv.bgz
|
|
656
|
+
stem_once = in_path.stem # drops final suffix (e.g., ".gz")
|
|
657
|
+
stem_twice = Path(stem_once).stem # drops ".tsv" if present
|
|
658
|
+
out_path = in_path.with_name(f"{stem_twice}_dedup.tsv.bgz")
|
|
659
|
+
else:
|
|
660
|
+
out_path = Path(output_file_name)
|
|
661
|
+
|
|
662
|
+
ncores = _default_cores()
|
|
663
|
+
sort_mem_pct = _calculate_sort_memory(ncores, sort_memory_percent) # Output is smaller
|
|
664
|
+
|
|
665
|
+
stages = [
|
|
666
|
+
("pigz -dc", _pigz_decompress_command(in_path)),
|
|
667
|
+
("sort", _sort_command(ncores, sort_mem_pct)),
|
|
668
|
+
("uniq", ["uniq"]),
|
|
669
|
+
("bgzip", _bgzip_command(ncores)),
|
|
670
|
+
]
|
|
671
|
+
|
|
672
|
+
logger.info(f"Starting deduplication pipeline: {ncores} cores, {sort_mem_pct}% memory per core")
|
|
673
|
+
_pipeline_run(stages, out_path, env=_deterministic_env())
|
|
674
|
+
logger.info(f"bgzip compression completed successfully for {out_path}")
|
|
675
|
+
return str(out_path)
|