Skip to content

Commit 165a37c

Browse files
authored
feat(grz-pydantic-models): Add a thresholds model and implement check for oncomine panel submissions (#469)
Fixes #462
1 parent 57c49e1 commit 165a37c

13 files changed

Lines changed: 1157 additions & 166 deletions

File tree

packages/grz-common/src/grz_common/workers/submission.py

Lines changed: 17 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,16 @@
1111
from os import PathLike
1212
from pathlib import Path
1313

14+
import grz_pydantic_models.submission.thresholds as thresholds_model
1415
from grz_pydantic_models.submission.metadata import get_accepted_versions
1516
from grz_pydantic_models.submission.metadata.v1 import (
1617
ChecksumType,
1718
File,
1819
FileType,
1920
GrzSubmissionMetadata,
20-
LibraryType,
2121
ReadOrder,
2222
SequenceData,
23-
SequenceSubtype,
2423
SequencingLayout,
25-
load_thresholds,
2624
)
2725
from grz_pydantic_models.submission.metadata.v1 import File as SubmissionFileMetadata
2826
from pydantic import ValidationError
@@ -236,15 +234,8 @@ def should_check_file(file_path: Path, file_metadata: File) -> bool:
236234
if not lab_data.sequence_data:
237235
continue
238236

239-
threshold_definitions = load_thresholds()
240-
thresholds = threshold_definitions[
241-
(
242-
self.metadata.content.submission.genomic_study_subtype,
243-
lab_data.library_type,
244-
lab_data.sequence_subtype,
245-
)
246-
]
247-
mean_read_length_threshold = thresholds.get("meanReadLength", 0)
237+
thresholds = self.metadata.content.determine_thresholds_for(donor, lab_data)
238+
mean_read_length_threshold = thresholds.mean_read_length
248239

249240
sequence_data = lab_data.sequence_data
250241
fastq_files = [f for f in sequence_data.files if f.file_type == FileType.fastq]
@@ -501,22 +492,26 @@ def find_bam_files(sequence_data: SequenceData) -> list[File]:
501492
return [f for f in sequence_data.files if f.file_type == FileType.bam]
502493

503494
for donor in self.metadata.content.donors:
504-
for lab_data in donor.lab_data:
505-
sequencing_layout = lab_data.sequencing_layout
506-
sequence_data = lab_data.sequence_data
495+
for lab_datum in donor.lab_data:
496+
sequencing_layout = lab_datum.sequencing_layout
497+
sequence_data = lab_datum.sequence_data
507498
# find all FASTQ files
508499
fastq_files = find_fastq_files(sequence_data) if sequence_data else []
509500
bam_files = find_bam_files(sequence_data) if sequence_data else []
510501

511-
if not lab_data.library_type.endswith("_lr"):
502+
if not lab_datum.library_type.endswith("_lr"):
512503
match sequencing_layout:
513504
case SequencingLayout.single_end | SequencingLayout.reverse | SequencingLayout.other:
514505
yield from self._validate_single_end_fallback(
515-
fastq_files, progress_logger, lab_data.library_type, lab_data.sequence_subtype
506+
fastq_files,
507+
progress_logger,
508+
self.metadata.content.determine_thresholds_for(donor, lab_datum),
516509
)
517510
case SequencingLayout.paired_end:
518511
yield from self._validate_paired_end_fallback(
519-
fastq_files, progress_logger, lab_data.library_type, lab_data.sequence_subtype
512+
fastq_files,
513+
progress_logger,
514+
self.metadata.content.determine_thresholds_for(donor, lab_datum),
520515
)
521516
yield from self._validate_bams_fallback(bam_files, progress_logger)
522517

@@ -559,22 +554,13 @@ def _validate_single_end_fallback(
559554
self,
560555
fastq_files: list[File],
561556
progress_logger: FileProgressLogger[ValidationState],
562-
library_type: LibraryType,
563-
sequence_subtype: SequenceSubtype,
557+
thresholds: thresholds_model.Thresholds,
564558
) -> Generator[str, None, None]:
565559
def validate_file(local_file_path, file_metadata: SubmissionFileMetadata) -> ValidationState:
566560
self.__log.debug("Validating '%s'...", str(local_file_path))
567561

568562
# validate the file
569-
threshold_definitions = load_thresholds()
570-
thresholds = threshold_definitions[
571-
(
572-
self.metadata.content.submission.genomic_study_subtype,
573-
library_type,
574-
sequence_subtype,
575-
)
576-
]
577-
mean_read_length_threshold = thresholds["meanReadLength"]
563+
mean_read_length_threshold = thresholds.mean_read_length
578564
errors = list(
579565
validate_single_end_reads(local_file_path, mean_read_length_threshold=mean_read_length_threshold)
580566
)
@@ -596,18 +582,9 @@ def _validate_paired_end_fallback(
596582
self,
597583
fastq_files: list[File],
598584
progress_logger: FileProgressLogger[ValidationState],
599-
library_type: LibraryType,
600-
sequence_subtype: SequenceSubtype,
585+
thresholds: thresholds_model.Thresholds,
601586
) -> Generator[str, None, None]:
602-
threshold_definitions = load_thresholds()
603-
thresholds = threshold_definitions[
604-
(
605-
self.metadata.content.submission.genomic_study_subtype,
606-
library_type,
607-
sequence_subtype,
608-
)
609-
]
610-
mean_read_length_threshold = thresholds["meanReadLength"]
587+
mean_read_length_threshold = thresholds.mean_read_length
611588
key = lambda f: (f.flowcell_id, f.lane_id)
612589
fastq_files.sort(key=key)
613590
for _key, group in groupby(fastq_files, key):

packages/grz-pydantic-models/src/grz_pydantic_models/submission/metadata/v1.py

Lines changed: 127 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import re
77
from datetime import date
88
from enum import StrEnum
9+
from functools import cache
910
from importlib.resources import files
1011
from itertools import groupby
1112
from operator import attrgetter
1213
from pathlib import Path, PurePosixPath
13-
from typing import Annotated, Any, Self
14+
from typing import Annotated, Self
1415

1516
from pydantic import (
1617
AfterValidator,
@@ -25,6 +26,7 @@
2526

2627
from ...common import StrictBaseModel
2728
from ...mii.consent import Consent, ProvisionType
29+
from .. import thresholds as thresholds_model
2830
from .versioning import Version
2931

3032
SCHEMA_URL_PATTERN = r"https://raw\.githubusercontent\.com/BfArM-MVH/MVGenomseq/refs/tags/v([0-9]+)\.([0-9]+)(?:\.([0-9]+))?/GRZ/grz-schema\.json"
@@ -1290,40 +1292,81 @@ def check_duplicate_file_paths(self):
12901292

12911293
return self
12921294

1295+
def determine_thresholds_for(
1296+
self,
1297+
donor: Donor,
1298+
lab_datum: LabDatum,
1299+
) -> thresholds_model.Thresholds:
1300+
"""
1301+
Determine the thresholds for a given donor and lab datum.
1302+
1303+
:param donor: The donor for which to determine the thresholds.
1304+
:param lab_datum: The lab datum for which to determine the thresholds.
1305+
:raise MissingThresholdsException: If no thresholds are found for the given combination.
1306+
:return: The thresholds for the given donor and lab datum.
1307+
"""
1308+
threshold_definitions = _load_thresholds()
1309+
1310+
genomic_study_subtype = self.submission.genomic_study_subtype
1311+
is_oncomine_panel = "oncomine" in lab_datum.kit_name.lower()
1312+
key = (genomic_study_subtype, lab_datum.library_type, lab_datum.sequence_subtype, is_oncomine_panel)
1313+
1314+
thresholds = threshold_definitions.get(key)
1315+
if thresholds is None:
1316+
allowed_combinations_list = sorted(list(threshold_definitions.keys()))
1317+
allowed_combinations = "\n".join([f" - {combination}" for combination in allowed_combinations_list])
1318+
names = (
1319+
"submission.genomicStudySubtype",
1320+
"labData.libraryType",
1321+
"labData.sequenceSubtype",
1322+
"isOncominePanel",
1323+
)
1324+
1325+
info = dict(zip(names, key, strict=True))
1326+
1327+
raise MissingThresholdsException(
1328+
f"No thresholds for the specified combination {info} found (donor {donor.donor_pseudonym})!\n"
1329+
f"Valid combinations:\n{allowed_combinations}.\n"
1330+
f"See https://www.bfarm.de/SharedDocs/Downloads/DE/Forschung/modellvorhaben-genomsequenzierung/Qs-durch-GRZ.pdf?__blob=publicationFile for more details.\n"
1331+
f"Skipping threshold validation."
1332+
)
1333+
1334+
return thresholds
1335+
1336+
def get_thresholds(self) -> dict[tuple[str, str], thresholds_model.Thresholds | None]:
1337+
"""
1338+
Get the thresholds for all lab data in the submission.
1339+
1340+
:return: A dictionary mapping (donor pseudonym, lab data name) to the thresholds,
1341+
or None if no thresholds are defined for this lab datum.
1342+
"""
1343+
thresholds_dict = {}
1344+
for donor in self.donors:
1345+
for lab_datum in donor.lab_data:
1346+
try:
1347+
thresholds = self.determine_thresholds_for(donor, lab_datum)
1348+
except MissingThresholdsException:
1349+
# assign None if no thresholds are found
1350+
thresholds = None
1351+
1352+
thresholds_dict[(donor.donor_pseudonym, lab_datum.lab_data_name)] = thresholds
1353+
1354+
return thresholds_dict
1355+
12931356
@model_validator(mode="after")
12941357
def validate_thresholds(self):
12951358
"""
12961359
Check if the submission meets the minimum mean coverage requirements.
12971360
"""
1298-
threshold_definitions = load_thresholds()
1299-
13001361
for donor in self.donors:
13011362
for lab_datum in donor.lab_data:
1302-
key = (
1303-
self.submission.genomic_study_subtype,
1304-
lab_datum.library_type,
1305-
lab_datum.sequence_subtype,
1306-
)
1307-
thresholds = threshold_definitions.get(key)
1308-
if thresholds is None:
1309-
allowed_combinations = sorted(list(threshold_definitions.keys()))
1310-
allowed_combinations = "\n".join([f" - {combination}" for combination in allowed_combinations])
1311-
names = (
1312-
"submission.genomicStudySubtype",
1313-
"labData.libraryType",
1314-
"labData.sequenceSubtype",
1315-
)
1316-
info = dict(zip(names, key, strict=True))
1317-
log.warning(
1318-
f"No thresholds for the specified combination {info} found (donor {donor.donor_pseudonym})!\n"
1319-
f"Valid combinations:\n{allowed_combinations}.\n"
1320-
f"See https://www.bfarm.de/SharedDocs/Downloads/DE/Forschung/modellvorhaben-genomsequenzierung/Qs-durch-GRZ.pdf?__blob=publicationFile for more details.\n"
1321-
f"Skipping threshold validation."
1322-
)
1363+
try:
1364+
thresholds = self.determine_thresholds_for(donor, lab_datum)
1365+
except MissingThresholdsException as e:
1366+
log.warning(str(e))
13231367
continue
13241368

13251369
_check_thresholds(donor, lab_datum, thresholds)
1326-
13271370
return self
13281371

13291372
@model_validator(mode="after")
@@ -1345,7 +1388,7 @@ def validate_reference_genome_compatibility(self):
13451388
return self
13461389

13471390

1348-
def _check_thresholds(donor: Donor, lab_datum: LabDatum, thresholds: dict[str, Any]): # noqa: C901
1391+
def _check_thresholds(donor: Donor, lab_datum: LabDatum, thresholds: thresholds_model.Thresholds): # noqa: C901
13491392
def raise_if_index(message: str) -> None:
13501393
if donor.relation == Relation.index_:
13511394
raise ValueError(message)
@@ -1359,36 +1402,36 @@ def raise_if_index(message: str) -> None:
13591402
lab_data_name = lab_datum.lab_data_name
13601403
sequence_data = lab_datum.sequence_data
13611404

1362-
mean_depth_of_coverage_t = thresholds.get("meanDepthOfCoverage")
1405+
mean_depth_of_coverage_t = thresholds.mean_depth_of_coverage
13631406
mean_depth_of_coverage_v = sequence_data.mean_depth_of_coverage
13641407
if mean_depth_of_coverage_t and mean_depth_of_coverage_v < mean_depth_of_coverage_t:
13651408
raise_if_index(
13661409
f"Mean depth of coverage for donor '{pseudonym}', lab datum '{lab_data_name}' "
13671410
f"below threshold: {mean_depth_of_coverage_v} < {mean_depth_of_coverage_t}"
13681411
)
13691412

1370-
if percent_bases_above_quality_threshold_t := thresholds.get("percentBasesAboveQualityThreshold"):
1371-
minimum_quality_t = percent_bases_above_quality_threshold_t.get("qualityThreshold")
1413+
if percent_bases_above_quality_threshold_t := thresholds.percent_bases_above_quality_threshold:
1414+
minimum_quality_t = percent_bases_above_quality_threshold_t.quality_threshold
13721415
minimum_quality_v = sequence_data.percent_bases_above_quality_threshold.minimum_quality
13731416
if minimum_quality_t and (minimum_quality_t != minimum_quality_v):
13741417
# TODO also print out genomic study subtype because that determines thresholds, but is defined at submission level
13751418
# this should error regardless of relation because it should match the official threshold
13761419
raise ValueError(
1377-
f"Expected minimumQuality '{minimum_quality_t}' for library type '{lab_datum.library_type}'"
1420+
f"Expected minimumQuality '{minimum_quality_t}' for library type '{lab_datum.library_type}' "
13781421
f"and sequence subtype '{lab_datum.sequence_subtype}'. Got '{minimum_quality_v}' instead."
13791422
)
1380-
percent_t = percent_bases_above_quality_threshold_t.get("percentBasesAbove")
1423+
percent_t = percent_bases_above_quality_threshold_t.percent_bases_above
13811424
percent_v = sequence_data.percent_bases_above_quality_threshold.percent
13821425
if percent_t and (percent_v < percent_t):
13831426
raise_if_index(
13841427
f"Percentage of bases above quality threshold are below threshold: {percent_v} < {percent_t}"
13851428
)
13861429

1387-
if t := thresholds.get("targetedRegionsAboveMinCoverage"):
1388-
min_coverage_t = t.get("minCoverage")
1430+
if t := thresholds.targeted_regions_above_min_coverage:
1431+
min_coverage_t = t.min_coverage
13891432
min_coverage_v = sequence_data.min_coverage
13901433

1391-
fraction_above_t = t.get("fractionAbove")
1434+
fraction_above_t = t.fraction_above
13921435
fraction_above_v = sequence_data.targeted_regions_above_min_coverage
13931436

13941437
if min_coverage_t and min_coverage_v != min_coverage_t:
@@ -1405,15 +1448,59 @@ def raise_if_index(message: str) -> None:
14051448
)
14061449

14071450

1408-
type Thresholds = dict[tuple[str, str, str], dict[str, Any]]
1451+
class MissingThresholdsException(ValueError):
1452+
"""Exception raised when no thresholds are found for a given combination."""
1453+
1454+
pass
1455+
14091456

1457+
# Dictionary type for thresholds lookup:
1458+
# Keys are (GenomicStudySubtype, LibraryType, SequenceSubtype, is_oncomine_panel)
1459+
_ThresholdsDict = dict[
1460+
tuple[GenomicStudySubtype, LibraryType, SequenceSubtype, bool],
1461+
thresholds_model.Thresholds,
1462+
]
14101463

1411-
def load_thresholds() -> Thresholds:
1412-
threshold_definitions = json.load(
1464+
1465+
class _ThresholdEntry(StrictBaseModel):
1466+
"""Threshold configuration for a particular sequencing setup."""
1467+
1468+
model_config = ConfigDict(populate_by_name=True)
1469+
1470+
library_type: LibraryType = Field(alias="libraryType")
1471+
sequence_subtype: SequenceSubtype = Field(alias="sequenceSubtype")
1472+
genomic_study_subtype: GenomicStudySubtype = Field(alias="genomicStudySubtype")
1473+
thresholds: thresholds_model.Thresholds
1474+
1475+
1476+
@cache
1477+
def _load_thresholds() -> _ThresholdsDict:
1478+
threshold_definitions_json = json.load(
14131479
files("grz_pydantic_models").joinpath("resources", "thresholds.json").open("r", encoding="utf-8")
14141480
)
1415-
threshold_definitions = {
1416-
(d["genomicStudySubtype"], d["libraryType"], d["sequenceSubtype"]): d["thresholds"]
1481+
try:
1482+
threshold_definitions: list[_ThresholdEntry] = [
1483+
_ThresholdEntry.model_validate(item) for item in threshold_definitions_json
1484+
]
1485+
except ValidationError as err:
1486+
raise RuntimeError("Invalid threshold definitions JSON") from err
1487+
1488+
threshold_definitions_dict = {
1489+
(d.genomic_study_subtype, d.library_type, d.sequence_subtype, False): d.thresholds
14171490
for d in threshold_definitions
14181491
}
1419-
return threshold_definitions
1492+
# add thresholds for oncomine panels
1493+
for d in threshold_definitions:
1494+
if d.library_type == LibraryType.panel:
1495+
thresholds = d.thresholds.model_copy(deep=True)
1496+
# we have different thresholds_model.percentBasesAboveQualityThreshold for oncomine panels:
1497+
# percentage of bases ≥ Q20 should be at least 70%
1498+
thresholds.percent_bases_above_quality_threshold = thresholds_model.PercentBasesAboveQualityThreshold(
1499+
qualityThreshold=20,
1500+
percentBasesAbove=70,
1501+
)
1502+
1503+
# add new entry for oncomine panels
1504+
threshold_definitions_dict[(d.genomic_study_subtype, d.library_type, d.sequence_subtype, True)] = thresholds
1505+
1506+
return threshold_definitions_dict
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .v1 import * # noqa: F403

0 commit comments

Comments
 (0)