Skip to content

Commit c84f451

Browse files
committed
**feat(genomics): Add targeted sequencing support and services**
- Added database schema for `test_type_target_region` to define targeted test regions. - Introduced new test types (`BIG_Y_700`, `Y_ELITE`, `MT_FULL_SEQUENCE`, etc.) with attributes and successor relationships. - Created `TargetedSequencingService` to manage targeted test capabilities, coverage assessments, and upgrade paths. - Updated `TestType` enum and repository with new targeted test types and capabilities. - Implemented unit tests for `TargetedSequencingService` to validate functionality and coverage assessment tiers.
1 parent 84dc6ea commit c84f451

9 files changed

Lines changed: 642 additions & 1 deletion

File tree

app/models/dal/DatabaseSchema.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ object DatabaseSchema {
8181
val specimenDonors = TableQuery[SpecimenDonorsTable]
8282
val validationServices = TableQuery[ValidationServicesTable]
8383
val testTypeDefinition = TableQuery[TestTypeTable]
84+
val testTypeTargetRegions = TableQuery[TestTypeTargetRegionTable]
8485

8586
// Consolidated variant schema (replaces variant + variant_alias)
8687
val variantsV2 = TableQuery[VariantV2Table]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package models.dal.domain.genomics
2+
3+
import models.dal.MyPostgresProfile
4+
import models.dal.MyPostgresProfile.api.*
5+
import models.domain.genomics.TestTypeTargetRegion
6+
import slick.lifted.{ProvenShape, Tag}
7+
8+
class TestTypeTargetRegionTable(tag: Tag)
9+
extends MyPostgresProfile.api.Table[TestTypeTargetRegion](tag, "test_type_target_region") {
10+
11+
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
12+
def testTypeId = column[Int]("test_type_id")
13+
def contigName = column[String]("contig_name")
14+
def startPosition = column[Option[Int]]("start_position")
15+
def endPosition = column[Option[Int]]("end_position")
16+
def regionName = column[String]("region_name")
17+
def regionType = column[String]("region_type")
18+
def expectedCoveragePct = column[Option[Double]]("expected_coverage_pct")
19+
def expectedMinDepth = column[Option[Double]]("expected_min_depth")
20+
21+
override def * : ProvenShape[TestTypeTargetRegion] = (
22+
id.?,
23+
testTypeId,
24+
contigName,
25+
startPosition,
26+
endPosition,
27+
regionName,
28+
regionType,
29+
expectedCoveragePct,
30+
expectedMinDepth
31+
).mapTo[TestTypeTargetRegion]
32+
}

app/models/domain/genomics/TestType.scala

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import play.api.mvc.QueryStringBindable
77
* This enum provides a structured way to classify genomic data based on how it was generated.
88
*/
99
enum TestType {
10-
case WGS, WES, TARGETED_Y, TARGETED_MT, SNP_ARRAY_23ANDME, SNP_ARRAY_ANCESTRY
10+
case WGS, WES, TARGETED_Y, TARGETED_MT, SNP_ARRAY_23ANDME, SNP_ARRAY_ANCESTRY,
11+
BIG_Y_700, BIG_Y_500, Y_ELITE, Y_PRIME, MT_FULL_SEQUENCE, MT_PLUS
1112

1213
override def toString: String = this match {
1314
case WGS => "WGS"
@@ -16,6 +17,12 @@ enum TestType {
1617
case TARGETED_MT => "TARGETED_MT"
1718
case SNP_ARRAY_23ANDME => "SNP_ARRAY_23ANDME"
1819
case SNP_ARRAY_ANCESTRY => "SNP_ARRAY_ANCESTRY"
20+
case BIG_Y_700 => "BIG_Y_700"
21+
case BIG_Y_500 => "BIG_Y_500"
22+
case Y_ELITE => "Y_ELITE"
23+
case Y_PRIME => "Y_PRIME"
24+
case MT_FULL_SEQUENCE => "MT_FULL_SEQUENCE"
25+
case MT_PLUS => "MT_PLUS"
1926
}
2027
}
2128

@@ -36,6 +43,12 @@ object TestType {
3643
case "TARGETED_MT" => Some(TARGETED_MT)
3744
case "SNP_ARRAY_23ANDME" => Some(SNP_ARRAY_23ANDME)
3845
case "SNP_ARRAY_ANCESTRY" => Some(SNP_ARRAY_ANCESTRY)
46+
case "BIG_Y_700" => Some(BIG_Y_700)
47+
case "BIG_Y_500" => Some(BIG_Y_500)
48+
case "Y_ELITE" => Some(Y_ELITE)
49+
case "Y_PRIME" => Some(Y_PRIME)
50+
case "MT_FULL_SEQUENCE" => Some(MT_FULL_SEQUENCE)
51+
case "MT_PLUS" => Some(MT_PLUS)
3952
case _ => None
4053
}
4154

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package models.domain.genomics
2+
3+
import play.api.libs.json.{Json, OFormat}
4+
5+
case class TestTypeTargetRegion(
6+
id: Option[Int] = None,
7+
testTypeId: Int,
8+
contigName: String,
9+
startPosition: Option[Int] = None,
10+
endPosition: Option[Int] = None,
11+
regionName: String,
12+
regionType: String,
13+
expectedCoveragePct: Option[Double] = None,
14+
expectedMinDepth: Option[Double] = None
15+
) {
16+
def regionSize: Option[Int] = for {
17+
s <- startPosition
18+
e <- endPosition
19+
} yield e - s + 1
20+
}
21+
22+
object TestTypeTargetRegion {
23+
implicit val format: OFormat[TestTypeTargetRegion] = Json.format[TestTypeTargetRegion]
24+
}
25+
26+
case class RegionCoverageResult(
27+
regionName: String,
28+
contigName: String,
29+
startPosition: Option[Int],
30+
endPosition: Option[Int],
31+
expectedCoveragePct: Option[Double],
32+
expectedMinDepth: Option[Double],
33+
actualMeanDepth: Option[Double],
34+
actualCoveragePct: Option[Double],
35+
meetsExpectation: Boolean
36+
)
37+
38+
object RegionCoverageResult {
39+
implicit val format: OFormat[RegionCoverageResult] = Json.format[RegionCoverageResult]
40+
}
41+
42+
case class TargetedCoverageAssessment(
43+
testTypeCode: String,
44+
testTypeDisplayName: String,
45+
targetRegions: Seq[RegionCoverageResult],
46+
overallCoveragePct: Double,
47+
overallMeetsExpectation: Boolean,
48+
qualityTier: String
49+
)
50+
51+
object TargetedCoverageAssessment {
52+
implicit val format: OFormat[TargetedCoverageAssessment] = Json.format[TargetedCoverageAssessment]
53+
54+
def qualityTierFromCoverage(coveragePct: Double): String = {
55+
if (coveragePct >= 0.95) "HIGH"
56+
else if (coveragePct >= 0.80) "MEDIUM"
57+
else if (coveragePct >= 0.50) "LOW"
58+
else "INSUFFICIENT"
59+
}
60+
}

app/modules/BaseModule.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ class BaseModule extends AbstractModule {
6262
.to(classOf[InstrumentProposalRepositoryImpl])
6363
.asEagerSingleton()
6464

65+
bind(classOf[TestTypeTargetRegionRepository])
66+
.to(classOf[TestTypeTargetRegionRepositoryImpl])
67+
.asEagerSingleton()
68+
6569
bind(classOf[SequenceFileRepository])
6670
.to(classOf[SequenceFileRepositoryImpl])
6771
.asEagerSingleton()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package repositories
2+
3+
import jakarta.inject.{Inject, Singleton}
4+
import models.dal.DatabaseSchema
5+
import models.domain.genomics.TestTypeTargetRegion
6+
import play.api.db.slick.DatabaseConfigProvider
7+
8+
import scala.concurrent.{ExecutionContext, Future}
9+
10+
trait TestTypeTargetRegionRepository {
11+
def findByTestTypeId(testTypeId: Int): Future[Seq[TestTypeTargetRegion]]
12+
def findByTestTypeCode(code: String): Future[Seq[TestTypeTargetRegion]]
13+
def findByContigName(contigName: String): Future[Seq[TestTypeTargetRegion]]
14+
def create(region: TestTypeTargetRegion): Future[TestTypeTargetRegion]
15+
def delete(id: Int): Future[Boolean]
16+
}
17+
18+
@Singleton
19+
class TestTypeTargetRegionRepositoryImpl @Inject()(
20+
override protected val dbConfigProvider: DatabaseConfigProvider
21+
)(implicit override protected val ec: ExecutionContext)
22+
extends BaseRepository(dbConfigProvider)
23+
with TestTypeTargetRegionRepository {
24+
25+
import models.dal.MyPostgresProfile.api.*
26+
27+
private val regions = DatabaseSchema.domain.genomics.testTypeTargetRegions
28+
private val testTypes = DatabaseSchema.domain.genomics.testTypeDefinition
29+
30+
override def findByTestTypeId(testTypeId: Int): Future[Seq[TestTypeTargetRegion]] = {
31+
db.run(regions.filter(_.testTypeId === testTypeId).result)
32+
}
33+
34+
override def findByTestTypeCode(code: String): Future[Seq[TestTypeTargetRegion]] = {
35+
val query = regions
36+
.join(testTypes).on(_.testTypeId === _.id)
37+
.filter(_._2.code === code)
38+
.map(_._1)
39+
db.run(query.result)
40+
}
41+
42+
override def findByContigName(contigName: String): Future[Seq[TestTypeTargetRegion]] = {
43+
db.run(regions.filter(_.contigName === contigName).result)
44+
}
45+
46+
override def create(region: TestTypeTargetRegion): Future[TestTypeTargetRegion] = {
47+
db.run(
48+
(regions returning regions.map(_.id)
49+
into ((r, id) => r.copy(id = Some(id)))) += region
50+
)
51+
}
52+
53+
override def delete(id: Int): Future[Boolean] = {
54+
db.run(regions.filter(_.id === id).delete.map(_ > 0))
55+
}
56+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package services
2+
3+
import jakarta.inject.{Inject, Singleton}
4+
import models.domain.genomics.*
5+
import play.api.Logging
6+
import repositories.{TestTypeRepository, TestTypeTargetRegionRepository}
7+
8+
import scala.concurrent.{ExecutionContext, Future}
9+
10+
case class TargetedTestCapabilities(
11+
testType: TestTypeRow,
12+
targetRegions: Seq[TestTypeTargetRegion],
13+
supportsYDna: Boolean,
14+
supportsMtDna: Boolean,
15+
primaryContig: Option[String],
16+
totalTargetedBases: Option[Long]
17+
)
18+
19+
@Singleton
20+
class TargetedSequencingService @Inject()(
21+
testTypeRepo: TestTypeRepository,
22+
targetRegionRepo: TestTypeTargetRegionRepository
23+
)(implicit ec: ExecutionContext) extends Logging {
24+
25+
def getTargetedTestCapabilities(testTypeCode: String): Future[Option[TargetedTestCapabilities]] = {
26+
testTypeRepo.findByCode(testTypeCode).flatMap {
27+
case None => Future.successful(None)
28+
case Some(testType) =>
29+
testType.id match {
30+
case None => Future.successful(None)
31+
case Some(ttId) =>
32+
targetRegionRepo.findByTestTypeId(ttId).map { regions =>
33+
val primaryContig = regions.headOption.map(_.contigName)
34+
val totalBases = regions.flatMap(_.regionSize).sum
35+
Some(TargetedTestCapabilities(
36+
testType = testType,
37+
targetRegions = regions,
38+
supportsYDna = testType.supportsHaplogroupY,
39+
supportsMtDna = testType.supportsHaplogroupMt,
40+
primaryContig = primaryContig,
41+
totalTargetedBases = if (totalBases > 0) Some(totalBases.toLong) else None
42+
))
43+
}
44+
}
45+
}
46+
}
47+
48+
def assessCoverage(
49+
testTypeCode: String,
50+
actualMeanDepth: Option[Double],
51+
actualCoveragePct: Option[Double]
52+
): Future[Option[TargetedCoverageAssessment]] = {
53+
testTypeRepo.findByCode(testTypeCode).flatMap {
54+
case None => Future.successful(None)
55+
case Some(testType) =>
56+
testType.id match {
57+
case None => Future.successful(None)
58+
case Some(ttId) =>
59+
targetRegionRepo.findByTestTypeId(ttId).map { regions =>
60+
if (regions.isEmpty) None
61+
else Some(buildAssessment(testType, regions, actualMeanDepth, actualCoveragePct))
62+
}
63+
}
64+
}
65+
}
66+
67+
private[services] def buildAssessment(
68+
testType: TestTypeRow,
69+
regions: Seq[TestTypeTargetRegion],
70+
actualMeanDepth: Option[Double],
71+
actualCoveragePct: Option[Double]
72+
): TargetedCoverageAssessment = {
73+
val regionResults = regions.map { region =>
74+
val meetsDepth = (actualMeanDepth, region.expectedMinDepth) match {
75+
case (Some(actual), Some(expected)) => actual >= expected
76+
case _ => true
77+
}
78+
val meetsCoverage = (actualCoveragePct, region.expectedCoveragePct) match {
79+
case (Some(actual), Some(expected)) => actual >= expected
80+
case _ => true
81+
}
82+
83+
RegionCoverageResult(
84+
regionName = region.regionName,
85+
contigName = region.contigName,
86+
startPosition = region.startPosition,
87+
endPosition = region.endPosition,
88+
expectedCoveragePct = region.expectedCoveragePct,
89+
expectedMinDepth = region.expectedMinDepth,
90+
actualMeanDepth = actualMeanDepth,
91+
actualCoveragePct = actualCoveragePct,
92+
meetsExpectation = meetsDepth && meetsCoverage
93+
)
94+
}
95+
96+
val overallCoverage = actualCoveragePct.getOrElse(0.0)
97+
val allMeet = regionResults.forall(_.meetsExpectation)
98+
99+
TargetedCoverageAssessment(
100+
testTypeCode = testType.code,
101+
testTypeDisplayName = testType.displayName,
102+
targetRegions = regionResults,
103+
overallCoveragePct = overallCoverage,
104+
overallMeetsExpectation = allMeet,
105+
qualityTier = TargetedCoverageAssessment.qualityTierFromCoverage(overallCoverage)
106+
)
107+
}
108+
109+
def getTargetedYTests: Future[Seq[TestTypeRow]] = {
110+
testTypeRepo.findByCapability(supportsY = Some(true)).map { tests =>
111+
tests.filter(_.targetType == TargetType.YChromosome)
112+
}
113+
}
114+
115+
def getTargetedMtTests: Future[Seq[TestTypeRow]] = {
116+
testTypeRepo.findByCapability(supportsMt = Some(true)).map { tests =>
117+
tests.filter(_.targetType == TargetType.MtDna)
118+
}
119+
}
120+
121+
def findUpgradePath(currentTestTypeCode: String): Future[Option[TestTypeRow]] = {
122+
testTypeRepo.findByCode(currentTestTypeCode).flatMap {
123+
case Some(current) if current.successorTestTypeId.isDefined =>
124+
testTypeRepo.getTestTypeRowsByIds(Seq(current.successorTestTypeId.get)).map(_.headOption)
125+
case _ => Future.successful(None)
126+
}
127+
}
128+
129+
def isTargetedTest(testTypeCode: String): Future[Boolean] = {
130+
testTypeRepo.findByCode(testTypeCode).map {
131+
case Some(tt) => tt.targetType == TargetType.YChromosome || tt.targetType == TargetType.MtDna
132+
case None => false
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)