diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/derivetime/TimeIntervalDeriver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/derivetime/TimeIntervalDeriver.kt new file mode 100644 index 00000000000..ddde8f6ad28 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/derivetime/TimeIntervalDeriver.kt @@ -0,0 +1,77 @@ +package de.rki.coronawarnapp.eventregistration.checkins.derivetime + +import de.rki.coronawarnapp.appconfig.PresenceTracingSubmissionParamContainer +import de.rki.coronawarnapp.risk.DefaultRiskLevels.Companion.inRange +import timber.log.Timber +import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.roundToLong + +private val INTERVAL_LENGTH_IN_SECONDS = TimeUnit.MINUTES.toSeconds(10L) + +private fun alignToInterval(timestamp: Long) = + (timestamp / INTERVAL_LENGTH_IN_SECONDS) * INTERVAL_LENGTH_IN_SECONDS + +/** + * Derive CheckIn start and end times + * @param startTimestampInSeconds [Long] timestamp in seconds + * @param endTimestampInSeconds [Long] timestamp in seconds + */ +fun PresenceTracingSubmissionParamContainer.deriveTime( + startTimestampInSeconds: Long, + endTimestampInSeconds: Long +): Pair? { + val durationInSeconds = max(0, endTimestampInSeconds - startTimestampInSeconds) + Timber.d("durationInSeconds: $durationInSeconds") + + val durationInMinutes = TimeUnit.SECONDS.toMinutes(durationInSeconds) + Timber.d("durationInMinutes: $durationInMinutes") + + val dropDueToDuration: Boolean = durationFilters.any { durationFilter -> + durationFilter.dropIfMinutesInRange.inRange(durationInMinutes) + } + Timber.d("dropDueToDuration: $dropDueToDuration") + if (dropDueToDuration) return null + + val aerosoleDecays: List = aerosoleDecayLinearFunctions.filter { aerosole -> + aerosole.minutesRange.inRange(durationInMinutes) + }.map { aerosole -> + aerosole.slope * durationInSeconds + TimeUnit.MINUTES.toSeconds(aerosole.intercept.toLong()) + } + Timber.d("aerosoleDecays:$aerosoleDecays") + val aerosoleDecayInSeconds: Double = aerosoleDecays.firstOrNull() ?: 0.0 // Default: zero, i.e. 'no decay' + Timber.d("aerosoleDecayInSeconds: $aerosoleDecayInSeconds") + + val relevantEndTimestamp = endTimestampInSeconds + aerosoleDecayInSeconds.toLong() + val relevantStartIntervalTimestamp = alignToInterval(startTimestampInSeconds) + val relevantEndIntervalTimestamp = alignToInterval(relevantEndTimestamp) + val overlapWithStartInterval = relevantStartIntervalTimestamp + INTERVAL_LENGTH_IN_SECONDS - startTimestampInSeconds + val overlapWithEndInterval = relevantEndTimestamp - relevantEndIntervalTimestamp + Timber.d("overlapWithStartInterval: $overlapWithStartInterval") + Timber.d("overlapWithEndInterval: $overlapWithEndInterval") + + val targetDurationInSeconds = + ((durationInSeconds + aerosoleDecayInSeconds) / INTERVAL_LENGTH_IN_SECONDS).roundToLong() * + INTERVAL_LENGTH_IN_SECONDS + + Timber.d("targetDurationInSeconds:$targetDurationInSeconds") + + return if (overlapWithEndInterval > overlapWithStartInterval) { + Timber.d( + "overlapWithEndInterval:%s > overlapWithStartInterval:%s", + overlapWithEndInterval, + overlapWithStartInterval + ) + val newEndTimestamp = relevantEndIntervalTimestamp + INTERVAL_LENGTH_IN_SECONDS + val newStartTimestamp = newEndTimestamp - targetDurationInSeconds + newStartTimestamp to newEndTimestamp + } else { + Timber.d( + "overlapWithEndInterval:%s, overlapWithStartInterval:%s", + overlapWithEndInterval, + overlapWithStartInterval + ) + val newEndTimestamp = relevantStartIntervalTimestamp + targetDurationInSeconds + relevantStartIntervalTimestamp to newEndTimestamp + } +} 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 85fd30cce11..b7d4b469423 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 @@ -310,7 +310,7 @@ class DefaultRiskLevels @Inject constructor() : RiskLevels { "The Report Type returned by the ENF is not known" ) - private fun RiskCalculationParametersOuterClass.Range.inRange(value: T): Boolean = + fun RiskCalculationParametersOuterClass.Range.inRange(value: T): Boolean = when { minExclusive && value.toDouble() <= min -> false !minExclusive && value.toDouble() < min -> false diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/derivetime/TimeIntervalDeriverTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/derivetime/TimeIntervalDeriverTest.kt new file mode 100644 index 00000000000..eed36da372c --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/derivetime/TimeIntervalDeriverTest.kt @@ -0,0 +1,281 @@ +package de.rki.coronawarnapp.eventregistration.checkins.derivetime + +import de.rki.coronawarnapp.appconfig.PresenceTracingSubmissionParamContainer +import de.rki.coronawarnapp.server.protocols.internal.v2 + .PresenceTracingParametersOuterClass.PresenceTracingSubmissionParameters.DurationFilter +import de.rki.coronawarnapp.server.protocols.internal.v2 + .PresenceTracingParametersOuterClass.PresenceTracingSubmissionParameters.AerosoleDecayFunctionLinear +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass.Range +import io.kotest.matchers.shouldBe +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.format.DateTimeFormat +import org.joda.time.format.DateTimeFormatter +import org.junit.jupiter.api.Test + +import testhelpers.BaseTest +import java.util.concurrent.TimeUnit + +/** + * Test scenarios reference: [https://github.com/corona-warn-app/cwa-app-tech-spec/blob/ + * proposal/event-registration-mvp/test-cases/pt-derive-time-interval-data.json] + */ +internal class TimeIntervalDeriverTest : BaseTest() { + + /* "defaultConfiguration": { + "durationFilters": [ + { + "dropIfDurationInRange": { + "min": 0, + "max": 10, + "maxExclusive": true + } + } + ], + "aerosoleDecayTime": [ + { + "durationRange": { + "min": 0, + "max": 30 + }, + "slope": 1, + "intercept": 0 + }, + { + "durationRange": { + "min": 30, + "max": 9999, + "minExclusive": true + }, + "slope": 0, + "intercept": 30 + } + ] + } */ + + private val timeFormat: DateTimeFormatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm Z") + .withZone(DateTimeZone.forID("Europe/Berlin")) + + private val presenceTracingConfig = PresenceTracingSubmissionParamContainer( + durationFilters = listOf( + DurationFilter.newBuilder() + .setDropIfMinutesInRange( + Range.newBuilder() + .setMin(0.0) + .setMax(10.0) + .setMaxExclusive(true) + .build() + ) + .build() + ), + aerosoleDecayLinearFunctions = listOf( + AerosoleDecayFunctionLinear.newBuilder() + .setMinutesRange( + Range.newBuilder() + .setMin(0.0) + .setMax(30.0) + .build() + ) + .setSlope(1.0) + .setIntercept(0.0) + .build(), + AerosoleDecayFunctionLinear.newBuilder() + .setMinutesRange( + Range.newBuilder() + .setMin(30.0) + .setMax(9999.0) + .setMinExclusive(true) + .build() + ) + .setSlope(0.0) + .setIntercept(30.0) + .build() + ) + ) + + @Test + fun `Scenario 1`() { + /* + "description": "Scenario 1", + "startDateStr": "2021-03-04 10:21+01:00", + "endDateStr": "2021-03-04 10:29+01:00", + "expStartDateStr": null, + "expEndDateStr": null + */ + presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:21 +01:00"), + timeInSeconds("2021-03-04 10:29 +01:00") + ) shouldBe null + } + + @Test + fun `Scenario 2`() { + /* + "description": "Scenario 2", + "startDateStr": "2021-03-04 10:20+01:00", + "endDateStr": "2021-03-04 10:30+01:00", + "expStartDateStr": "2021-03-04 10:20+01:00", + "expEndDateStr": "2021-03-04 10:40+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:20 +01:00"), + timeInSeconds("2021-03-04 10:30 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:20 +0100" + timeToString(endTime) shouldBe "2021-03-04 10:40 +0100" + } + + @Test + fun `Scenario 3`() { + /* + "description": "Scenario 3", + "startDateStr": "2021-03-04 10:21+01:00", + "endDateStr": "2021-03-04 10:31+01:00", + "expStartDateStr": "2021-03-04 10:20+01:00", + "expEndDateStr": "2021-03-04 10:40+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:21 +01:00"), + timeInSeconds("2021-03-04 10:31 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:20 +0100" + timeToString(endTime) shouldBe "2021-03-04 10:40 +0100" + } + + @Test + fun `Scenario 4`() { + /* + "description": "Scenario 4", + "startDateStr": "2021-03-04 10:26+01:00", + "endDateStr": "2021-03-04 10:36+01:00", + "expStartDateStr": "2021-03-04 10:30+01:00", + "expEndDateStr": "2021-03-04 10:50+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:26 +01:00"), + timeInSeconds("2021-03-04 10:36 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:30 +0100" + timeToString(endTime) shouldBe "2021-03-04 10:50 +0100" + } + + @Test + fun `Scenario 5`() { + /* + "description": "Scenario 5", + "startDateStr": "2021-03-04 10:21+01:00", + "endDateStr": "2021-03-04 10:33+01:00", + "expStartDateStr": "2021-03-04 10:20+01:00", + "expEndDateStr": "2021-03-04 10:40+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:21 +01:00"), + timeInSeconds("2021-03-04 10:33 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:20 +0100" + timeToString(endTime) shouldBe "2021-03-04 10:40 +0100" + } + + @Test + fun `Scenario 6`() { + /* + "description": "Scenario 6", + "startDateStr": "2021-03-04 10:24+01:00", + "endDateStr": "2021-03-04 10:36+01:00", + "expStartDateStr": "2021-03-04 10:30+01:00", + "expEndDateStr": "2021-03-04 10:50+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:24 +01:00"), + timeInSeconds("2021-03-04 10:36 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:30 +0100" + timeToString(endTime) shouldBe "2021-03-04 10:50 +0100" + } + + @Test + fun `Scenario 7`() { + /* + "description": "Scenario 7", + "startDateStr": "2021-03-04 10:25+01:00", + "endDateStr": "2021-03-04 10:39+01:00", + "expStartDateStr": "2021-03-04 10:20+01:00", + "expEndDateStr": "2021-03-04 10:50+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:25 +01:00"), + timeInSeconds("2021-03-04 10:39 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:20 +0100" + timeToString(endTime) shouldBe "2021-03-04 10:50 +0100" + } + + @Test + fun `Scenario 8`() { + /* + "description": "Scenario 8", + "startDateStr": "2021-03-04 10:28+01:00", + "endDateStr": "2021-03-04 10:42+01:00", + "expStartDateStr": "2021-03-04 10:30+01:00", + "expEndDateStr": "2021-03-04 11:00+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:28 +01:00"), + timeInSeconds("2021-03-04 10:42 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:30 +0100" + timeToString(endTime) shouldBe "2021-03-04 11:00 +0100" + } + + @Test + fun `Scenario 9`() { + /* + "description": "Scenario 9", + "startDateStr": "2021-03-04 10:25+01:00", + "endDateStr": "2021-03-04 10:40+01:00", + "expStartDateStr": "2021-03-04 10:20+01:00", + "expEndDateStr": "2021-03-04 10:50+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:25 +01:00"), + timeInSeconds("2021-03-04 10:40 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:20 +0100" + timeToString(endTime) shouldBe "2021-03-04 10:50 +0100" + } + + @Test + fun `Scenario 10`() { + /* + "description": "Scenario 10", + "startDateStr": "2021-03-04 10:22+01:00", + "endDateStr": "2021-03-04 11:12+01:00", + "expStartDateStr": "2021-03-04 10:20+01:00", + "expEndDateStr": "2021-03-04 11:40+01:00" + */ + val (startTime, endTime) = presenceTracingConfig.deriveTime( + timeInSeconds("2021-03-04 10:22 +01:00"), + timeInSeconds("2021-03-04 11:12 +01:00") + )!! + + timeToString(startTime) shouldBe "2021-03-04 10:20 +0100" + timeToString(endTime) shouldBe "2021-03-04 11:40 +0100" + } + + private fun timeInSeconds(dateTime: String): Long { + val millis = timeFormat.parseDateTime(dateTime).millis + return TimeUnit.MILLISECONDS.toSeconds(millis) + } + + private fun timeToString(timeInSecond: Long): String { + return DateTime(TimeUnit.SECONDS.toMillis(timeInSecond)).toString(timeFormat) + } +}