Skip to content

Commit

Permalink
Add CVD Risk Calculation sheet (#5155)
Browse files Browse the repository at this point in the history
  • Loading branch information
siddh1004 and Siddharth Agarwal authored Jan 8, 2025
1 parent 8426795 commit 278da5b
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.simple.clinic.cvdrisk

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
class CVDRiskCalculationSheet(
val women: CVDRiskCategory,
val men: CVDRiskCategory,
)

@JsonClass(generateAdapter = true)
data class CVDRiskCategory(
val smoking: SmokingData,

@Json(name = "nonsmoking")
val nonSmoking: SmokingData
)

@JsonClass(generateAdapter = true)
data class SmokingData(
@Json(name = "40 - 44") val age40to44: List<RiskEntry>?,
@Json(name = "45 - 49") val age45to49: List<RiskEntry>?,
@Json(name = "50 - 54") val age50to54: List<RiskEntry>?,
@Json(name = "55 - 59") val age55to59: List<RiskEntry>?,
@Json(name = "60 - 64") val age60to64: List<RiskEntry>?,
@Json(name = "65 - 69") val age65to69: List<RiskEntry>?,
@Json(name = "70 - 74") val age70to74: List<RiskEntry>?
)

@JsonClass(generateAdapter = true)
data class RiskEntry(
@Json(name = "sbp") val systolic: String,
val bmi: String,
val risk: Int
)
93 changes: 93 additions & 0 deletions app/src/main/java/org/simple/clinic/cvdrisk/CVDRiskCalculator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package org.simple.clinic.cvdrisk

import org.simple.clinic.di.AppScope
import org.simple.clinic.medicalhistory.Answer
import org.simple.clinic.patient.Gender
import javax.inject.Inject

@AppScope
class CVDRiskCalculator @Inject constructor(
private val cvdRiskCalculationSheet: Lazy<CVDRiskCalculationSheet>,
) {

fun calculateCvdRisk(cvdRiskInput: CVDRiskInput): String? {
with(cvdRiskInput) {
val riskEntries = getRiskEntries(cvdRiskInput) ?: return null

val systolicRange = getSystolicRange(systolic)
val bmiRangeList = getBMIRangeList(bmi)
val risks = riskEntries.filter { it.systolic == systolicRange && it.bmi in bmiRangeList }.map { it.risk }
return formatRisk(risks)
}
}

private fun getRiskEntries(cvdRiskInput: CVDRiskInput): List<RiskEntry>? {
with(cvdRiskInput) {
val sheet = cvdRiskCalculationSheet.value
val genderData = getGenderData(sheet, gender)
val smokingDataList = genderData?.let { getSmokingDataList(it, isSmoker) }
return smokingDataList?.let { getAgeRange(smokingDataList, age) }
}
}

private fun getGenderData(cvdRiskData: CVDRiskCalculationSheet, gender: Gender) = when (gender) {
Gender.Female -> cvdRiskData.women
Gender.Male -> cvdRiskData.men
else -> null
}

private fun getSmokingDataList(genderData: CVDRiskCategory, isSmoker: Answer) = when (isSmoker) {
Answer.Yes -> listOf(genderData.smoking)
Answer.No -> listOf(genderData.nonSmoking)
else -> listOf(genderData.nonSmoking, genderData.smoking)
}

private fun getAgeRange(smokingDataList: List<SmokingData>, age: Int): List<RiskEntry>? {
val ageToRiskMapping = mapOf(
40..44 to { data: SmokingData -> data.age40to44 },
45..49 to { data: SmokingData -> data.age45to49 },
50..54 to { data: SmokingData -> data.age50to54 },
55..59 to { data: SmokingData -> data.age55to59 },
60..64 to { data: SmokingData -> data.age60to64 },
65..69 to { data: SmokingData -> data.age65to69 },
70..74 to { data: SmokingData -> data.age70to74 }
)

val riskExtractor = ageToRiskMapping.entries
.firstOrNull { age in it.key }
?.value ?: return null

return smokingDataList.mapNotNull(riskExtractor).flatten()
}

private fun getSystolicRange(sbp: Int) = when (sbp) {
in 0..119 -> "120-"
in 120..139 -> "120 - 139"
in 140..159 -> "140 - 159"
in 160..179 -> "160 - 179"
else -> "180+"
}

private fun getBMIRangeList(bmi: Float?): List<String> {
return bmi?.let { listOf(getBMIRange(it)) }
?: listOf("20-", "20 - 24", "25 - 29", "30 - 35", "35+")
}

private fun getBMIRange(bmi: Float): String {
return when (bmi) {
in 0.0..19.9 -> "20-"
in 20.0..24.9 -> "20 - 24"
in 25.0..29.9 -> "25 - 29"
in 30.0..34.9 -> "30 - 35"
else -> "35+"
}
}

private fun formatRisk(risks: List<Int>): String? {
return when {
risks.isEmpty() -> null
risks.size == 1 -> risks.first().toString()
else -> "${risks.minOrNull()} - ${risks.maxOrNull()}"
}
}
}
12 changes: 12 additions & 0 deletions app/src/main/java/org/simple/clinic/cvdrisk/CVDRiskInput.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.simple.clinic.cvdrisk

import org.simple.clinic.medicalhistory.Answer
import org.simple.clinic.patient.Gender

data class CVDRiskInput(
val gender: Gender,
val age: Int,
val systolic: Int,
val isSmoker: Answer = Answer.Unanswered,
val bmi: Float? = null
)
21 changes: 21 additions & 0 deletions app/src/main/java/org/simple/clinic/cvdrisk/CVDRiskModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ package org.simple.clinic.cvdrisk

import com.f2prateek.rx.preferences2.Preference
import com.f2prateek.rx.preferences2.RxSharedPreferences
import com.google.gson.JsonObject
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.Module
import dagger.Provides
import org.simple.clinic.AppDatabase
import org.simple.clinic.cvdrisk.sync.CVDRiskSyncApi
import org.simple.clinic.di.AppScope
import org.simple.clinic.drugs.DiagnosisWarningPrescriptions
import org.simple.clinic.main.TypedPreference
import org.simple.clinic.main.TypedPreference.Type.LastCVDRiskPullToken
import org.simple.clinic.platform.crash.CrashReporter
import org.simple.clinic.remoteconfig.ConfigReader
import org.simple.clinic.util.preference.StringPreferenceConverter
import org.simple.clinic.util.preference.getOptional
import retrofit2.Retrofit
Expand All @@ -31,4 +38,18 @@ class CVDRiskModule {
fun lastPullToken(rxSharedPrefs: RxSharedPreferences): Preference<Optional<String>> {
return rxSharedPrefs.getOptional("last_cvd_risk_pull_token", StringPreferenceConverter())
}

@Provides
@OptIn(ExperimentalStdlibApi::class)
fun cvdRiskCalculationSheet(moshi: Moshi, configReader: ConfigReader): CVDRiskCalculationSheet? {
val adapter = moshi.adapter<CVDRiskCalculationSheet>()
val json = configReader.string("cvd_risk_calculation_sheet_v0", "{}")

return try {
adapter.fromJson(json)
} catch (e: Throwable) {
CrashReporter.report(e)
null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package org.simple.clinic.cvdrisk

import org.junit.Assert.assertEquals
import org.junit.Test
import org.simple.clinic.medicalhistory.Answer
import org.simple.clinic.patient.Gender
import org.simple.sharedTestCode.TestData

class CVDRiskCalculatorTest {

private val cvdRiskCalculationSheet = TestData.cvdRiskCalculationSheet()
private val cvdRiskCalculator = CVDRiskCalculator(
cvdRiskCalculationSheet = lazy { cvdRiskCalculationSheet }
)

@Test
fun `should return exact risk for matching data`() {
val cvdRiskInput = CVDRiskInput(
gender = Gender.Female,
age = 40,
systolic = 130,
isSmoker = Answer.Yes,
bmi = 27f
)

val risk = cvdRiskCalculator.calculateCvdRisk(cvdRiskInput)
assertEquals("5", risk)
}

@Test
fun `should return risk range when bmi is not specified`() {
val cvdRiskInput = CVDRiskInput(
gender = Gender.Female,
age = 40,
systolic = 130,
isSmoker = Answer.Yes,
bmi = null
)
val risk = cvdRiskCalculator.calculateCvdRisk(cvdRiskInput)
assertEquals("5 - 6", risk)
}

@Test
fun `should return null when no matching data`() {
val cvdRiskInput = CVDRiskInput(
gender = Gender.Male,
age = 80,
systolic = 200,
isSmoker = Answer.Yes,
bmi = 40f
)
val risk = cvdRiskCalculator.calculateCvdRisk(cvdRiskInput)
assertEquals(null, risk)
}

@Test
fun `should handle nonsmoking data correctly`() {
val cvdRiskInput = CVDRiskInput(
gender = Gender.Male,
age = 40,
systolic = 125,
isSmoker = Answer.No,
bmi = 27f
)
val risk = cvdRiskCalculator.calculateCvdRisk(cvdRiskInput)
assertEquals("3", risk)
}

@Test
fun `should return risk range when smoking is unanswered`() {
val cvdRiskInput = CVDRiskInput(
gender = Gender.Male,
age = 40,
systolic = 125,
isSmoker = Answer.Unanswered,
bmi = 27f
)
val risk = cvdRiskCalculator.calculateCvdRisk(cvdRiskInput)
assertEquals("3 - 6", risk)
}

@Test
fun `should return risk range when bmi and smoking is not specified`() {
val cvdRiskInput = CVDRiskInput(
gender = Gender.Male,
age = 40,
systolic = 125,
)
val risk = cvdRiskCalculator.calculateCvdRisk(cvdRiskInput)
assertEquals("3 - 8", risk)
}
}

85 changes: 85 additions & 0 deletions sharedTestCode/src/main/java/org/simple/sharedTestCode/TestData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import org.simple.clinic.bp.BloodPressureReading
import org.simple.clinic.bp.sync.BloodPressureMeasurementPayload
import org.simple.clinic.contactpatient.ContactPatientProfile
import org.simple.clinic.cvdrisk.CVDRisk
import org.simple.clinic.cvdrisk.CVDRiskCalculationSheet
import org.simple.clinic.cvdrisk.CVDRiskCategory
import org.simple.clinic.cvdrisk.RiskEntry
import org.simple.clinic.cvdrisk.SmokingData
import org.simple.clinic.drugs.PrescribedDrug
import org.simple.clinic.drugs.search.Answer.Yes
import org.simple.clinic.drugs.search.Drug
Expand Down Expand Up @@ -1687,4 +1691,85 @@ object TestData {
syncStatus = syncStatus
)
}

fun cvdRiskCalculationSheet(): CVDRiskCalculationSheet {
val smokingDataWomen = SmokingData(
age40to44 = listOf(
RiskEntry(systolic = "180+", bmi = "20-", risk = 11),
RiskEntry(systolic = "160 - 179", bmi = "20 - 24", risk = 9),
RiskEntry(systolic = "120 - 139", bmi = "25 - 29", risk = 5),
RiskEntry(systolic = "120 - 139", bmi = "30 - 35", risk = 6),
RiskEntry(systolic = "120 - 139", bmi = "35+", risk = 6)
),
age45to49 = null,
age50to54 = null,
age55to59 = null,
age60to64 = listOf(
RiskEntry(systolic = "180+", bmi = "20-", risk = 21),
RiskEntry(systolic = "160 - 179", bmi = "20 - 24", risk = 18),
RiskEntry(systolic = "120 - 139", bmi = "25 - 29", risk = 12),
RiskEntry(systolic = "120 - 139", bmi = "30 - 35", risk = 13),
RiskEntry(systolic = "120 - 139", bmi = "35+", risk = 14)
),
age65to69 = null,
age70to74 = null
)

val nonSmokingDataWomen = SmokingData(
age40to44 = listOf(
RiskEntry(systolic = "180+", bmi = "20-", risk = 5),
RiskEntry(systolic = "160 - 179", bmi = "20 - 24", risk = 4),
RiskEntry(systolic = "120 - 139", bmi = "25 - 29", risk = 2),
RiskEntry(systolic = "120 - 139", bmi = "30 - 35", risk = 2),
RiskEntry(systolic = "120 - 139", bmi = "35+", risk = 2)
),
age45to49 = null,
age50to54 = null,
age55to59 = null,
age60to64 = listOf(
RiskEntry(systolic = "180+", bmi = "20-", risk = 13),
RiskEntry(systolic = "160 - 179", bmi = "20 - 24", risk = 11),
RiskEntry(systolic = "120 - 139", bmi = "25 - 29", risk = 7),
RiskEntry(systolic = "120 - 139", bmi = "30 - 35", risk = 8),
RiskEntry(systolic = "120 - 139", bmi = "35+", risk = 8)
),
age65to69 = null,
age70to74 = null
)

val womenGenderData = CVDRiskCategory(
smoking = smokingDataWomen,
nonSmoking = nonSmokingDataWomen
)

val smokingDataMen = smokingDataWomen.copy(
age40to44 = listOf(
RiskEntry(systolic = "180+", bmi = "20-", risk = 10),
RiskEntry(systolic = "160 - 179", bmi = "20 - 24", risk = 9),
RiskEntry(systolic = "120 - 139", bmi = "25 - 29", risk = 6),
RiskEntry(systolic = "120 - 139", bmi = "30 - 35", risk = 7),
RiskEntry(systolic = "120 - 139", bmi = "35+", risk = 8)
)
)

val nonSmokingDataMen = nonSmokingDataWomen.copy(
age40to44 = listOf(
RiskEntry(systolic = "180+", bmi = "20-", risk = 5),
RiskEntry(systolic = "160 - 179", bmi = "20 - 24", risk = 5),
RiskEntry(systolic = "120 - 139", bmi = "25 - 29", risk = 3),
RiskEntry(systolic = "120 - 139", bmi = "30 - 35", risk = 3),
RiskEntry(systolic = "120 - 139", bmi = "35+", risk = 4)
)
)

val menGenderData = CVDRiskCategory(
smoking = smokingDataMen,
nonSmoking = nonSmokingDataMen
)

return CVDRiskCalculationSheet(
women = womenGenderData,
men = menGenderData
)
}
}

0 comments on commit 278da5b

Please sign in to comment.