diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt index ada0d0cc078c72aa8a5c5a09ce8f2e1d640ee7c1..146f92532b706a663fde17ff13420001aa0ab2df 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt @@ -308,7 +308,7 @@ class DefaultRiskLevels @Inject constructor( .filter { it.attenuationRange.inRange(scanInstance.typicalAttenuationDb) } .map { it.weight } .firstOrNull() ?: .0 - return seconds + scanInstance.secondsSinceLastScan * weight + seconds + scanInstance.secondsSinceLastScan * weight } private fun determineRiskLevel( diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/ExposureWindowsCalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/ExposureWindowsCalculationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3dab010d2aa1d892ad60015075aa6aec837bac91 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/ExposureWindowsCalculationTest.kt @@ -0,0 +1,402 @@ +package de.rki.coronawarnapp.nearby.windows + +import com.google.android.gms.nearby.exposurenotification.ExposureWindow +import com.google.android.gms.nearby.exposurenotification.ScanInstance +import com.google.gson.Gson +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.ConfigData +import de.rki.coronawarnapp.appconfig.DefaultConfigData +import de.rki.coronawarnapp.nearby.windows.entities.ExposureWindowsJsonInput +import de.rki.coronawarnapp.nearby.windows.entities.cases.JsonScanInstance +import de.rki.coronawarnapp.nearby.windows.entities.cases.JsonWindow +import de.rki.coronawarnapp.nearby.windows.entities.cases.TestCase +import de.rki.coronawarnapp.nearby.windows.entities.configuration.DefaultRiskCalculationConfiguration +import de.rki.coronawarnapp.nearby.windows.entities.configuration.JsonMinutesAtAttenuationFilter +import de.rki.coronawarnapp.nearby.windows.entities.configuration.JsonMinutesAtAttenuationWeight +import de.rki.coronawarnapp.nearby.windows.entities.configuration.JsonNormalizedTimeToRiskLevelMapping +import de.rki.coronawarnapp.nearby.windows.entities.configuration.JsonTrlFilter +import de.rki.coronawarnapp.risk.DefaultRiskLevels +import de.rki.coronawarnapp.risk.result.AggregatedRiskResult +import de.rki.coronawarnapp.risk.result.RiskResult +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.serialization.fromJson +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import org.joda.time.DateTimeConstants +import org.joda.time.Duration +import org.joda.time.Instant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import timber.log.Timber +import java.io.FileReader +import java.nio.file.Paths + +class ExposureWindowsCalculationTest : BaseTest() { + + @MockK lateinit var appConfigProvider: AppConfigProvider + @MockK lateinit var configData: ConfigData + @MockK lateinit var timeStamper: TimeStamper + + private lateinit var riskLevels: DefaultRiskLevels + private lateinit var testConfig: ConfigData + + // Json file (located in /test/resources/exposure-windows-risk-calculation.json) + private val fileName = "exposure-windows-risk-calculation.json" + + // Debug logs + private enum class LogLevel { + NONE, + ONLY_COMPARISON, + EXTENDED, + ALL + } + private val logLevel = LogLevel.ONLY_COMPARISON + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { timeStamper.nowUTC } returns Instant.now() + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun debugLog(s: String, toShow: LogLevel = LogLevel.ALL) { + if (logLevel < toShow) + return + Timber.v(s) + } + + @Test + fun `one test to rule them all`(): Unit = runBlocking { + // 1 - Load and parse json file + val jsonFile = Paths.get("src", "test", "resources", fileName).toFile() + jsonFile shouldNotBe null + val jsonString = FileReader(jsonFile).readText() + jsonString.length shouldBeGreaterThan 0 + val json = Gson().fromJson<ExposureWindowsJsonInput>(jsonString) + json shouldNotBe null + + // 2 - Check test cases + for (case: TestCase in json.testCases) { + checkTestCase(case) + } + debugLog("Test cases checked. Total count: ${json.testCases.size}") + + // 3 - Mock calculation configuration and create default risk level with it + jsonToConfiguration(json.defaultRiskCalculationConfiguration) + coEvery { appConfigProvider.getAppConfig() } returns testConfig + every { appConfigProvider.currentConfig } returns flow { testConfig } + logConfiguration(testConfig) + riskLevels = DefaultRiskLevels(appConfigProvider) + + // 4 - Mock and log exposure windows + val allExposureWindows = mutableListOf<ExposureWindow>() + for (case: TestCase in json.testCases) { + val exposureWindows: List<ExposureWindow> = + case.exposureWindows.map { window -> jsonToExposureWindow(window) } + allExposureWindows.addAll(exposureWindows) + + // 5 - Calculate risk level for test case and aggregate results + val exposureWindowsAndResult = HashMap<ExposureWindow, RiskResult>() + for (exposureWindow: ExposureWindow in exposureWindows) { + + logExposureWindow(exposureWindow, "➡➡ EXPOSURE WINDOW PASSED ➡➡", LogLevel.EXTENDED) + val riskResult = riskLevels.calculateRisk(exposureWindow) ?: continue + exposureWindowsAndResult[exposureWindow] = riskResult + } + debugLog("Exposure windows and result: ${exposureWindowsAndResult.size}") + + val aggregatedRiskResult = riskLevels.aggregateResults(exposureWindowsAndResult) + + debugLog( + "\n" + comparisonDebugTable(aggregatedRiskResult, case), + LogLevel.ONLY_COMPARISON + ) + + // 6 - Check with expected result from test case + aggregatedRiskResult.totalRiskLevel.number shouldBe case.expTotalRiskLevel + aggregatedRiskResult.mostRecentDateWithHighRisk shouldBe getTestCaseDate(case.expAgeOfMostRecentDateWithHighRisk) + aggregatedRiskResult.mostRecentDateWithLowRisk shouldBe getTestCaseDate(case.expAgeOfMostRecentDateWithLowRisk) + aggregatedRiskResult.totalMinimumDistinctEncountersWithHighRisk shouldBe case.expTotalMinimumDistinctEncountersWithHighRisk + aggregatedRiskResult.totalMinimumDistinctEncountersWithLowRisk shouldBe case.expTotalMinimumDistinctEncountersWithLowRisk + } + } + + private fun getTestCaseDate(expAge: Long?): Instant? { + if (expAge == null) return null + return timeStamper.nowUTC - expAge * DateTimeConstants.MILLIS_PER_DAY + } + + private fun comparisonDebugTable(aggregated: AggregatedRiskResult, case: TestCase): String { + val result = StringBuilder() + result.append("\n").append(case.description) + result.append("\n").append("+----------------------+--------------------------+--------------------------+") + result.append("\n").append("| Property | Actual | Expected |") + result.append("\n").append("+----------------------+--------------------------+--------------------------+") + result.append( + addPropertyCheckToComparisonDebugTable( + "Total Risk", + aggregated.totalRiskLevel.number, + case.expTotalRiskLevel + ) + ) + result.append( + addPropertyCheckToComparisonDebugTable( + "Date With High Risk", + aggregated.mostRecentDateWithHighRisk, + getTestCaseDate(case.expAgeOfMostRecentDateWithHighRisk) + ) + ) + result.append( + addPropertyCheckToComparisonDebugTable( + "Date With Low Risk", + aggregated.mostRecentDateWithLowRisk, + getTestCaseDate(case.expAgeOfMostRecentDateWithLowRisk) + ) + ) + result.append( + addPropertyCheckToComparisonDebugTable( + "Encounters High Risk", + aggregated.totalMinimumDistinctEncountersWithHighRisk, + case.expTotalMinimumDistinctEncountersWithHighRisk + ) + ) + result.append( + addPropertyCheckToComparisonDebugTable( + "Encounters Low Risk", + aggregated.totalMinimumDistinctEncountersWithLowRisk, + case.expTotalMinimumDistinctEncountersWithLowRisk + ) + ) + result.append("\n") + return result.toString() + } + + private fun addPropertyCheckToComparisonDebugTable(propertyName: String, expected: Any?, actual: Any?): String { + val format = "| %-20s | %-24s | %-24s |" + val result = StringBuilder() + result.append("\n").append(String.format(format, propertyName, expected, actual)) + result.append("\n").append("+----------------------+--------------------------+--------------------------+") + return result.toString() + } + + private fun checkTestCase(case: TestCase) { + debugLog("Checking ${case.description}", LogLevel.ALL) + case.expTotalRiskLevel shouldNotBe null + case.expTotalMinimumDistinctEncountersWithLowRisk shouldNotBe null + case.expTotalMinimumDistinctEncountersWithHighRisk shouldNotBe null + case.exposureWindows.map { exposureWindow -> checkExposureWindow(exposureWindow) } + } + + private fun checkExposureWindow(jsonWindow: JsonWindow) { + jsonWindow.ageInDays shouldNotBe null + jsonWindow.reportType shouldNotBe null + jsonWindow.infectiousness shouldNotBe null + jsonWindow.calibrationConfidence shouldNotBe null + } + + private fun logConfiguration(config: ConfigData) { + val result = StringBuilder() + result.append("\n\n").append("----------------- \uD83D\uDEE0 CONFIGURATION \uD83D\uDEE0 -----------") + + result.append("\n").append("◦ Minutes At Attenuation Filters (${config.minutesAtAttenuationFilters.size})") + for (filter: RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter in config.minutesAtAttenuationFilters) { + result.append("\n\t").append("⇥ Filter") + result.append(logRange(filter.attenuationRange, "Attenuation Range")) + result.append(logRange(filter.dropIfMinutesInRange, "Drop If Minutes In Range")) + } + + result.append("\n").append("◦ Minutes At Attenuation Weights (${config.minutesAtAttenuationWeights.size})") + for (weight: RiskCalculationParametersOuterClass.MinutesAtAttenuationWeight in config.minutesAtAttenuationWeights) { + result.append("\n\t").append("⇥ Weight") + result.append(logRange(weight.attenuationRange, "Attenuation Range")) + result.append("\n\t\t").append("↳ Weight: ${weight.weight}") + } + + result.append("\n").append("◦ Normalized Time Per Day To Risk Level Mapping List (${config.normalizedTimePerDayToRiskLevelMappingList.size})") + for (mapping: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping in config.normalizedTimePerDayToRiskLevelMappingList) { + result.append("\n\t").append("⇥ Mapping") + result.append(logRange(mapping.normalizedTimeRange, "Normalized Time Range")) + result.append("\n\t\t").append("↳ Risk Level: ${mapping.riskLevel}") + } + + result.append("\n").append("◦ Normalized Time Per Exposure Window To Risk Level Mapping (${config.normalizedTimePerExposureWindowToRiskLevelMapping.size})") + for (mapping: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping in config.normalizedTimePerExposureWindowToRiskLevelMapping) { + result.append("\n\t").append("⇥ Mapping") + result.append(logRange(mapping.normalizedTimeRange, "Normalized Time Range")) + result.append("\n\t\t").append("↳ Risk Level: ${mapping.riskLevel}") + } + + result.append("\n").append("◦ Transmission Risk Level Encoding:") + result.append("\n\t").append("↳ Infectiousness Offset High: ${config.transmissionRiskLevelEncoding.infectiousnessOffsetHigh}") + result.append("\n\t").append("↳ Infectiousness Offset Standard: ${config.transmissionRiskLevelEncoding.infectiousnessOffsetStandard}") + result.append("\n\t").append("↳ Report Type Offset Confirmed Clinical Diagnosis: ${config.transmissionRiskLevelEncoding.reportTypeOffsetConfirmedClinicalDiagnosis}") + result.append("\n\t").append("↳ Report Type Offset Confirmed Test: ${config.transmissionRiskLevelEncoding.reportTypeOffsetConfirmedTest}") + result.append("\n\t").append("↳ Report Type Offset Recursive: ${config.transmissionRiskLevelEncoding.reportTypeOffsetRecursive}") + result.append("\n\t").append("↳ Report Type Offset Self Report: ${config.transmissionRiskLevelEncoding.reportTypeOffsetSelfReport}") + + result.append("\n").append("◦ Transmission Risk Level Filters (${config.transmissionRiskLevelFilters.size})") + for (filter: RiskCalculationParametersOuterClass.TrlFilter in config.transmissionRiskLevelFilters) { + result.append("\n\t").append("⇥ Trl Filter") + result.append(logRange(filter.dropIfTrlInRange, "Drop If Trl In Range")) + } + + result.append("\n").append("◦ Transmission Risk Level Multiplier: ${config.transmissionRiskLevelMultiplier}") + result.append("\n").append("-------------------------------------------- ⚙ -").append("\n") + debugLog(result.toString(), LogLevel.NONE) + } + + private fun logRange(range: RiskCalculationParametersOuterClass.Range, rangeName: String): String { + val builder = StringBuilder() + builder.append("\n\t\t").append("⇥ $rangeName") + builder.append("\n\t\t\t").append("↳ Min: ${range.min}") + builder.append("\n\t\t\t").append("↳ Max: ${range.max}") + builder.append("\n\t\t\t").append("↳ Min Exclusive: ${range.minExclusive}") + builder.append("\n\t\t\t").append("↳ Max Exclusive: ${range.maxExclusive}") + return builder.toString() + } + + private fun logExposureWindow(exposureWindow: ExposureWindow, title: String, logLevel: LogLevel = LogLevel.ALL) { + val result = StringBuilder() + result.append("\n\n").append("------------ $title -----------") + result.append("\n").append("Mocked Exposure window: #${exposureWindow.hashCode()}") + result.append("\n").append("◦ Calibration Confidence: ${exposureWindow.calibrationConfidence}") + result.append("\n").append("◦ Date Millis Since Epoch: ${exposureWindow.dateMillisSinceEpoch}") + result.append("\n").append("◦ Infectiousness: ${exposureWindow.infectiousness}") + result.append("\n").append("◦ Report type: ${exposureWindow.reportType}") + + result.append("\n").append("‣ Scan Instances (${exposureWindow.scanInstances.size}):") + for (scan: ScanInstance in exposureWindow.scanInstances) { + result.append("\n\t").append("⇥ Mocked Scan Instance: #${scan.hashCode()}") + result.append("\n\t\t").append("↳ Min Attenuation: ${scan.minAttenuationDb}") + result.append("\n\t\t").append("↳ Seconds Since Last Scan: ${scan.secondsSinceLastScan}") + result.append("\n\t\t").append("↳ Typical Attenuation: ${scan.typicalAttenuationDb}") + } + result.append("\n").append("-------------------------------------------- ✂ ----").append("\n") + debugLog(result.toString(), logLevel) + } + + private fun jsonToConfiguration(json: DefaultRiskCalculationConfiguration) { + + testConfig = DefaultConfigData( + serverTime = Instant.now(), + localOffset = Duration.ZERO, + mappedConfig = configData, + isFallback = false + ) + + val attenuationFilters = mutableListOf<RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter>() + for (jsonFilter: JsonMinutesAtAttenuationFilter in json.minutesAtAttenuationFilters) { + val filter: RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter = mockk() + every { filter.attenuationRange.min } returns jsonFilter.attenuationRange.min + every { filter.attenuationRange.max } returns jsonFilter.attenuationRange.max + every { filter.attenuationRange.minExclusive } returns jsonFilter.attenuationRange.minExclusive + every { filter.attenuationRange.maxExclusive } returns jsonFilter.attenuationRange.maxExclusive + every { filter.dropIfMinutesInRange.min } returns jsonFilter.dropIfMinutesInRange.min + every { filter.dropIfMinutesInRange.max } returns jsonFilter.dropIfMinutesInRange.max + every { filter.dropIfMinutesInRange.minExclusive } returns jsonFilter.dropIfMinutesInRange.minExclusive + every { filter.dropIfMinutesInRange.maxExclusive } returns jsonFilter.dropIfMinutesInRange.maxExclusive + attenuationFilters.add(filter) + } + every { testConfig.minutesAtAttenuationFilters } returns attenuationFilters + + val attenuationWeights = mutableListOf<RiskCalculationParametersOuterClass.MinutesAtAttenuationWeight>() + for (jsonWeight: JsonMinutesAtAttenuationWeight in json.minutesAtAttenuationWeights) { + val weight: RiskCalculationParametersOuterClass.MinutesAtAttenuationWeight = mockk() + every { weight.attenuationRange.min } returns jsonWeight.attenuationRange.min + every { weight.attenuationRange.max } returns jsonWeight.attenuationRange.max + every { weight.attenuationRange.minExclusive } returns jsonWeight.attenuationRange.minExclusive + every { weight.attenuationRange.maxExclusive } returns jsonWeight.attenuationRange.maxExclusive + every { weight.weight } returns jsonWeight.weight + attenuationWeights.add(weight) + } + every { testConfig.minutesAtAttenuationWeights } returns attenuationWeights + + val normalizedTimePerDayToRiskLevelMapping = mutableListOf< RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping>() + for (jsonMapping: JsonNormalizedTimeToRiskLevelMapping in json.normalizedTimePerDayToRiskLevelMapping) { + val mapping: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping = mockk() + every { mapping.riskLevel } returns RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.forNumber(jsonMapping.riskLevel) + every { mapping.normalizedTimeRange.min } returns jsonMapping.normalizedTimeRange.min + every { mapping.normalizedTimeRange.max } returns jsonMapping.normalizedTimeRange.max + every { mapping.normalizedTimeRange.minExclusive } returns jsonMapping.normalizedTimeRange.minExclusive + every { mapping.normalizedTimeRange.maxExclusive } returns jsonMapping.normalizedTimeRange.maxExclusive + normalizedTimePerDayToRiskLevelMapping.add(mapping) + } + every { testConfig.normalizedTimePerDayToRiskLevelMappingList } returns normalizedTimePerDayToRiskLevelMapping + + val normalizedTimePerExposureWindowToRiskLevelMapping = mutableListOf< RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping>() + for (jsonMapping: JsonNormalizedTimeToRiskLevelMapping in json.normalizedTimePerEWToRiskLevelMapping) { + val mapping: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping = mockk() + every { mapping.riskLevel } returns RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.forNumber(jsonMapping.riskLevel) + every { mapping.normalizedTimeRange.min } returns jsonMapping.normalizedTimeRange.min + every { mapping.normalizedTimeRange.max } returns jsonMapping.normalizedTimeRange.max + every { mapping.normalizedTimeRange.minExclusive } returns jsonMapping.normalizedTimeRange.minExclusive + every { mapping.normalizedTimeRange.maxExclusive } returns jsonMapping.normalizedTimeRange.maxExclusive + normalizedTimePerExposureWindowToRiskLevelMapping.add(mapping) + } + every { testConfig.normalizedTimePerExposureWindowToRiskLevelMapping } returns normalizedTimePerExposureWindowToRiskLevelMapping + + every { testConfig.transmissionRiskLevelMultiplier } returns json.transmissionRiskLevelMultiplier + + val trlEncoding: RiskCalculationParametersOuterClass.TransmissionRiskLevelEncoding = mockk() + every { trlEncoding.infectiousnessOffsetHigh } returns json.trlEncoding.infectiousnessOffsetHigh + every { trlEncoding.infectiousnessOffsetStandard } returns json.trlEncoding.infectiousnessOffsetStandard + every { trlEncoding.reportTypeOffsetConfirmedClinicalDiagnosis } returns json.trlEncoding.reportTypeOffsetConfirmedClinicalDiagnosis + every { trlEncoding.reportTypeOffsetConfirmedTest } returns json.trlEncoding.reportTypeOffsetConfirmedTest + every { trlEncoding.reportTypeOffsetRecursive } returns json.trlEncoding.reportTypeOffsetRecursive + every { trlEncoding.reportTypeOffsetSelfReport } returns json.trlEncoding.reportTypeOffsetSelfReport + every { testConfig.transmissionRiskLevelEncoding } returns trlEncoding + + val trlFilters = mutableListOf<RiskCalculationParametersOuterClass.TrlFilter>() + for (jsonFilter: JsonTrlFilter in json.trlFilters) { + val filter: RiskCalculationParametersOuterClass.TrlFilter = mockk() + every { filter.dropIfTrlInRange.min } returns jsonFilter.dropIfTrlInRange.min + every { filter.dropIfTrlInRange.max } returns jsonFilter.dropIfTrlInRange.max + every { filter.dropIfTrlInRange.minExclusive } returns jsonFilter.dropIfTrlInRange.minExclusive + every { filter.dropIfTrlInRange.maxExclusive } returns jsonFilter.dropIfTrlInRange.maxExclusive + trlFilters.add(filter) + } + every { testConfig.transmissionRiskLevelFilters } returns trlFilters + } + + private fun jsonToExposureWindow(json: JsonWindow): ExposureWindow { + val exposureWindow: ExposureWindow = mockk() + + every { exposureWindow.calibrationConfidence } returns json.calibrationConfidence + every { exposureWindow.dateMillisSinceEpoch } returns timeStamper.nowUTC.millis - (DateTimeConstants.MILLIS_PER_DAY * json.ageInDays).toLong() + every { exposureWindow.infectiousness } returns json.infectiousness + every { exposureWindow.reportType } returns json.reportType + every { exposureWindow.scanInstances } returns json.scanInstances.map { scanInstance -> + jsonToScanInstance( + scanInstance + ) + } + + logExposureWindow(exposureWindow, "⊞ EXPOSURE WINDOW MOCK ⊞") + + return exposureWindow + } + + private fun jsonToScanInstance(json: JsonScanInstance): ScanInstance { + val scanInstance: ScanInstance = mockk() + every { scanInstance.minAttenuationDb } returns json.minAttenuation + every { scanInstance.secondsSinceLastScan } returns json.secondsSinceLastScan + every { scanInstance.typicalAttenuationDb } returns json.typicalAttenuation + return scanInstance + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/ExposureWindowsJsonInput.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/ExposureWindowsJsonInput.kt new file mode 100644 index 0000000000000000000000000000000000000000..3c0a77a3a27bdc197a2ca75cd885303a3ec587eb --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/ExposureWindowsJsonInput.kt @@ -0,0 +1,14 @@ +package de.rki.coronawarnapp.nearby.windows.entities + +import com.google.gson.annotations.SerializedName +import de.rki.coronawarnapp.nearby.windows.entities.cases.TestCase +import de.rki.coronawarnapp.nearby.windows.entities.configuration.DefaultRiskCalculationConfiguration + +data class ExposureWindowsJsonInput( + @SerializedName("__comment__") + val comment: String, + @SerializedName("defaultRiskCalculationConfiguration") + val defaultRiskCalculationConfiguration: DefaultRiskCalculationConfiguration, + @SerializedName("testCases") + val testCases: List<TestCase> +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/JsonScanInstance.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/JsonScanInstance.kt new file mode 100644 index 0000000000000000000000000000000000000000..a9ea714b7e7f440c4e71c5e27f8f8c912a3c575c --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/JsonScanInstance.kt @@ -0,0 +1,12 @@ +package de.rki.coronawarnapp.nearby.windows.entities.cases + +import com.google.gson.annotations.SerializedName + +data class JsonScanInstance( + @SerializedName("minAttenuation") + val minAttenuation: Int, + @SerializedName("secondsSinceLastScan") + val secondsSinceLastScan: Int, + @SerializedName("typicalAttenuation") + val typicalAttenuation: Int +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/JsonWindow.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/JsonWindow.kt new file mode 100644 index 0000000000000000000000000000000000000000..7394b329509f98a10e6d7c368dc3033b62c442ef --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/JsonWindow.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.nearby.windows.entities.cases + +import com.google.gson.annotations.SerializedName + +data class JsonWindow( + @SerializedName("ageInDays") + val ageInDays: Int, + @SerializedName("calibrationConfidence") + val calibrationConfidence: Int, + @SerializedName("infectiousness") + val infectiousness: Int, + @SerializedName("reportType") + val reportType: Int, + @SerializedName("scanInstances") + val scanInstances: List<JsonScanInstance> +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/TestCase.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/TestCase.kt new file mode 100644 index 0000000000000000000000000000000000000000..fa8f45277f640f11580b310bfea5cc2a12fd2eab --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/cases/TestCase.kt @@ -0,0 +1,20 @@ +package de.rki.coronawarnapp.nearby.windows.entities.cases + +import com.google.gson.annotations.SerializedName + +data class TestCase( + @SerializedName("description") + val description: String, + @SerializedName("expAgeOfMostRecentDateWithHighRisk") + val expAgeOfMostRecentDateWithHighRisk: Long?, + @SerializedName("expAgeOfMostRecentDateWithLowRisk") + val expAgeOfMostRecentDateWithLowRisk: Long?, + @SerializedName("expTotalMinimumDistinctEncountersWithHighRisk") + val expTotalMinimumDistinctEncountersWithHighRisk: Int, + @SerializedName("expTotalMinimumDistinctEncountersWithLowRisk") + val expTotalMinimumDistinctEncountersWithLowRisk: Int, + @SerializedName("expTotalRiskLevel") + val expTotalRiskLevel: Int, + @SerializedName("exposureWindows") + val exposureWindows: List<JsonWindow> +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/DefaultRiskCalculationConfiguration.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/DefaultRiskCalculationConfiguration.kt new file mode 100644 index 0000000000000000000000000000000000000000..7445d86e786d7250f7cf57dc4c5498eba97fabeb --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/DefaultRiskCalculationConfiguration.kt @@ -0,0 +1,20 @@ +package de.rki.coronawarnapp.nearby.windows.entities.configuration + +import com.google.gson.annotations.SerializedName + +data class DefaultRiskCalculationConfiguration( + @SerializedName("minutesAtAttenuationFilters") + val minutesAtAttenuationFilters: List<JsonMinutesAtAttenuationFilter>, + @SerializedName("minutesAtAttenuationWeights") + val minutesAtAttenuationWeights: List<JsonMinutesAtAttenuationWeight>, + @SerializedName("normalizedTimePerDayToRiskLevelMapping") + val normalizedTimePerDayToRiskLevelMapping: List<JsonNormalizedTimeToRiskLevelMapping>, + @SerializedName("normalizedTimePerEWToRiskLevelMapping") + val normalizedTimePerEWToRiskLevelMapping: List<JsonNormalizedTimeToRiskLevelMapping>, + @SerializedName("transmissionRiskLevelMultiplier") + val transmissionRiskLevelMultiplier: Double, + @SerializedName("trlEncoding") + val trlEncoding: JsonTrlEncoding, + @SerializedName("trlFilters") + val trlFilters: List<JsonTrlFilter> +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonMinutesAtAttenuationFilter.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonMinutesAtAttenuationFilter.kt new file mode 100644 index 0000000000000000000000000000000000000000..a01efc1f6022a2a68a8a20e25aeefc6320591b2b --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonMinutesAtAttenuationFilter.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.nearby.windows.entities.configuration + +import com.google.gson.annotations.SerializedName + +data class JsonMinutesAtAttenuationFilter( + @SerializedName("attenuationRange") + val attenuationRange: Range, + @SerializedName("dropIfMinutesInRange") + val dropIfMinutesInRange: Range +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonMinutesAtAttenuationWeight.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonMinutesAtAttenuationWeight.kt new file mode 100644 index 0000000000000000000000000000000000000000..3af598da7dc5c6d71bf0bbeeac85788f038a90ee --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonMinutesAtAttenuationWeight.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.nearby.windows.entities.configuration + +import com.google.gson.annotations.SerializedName + +data class JsonMinutesAtAttenuationWeight( + @SerializedName("attenuationRange") + val attenuationRange: Range, + @SerializedName("weight") + val weight: Double +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonNormalizedTimeToRiskLevelMapping.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonNormalizedTimeToRiskLevelMapping.kt new file mode 100644 index 0000000000000000000000000000000000000000..4f0fc786998558caf43be1d5cee1e6026f7bb919 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonNormalizedTimeToRiskLevelMapping.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.nearby.windows.entities.configuration + +import com.google.gson.annotations.SerializedName + +data class JsonNormalizedTimeToRiskLevelMapping( + @SerializedName("normalizedTimeRange") + val normalizedTimeRange: Range, + @SerializedName("riskLevel") + val riskLevel: Int +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonTrlEncoding.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonTrlEncoding.kt new file mode 100644 index 0000000000000000000000000000000000000000..00a3e5af78270ef34fb384909f1f69ddab92f2ba --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonTrlEncoding.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.nearby.windows.entities.configuration + +import com.google.gson.annotations.SerializedName + +data class JsonTrlEncoding( + @SerializedName("infectiousnessOffsetHigh") + val infectiousnessOffsetHigh: Int, + @SerializedName("infectiousnessOffsetStandard") + val infectiousnessOffsetStandard: Int, + @SerializedName("reportTypeOffsetConfirmedClinicalDiagnosis") + val reportTypeOffsetConfirmedClinicalDiagnosis: Int, + @SerializedName("reportTypeOffsetConfirmedTest") + val reportTypeOffsetConfirmedTest: Int, + @SerializedName("reportTypeOffsetRecursive") + val reportTypeOffsetRecursive: Int, + @SerializedName("reportTypeOffsetSelfReport") + val reportTypeOffsetSelfReport: Int +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonTrlFilter.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonTrlFilter.kt new file mode 100644 index 0000000000000000000000000000000000000000..6e529bc6af34385922152903db58c5a1521c725e --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/JsonTrlFilter.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.nearby.windows.entities.configuration + +import com.google.gson.annotations.SerializedName + +data class JsonTrlFilter( + @SerializedName("dropIfTrlInRange") + val dropIfTrlInRange: Range +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/Range.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/Range.kt new file mode 100644 index 0000000000000000000000000000000000000000..a4d686a74cd8183a8261ec35bf8487b0ad2db005 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/windows/entities/configuration/Range.kt @@ -0,0 +1,14 @@ +package de.rki.coronawarnapp.nearby.windows.entities.configuration + +import com.google.gson.annotations.SerializedName + +data class Range( + @SerializedName("min") + val min: Double, + @SerializedName("minExclusive") + val minExclusive: Boolean, + @SerializedName("max") + val max: Double, + @SerializedName("maxExclusive") + val maxExclusive: Boolean +) diff --git a/Corona-Warn-App/src/test/resources/exposure-windows-risk-calculation.json b/Corona-Warn-App/src/test/resources/exposure-windows-risk-calculation.json new file mode 100644 index 0000000000000000000000000000000000000000..4338b8f94fc85935bc7a060cc7a52a0f5340dae0 --- /dev/null +++ b/Corona-Warn-App/src/test/resources/exposure-windows-risk-calculation.json @@ -0,0 +1,1025 @@ +{ + "__comment__": "JSON has been generated from YAML, see README", + "defaultRiskCalculationConfiguration": { + "minutesAtAttenuationFilters": [ + { + "attenuationRange": { + "min": 0, + "max": 73, + "maxExclusive": true + }, + "dropIfMinutesInRange": { + "min": 0, + "max": 10, + "maxExclusive": true + } + } + ], + "trlFilters": [ + { + "dropIfTrlInRange": { + "min": 1, + "max": 2 + } + } + ], + "minutesAtAttenuationWeights": [ + { + "attenuationRange": { + "min": 0, + "max": 55, + "maxExclusive": true + }, + "weight": 1 + }, + { + "attenuationRange": { + "min": 55, + "max": 63, + "maxExclusive": true + }, + "weight": 0.5 + } + ], + "normalizedTimePerEWToRiskLevelMapping": [ + { + "normalizedTimeRange": { + "min": 0, + "max": 15, + "maxExclusive": true + }, + "riskLevel": 1 + }, + { + "normalizedTimeRange": { + "min": 15, + "max": 9999 + }, + "riskLevel": 2 + } + ], + "normalizedTimePerDayToRiskLevelMapping": [ + { + "normalizedTimeRange": { + "min": 0, + "max": 15, + "maxExclusive": true + }, + "riskLevel": 1 + }, + { + "normalizedTimeRange": { + "min": 15, + "max": 9999 + }, + "riskLevel": 2 + } + ], + "trlEncoding": { + "infectiousnessOffsetStandard": 0, + "infectiousnessOffsetHigh": 4, + "reportTypeOffsetRecursive": 4, + "reportTypeOffsetSelfReport": 3, + "reportTypeOffsetConfirmedClinicalDiagnosis": 2, + "reportTypeOffsetConfirmedTest": 1 + }, + "transmissionRiskLevelMultiplier": 0.2 + }, + "testCases": [ + { + "description": "drop Exposure Windows that do not match minutesAtAttenuationFilters (< 10 minutes)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 2, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 299 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "keep Exposure Windows that match minutesAtAttenuationFilters (>= 10 minutes)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 2, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "drop Exposure Windows that do not match minutesAtAttenuationFilters (>= 73 dB)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 2, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 73, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 73, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "keep Exposure Windows that match minutesAtAttenuationFilters (< 73 dB)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 2, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 72, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 72, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "drop Exposure Windows that do not match trlFilters (<= 2)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 2, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "keep Exposure Windows that match trlFilters (> 2)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "identify Exposure Window as Low Risk based on normalizedTime (< 15)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 1, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 299 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0, + "expNumberOfExposureWindowsWithLowRisk": 1, + "expNumberOfExposureWindowsWithHighRisk": 0 + }, + { + "description": "identify Exposure Window as High Risk based on normalizedTime (>= 15)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 1, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 1, + "expNumberOfExposureWindowsWithLowRisk": 1, + "expNumberOfExposureWindowsWithHighRisk": 0 + }, + { + "description": "identify the most recent date with Low Risk", + "exposureWindows": [ + { + "ageInDays": 3, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 2, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 4, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 3, + "expAgeOfMostRecentDateWithLowRisk": 2, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "count Exposure Windows with same Date/TRL/CallibrationConfidence only once towards distinct encounters with Low Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "count Exposure Windows with same Date/TRL but different CallibrationConfidence separately towards distinct encounters with Low Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 1, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 2, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "count Exposure Windows with same Date/CallibrationConfidence but different TRL separately towards distinct encounters with Low Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 2, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "count Exposure Windows with same TRL/CallibrationConfidence but different Date separately towards distinct encounters with Low Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 2, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 2, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "determine High Risk in total if there are sufficient Exposure Windows with a Low Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 0 + }, + { + "description": "identify the most recent date with High Risk", + "exposureWindows": [ + { + "ageInDays": 3, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + }, + { + "ageInDays": 2, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + }, + { + "ageInDays": 4, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": 2, + "expTotalMinimumDistinctEncountersWithHighRisk": 3 + }, + { + "description": "count Exposure Windows with same Date/TRL/CallibrationConfidence only once towards distinct encounters with High Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + }, + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 1 + }, + { + "description": "count Exposure Windows with same Date/TRL but different CallibrationConfidence separately towards distinct encounters with High Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + }, + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 1, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 2 + }, + { + "description": "count Exposure Windows with same Date/CallibrationConfidence but different TRL separately towards distinct encounters with High Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + }, + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 2 + }, + { + "description": "count Exposure Windows with same TRL/CallibrationConfidence but different Date separately towards distinct encounters with High Risk", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + }, + { + "ageInDays": 2, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 2 + }, + { + "description": "determine High Risk in total if there is at least one Exposure Window with High Risk", + "exposureWindows": [ + { + "ageInDays": 2, + "reportType": 3, + "infectiousness": 1, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + }, + { + "ageInDays": 1, + "reportType": 4, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + }, + { + "typicalAttenuation": 30, + "minAttenuation": 25, + "secondsSinceLastScan": 420 + } + ] + } + ], + "expTotalRiskLevel": 2, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expAgeOfMostRecentDateWithLowRisk": 2, + "expAgeOfMostRecentDateWithHighRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 1 + }, + { + "description": "handle empty set of Exposure Windows", + "exposureWindows": [], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expTotalMinimumDistinctEncountersWithHighRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": null + }, + { + "description": "handle empty set of Scan Instances (should never happen)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 2, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 0, + "expTotalMinimumDistinctEncountersWithHighRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": null, + "expAgeOfMostRecentDateWithHighRisk": null + }, + { + "description": "handle a typicalAttenuation of zero (should never happen)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 0, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 70, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expNumberOfExposureWindowsWithLowRisk": 1, + "expNumberOfExposureWindowsWithHighRisk": 0 + }, + { + "description": "handle secondsSinceLastScan of zero (should never happen)", + "exposureWindows": [ + { + "ageInDays": 1, + "reportType": 3, + "infectiousness": 2, + "calibrationConfidence": 0, + "scanInstances": [ + { + "typicalAttenuation": 70, + "minAttenuation": 25, + "secondsSinceLastScan": 0 + }, + { + "typicalAttenuation": 70, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + }, + { + "typicalAttenuation": 70, + "minAttenuation": 25, + "secondsSinceLastScan": 300 + } + ] + } + ], + "expTotalRiskLevel": 1, + "expTotalMinimumDistinctEncountersWithLowRisk": 1, + "expTotalMinimumDistinctEncountersWithHighRisk": 0, + "expAgeOfMostRecentDateWithLowRisk": 1, + "expAgeOfMostRecentDateWithHighRisk": null, + "expNumberOfExposureWindowsWithLowRisk": 1, + "expNumberOfExposureWindowsWithHighRisk": 0 + } + ] +}