66import re
77from datetime import date
88from enum import StrEnum
9+ from functools import cache
910from importlib .resources import files
1011from itertools import groupby
1112from operator import attrgetter
1213from pathlib import Path , PurePosixPath
13- from typing import Annotated , Any , Self
14+ from typing import Annotated , Self
1415
1516from pydantic import (
1617 AfterValidator ,
2526
2627from ...common import StrictBaseModel
2728from ...mii .consent import Consent , ProvisionType
29+ from .. import thresholds as thresholds_model
2830from .versioning import Version
2931
3032SCHEMA_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
0 commit comments