grz-cli 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.
@@ -0,0 +1,845 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: grz-schema.json
3
+ # timestamp: 2024-11-21T12:40:39+00:00
4
+ # version: 0.26.2
5
+ ### Command run:
6
+ ## datamodel-codegen --output-model-type pydantic_v2.BaseModel \
7
+ ## --input src/grz_cli/resources/grz-schema.json \
8
+ ## --use-schema-description --use-field-description --snake-case-field --use-standard-collections --use-union-operator \
9
+ ## --field-constraints --use-annotated --enable-version-header --reuse-model --use-subclass-enum --union-mode smart \
10
+ ## --class-name 'GrzSubmissionMetadata' --use-double-quotes --target-python-version 3.12
11
+ ### Custom modifications:
12
+ ### - disallow extra fields (no additional properties allowed)
13
+ ### - validate_assignment (i.e. when setting properties, re-trigger validations)
14
+ ### - use_enum_values (use values [strings] instead of enum variants)
15
+ ### - remove those aliases which are covered by a global `alias_generator=to_camel` setting
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Generator
20
+ from datetime import date
21
+ from enum import StrEnum
22
+ from pathlib import Path
23
+ from typing import Annotated, Any
24
+
25
+ from pydantic import (
26
+ BaseModel,
27
+ ConfigDict,
28
+ Field,
29
+ StringConstraints,
30
+ model_validator,
31
+ )
32
+ from pydantic.alias_generators import to_camel
33
+
34
+ from grz_cli.file_operations import calculate_sha256 # type: ignore
35
+
36
+
37
+ class StrictBaseModel(BaseModel):
38
+ model_config = ConfigDict(
39
+ extra="forbid",
40
+ validate_assignment=True,
41
+ use_enum_values=True,
42
+ alias_generator=to_camel,
43
+ )
44
+
45
+
46
+ class SubmissionType(StrEnum):
47
+ """
48
+ The options are: 'initial' for first submission, 'followup' is for followup submissions, 'addition' for additional submission, 'correction' for correction
49
+ """
50
+
51
+ initial = "initial"
52
+ followup = "followup"
53
+ addition = "addition"
54
+ correction = "correction"
55
+
56
+
57
+ class GenomicStudyType(StrEnum):
58
+ """
59
+ whether additional persons are tested as well
60
+ """
61
+
62
+ single = "single"
63
+ duo = "duo"
64
+ trio = "trio"
65
+
66
+
67
+ class GenomicStudySubtype(StrEnum):
68
+ """
69
+ whether tumor and/or germ-line are tested
70
+ """
71
+
72
+ tumor_only = "tumor-only"
73
+ tumor_germline = "tumor+germline"
74
+ germline_only = "germline-only"
75
+
76
+
77
+ class Submission(StrictBaseModel):
78
+ submission_date: date
79
+ """
80
+ Date of submission in ISO 8601 format YYYY-MM-DD
81
+ """
82
+
83
+ submission_type: SubmissionType
84
+ """
85
+ The options are: 'initial' for first submission, 'followup' is for followup submissions, 'addition' for additional submission, 'correction' for correction
86
+ """
87
+
88
+ tan_g: Annotated[str, StringConstraints(pattern=r"^[a-fA-F0-9]{32}$")]
89
+ """
90
+ The VNg of the genomic data of the patient that will be reimbursed --> a unique 32-byte hex ID.
91
+ """
92
+
93
+ local_case_id: str | None = None
94
+ """
95
+ A local case identifier for synchronizing locally
96
+ """
97
+
98
+ submitter_id: Annotated[str, StringConstraints(pattern=r"^[0-9]{9}$")]
99
+ """
100
+ Institutional ID of the submitter according to §293 SGB V.
101
+ """
102
+
103
+ genomic_data_center_id: Annotated[
104
+ str,
105
+ StringConstraints(pattern=r"^(GRZ|KDK)[A-Z0-9]{3}[0-9]{3}$"),
106
+ ]
107
+ """
108
+ ID of the genomic data center in the format GRZXXXnnn.
109
+ """
110
+
111
+ clinical_data_node_id: Annotated[
112
+ str, StringConstraints(pattern=r"^(GRZ|KDK)[A-Z0-9]{3}[0-9]{3}$")
113
+ ]
114
+ """
115
+ ID of the genomic data center in the format KDKXXXnnn.
116
+ """
117
+
118
+ genomic_study_type: GenomicStudyType
119
+ """
120
+ whether additional persons are tested as well
121
+ """
122
+
123
+ genomic_study_subtype: GenomicStudySubtype
124
+ """
125
+ whether tumor and/or germ-line are tested
126
+ """
127
+
128
+ lab_name: str
129
+ """
130
+ Name of the sequencing lab.
131
+ """
132
+
133
+
134
+ class Sex(StrEnum):
135
+ """
136
+ Sex of the donor.
137
+ """
138
+
139
+ male = "male"
140
+ female = "female"
141
+ other = "other"
142
+ unknown = "unknown"
143
+
144
+
145
+ class Relation(StrEnum):
146
+ """
147
+ Relationship of the donor in respect to the index patient, e.g. 'index', 'brother', 'mother', etc.
148
+ """
149
+
150
+ mother = "mother"
151
+ father = "father"
152
+ brother = "brother"
153
+ sister = "sister"
154
+ child = "child"
155
+ self = "self"
156
+
157
+
158
+ class MvConsentScope(StrictBaseModel):
159
+ """
160
+ The scope of the Modellvorhaben consent given by the donor.
161
+ """
162
+
163
+ consent_mv_sequencing: bool
164
+ """
165
+ The donor's consent to participate in the Modellvorhaben and sequencing.
166
+ """
167
+
168
+ consent_case_identification: bool
169
+ """
170
+ The donor's consent to identify the case.
171
+ """
172
+
173
+ consent_re_identification: bool
174
+ """
175
+ The donor's consent to be re-identified and to be re-contacted in case of new findings.
176
+ """
177
+
178
+
179
+ class MvConsentTerminationScope(StrictBaseModel):
180
+ """
181
+ The scope of the termination of the Modellvorhaben consent.
182
+ """
183
+
184
+ consent_mv_sequencing_termination: bool | None = None
185
+ """
186
+ The patient's termination of the Modellvorhaben consent to participate in the Modellvorhaben and sequencing
187
+ """
188
+
189
+ consent_case_identification_termination: bool | None = None
190
+ """
191
+ The patient's termination of the Modellvorhaben consent to identify the case
192
+ """
193
+
194
+ consent_re_identification_termination: bool | None = None
195
+ """
196
+ The patient's termination of the Modellvorhaben consent to re-identify the case and to re-contact the patient in case of new findings
197
+ """
198
+
199
+
200
+ class TissueOntology(StrictBaseModel):
201
+ name: str
202
+ """
203
+ Name of the tissue ontology
204
+ """
205
+
206
+ version: str
207
+ """
208
+ Version of the tissue ontology
209
+ """
210
+
211
+
212
+ class SampleConservation(StrEnum):
213
+ """
214
+ Sample conservation
215
+ """
216
+
217
+ fresh_tissue = "fresh-tissue"
218
+ cryo_frozen = "cryo-frozen"
219
+ ffpe = "ffpe"
220
+ other = "other"
221
+ unknown = "unknown"
222
+
223
+
224
+ class SequenceType(StrEnum):
225
+ """
226
+ Type of sequence (DNA or RNA)
227
+ """
228
+
229
+ dna = "dna"
230
+ rna = "rna"
231
+
232
+
233
+ class SequenceSubtype(StrEnum):
234
+ """
235
+ Subtype of sequence (germline, somatic, etc.)
236
+ """
237
+
238
+ germline = "germline"
239
+ somatic = "somatic"
240
+ other = "other"
241
+ unknown = "unknown"
242
+
243
+
244
+ class FragmentationMethod(StrEnum):
245
+ """
246
+ Fragmentation method
247
+ """
248
+
249
+ sonication = "sonication"
250
+ enzymatic = "enzymatic"
251
+ none = "none"
252
+ other = "other"
253
+ unknown = "unknown"
254
+
255
+
256
+ class LibraryType(StrEnum):
257
+ """
258
+ Library type
259
+ """
260
+
261
+ panel = "panel"
262
+ wes = "wes"
263
+ wgs = "wgs"
264
+ wgs_lr = "wgs_lr"
265
+ wxs = "wxs"
266
+ other = "other"
267
+ unknown = "unknown"
268
+
269
+
270
+ class EnrichmentKitManufacturer(StrEnum):
271
+ """
272
+ Manufacturer of the enrichment kit
273
+ """
274
+
275
+ illumina = "Illumina"
276
+ agilent = "Agilent"
277
+ twist = "Twist"
278
+ neb = "NEB"
279
+ other = "other"
280
+ unknown = "unknown"
281
+
282
+
283
+ class SequencingLayout(StrEnum):
284
+ """
285
+ The sequencing layout, aka the end type of sequencing.
286
+ """
287
+
288
+ single_end = "single-end"
289
+ paired_end = "paired-end"
290
+ reverse = "reverse"
291
+ other = "other"
292
+
293
+
294
+ class TumorCellCountMethod(StrEnum):
295
+ """
296
+ Method used to determine cell count.
297
+ """
298
+
299
+ pathology = "Pathology"
300
+ bioinformatics = "Bioinformatics"
301
+ other = "other"
302
+ unknown = "unknown"
303
+
304
+
305
+ class CallerUsedItem(StrictBaseModel):
306
+ name: str
307
+ """
308
+ Name of the caller used
309
+ """
310
+
311
+ version: str
312
+ """
313
+ Version of the caller used
314
+ """
315
+
316
+
317
+ class FileType(StrEnum):
318
+ """
319
+ Type of the file; if BED file is submitted, only 1 file is allowed.
320
+ """
321
+
322
+ bam = "bam"
323
+ vcf = "vcf"
324
+ bed = "bed"
325
+ fastq = "fastq"
326
+
327
+
328
+ class ChecksumType(StrEnum):
329
+ """
330
+ Type of checksum algorithm used
331
+ """
332
+
333
+ sha256 = "sha256"
334
+
335
+
336
+ class ReadOrder(StrEnum):
337
+ """
338
+ Indicates the read order for paired-end reads.
339
+ """
340
+
341
+ r1 = "R1"
342
+ r2 = "R2"
343
+
344
+
345
+ class File(StrictBaseModel):
346
+ file_path: str
347
+ """
348
+ Path relative to the submission root, e.g.: sequencing_data/patient_001/patient_001_dna.bam
349
+ """
350
+
351
+ file_type: FileType
352
+ """
353
+ Type of the file; if BED file is submitted, only 1 file is allowed.
354
+ """
355
+
356
+ checksum_type: ChecksumType | None = ChecksumType.sha256
357
+ """
358
+ Type of checksum algorithm used
359
+ """
360
+
361
+ file_checksum: Annotated[str, StringConstraints(pattern=r"^[a-fA-F0-9]{64}$")]
362
+ """
363
+ checksum of the file
364
+ """
365
+
366
+ file_size_in_bytes: Annotated[int, Field(strict=True, ge=0)]
367
+ """
368
+ Size of the file in bytes
369
+ """
370
+
371
+ read_order: ReadOrder | None = None
372
+ """
373
+ Indicates the read order for paired-end reads.
374
+ """
375
+
376
+ flowcell_id: str | None = None
377
+ """
378
+ Indicates the flow cell.
379
+ """
380
+
381
+ lane_id: str | None = None
382
+ """
383
+ Indicates the lane
384
+ """
385
+
386
+ def validate_data(self, local_file_path: Path) -> Generator[str]:
387
+ """
388
+ Validates whether the provided file matches this metadata.
389
+
390
+ :param local_file_path: Path to the actual file (resolved if symlinked)
391
+ :return: Generator of errors
392
+ """
393
+ # Resolve file path
394
+ local_file_path = local_file_path.resolve()
395
+
396
+ # Check if path exists
397
+ if not local_file_path.exists():
398
+ yield f"{str(self.file_path)} does not exist!"
399
+ # Return here as following tests cannot work
400
+ return
401
+
402
+ # Check if path is a file
403
+ if not local_file_path.is_file():
404
+ yield f"{str(self.file_path)} is not a file!"
405
+ # Return here as following tests cannot work
406
+ return
407
+
408
+ # Check if the checksum is correct
409
+ if self.checksum_type == "sha256":
410
+ calculated_checksum = calculate_sha256(local_file_path)
411
+ if self.file_checksum != calculated_checksum:
412
+ yield (
413
+ f"{str(self.file_path)}: Checksum mismatch! "
414
+ f"Expected: '{self.file_checksum}', calculated: '{calculated_checksum}'."
415
+ )
416
+ else:
417
+ yield (
418
+ f"{str(self.file_path)}: Unsupported checksum type: {self.checksum_type}. "
419
+ f"Supported types: {[e.value for e in ChecksumType]}"
420
+ )
421
+
422
+ # Check file size
423
+ if self.file_size_in_bytes != local_file_path.stat().st_size:
424
+ yield (
425
+ f"{str(self.file_path)}: File size mismatch! "
426
+ f"Expected: '{self.file_size_in_bytes}', observed: '{local_file_path.stat().st_size}'."
427
+ )
428
+
429
+ def encrypted_file_path(self):
430
+ return self.file_path + ".c4gh"
431
+
432
+
433
+ class SequenceData(StrictBaseModel):
434
+ bioinformatics_pipeline_name: str
435
+ """
436
+ Name of the bioinformatics pipeline used
437
+ """
438
+
439
+ bioinformatics_pipeline_version: str
440
+ """
441
+ Version or commit hash of the bioinformatics pipeline
442
+ """
443
+
444
+ reference_genome: str
445
+ """
446
+ Reference genome used
447
+ """
448
+
449
+ percent_bases_q30: Annotated[float, Field(strict=True, ge=0.0, le=100.0)]
450
+ """
451
+ Percentage of bases with Q30 or higher
452
+ """
453
+
454
+ mean_depth_of_coverage: Annotated[float, Field(strict=True, ge=0.0)]
455
+ """
456
+ Mean depth of coverage
457
+ """
458
+
459
+ min_coverage: Annotated[float, Field(strict=True, ge=0.0)]
460
+ """
461
+ Minimum coverage
462
+ """
463
+
464
+ targeted_regions_above_min_coverage: Annotated[
465
+ float, Field(strict=True, ge=0.0, le=1.0)
466
+ ]
467
+ """
468
+ Fraction of targeted regions that are above minimum coverage
469
+ """
470
+
471
+ read_length: Annotated[int, Field(strict=True, ge=0)]
472
+ """
473
+ The read length; in the case of long-read sequencing it is the rounded average read length.
474
+ """
475
+
476
+ non_coding_variants: bool
477
+ """
478
+ The analysis includes non-coding variants -> true or false
479
+ """
480
+
481
+ caller_used: list[CallerUsedItem]
482
+ """
483
+ Caller that is used in the pipeline
484
+ """
485
+
486
+ files: list[File]
487
+ """
488
+ List of files generated and required in this analysis.
489
+ """
490
+
491
+
492
+ class LabDatum(StrictBaseModel):
493
+ lab_data_name: str
494
+ """
495
+ Name/ID of the biospecimen e.g. 'Blut DNA normal'
496
+ """
497
+
498
+ tissue_ontology: TissueOntology
499
+
500
+ tissue_type_id: str
501
+ """
502
+ Tissue ID according to the ontology in use.
503
+ """
504
+
505
+ tissue_type_name: str
506
+ """
507
+ Tissue name according to the ontology in use.
508
+ """
509
+
510
+ sample_date: date
511
+ """
512
+ Date of sample in ISO 8601 format YYYY-MM-DD
513
+ """
514
+
515
+ sample_conservation: SampleConservation
516
+ """
517
+ Sample conservation
518
+ """
519
+
520
+ sequence_type: SequenceType
521
+ """
522
+ Type of sequence (DNA or RNA)
523
+ """
524
+
525
+ sequence_subtype: SequenceSubtype
526
+ """
527
+ Subtype of sequence (germline, somatic, etc.)
528
+ """
529
+
530
+ fragmentation_method: FragmentationMethod
531
+ """
532
+ Fragmentation method
533
+ """
534
+
535
+ library_type: LibraryType
536
+ """
537
+ Library type
538
+ """
539
+
540
+ library_prep_kit: str
541
+ """
542
+ Name/version of the library prepkit
543
+ """
544
+
545
+ library_prep_kit_manufacturer: str
546
+ """
547
+ Library prep kit manufacturer
548
+ """
549
+
550
+ sequencer_model: str
551
+ """
552
+ Name/version of the sequencer model
553
+ """
554
+
555
+ sequencer_manufacturer: str
556
+ """
557
+ Sequencer manufacturer
558
+ """
559
+
560
+ kit_name: str
561
+ """
562
+ Name/version of the sequencing kit
563
+ """
564
+
565
+ kit_manufacturer: str
566
+ """
567
+ Sequencing kit manufacturer
568
+ """
569
+
570
+ enrichment_kit_manufacturer: EnrichmentKitManufacturer
571
+ """
572
+ Manufacturer of the enrichment kit
573
+ """
574
+
575
+ enrichment_kit_description: str
576
+ """
577
+ Name/version of the enrichment kit
578
+ """
579
+
580
+ barcode: str
581
+ """
582
+ The barcode used or 'na'
583
+ """
584
+
585
+ sequencing_layout: SequencingLayout
586
+ """
587
+ The sequencing layout, aka the end type of sequencing.
588
+ """
589
+
590
+ tumor_cell_count: Annotated[
591
+ float | None, Field(alias="tumorCellCount", ge=0.0, le=100.0)
592
+ ] = None
593
+ """
594
+ Tumor cell count in %
595
+ """
596
+
597
+ tumor_cell_count_method: TumorCellCountMethod | None = None
598
+ """
599
+ Method used to determine cell count.
600
+ """
601
+
602
+ sequence_data: SequenceData
603
+ """
604
+ Sequence data generated from the wet lab experiment.
605
+ """
606
+
607
+
608
+ class Donor(StrictBaseModel):
609
+ case_id: Annotated[str, StringConstraints(pattern=r"^[a-fA-F0-9]{32}$")]
610
+ """
611
+ The VNg of the genomic data of the donor --> a unique 32-byte hex ID.
612
+ """
613
+
614
+ sex: Sex
615
+ """
616
+ Sex of the donor.
617
+ """
618
+
619
+ relation: Relation
620
+ """
621
+ Relationship of the donor in respect to the index patient, e.g. 'index', 'brother', 'mother', etc.
622
+ """
623
+
624
+ mv_consent_date: date
625
+ """
626
+ Date of the signature of the Modellvorhaben consent; Date in ISO 8601 format YYYY-MM-DD
627
+ """
628
+
629
+ mv_consent_presented_date: date | None = None
630
+ """
631
+ Date of the presentation of the Modellvorhaben consent; Date in ISO 8601 format YYYY-MM-DD
632
+ """
633
+
634
+ mv_consent_version: str
635
+ """
636
+ Version of the research consent
637
+ """
638
+
639
+ mv_consent_scope: MvConsentScope
640
+ """
641
+ The scope of the Modellvorhaben consent given by the donor.
642
+ """
643
+
644
+ mv_consent_termination_date: date | None = None
645
+ """
646
+ Date of the termination of the Modellvorhaben consent; Date in ISO 8601 format YYYY-MM-DD
647
+ """
648
+
649
+ mv_consent_termination_scope: MvConsentTerminationScope | None = None
650
+ """
651
+ The scope of the termination of the Modellvorhaben consent.
652
+ """
653
+
654
+ research_consent_date: date | None = None
655
+ """
656
+ Date of the signature of the research consent; Date in ISO 8601 format YYYY-MM-DD
657
+ """
658
+
659
+ research_consent_presented_date: date | None = None
660
+ """
661
+ Date of the presentation of the research consent; Date in ISO 8601 format YYYY-MM-DD
662
+ """
663
+
664
+ research_consent_version: str | None = None
665
+ """
666
+ Version of the research consent
667
+ """
668
+
669
+ research_consent_scope: dict[str, Any] | None = None
670
+ """
671
+ Scope of the research consent given by the patient; shall be given in JSON format.
672
+ """
673
+
674
+ research_consent_revocation_date: date | None = None
675
+ """
676
+ Date of the revocation of the research consent; Date in ISO 8601 format YYYY-MM-DD
677
+ """
678
+
679
+ research_consent_revocation_scope: dict[str, Any] | None = None
680
+ """
681
+ Scope of the revocation of the research consent given by the patient; shall be given in JSON format
682
+ """
683
+
684
+ lab_data: list[LabDatum]
685
+ """
686
+ Lab data related to the donor.
687
+ """
688
+
689
+ @model_validator(mode="after")
690
+ def validate_target_bed_files_exist(self):
691
+ """
692
+ Check if the submission has the required bed files for panel sequencing.
693
+ """
694
+
695
+ def contains_bed_files(sequence_data: SequenceData) -> bool:
696
+ return any(f.file_type == FileType.bed for f in sequence_data.files)
697
+
698
+ lib_type = {LibraryType.panel, LibraryType.wes}
699
+
700
+ for lab_datum in self.lab_data:
701
+ if lab_datum.library_type in lib_type and not contains_bed_files(
702
+ lab_datum.sequence_data
703
+ ):
704
+ raise ValueError(
705
+ f"BED file missing for lab datum '{lab_datum.lab_data_name}' in donor '{self.case_id}'."
706
+ )
707
+
708
+ return self
709
+
710
+ @model_validator(mode="after")
711
+ def validate_vcf_file_exists(self):
712
+ """
713
+ check if there is a VCF file
714
+ """
715
+
716
+ def contains_vcf_files(sequence_data: SequenceData) -> bool:
717
+ return any(f.file_type == FileType.vcf for f in sequence_data.files)
718
+
719
+ for lab_datum in self.lab_data:
720
+ if not contains_vcf_files(lab_datum.sequence_data):
721
+ raise ValueError(
722
+ f"VCF file missing for lab datum '{lab_datum.lab_data_name}' in donor '{self.case_id}'."
723
+ )
724
+
725
+ return self
726
+
727
+ @model_validator(mode="after")
728
+ def validate_fastq_file_exists(self):
729
+ """
730
+ check if there is a FASTQ file
731
+ """
732
+
733
+ def get_fastq_files(sequence_data: SequenceData):
734
+ return [f for f in sequence_data.files if f.file_type == FileType.fastq]
735
+
736
+ for lab_datum in self.lab_data:
737
+ fastq_files = get_fastq_files(lab_datum.sequence_data)
738
+
739
+ if len(fastq_files) == 0:
740
+ raise ValueError("No FASTQ file found!")
741
+ elif lab_datum.sequencing_layout == SequencingLayout.paired_end:
742
+ # check if read order is specified
743
+ for i in fastq_files:
744
+ if i.read_order is None:
745
+ raise ValueError(
746
+ f"Error in lab datum '{lab_datum.lab_data_name}' of donor '{self.case_id}': "
747
+ f"No read order specified for FASTQ file '{i.file_path}'!"
748
+ )
749
+
750
+ # check if there is an equal number of R1 and R2 files
751
+ r1_fastq_files = [
752
+ i for i in fastq_files if i.read_order == ReadOrder.r1
753
+ ]
754
+ r2_fastq_files = [
755
+ i for i in fastq_files if i.read_order == ReadOrder.r2
756
+ ]
757
+
758
+ if len(r1_fastq_files) != len(r2_fastq_files):
759
+ raise ValueError(
760
+ f"Error in lab datum '{lab_datum.lab_data_name}' of donor '{self.case_id}': "
761
+ f"Paired end sequencing layout but number of R1 FASTQ files ({len(r1_fastq_files)})"
762
+ f" differs from number of R2 FASTQ files ({len(r2_fastq_files)})!"
763
+ )
764
+ return self
765
+
766
+
767
+ class GrzSubmissionMetadata(StrictBaseModel):
768
+ """
769
+ General metadata schema for submissions to the GRZ
770
+ """
771
+
772
+ submission: Submission
773
+
774
+ donors: list[Donor]
775
+ """
776
+ List of donors including the index patient.
777
+ """
778
+
779
+ @model_validator(mode="after")
780
+ def validate_donor_count(self):
781
+ """
782
+ Check whether the submission has the required number of donors based on the study type.
783
+ """
784
+ study_type = self.submission.genomic_study_type
785
+
786
+ match study_type:
787
+ case GenomicStudyType.single:
788
+ # Check if the submission has at least one donor
789
+ if not self.donors:
790
+ raise ValueError(
791
+ "At least one donor is required for a single study."
792
+ )
793
+ case GenomicStudyType.duo:
794
+ # Check if the submission has at least two donors
795
+ if len(self.donors) < 2:
796
+ raise ValueError(
797
+ "At least two donors are required for a duo study."
798
+ )
799
+ case GenomicStudyType.trio:
800
+ # Check if the submission has at least three donors
801
+ if len(self.donors) < 3:
802
+ raise ValueError(
803
+ "At least three donors are required for a trio study."
804
+ )
805
+
806
+ return self
807
+
808
+ @model_validator(mode="after")
809
+ def validate_minimum_mean_coverage(self):
810
+ """
811
+ Check if the submission meets the minimum mean coverage requirements.
812
+ """
813
+ thresholds_somatic = {
814
+ (LibraryType.panel, SequenceSubtype.somatic): 500,
815
+ (LibraryType.wes, SequenceSubtype.somatic): 250,
816
+ (LibraryType.wes, SequenceSubtype.germline): 100,
817
+ (LibraryType.wgs, SequenceSubtype.somatic): 80,
818
+ (LibraryType.wgs, SequenceSubtype.germline): 40,
819
+ }
820
+ thresholds_rare_disease = {(LibraryType.wgs, SequenceSubtype.germline): 30}
821
+
822
+ study_subtype = self.submission.genomic_study_subtype
823
+ thresholds = (
824
+ thresholds_rare_disease
825
+ if study_subtype == GenomicStudySubtype.germline_only
826
+ else thresholds_somatic
827
+ )
828
+
829
+ for donor in self.donors:
830
+ for lab_datum in donor.lab_data:
831
+ threshold = thresholds.get(
832
+ (lab_datum.library_type, lab_datum.sequence_subtype)
833
+ )
834
+ if not threshold:
835
+ raise ValueError(
836
+ f"Threshold for {lab_datum.library_type} {lab_datum.sequence_subtype} not found."
837
+ )
838
+ sequence_data = lab_datum.sequence_data
839
+ if sequence_data.mean_depth_of_coverage < threshold:
840
+ raise ValueError(
841
+ f"Mean depth of coverage for donor '{donor.case_id}', lab datum '{lab_datum.lab_data_name}' "
842
+ f"below threshold ({threshold}): {sequence_data.mean_depth_of_coverage}"
843
+ )
844
+
845
+ return self