grz-cli 0.1.2__tar.gz → 0.1.3__tar.gz

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.
Files changed (38) hide show
  1. {grz_cli-0.1.2 → grz_cli-0.1.3}/MANIFEST.in +1 -0
  2. {grz_cli-0.1.2/src/grz_cli.egg-info → grz_cli-0.1.3}/PKG-INFO +1 -1
  3. {grz_cli-0.1.2 → grz_cli-0.1.3}/pyproject.toml +7 -3
  4. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/cli.py +1 -3
  5. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/download.py +7 -22
  6. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/fastq_validation.py +3 -9
  7. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/file_operations.py +4 -12
  8. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/config.py +3 -11
  9. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/v1_0_0/metadata.py +112 -61
  10. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/parser.py +36 -115
  11. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/resources/grz-schema.json +25 -11
  12. grz_cli-0.1.3/src/grz_cli/resources/thresholds.json +299 -0
  13. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/upload.py +7 -22
  14. {grz_cli-0.1.2 → grz_cli-0.1.3/src/grz_cli.egg-info}/PKG-INFO +1 -1
  15. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/SOURCES.txt +1 -0
  16. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_cli.py +8 -26
  17. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_download.py +5 -16
  18. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_fastq_validation.py +1 -5
  19. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_file_operations.py +2 -6
  20. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_file_progress_logging.py +1 -3
  21. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_parser.py +2 -6
  22. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_upload.py +5 -17
  23. {grz_cli-0.1.2 → grz_cli-0.1.3}/LICENSE +0 -0
  24. {grz_cli-0.1.2 → grz_cli-0.1.3}/README.md +0 -0
  25. {grz_cli-0.1.2 → grz_cli-0.1.3}/setup.cfg +0 -0
  26. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/__init__.py +0 -0
  27. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/__init__.py +0 -0
  28. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/constants.py +0 -0
  29. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/logging_setup.py +0 -0
  30. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/__init__.py +0 -0
  31. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/models/v1_0_0/__init__.py +0 -0
  32. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/progress_logging.py +0 -0
  33. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli/states.py +0 -0
  34. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/dependency_links.txt +0 -0
  35. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/entry_points.txt +0 -0
  36. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/requires.txt +0 -0
  37. {grz_cli-0.1.2 → grz_cli-0.1.3}/src/grz_cli.egg-info/top_level.txt +0 -0
  38. {grz_cli-0.1.2 → grz_cli-0.1.3}/tests/test_read_multiple_json.py +0 -0
@@ -1 +1,2 @@
1
1
  include src/grz_cli/resources/grz-schema.json
2
+ include src/grz_cli/resources/thresholds.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: grz-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Tool for validation, encryption and upload of MV submissions to GDCs.
5
5
  Author-email: Koray Kirli <koraykirli@gmail.com>, Mathias Lesche <mathias.lesche@tu-dresden.de>, "Florian R. Hölzlwimmer" <git.ich@frhoelzlwimmer.de>, Till Hartmann <till.hartmann@bih-charite.de>, Thomas Sell <thomas.sell@bih-charite.de>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "grz-cli"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Tool for validation, encryption and upload of MV submissions to GDCs."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -76,7 +76,7 @@ exclude = [
76
76
  "dist",
77
77
  "out"
78
78
  ]
79
- line-length = 88
79
+ line-length = 120
80
80
  src = [
81
81
  "src",
82
82
  "tests"
@@ -149,7 +149,7 @@ convention = "pep257"
149
149
 
150
150
  [tool.tox]
151
151
  requires = ["tox>=4.23"]
152
- env_list = ["format-check", "lints", "mypy", "3.12"]
152
+ env_list = ["format-check", "lints", "mypy", "3.12", "3.13"]
153
153
  allowlist_externals = ["pytest", "ruff"]
154
154
  isolated_build = true
155
155
 
@@ -188,3 +188,7 @@ python_version = "3.12"
188
188
  plugins = "pydantic.mypy"
189
189
  packages = "src"
190
190
 
191
+ [tool.pytest.ini_options]
192
+ addopts = [
193
+ "--import-mode=importlib",
194
+ ]
@@ -48,9 +48,7 @@ DIR_RW_C = click.Path(
48
48
  writable=True,
49
49
  resolve_path=True,
50
50
  )
51
- FILE_R_E = click.Path(
52
- exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True
53
- )
51
+ FILE_R_E = click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True)
54
52
 
55
53
  submission_dir = click.option(
56
54
  "--submission-dir",
@@ -74,10 +74,7 @@ class S3BotoDownloadWorker:
74
74
 
75
75
  # configure proxies if proxy_url is defined
76
76
  proxy_url = empty_str_to_none(self._config.s3_options.proxy_url)
77
- if proxy_url is not None:
78
- config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url})
79
- else:
80
- config = None
77
+ config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url}) if proxy_url is not None else None
81
78
 
82
79
  # Initialize S3 client for uploading
83
80
  self._s3_client: boto3.session.Session.client = boto3_client(
@@ -149,9 +146,7 @@ class S3BotoDownloadWorker:
149
146
  if filesize / MULTIPART_CHUNKSIZE > MULTIPART_MAX_CHUNKS
150
147
  else MULTIPART_CHUNKSIZE
151
148
  )
152
- self.__log.debug(
153
- f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize/chunksize} chunks"
154
- )
149
+ self.__log.debug(f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize/chunksize} chunks")
155
150
 
156
151
  config = TransferConfig(
157
152
  multipart_threshold=MULTIPART_THRESHOLD,
@@ -160,9 +155,7 @@ class S3BotoDownloadWorker:
160
155
  )
161
156
 
162
157
  transfer = S3Transfer(self._s3_client, config)
163
- progress_bar = tqdm(
164
- total=filesize, unit="B", unit_scale=True, unit_divisor=1024
165
- )
158
+ progress_bar = tqdm(total=filesize, unit="B", unit_scale=True, unit_divisor=1024)
166
159
  transfer.download_file(
167
160
  self._config.s3_options.bucket,
168
161
  s3_object_id,
@@ -192,15 +185,11 @@ class S3BotoDownloadWorker:
192
185
  file_path = Path(file_key).relative_to(encrypted_files_key_prefix)
193
186
  full_path = encrypted_submission.encrypted_files_dir / file_path
194
187
  if full_path not in encrypted_submission.encrypted_files:
195
- raise DownloadError(
196
- f"File {file_path} not listed in metadata.json"
197
- )
188
+ raise DownloadError(f"File {file_path} not listed in metadata.json")
198
189
  file_metadata = encrypted_submission.encrypted_files[full_path]
199
190
  logged_state = progress_logger.get_state(full_path, file_metadata)
200
191
 
201
- if (logged_state is None) or not logged_state.get(
202
- "download_successful", False
203
- ):
192
+ if (logged_state is None) or not logged_state.get("download_successful", False):
204
193
  self.__log.info(
205
194
  "Download file: '%s' -> '%s'",
206
195
  file_key,
@@ -208,9 +197,7 @@ class S3BotoDownloadWorker:
208
197
  )
209
198
 
210
199
  try:
211
- full_path.parent.mkdir(
212
- mode=0o770, parents=True, exist_ok=True
213
- )
200
+ full_path.parent.mkdir(mode=0o770, parents=True, exist_ok=True)
214
201
  self.download_file(str(full_path), file_key, file["Size"])
215
202
 
216
203
  self.__log.info(f"Download complete for {str(full_path)}. ")
@@ -225,9 +212,7 @@ class S3BotoDownloadWorker:
225
212
  progress_logger.set_state(
226
213
  full_path,
227
214
  file_metadata,
228
- state=DownloadState(
229
- download_successful=False, errors=[str(e)]
230
- ),
215
+ state=DownloadState(download_successful=False, errors=[str(e)]),
231
216
  )
232
217
 
233
218
  raise e
@@ -29,9 +29,7 @@ def is_gzipped(file_path: str | PathLike) -> bool:
29
29
 
30
30
 
31
31
  @contextmanager
32
- def open_fastq(
33
- file_path: str | PathLike, progress=True
34
- ) -> Generator[TextIO, None, None]:
32
+ def open_fastq(file_path: str | PathLike, progress=True) -> Generator[TextIO, None, None]:
35
33
  """
36
34
  Open a FASTQ file, handling both regular and gzipped formats.
37
35
 
@@ -103,9 +101,7 @@ def validate_fastq_file(fastq_file: str | PathLike) -> tuple[int, set[int], list
103
101
 
104
102
  # Check if the number of lines in a FASTQ file is a multiple of 4.
105
103
  if num_lines % 4 != 0:
106
- errors.append(
107
- f"{fastq_file}: Number of lines is not a multiple of 4! Found {num_lines} lines."
108
- )
104
+ errors.append(f"{fastq_file}: Number of lines is not a multiple of 4! Found {num_lines} lines.")
109
105
  else:
110
106
  log.debug("%s: %s lines", fastq_file, num_lines)
111
107
 
@@ -132,9 +128,7 @@ def validate_single_end_reads(fastq_file: str | PathLike) -> Generator[str]:
132
128
  yield from errors
133
129
 
134
130
 
135
- def validate_paired_end_reads(
136
- fastq_file1: str | PathLike, fastq_file2: str | PathLike
137
- ) -> Generator[str]:
131
+ def validate_paired_end_reads(fastq_file1: str | PathLike, fastq_file2: str | PathLike) -> Generator[str]:
138
132
  """
139
133
  Validate two paired-end FASTQ files.
140
134
 
@@ -69,9 +69,7 @@ def calculate_md5(file_path, chunk_size=2**16, progress=True) -> str:
69
69
  md5_hash = hashlib.md5() # noqa: S324
70
70
  with open(file_path, "rb") as f:
71
71
  if progress and (total_size > chunk_size):
72
- with tqdm(
73
- total=total_size, unit="B", unit_scale=True, desc="Calculating MD5"
74
- ) as pbar:
72
+ with tqdm(total=total_size, unit="B", unit_scale=True, desc="Calculating MD5") as pbar:
75
73
  while chunk := f.read(chunk_size):
76
74
  md5_hash.update(chunk)
77
75
  pbar.update(len(chunk))
@@ -117,9 +115,7 @@ def read_multiple_json(input: TextIO, buffer_size=65536, max_buffer_size=1342177
117
115
  raise ValueError("Remaining data is not empty. Is there invalid JSON?")
118
116
 
119
117
 
120
- def is_relative_subdirectory(
121
- relative_path: str | PathLike, root_directory: str | PathLike
122
- ) -> bool:
118
+ def is_relative_subdirectory(relative_path: str | PathLike, root_directory: str | PathLike) -> bool:
123
119
  """
124
120
  Check if the target path is a subdirectory of the root path
125
121
  using os.path.commonpath() without checking the file system.
@@ -223,9 +219,7 @@ class Crypt4GH:
223
219
  if passphrase:
224
220
  passphrase_callback = lambda: passphrase
225
221
  else:
226
- passphrase_callback = partial(
227
- getpass, prompt=f"Passphrase for {seckey_path}: "
228
- )
222
+ passphrase_callback = partial(getpass, prompt=f"Passphrase for {seckey_path}: ")
229
223
 
230
224
  return crypt4gh.keys.get_private_key(seckeypath, passphrase_callback)
231
225
 
@@ -254,9 +248,7 @@ class Crypt4GH:
254
248
  ) as pbar_in_fd,
255
249
  ):
256
250
  crypt4gh.lib.decrypt(
257
- keys=[
258
- (0, private_key, None)
259
- ], # list of (method, privkey, recipient_pubkey=None),
251
+ keys=[(0, private_key, None)], # list of (method, privkey, recipient_pubkey=None),
260
252
  infile=pbar_in_fd,
261
253
  outfile=out_fd,
262
254
  )
@@ -97,22 +97,14 @@ class ConfigModel(StrictBaseModel):
97
97
  @field_validator("grz_public_key")
98
98
  @classmethod
99
99
  def check_grz_public_key(cls, v):
100
- if (
101
- v is not None
102
- and "BEGIN CRYPT4GH PUBLIC KEY" not in v
103
- and "END CRYPT4GH PUBLIC KEY" not in v
104
- ):
100
+ if v is not None and "BEGIN CRYPT4GH PUBLIC KEY" not in v and "END CRYPT4GH PUBLIC KEY" not in v:
105
101
  raise ValueError("Invalid public key format")
106
102
  return v
107
103
 
108
104
  @model_validator(mode="after")
109
105
  def validate_grz_public_key(self) -> Self:
110
106
  if self.grz_public_key is None and self.grz_public_key_path is None:
111
- raise ValueError(
112
- "Either grz_public_key or grz_public_key_path must be set."
113
- )
107
+ raise ValueError("Either grz_public_key or grz_public_key_path must be set.")
114
108
  if self.grz_public_key is not None and self.grz_public_key_path is not None:
115
- raise ValueError(
116
- "Only one of grz_public_key or grz_public_key_path must be set."
117
- )
109
+ raise ValueError("Only one of grz_public_key or grz_public_key_path must be set.")
118
110
  return self
@@ -16,12 +16,14 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import json
19
20
  import logging
20
21
  from collections.abc import Generator
21
22
  from datetime import date
22
23
  from enum import StrEnum
24
+ from importlib.resources import files
23
25
  from pathlib import Path
24
- from typing import Annotated, Any
26
+ from typing import Annotated, Any, Self
25
27
 
26
28
  from pydantic import (
27
29
  BaseModel,
@@ -105,17 +107,15 @@ class Submission(StrictBaseModel):
105
107
 
106
108
  genomic_data_center_id: Annotated[
107
109
  str,
108
- StringConstraints(pattern=r"^(GRZ|KDK)[A-Z0-9]{3}[0-9]{3}$"),
110
+ StringConstraints(pattern=r"^(GRZ)[A-Z0-9]{3}[0-9]{3}$"),
109
111
  ]
110
112
  """
111
113
  ID of the genomic data center in the format GRZXXXnnn.
112
114
  """
113
115
 
114
- clinical_data_node_id: Annotated[
115
- str, StringConstraints(pattern=r"^(GRZ|KDK)[A-Z0-9]{3}[0-9]{3}$")
116
- ]
116
+ clinical_data_node_id: Annotated[str, StringConstraints(pattern=r"^(KDK)[A-Z0-9]{3}[0-9]{3}$")]
117
117
  """
118
- ID of the genomic data center in the format KDKXXXnnn.
118
+ ID of the clinical data node in the format KDKXXXnnn.
119
119
  """
120
120
 
121
121
  genomic_study_type: GenomicStudyType
@@ -262,10 +262,13 @@ class LibraryType(StrEnum):
262
262
  """
263
263
 
264
264
  panel = "panel"
265
+ panel_lr = "panel_lr"
265
266
  wes = "wes"
267
+ wes_lr = "wes_lr"
266
268
  wgs = "wgs"
267
269
  wgs_lr = "wgs_lr"
268
270
  wxs = "wxs"
271
+ wxs_lr = "wxs_lr"
269
272
  other = "other"
270
273
  unknown = "unknown"
271
274
 
@@ -433,6 +436,16 @@ class File(StrictBaseModel):
433
436
  return self.file_path + ".c4gh"
434
437
 
435
438
 
439
+ class PercentBasesAboveQualityThreshold(StrictBaseModel):
440
+ """Percentage of bases with a specified minimum quality threshold"""
441
+
442
+ minimum_quality: Annotated[float, Field(strict=True, ge=0.0)]
443
+ """The minimum quality score threshold"""
444
+
445
+ percent: Annotated[float, Field(strict=True, ge=0.0, le=100.0)]
446
+ """Percentage of bases that meet or exceed the minimum quality score"""
447
+
448
+
436
449
  class SequenceData(StrictBaseModel):
437
450
  bioinformatics_pipeline_name: str
438
451
  """
@@ -449,9 +462,9 @@ class SequenceData(StrictBaseModel):
449
462
  Reference genome used
450
463
  """
451
464
 
452
- percent_bases_q30: Annotated[float, Field(strict=True, ge=0.0, le=100.0)]
465
+ percent_bases_above_quality_threshold: PercentBasesAboveQualityThreshold
453
466
  """
454
- Percentage of bases with Q30 or higher
467
+ Percentage of bases with a specified minimum quality threshold
455
468
  """
456
469
 
457
470
  mean_depth_of_coverage: Annotated[float, Field(strict=True, ge=0.0)]
@@ -464,9 +477,7 @@ class SequenceData(StrictBaseModel):
464
477
  Minimum coverage
465
478
  """
466
479
 
467
- targeted_regions_above_min_coverage: Annotated[
468
- float, Field(strict=True, ge=0.0, le=1.0)
469
- ]
480
+ targeted_regions_above_min_coverage: Annotated[float, Field(strict=True, ge=0.0, le=1.0)]
470
481
  """
471
482
  Fraction of targeted regions that are above minimum coverage
472
483
  """
@@ -590,9 +601,7 @@ class LabDatum(StrictBaseModel):
590
601
  The sequencing layout, aka the end type of sequencing.
591
602
  """
592
603
 
593
- tumor_cell_count: Annotated[
594
- float | None, Field(alias="tumorCellCount", ge=0.0, le=100.0)
595
- ] = None
604
+ tumor_cell_count: Annotated[float | None, Field(alias="tumorCellCount", ge=0.0, le=100.0)] = None
596
605
  """
597
606
  Tumor cell count in %
598
607
  """
@@ -607,6 +616,15 @@ class LabDatum(StrictBaseModel):
607
616
  Sequence data generated from the wet lab experiment.
608
617
  """
609
618
 
619
+ @model_validator(mode="after")
620
+ def validate_sequencing_setup(self) -> Self:
621
+ if self.library_type in {LibraryType.wxs, LibraryType.wxs_lr} and self.sequence_type != SequenceType.rna:
622
+ raise ValueError(
623
+ f"Error in lab datum '{self.lab_data_name}': "
624
+ f"WXS requires RNA sequencing, but got '{self.sequence_type}'."
625
+ )
626
+ return self
627
+
610
628
 
611
629
  class Donor(StrictBaseModel):
612
630
  case_id: Annotated[str, StringConstraints(pattern=r"^[a-fA-F0-9]{32}$")]
@@ -698,12 +716,17 @@ class Donor(StrictBaseModel):
698
716
  def contains_bed_files(sequence_data: SequenceData) -> bool:
699
717
  return any(f.file_type == FileType.bed for f in sequence_data.files)
700
718
 
701
- lib_type = {LibraryType.panel, LibraryType.wes}
719
+ lib_types = {
720
+ LibraryType.panel,
721
+ LibraryType.wes,
722
+ LibraryType.wxs,
723
+ LibraryType.panel_lr,
724
+ LibraryType.wes_lr,
725
+ LibraryType.wxs_lr,
726
+ }
702
727
 
703
728
  for lab_datum in self.lab_data:
704
- if lab_datum.library_type in lib_type and not contains_bed_files(
705
- lab_datum.sequence_data
706
- ):
729
+ if lab_datum.library_type in lib_types and not contains_bed_files(lab_datum.sequence_data):
707
730
  raise ValueError(
708
731
  f"BED file missing for lab datum '{lab_datum.lab_data_name}' in donor '{self.case_id}'."
709
732
  )
@@ -751,12 +774,8 @@ class Donor(StrictBaseModel):
751
774
  )
752
775
 
753
776
  # check if there is an equal number of R1 and R2 files
754
- r1_fastq_files = [
755
- i for i in fastq_files if i.read_order == ReadOrder.r1
756
- ]
757
- r2_fastq_files = [
758
- i for i in fastq_files if i.read_order == ReadOrder.r2
759
- ]
777
+ r1_fastq_files = [i for i in fastq_files if i.read_order == ReadOrder.r1]
778
+ r2_fastq_files = [i for i in fastq_files if i.read_order == ReadOrder.r2]
760
779
 
761
780
  if len(r1_fastq_files) != len(r2_fastq_files):
762
781
  raise ValueError(
@@ -790,60 +809,92 @@ class GrzSubmissionMetadata(StrictBaseModel):
790
809
  case GenomicStudyType.single:
791
810
  # Check if the submission has at least one donor
792
811
  if not self.donors:
793
- raise ValueError(
794
- "At least one donor is required for a single study."
795
- )
812
+ raise ValueError("At least one donor is required for a single study.")
796
813
  case GenomicStudyType.duo:
797
814
  # Check if the submission has at least two donors
798
815
  if len(self.donors) < 2:
799
- raise ValueError(
800
- "At least two donors are required for a duo study."
801
- )
816
+ raise ValueError("At least two donors are required for a duo study.")
802
817
  case GenomicStudyType.trio:
803
818
  # Check if the submission has at least three donors
804
819
  if len(self.donors) < 3:
805
- raise ValueError(
806
- "At least three donors are required for a trio study."
807
- )
820
+ raise ValueError("At least three donors are required for a trio study.")
808
821
 
809
822
  return self
810
823
 
811
824
  @model_validator(mode="after")
812
- def validate_minimum_mean_coverage(self):
825
+ def validate_thresholds(self):
813
826
  """
814
827
  Check if the submission meets the minimum mean coverage requirements.
815
828
  """
816
- thresholds_somatic = {
817
- (LibraryType.panel, SequenceSubtype.somatic): 500,
818
- (LibraryType.wes, SequenceSubtype.somatic): 250,
819
- (LibraryType.wes, SequenceSubtype.germline): 100,
820
- (LibraryType.wgs, SequenceSubtype.somatic): 80,
821
- (LibraryType.wgs, SequenceSubtype.germline): 40,
822
- }
823
- thresholds_rare_disease = {(LibraryType.wgs, SequenceSubtype.germline): 30}
824
-
825
- study_subtype = self.submission.genomic_study_subtype
826
- thresholds = (
827
- thresholds_rare_disease
828
- if study_subtype == GenomicStudySubtype.germline_only
829
- else thresholds_somatic
830
- )
829
+ threshold_definitions = _load_thresholds()
831
830
 
832
831
  for donor in self.donors:
833
832
  for lab_datum in donor.lab_data:
834
- threshold = thresholds.get(
835
- (lab_datum.library_type, lab_datum.sequence_subtype)
833
+ key = (
834
+ self.submission.genomic_study_subtype,
835
+ lab_datum.library_type,
836
+ lab_datum.sequence_subtype,
836
837
  )
837
- if threshold is None:
838
- log.warning(
839
- f"Threshold for {lab_datum.library_type} - {lab_datum.sequence_subtype} not found!"
840
- )
838
+ thresholds = threshold_definitions.get(key)
839
+ if thresholds is None:
840
+ log.warning(f"Thresholds for {key} not found! Skipping.")
841
+ continue
841
842
 
842
- sequence_data = lab_datum.sequence_data
843
- if sequence_data.mean_depth_of_coverage < threshold:
844
- raise ValueError(
845
- f"Mean depth of coverage for donor '{donor.case_id}', lab datum '{lab_datum.lab_data_name}' "
846
- f"below threshold ({threshold}): {sequence_data.mean_depth_of_coverage}"
847
- )
843
+ _check_thresholds(donor, lab_datum, thresholds)
848
844
 
849
845
  return self
846
+
847
+
848
+ def _check_thresholds(donor: Donor, lab_datum: LabDatum, thresholds: dict[str, Any]):
849
+ sequence_data = lab_datum.sequence_data
850
+ case_id = donor.case_id
851
+ lab_data_name = lab_datum.lab_data_name
852
+
853
+ mean_depth_of_coverage_t = thresholds.get("meanDepthOfCoverage")
854
+ mean_depth_of_coverage_v = sequence_data.mean_depth_of_coverage
855
+ if mean_depth_of_coverage_t and mean_depth_of_coverage_v < mean_depth_of_coverage_t:
856
+ raise ValueError(
857
+ f"Mean depth of coverage for donor '{case_id}', lab datum '{lab_data_name}' "
858
+ f"below threshold: {mean_depth_of_coverage_v} < {mean_depth_of_coverage_t}"
859
+ )
860
+
861
+ read_length_t = thresholds.get("readLength")
862
+ read_length_v = sequence_data.read_length
863
+ if read_length_t and read_length_v < read_length_t:
864
+ raise ValueError(
865
+ f"Read length for donor '{case_id}', lab datum '{lab_data_name}' "
866
+ f"below threshold: {read_length_v} < {read_length_t}"
867
+ )
868
+
869
+ if t := thresholds.get("targetedRegionsAboveMinCoverage"):
870
+ min_coverage_t = t.get("minCoverage")
871
+ min_coverage_v = sequence_data.min_coverage
872
+
873
+ fraction_above_t = t.get("fractionAbove")
874
+ fraction_above_v = sequence_data.targeted_regions_above_min_coverage
875
+
876
+ if min_coverage_t and min_coverage_v < min_coverage_t:
877
+ raise ValueError(
878
+ f"Minimum coverage for donor '{case_id}', lab datum '{lab_data_name}' "
879
+ f"below threshold: {min_coverage_v} < {min_coverage_t}"
880
+ )
881
+ if fraction_above_t and fraction_above_v < fraction_above_t:
882
+ raise ValueError(
883
+ f"Fraction of targeted regions above minimum coverage for donor '{case_id}', "
884
+ f"lab datum '{lab_data_name}' below threshold: "
885
+ f"{fraction_above_v} < {fraction_above_t}"
886
+ )
887
+
888
+
889
+ type Thresholds = dict[tuple[str, str, str], dict[str, Any]]
890
+
891
+
892
+ def _load_thresholds() -> Thresholds:
893
+ threshold_definitions = json.load(
894
+ files("grz_cli").joinpath("resources", "thresholds.json").open("r", encoding="utf-8")
895
+ )
896
+ threshold_definitions = {
897
+ (d["genomicStudySubtype"], d["libraryType"], d["sequenceSubtype"]): d["thresholds"]
898
+ for d in threshold_definitions
899
+ }
900
+ return threshold_definitions