This guide explains how to estimate the risk of exposure to COVID-19 using the Exposure Notifications API. You can compute a risk score manually or configure and use precalculated daily summaries. This guide contains the following sections:
Exposure data and risk score: an overview of what exposure notifications are and how the Exposure Notifications system helps public health authorities (PHAs) keep users informed of potential exposures.
Mapping diagnosis keys to exposure window data: configuring how the Exposure Notifications system translates diagnosis key data to exposure data.
Risk scoring options: an explanation of the ways that you can get risk scores.
Daily summaries: example code for using the v1.6 and higher feature that provides an implementation of risk score based on aggregated daily scores.
Exposure data format: the data structures and fields provided by the Exposure Notifications API in v1.5 and higher.
Example of manual risk scoring: example code that computes risk scores manually.
Notifying the user of exposure: when and how to notify the user.
Exposure data and risk score
Exposure data is the set of information that enables a PHA to determine the following:
- The level of exposure of a particular user to other app users who've reported positive COVID-19 diagnoses.
- Which of those interactions constitute meaningful exposures to COVID-19.
The risk score quantifies the level of meaningfulness of the exposures, and is used in Exposure Notifications apps to determine whether and how to notify the user. Exposure Notifications apps compute the risk score based on the exposure data available in each user’s device.
The following illustrations show how apps can detect and react to COVID-19 exposure.
Figure 1: When an app user gets a positive test result, they can choose
to notify their recent contacts.
Figure 2: Recent contacts receive notifications of exposure.
As the illustrations show, the Exposure Notifications API collects non-identifiable Bluetooth beacons when two users are close to each other. The API lets users who are diagnosed with COVID-19 report their diagnosis via the app, causing temporary exposure keys (TEKs) to be uploaded to the server and distributed to other users’ devices. These downloaded TEKs are called diagnosis keys.
The app provides the diagnosis keys
to the Exposure Notifications system,
which finds collected beacons that match
and then computes exposure data in the form of
ExposureWindow objects.
The app gets the risk score associated with the exposure windows,
either by requesting daily summaries
or by using the data from ExposureWindows to manually calculate the score.
Mapping diagnosis keys to exposure window data
The app must use
setDiagnosisKeysDataMapping()
to provide a
DiagnosisKeysDataMapping
object that configures how the Exposure Notifications system translates
diagnosis key data
to the corresponding fields in ExposureWindow.
In v1.6, the parameters in the DiagnosisKeysDataMapping object
are applied at matching time when getting diagnosis keys
(using provideDiagnosisKeys(), as described in
Risk scoring options.
Calling setDiagnosisKeysDataMapping() doesn't
modify ExposureWindows for keys provided in past calls,
but this behavior might change in future versions.
If setDiagnosisKeysDataMapping() is called twice within 7 days,
the second call has no effect and
raises an exception with status code FAILED_RATE_LIMITED.
Here's an example of creating a DiagnosisKeysDataMapping object:
/**
* Configures the interpretation of diagnosis key data to create ExposureWindow data.
*/
val diagnosisKeysDataMapping: DiagnosisKeysDataMapping by lazy {
val daysToInfectiousness = mutableMapOf<Int, Int>()
for (i in -14..14) {
when (i) {
in -5..-3 -> daysToInfectiousness[i] = Infectiousness.STANDARD
in -2..5 -> daysToInfectiousness[i] = Infectiousness.HIGH
in 6..10 -> daysToInfectiousness[i] = Infectiousness.STANDARD
else -> daysToInfectiousness[i] = Infectiousness.NONE
}
}
DiagnosisKeysDataMapping.DiagnosisKeysDataMappingBuilder()
.setDaysSinceOnsetToInfectiousness(daysToInfectiousness)
.setInfectiousnessWhenDaysSinceOnsetMissing(Infectiousness.STANDARD)
.setReportTypeWhenMissing(ReportType.CONFIRMED_TEST)
.build()
}
Risk scoring options
You can either get precalculated risk scores or
you can manually compute risk scores, based on raw data.
Either way, you must configure how diagnosis key data
is translated to exposure window data by providing a
DiagnosisKeysDataMapping object, as described in
Map diagnosis keys to exposure window data.
Getting precalculated risk scores
To request risk scores, first retrieve the diagnosis keys from the server,
then provide them to the Exposure Notifications system using
provideDiagnosisKeys().
Once the diagnosis keys are
stored in the user’s device and registered,
the framework internally creates ExposureWindow objects.
The risk score is calculated per exposure window and aggregated into
DailySummary
objects for the last 14 days.
The Daily summaries section of this guide explains
how to use this feature.
Manually computing risk scores
Once the diagnosis keys are ready, the
getExposureWindows()
method provides the exposures matching these keys.
This raw data lets the app calculate the risk score in different ways,
providing flexibility and more control over the estimation of risk for each PHA.
For sample code, see the
Example of manual risk scoring section.
Daily summaries
To get precalculated risk scores, call
getDailySummaries(),
which returns a list of DailySummary objects corresponding to
the last 14 days of exposure data.
The getDailySummaries() method takes a
DailySummariesConfig
object, which must contain the weights and thresholds to apply to
the exposure data.
Here is an example of how to create a DailySummariesConfig object:
/**
* Configure mapping from ExposureWindow data to DailySummary data
*/
val dailySummariesConfig: DailySummariesConfig by lazy {
DailySummariesConfig.DailySummariesConfigBuilder()
// Don't filter based on ExposureWindow scores.
.setMinimumWindowScore(0.0)
// Include exposures for only the last 10 days.
.setDaysSinceExposureThreshold(10)
// Upweight attenuations indicating very close exposures.
// Downweight attenuations where distance is less certain.
.setAttenuationBuckets(listOf(56, 62, 70), listOf(1.0, 1.0, 0.3, 0.0))
// Double High Infectiousness weight and drop when none
.setInfectiousnessWeight(Infectiousness.STANDARD, 1.0)
.setInfectiousnessWeight(Infectiousness.HIGH, 2.0)
.setInfectiousnessWeight(Infectiousness.NONE, 0.0)
// Include all report types.
.setReportTypeWeight(ReportType.CONFIRMED_CLINICAL_DIAGNOSIS, 1.0)
.setReportTypeWeight(ReportType.CONFIRMED_TEST, 1.0)
.setReportTypeWeight(ReportType.SELF_REPORT, 1.0)
.build()
}
Each DailySummary contains the
ExposureSummaryData
for that particular day,
available for the entire set of exposure data and filtered by reportType.
The data in an ExposureSummaryData object includes the following:
maximumScore: the highest risk score, looking at allExposureWindows aggregated into the summary.scoreSum: sum of the risk scores for allExposureWindows aggregated into the summary.weightedDurationSum: sum of weighted durations for allExposureWindows aggregated into the summary. SeeDailySummariesConfigto learn how to assign attenuation bucket thresholds and weights. The value ofweightedDurationSumis computed in a similar way to the implementation ofgetWindowScore()in the Example of manual risk scoring section.
Exposure data format
The Exposure Notifications API formats exposure data as a series of
ExposureWindow objects.
Each ExposureWindow instance represents
up to 30 minutes of exposure information.
As a result, long exposures to a particular key might be split into
multiple 30-minute blocks.
Each sighting within a 30-minute window is represented by a
ScanInstance
corresponding to a few seconds during which
a beacon with the diagnosis key causing this exposure was observed.
The following code shows the data structures of
ExposureWindow and ScanInstance:
ExposureWindow {
Date day
ReportType reportType
Infectiousness infectiousness
CalibrationConfidence calibrationConfidence
List<ScanInstance> scanInstances
}
ScanInstance {
byte typicalAttenuation
byte minAttenuation
byte secondsSinceLastScan
}
The infectiousness and calibrationConfidence fields, added to
ExposureWindow
in v1.6, are additional factors to contribute to the risk evaluation.
Example of manual risk scoring
This section features an example of
how to compute your own risk score based on ExposureWindows.
The advantage of using this manual method to obtain a risk score is that
the PHA controls exactly how to combine exposure data to
determine whether and how to notify the user of an exposure.
The following example computes the risk score similarly to how the Exposure Notifications system computes daily summaries, based on three factors:
- Weighted minutes-at-attenuation
- Infectiousness weight (note that infectiousness is available only for v1.6 and later)
- Report type weight
/**
* Gets the daily list of risk scores from the given exposure windows.
*/
fun getDailyRiskScores(windows: List<ExposureWindow>): Map<Long, Double> {
val perDayScore = mutableMapOf<Long, Double>()
windows.forEach { window ->
val date = window.dateMillisSinceEpoch
perDayScore[date] = perDayScore.getOrElse(date, { 0.0 }) + getWindowScore(window)
}
// Filter out scores lower than defined threshold. Score is calculated in seconds.
return perDayScore.filterValues {
// Filter out days with a score of less than 15 minutes.
it >= 900
}
}
The method above iterates through the list of
ExposureWindow objects retrieved from the API.
For each ExposureWindow,
the method calculates a risk score based on how many seconds the user has been
within close distance of a user that reported a case.
That’s the window score that gets added to
the corresponding day’s score.
The result is a map of dates with user exposures, measured in seconds.
The code then uses a filter to
remove days with less than 15 minutes of relevant exposure.
The following method calculates the window score:
/**
* Computes the risk score associated with a single window based on the exposure
* seconds, attenuation, and report type.
*/
fun getWindowScore(window: ExposureWindow): Double {
val scansScore = window.scanInstances.sumByDouble { scan ->
scan.secondsSinceLastScan * getAttenuationMultiplier(scan.typicalAttenuationDb)
}
return (scansScore * getReportTypeMultiplier(window.reportType)
* getInfectiousnessMultiplier(window.infectiousness))
}
The method iterates over the different ScanInstance objects and
calculates the score based on the duration of the scan and
the multiplier values associated with
attenuation, report type, and infectiousness.
Here is an example of how to define those multipliers:
fun getReportTypeMultiplier(reportType: Int): Double {
// We only consider exposures to confirmed cases.
return when (reportType) {
ReportType.CONFIRMED_CLINICAL_DIAGNOSIS,
ReportType.CONFIRMED_TEST -> 1.0
else -> 0.0
}
}
fun getAttenuationMultiplier(attenuationDb: Int): Double {
// We simulate attenuation bucket edges of 55, 63 and 70
// with bucket weights 1.0, 0.5, 0.1, and 0.
return when {
attenuationDb <= 55 -> 1.0
attenuationDb <= 63 -> 0.5
attenuationDb <= 70 -> 0.1
else -> 0.0
}
}
fun getInfectiousnessMultiplier(infectiousness: Int): Double {
// STANDARD infectiousness is given a 100% weight,
// HIGH infectiousness is given a 200% weight.
// Note: ExposureWindows with infectiousness==NONE might be dropped
// before we receive them.
return when (infectiousness) {
Infectiousness.STANDARD -> 1.0
Infectiousness.HIGH -> 2.0
else -> 0.0
}
}
Notifying the user of exposure
When the app determines that the user has had meaningful exposure to COVID-19—for example, when the risk score for a day exceeds a threshold—the app needs to notify the user.