Merge branch 'feature/short_range_concentration' into 'master'
Short range implementation See merge request cara/cara!335
This commit is contained in:
commit
f6bd576134
22 changed files with 1740 additions and 367 deletions
14
README.md
14
README.md
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
|
||||
|
||||
CARA models the concentration profile of potential virions in enclosed spaces with clear and intuitive graphs.
|
||||
CARA models the concentration profile of potential virions in enclosed spaces , both as background (room) concentration and during close-proximity interations, with clear and intuitive graphs.
|
||||
The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation.
|
||||
The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.
|
||||
|
||||
The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 infection therein.
|
||||
The results DO NOT include short-range airborne exposure (where the physical distance is a significant factor) nor the other known modes of SARS-CoV-2 transmission.
|
||||
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.
|
||||
The risk assessment tool simulates the airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture and a two-stage exhaled jet model, and estimates the risk of COVID-19 infection therein.
|
||||
The results DO NOT include the other known modes of SARS-CoV-2 transmission, such as fomite or blood-bound.
|
||||
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as good hand hygiene and other barrier measures.
|
||||
|
||||
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021.
|
||||
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2022.
|
||||
It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
|
||||
|
||||
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume.
|
||||
|
|
@ -44,11 +44,13 @@ CARA – COVID Airborne Risk Assessment tool
|
|||
**For use of the model**
|
||||
Henriques A, Mounet N, Aleixo L, Elson P, Devine J, Azzopardi G, Andreini M, Rognlien M, Tarocco N, Tang J. (2022). Modelling airborne transmission of SARS-CoV-2 using CARA: risk assessment for enclosed spaces. _Interface Focus 20210076_. https://doi.org/10.1098/rsfs.2021.0076
|
||||
|
||||
_Note that the short-range component of the model has not yet been published._
|
||||
|
||||
## Applications
|
||||
|
||||
### COVID Calculator
|
||||
|
||||
A risk assessment tool which simulates the long range airborne spread of the SARS-CoV-2 virus for space managers.
|
||||
A risk assessment tool which simulates the long-range airborne spread of the SARS-CoV-2 virus for space managers.
|
||||
|
||||
|
||||
### CARA Expert App
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from .user import AuthenticatedUser, AnonymousUser
|
|||
# calculator version. If the calculator needs to make breaking changes (e.g. change
|
||||
# form attributes) then it can also increase its MAJOR version without needing to
|
||||
# increase the overall CARA version (found at ``cara.__version__``).
|
||||
__version__ = "4.0.0"
|
||||
__version__ = "4.1.0"
|
||||
|
||||
|
||||
class BaseRequestHandler(RequestHandler):
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import datetime
|
|||
import html
|
||||
import logging
|
||||
import typing
|
||||
import ast
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
|
@ -11,8 +13,8 @@ from cara import data
|
|||
import cara.data.weather
|
||||
import cara.monte_carlo as mc
|
||||
from .. import calculator
|
||||
from cara.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions
|
||||
from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions
|
||||
from cara.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions, short_range_distances
|
||||
from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions, short_range_expiration_distributions
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
|
@ -76,6 +78,8 @@ class FormData:
|
|||
window_width: float
|
||||
windows_number: int
|
||||
window_opening_regime: str
|
||||
short_range_option: str
|
||||
short_range_interactions: list
|
||||
|
||||
#: The default values for undefined fields. Note that the defaults here
|
||||
#: and the defaults in the html form must not be contradictory.
|
||||
|
|
@ -127,6 +131,8 @@ class FormData:
|
|||
'windows_frequency': 0.,
|
||||
'windows_number': 0,
|
||||
'window_opening_regime': 'windows_open_permanently',
|
||||
'short_range_option': 'short_range_no',
|
||||
'short_range_interactions': '[]',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -240,14 +246,27 @@ class FormData:
|
|||
humidity = 0.5
|
||||
room = models.Room(volume=volume, humidity=humidity)
|
||||
|
||||
infected_population = self.infected_population()
|
||||
|
||||
short_range = []
|
||||
if self.short_range_option == "short_range_yes":
|
||||
for interaction in self.short_range_interactions:
|
||||
short_range.append(mc.ShortRangeModel(
|
||||
expiration=short_range_expiration_distributions[interaction['expiration']],
|
||||
activity=infected_population.activity,
|
||||
presence=self.short_range_interval(interaction),
|
||||
distance=short_range_distances,
|
||||
))
|
||||
|
||||
# Initializes and returns a model with the attributes defined above
|
||||
return mc.ExposureModel(
|
||||
concentration_model=mc.ConcentrationModel(
|
||||
room=room,
|
||||
ventilation=self.ventilation(),
|
||||
infected=self.infected_population(),
|
||||
infected=infected_population,
|
||||
evaporation_factor=0.3,
|
||||
),
|
||||
short_range = tuple(short_range),
|
||||
exposed=self.exposed_population(),
|
||||
)
|
||||
|
||||
|
|
@ -629,6 +648,11 @@ class FormData:
|
|||
breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(),
|
||||
)
|
||||
|
||||
def short_range_interval(self, interaction) -> models.SpecificInterval:
|
||||
start_time = time_string_to_minutes(interaction['start_time'])
|
||||
duration = float(interaction['duration'])
|
||||
return models.SpecificInterval(present_times=((start_time/60, (start_time + duration)/60),))
|
||||
|
||||
def exposed_present_interval(self) -> models.Interval:
|
||||
return self.present_interval(
|
||||
self.exposed_start, self.exposed_finish,
|
||||
|
|
@ -636,7 +660,7 @@ class FormData:
|
|||
)
|
||||
|
||||
|
||||
def build_expiration(expiration_definition) -> models._ExpirationBase:
|
||||
def build_expiration(expiration_definition) -> mc._ExpirationBase:
|
||||
if isinstance(expiration_definition, str):
|
||||
return expiration_distributions[expiration_definition]
|
||||
elif isinstance(expiration_definition, dict):
|
||||
|
|
@ -645,7 +669,7 @@ def build_expiration(expiration_definition) -> models._ExpirationBase:
|
|||
np.array(expiration_BLO_factors[exp_type]) * weight/total_weight
|
||||
for exp_type, weight in expiration_definition.items()
|
||||
], axis=0)
|
||||
return expiration_distribution(tuple(BLO_factors))
|
||||
return expiration_distribution(BLO_factors=tuple(BLO_factors))
|
||||
|
||||
|
||||
def baseline_raw_form_data():
|
||||
|
|
@ -697,7 +721,9 @@ def baseline_raw_form_data():
|
|||
'window_type': 'window_sliding',
|
||||
'window_width': '2',
|
||||
'windows_number': '1',
|
||||
'window_opening_regime': 'windows_open_permanently'
|
||||
'window_opening_regime': 'windows_open_permanently',
|
||||
'short_range_option': 'short_range_no',
|
||||
'short_range_interactions': '[]',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -742,6 +768,14 @@ def time_minutes_to_string(time: int) -> str:
|
|||
return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time%60)
|
||||
|
||||
|
||||
def string_to_list(l: str) -> list:
|
||||
return list(ast.literal_eval(l.replace(""", "\"")))
|
||||
|
||||
|
||||
def list_to_string(s: list) -> str:
|
||||
return json.dumps(s)
|
||||
|
||||
|
||||
def _safe_int_cast(value) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
|
|
@ -773,3 +807,6 @@ for _field in dataclasses.fields(FormData):
|
|||
elif _field.type is bool:
|
||||
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = lambda v: v == '1'
|
||||
_CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = int
|
||||
elif _field.type is list:
|
||||
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = string_to_list
|
||||
_CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = list_to_string
|
||||
|
|
|
|||
|
|
@ -97,29 +97,54 @@ def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.L
|
|||
return nice_times
|
||||
|
||||
|
||||
def calculate_report_data(model: models.ExposureModel):
|
||||
times = interesting_times(model)
|
||||
def concentrations_with_sr_breathing(form: FormData, model: models.ExposureModel, times: typing.List[float], short_range_intervals: typing.List) -> typing.List[float]:
|
||||
lower_concentrations = []
|
||||
for time in times:
|
||||
for index, (start, stop) in enumerate(short_range_intervals):
|
||||
# For visualization issues, add short-range breathing activity to the initial long-range concentrations
|
||||
if start <= time <= stop and form.short_range_interactions[index]['expiration'] == 'Breathing':
|
||||
lower_concentrations.append(np.array(model.concentration(float(time))).mean())
|
||||
break
|
||||
lower_concentrations.append(np.array(model.concentration_model.concentration(float(time))).mean())
|
||||
return lower_concentrations
|
||||
|
||||
|
||||
def calculate_report_data(form: FormData, model: models.ExposureModel):
|
||||
times = interesting_times(model)
|
||||
short_range_intervals = [interaction.presence.boundaries()[0] for interaction in model.short_range]
|
||||
short_range_expirations = [interaction['expiration'] for interaction in form.short_range_interactions] if form.short_range_option == "short_range_yes" else []
|
||||
|
||||
concentrations = [
|
||||
np.array(model.concentration_model.concentration(float(time))).mean()
|
||||
np.array(model.concentration(float(time))).mean()
|
||||
for time in times
|
||||
]
|
||||
]
|
||||
lower_concentrations = concentrations_with_sr_breathing(form, model, times, short_range_intervals)
|
||||
highest_const = max(concentrations)
|
||||
prob = np.array(model.infection_probability()).mean()
|
||||
er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean()
|
||||
exposed_occupants = model.exposed.number
|
||||
expected_new_cases = np.array(model.expected_new_cases()).mean()
|
||||
|
||||
cumulative_doses = np.cumsum([
|
||||
np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean()
|
||||
for time1, time2 in zip(times[:-1], times[1:])
|
||||
])
|
||||
long_range_cumulative_doses = np.cumsum([
|
||||
np.array(model.long_range_deposited_exposure_between_bounds(float(time1), float(time2))).mean()
|
||||
for time1, time2 in zip(times[:-1], times[1:])
|
||||
])
|
||||
|
||||
prob = np.array(model.infection_probability()).mean()
|
||||
er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean()
|
||||
exposed_occupants = model.exposed.number
|
||||
expected_new_cases = np.array(model.expected_new_cases()).mean()
|
||||
|
||||
return {
|
||||
"times": list(times),
|
||||
"exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()],
|
||||
"cumulative_doses": list(cumulative_doses),
|
||||
"short_range_intervals": short_range_intervals,
|
||||
"short_range_expirations": short_range_expirations,
|
||||
"concentrations": concentrations,
|
||||
"concentrations_zoomed": lower_concentrations,
|
||||
"highest_const": highest_const,
|
||||
"cumulative_doses": list(cumulative_doses),
|
||||
"long_range_cumulative_doses": list(long_range_cumulative_doses),
|
||||
"prob_inf": prob,
|
||||
"emission_rate": er,
|
||||
"exposed_occupants": exposed_occupants,
|
||||
|
|
@ -197,51 +222,52 @@ def non_zero_percentage(percentage: int) -> str:
|
|||
|
||||
def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.ExposureModel]:
|
||||
scenarios = {}
|
||||
if (form.short_range_option == "short_range_no"):
|
||||
# Two special option cases - HEPA and/or FFP2 masks.
|
||||
FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2')
|
||||
if FFP2_being_worn and form.hepa_option:
|
||||
FFP2andHEPAalternative = dataclass_utils.replace(form, mask_type='Type I')
|
||||
scenarios['Base scenario with HEPA filter and Type I masks'] = FFP2andHEPAalternative.build_mc_model()
|
||||
if not FFP2_being_worn and form.hepa_option:
|
||||
noHEPAalternative = dataclass_utils.replace(form, mask_type = 'FFP2')
|
||||
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, mask_wearing_option = 'mask_on')
|
||||
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, hepa_option=False)
|
||||
scenarios['Base scenario without HEPA filter, with FFP2 masks'] = noHEPAalternative.build_mc_model()
|
||||
|
||||
# Two special option cases - HEPA and/or FFP2 masks.
|
||||
FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2')
|
||||
if FFP2_being_worn and form.hepa_option:
|
||||
FFP2andHEPAalternative = dataclass_utils.replace(form, mask_type='Type I')
|
||||
scenarios['Base scenario with HEPA filter and Type I masks'] = FFP2andHEPAalternative.build_mc_model()
|
||||
if not FFP2_being_worn and form.hepa_option:
|
||||
noHEPAalternative = dataclass_utils.replace(form, mask_type = 'FFP2')
|
||||
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, mask_wearing_option = 'mask_on')
|
||||
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, hepa_option=False)
|
||||
scenarios['Base scenario without HEPA filter, with FFP2 masks'] = noHEPAalternative.build_mc_model()
|
||||
# The remaining scenarios are based on Type I masks (possibly not worn)
|
||||
# and no HEPA filtration.
|
||||
form = dataclass_utils.replace(form, mask_type='Type I')
|
||||
if form.hepa_option:
|
||||
form = dataclass_utils.replace(form, hepa_option=False)
|
||||
|
||||
# The remaining scenarios are based on Type I masks (possibly not worn)
|
||||
# and no HEPA filtration.
|
||||
form = dataclass_utils.replace(form, mask_type='Type I')
|
||||
if form.hepa_option:
|
||||
form = dataclass_utils.replace(form, hepa_option=False)
|
||||
with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on')
|
||||
without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off')
|
||||
|
||||
with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on')
|
||||
without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off')
|
||||
if form.ventilation_type == 'mechanical_ventilation':
|
||||
#scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model()
|
||||
scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model()
|
||||
|
||||
if form.ventilation_type == 'mechanical_ventilation':
|
||||
#scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model()
|
||||
scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model()
|
||||
elif form.ventilation_type == 'natural_ventilation':
|
||||
#scenarios['Windows open with Type I masks'] = with_mask.build_mc_model()
|
||||
scenarios['Windows open without masks'] = without_mask.build_mc_model()
|
||||
|
||||
elif form.ventilation_type == 'natural_ventilation':
|
||||
#scenarios['Windows open with Type I masks'] = with_mask.build_mc_model()
|
||||
scenarios['Windows open without masks'] = without_mask.build_mc_model()
|
||||
|
||||
# No matter the ventilation scheme, we include scenarios which don't have any ventilation.
|
||||
with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation')
|
||||
without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation')
|
||||
scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model()
|
||||
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model()
|
||||
# No matter the ventilation scheme, we include scenarios which don't have any ventilation.
|
||||
with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation')
|
||||
without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation')
|
||||
scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model()
|
||||
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model()
|
||||
|
||||
return scenarios
|
||||
|
||||
|
||||
def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray):
|
||||
def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[float]):
|
||||
model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE)
|
||||
|
||||
return {
|
||||
'probability_of_infection': np.mean(model.infection_probability()),
|
||||
'expected_new_cases': np.mean(model.expected_new_cases()),
|
||||
'concentrations': [
|
||||
np.mean(model.concentration_model.concentration(time))
|
||||
np.mean(model.concentration(time))
|
||||
for time in sample_times
|
||||
],
|
||||
}
|
||||
|
|
@ -301,7 +327,7 @@ class ReportGenerator:
|
|||
|
||||
scenario_sample_times = interesting_times(model)
|
||||
|
||||
context.update(calculate_report_data(model))
|
||||
context.update(calculate_report_data(form, model))
|
||||
alternative_scenarios = manufacture_alternative_scenarios(form)
|
||||
context['alternative_scenarios'] = comparison_report(
|
||||
alternative_scenarios, scenario_sample_times, executor_factory=executor_factory,
|
||||
|
|
|
|||
|
|
@ -144,6 +144,15 @@ p.notes {
|
|||
padding: 15px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
#button_full_exposure, #button_hide_high_concentration {
|
||||
display: none!important;
|
||||
}
|
||||
#long_range_cumulative_checkbox, #lr_cumulative_checkbox_label {
|
||||
display: none!important;
|
||||
}
|
||||
#button_alternative_full_exposure, #button_alternative_hide_high_concentration {
|
||||
display: none!important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -242,6 +242,16 @@ function on_wearing_mask_change() {
|
|||
if (this.checked) {
|
||||
getChildElement($(this)).show();
|
||||
require_fields(this);
|
||||
if (this.id == "mask_on") {
|
||||
$('#short_range_no').click();
|
||||
$('input[name="short_range_option"]').attr('disabled', true);
|
||||
$("#short_range_warning").show();
|
||||
}
|
||||
else {
|
||||
$('input[name="short_range_option"]').attr('disabled', false);
|
||||
$("#short_range_warning").hide();
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
getChildElement($(this)).hide();
|
||||
|
|
@ -250,6 +260,20 @@ function on_wearing_mask_change() {
|
|||
})
|
||||
}
|
||||
|
||||
function on_short_range_option_change() {
|
||||
short_range = $('input[type=radio][name=short_range_option]')
|
||||
short_range.each(function (index){
|
||||
if (this.checked) {
|
||||
getChildElement($(this)).show();
|
||||
require_fields(this);
|
||||
}
|
||||
else {
|
||||
getChildElement($(this)).hide();
|
||||
require_fields(this);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* -------UI------- */
|
||||
|
||||
function show_disclaimer() {
|
||||
|
|
@ -379,6 +403,27 @@ function validate_form(form) {
|
|||
}
|
||||
}
|
||||
|
||||
// Generate the short-range interactions list
|
||||
var short_range_interactions = [];
|
||||
$(".form_field_outer_row").each(function (index, element){
|
||||
let obj = {};
|
||||
const $element = $(element);
|
||||
obj.expiration = $element.find("[name='short_range_expiration']").val();
|
||||
obj.start_time = $element.find("[name='short_range_start_time']").val();
|
||||
obj.duration = $element.find("[name='short_range_duration']").val();
|
||||
short_range_interactions.push(JSON.stringify(obj));
|
||||
});
|
||||
|
||||
// Sort list by time
|
||||
short_range_interactions.sort(function (a, b) {
|
||||
return JSON.parse(a).start_time.localeCompare(JSON.parse(b).start_time);
|
||||
});
|
||||
$("input[type=text][name=short_range_interactions]").val('[' + short_range_interactions + ']');
|
||||
if (short_range_interactions.length == 0) {
|
||||
$("input[type=radio][id=short_range_no]").prop("checked", true);
|
||||
on_short_range_option_change();
|
||||
}
|
||||
|
||||
if (submit) {
|
||||
$("#generate_report").prop("disabled", true);
|
||||
//Add spinner to button
|
||||
|
|
@ -492,6 +537,84 @@ function validateLunchTime(obj) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function overlapped_times(obj, start_time, finish_time) {
|
||||
removeErrorFor($(obj));
|
||||
$(obj).removeClass("red_border");
|
||||
|
||||
let parameter = document.getElementById($(obj).attr('id'));
|
||||
|
||||
if ($(obj).attr('name') == "short_range_duration" && parseFloat($(obj).val()) < 15.0) {
|
||||
if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message.
|
||||
insertErrorFor(parameter, "Must be ≥ 15 min.")
|
||||
return false;
|
||||
}
|
||||
|
||||
let simulation_start = parseTimeToMins($("#exposed_start").val())
|
||||
let simulation_finish = parseTimeToMins($("#exposed_finish").val())
|
||||
var simulation_lunch_start, simulation_lunch_finish;
|
||||
if ($('input[name=exposed_lunch_option]:checked').val() == 1) {
|
||||
simulation_lunch_start = parseTimeToMins($("#exposed_lunch_start").val())
|
||||
simulation_lunch_finish = parseTimeToMins($("#exposed_lunch_finish").val())
|
||||
} else {
|
||||
simulation_lunch_start = 0
|
||||
simulation_lunch_finish = 0
|
||||
}
|
||||
if (start_time < simulation_start || start_time > simulation_finish ||
|
||||
finish_time < simulation_start || finish_time > simulation_finish ||
|
||||
start_time >= simulation_lunch_start && start_time <= simulation_lunch_finish ||
|
||||
finish_time >= simulation_lunch_start && finish_time <= simulation_lunch_finish ) {//If start and finish inputs are out of the simulation period
|
||||
//Adds the red border and error message.
|
||||
if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border");
|
||||
insertErrorFor(parameter, "Out of event time.");
|
||||
return false;
|
||||
}
|
||||
let current_interaction = $(obj).closest(".form_field_outer_row");
|
||||
var toReturn = true;
|
||||
$(".form_field_outer_row.row_validated").not(current_interaction).each(function(index, el) {
|
||||
let current_start_el = $(el).find("input[name='short_range_start_time']");
|
||||
let current_duration_el = $(el).find("input[name='short_range_duration']")
|
||||
start_time_2 = parseTimeToMins(current_start_el.val())
|
||||
finish_time_2 = parseTimeToMins(current_start_el.val()) + parseInt(current_duration_el.val());
|
||||
if ((start_time > start_time_2 && start_time < finish_time_2) || ( //If hour input is within other time range
|
||||
finish_time > start_time_2 && finish_time < finish_time_2) || //If finish time input is within other time range
|
||||
(start_time <= start_time_2 && finish_time >= finish_time_2) || //If start and finish inputs encompass other time range
|
||||
start_time == start_time_2) {
|
||||
if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message.
|
||||
insertErrorFor(parameter, "Time overlap.")
|
||||
toReturn = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
function validate_sr_time(obj) {
|
||||
let obj_id = $(obj).attr('id').split('_').slice(-1)[0];
|
||||
var start_time, finish_time;
|
||||
if ($(obj).val() != "") {
|
||||
if ($('#sr_start_no_' + String(obj_id)).val()) start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val());
|
||||
else start = 0.
|
||||
finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val());
|
||||
}
|
||||
return overlapped_times(obj, start_time, finish_time);
|
||||
};
|
||||
|
||||
// Check if short-range durations are filled, and if there is no repetitions
|
||||
function validate_sr_parameter(obj, error_message) {
|
||||
if ($(obj).val() == "" || $(obj).val() == null) {
|
||||
if (!$(obj).hasClass("red_border") && !$(obj).prop("disabled")) {
|
||||
var parameter = document.getElementById($(obj).attr('id'));
|
||||
insertErrorFor(parameter, error_message)
|
||||
$(parameter).addClass("red_border");
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
removeErrorFor($(obj));
|
||||
$(obj).removeClass("red_border");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function parseValToNumber(val) {
|
||||
return parseInt(val.replace(':',''), 10);
|
||||
}
|
||||
|
|
@ -529,6 +652,22 @@ $(document).ready(function () {
|
|||
elemObj.checked = (value==1);
|
||||
}
|
||||
|
||||
// Read short-range from URL
|
||||
else if (name == 'short_range_interactions') {
|
||||
let index = 1;
|
||||
for (const interaction of JSON.parse(value)) {
|
||||
$("#dialog_sr").append(inject_sr_interaction(index, value = interaction, is_validated="row_validated"))
|
||||
$('#sr_expiration_no_' + String(index)).val(interaction.expiration).change();
|
||||
document.getElementById('sr_expiration_no_' + String(index)).disabled = true;
|
||||
document.getElementById('sr_start_no_' + String(index)).disabled = true;
|
||||
document.getElementById('sr_duration_no_' + String(index)).disabled = true;
|
||||
document.getElementById('edit_row_no_' + String(index)).style.cssText = 'display:inline !important';
|
||||
document.getElementById('validate_row_no_' + String(index)).style.cssText = 'display: none !important';
|
||||
index++;
|
||||
}
|
||||
$("#sr_interactions").text(index - 1);
|
||||
}
|
||||
|
||||
//Ignore 0 (default) values from server side
|
||||
else if (!(elemObj.classList.contains("non_zero") || elemObj.classList.contains("remove_zero")) || (value != "0.0" && value != "0")) {
|
||||
elemObj.value = value;
|
||||
|
|
@ -578,6 +717,13 @@ $(document).ready(function () {
|
|||
// Call the function now to handle forward/back button presses in the browser.
|
||||
on_wearing_mask_change();
|
||||
|
||||
// When the short_range_option changes we want to make its respective
|
||||
// children show/hide.
|
||||
$("input[type=radio][name=short_range_option]").change(on_short_range_option_change);
|
||||
|
||||
// Call the function now to handle forward/back button presses in the browser.
|
||||
on_short_range_option_change();
|
||||
|
||||
// Setup the maximum number of people at page load (to handle back/forward),
|
||||
// and update it when total people is changed.
|
||||
setMaxInfectedPeople();
|
||||
|
|
@ -690,6 +836,134 @@ $(document).ready(function () {
|
|||
}
|
||||
return selectedSuggestion.text;
|
||||
}
|
||||
|
||||
function inject_sr_interaction(index, value, is_validated) {
|
||||
return `<div class="col-md-12 form_field_outer p-0">
|
||||
<div class="form_field_outer_row ${is_validated} split">
|
||||
|
||||
<div class='form-group row'>
|
||||
<div class="col-sm-4"><label class="col-form-label col-form-label-sm"> Expiration: </label><br></div>
|
||||
<div class="col-sm-8"><select id="sr_expiration_no_${index}" name="short_range_expiration" class="form-control form-control-sm" onchange="validate_sr_parameter(this)" form="not-submitted">
|
||||
<option value="" selected disabled>Select type</option>
|
||||
<option value="Breathing">Breathing</option>
|
||||
<option value="Speaking">Speaking</option>
|
||||
<option value="Shouting">Shouting</option>
|
||||
</select><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='form-group row'>
|
||||
<div class="col-sm-4"><label class="col-form-label col-form-label-sm"> Start: </label></div>
|
||||
<div class="col-sm-8"><input type="time" class="form-control form-control-sm short_range_option" name="short_range_start_time" id="sr_start_no_${index}" value="${value.start_time}" onchange="validate_sr_time(this)" form="not-submitted"><br></div>
|
||||
</div>
|
||||
|
||||
<div class='form-group row'>
|
||||
<div class="col-sm-4"><label class="col-form-label col-form-label-sm"> Duration:</label></div>
|
||||
<div class="col-sm-8"><input type="number" id="sr_duration_no_${index}" value="${value.duration}" class="form-control form-control-sm short_range_option" name="short_range_duration" min=1 placeholder="Minutes" onchange="validate_sr_time(this)" form="not-submitted"><br></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="max-width: 8rem">
|
||||
<button type="button" id="edit_row_no_${index}" class="edit_node_btn_frm_field btn btn-success btn-sm d-none">Edit</button>
|
||||
<button type="button" id="validate_row_no_${index}" class="validate_node_btn_frm_field btn btn-success btn-sm">Save</button>
|
||||
<button type="button" class="remove_node_btn_frm_field btn btn-danger btn-sm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// Add one empty row if none.
|
||||
$("#set_interactions_button").on("click", e => {
|
||||
if ($(".form_field_outer").find(".form_field_outer_row").length == 0) $(".add_node_btn_frm_field").click();
|
||||
});
|
||||
|
||||
// When short_range_yes option is selected, we want to inject rows for each expiractory activity, start_time and duration.
|
||||
$("body").on("click", ".add_node_btn_frm_field", function(e) {
|
||||
let last_row = $(".form_field_outer").find(".form_field_outer_row");
|
||||
if (last_row.length == 0) $("#dialog_sr").append(inject_sr_interaction(1, value = { activity: "", start_time: "", duration: "15" }));
|
||||
else {
|
||||
last_index = last_row.last().find(".short_range_option").prop("id").split("_").slice(-1)[0];
|
||||
index = parseInt(last_index) + 1;
|
||||
$("#dialog_sr").append(inject_sr_interaction(index, value = { activity: "", start_time: "", duration: "15" }));
|
||||
}
|
||||
});
|
||||
|
||||
// Validate row button (Save button)
|
||||
$("body").on("click", ".validate_node_btn_frm_field", function() {
|
||||
var index = $(this).attr('id').split('_').slice(-1)[0];
|
||||
let activity = validate_sr_parameter('#sr_expiration_no_' + String(index)[0], "Required input.");
|
||||
let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input.");
|
||||
let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input.");
|
||||
if (activity && start && duration) {
|
||||
if (validate_sr_time('#sr_start_no_' + String(index)) && validate_sr_time('#sr_duration_no_' + String(index))) {
|
||||
document.getElementById('sr_expiration_no_' + String(index)).disabled = true;
|
||||
document.getElementById('sr_start_no_' + String(index)).disabled = true;
|
||||
document.getElementById('sr_duration_no_' + String(index)).disabled = true;
|
||||
document.getElementById('edit_row_no_' + String(index)).style.cssText = 'display:inline !important';
|
||||
$(this).closest(".form_field_outer_row").addClass("row_validated");
|
||||
$(this).hide();
|
||||
index = index + 1;
|
||||
}
|
||||
}
|
||||
// On save, check open/unvalidated rows.
|
||||
$(".validate_node_btn_frm_field").not(".row_validated").not(this).each(function( index ) {
|
||||
index = $(this).attr('id').split('_').slice(-1)[0];
|
||||
if ($('#sr_start_no_' + String(index)[0]).val() != "") {
|
||||
validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input.")
|
||||
validate_sr_time('#sr_start_no_' + String(index));
|
||||
};
|
||||
if ($('#sr_duration_no_' + String(index)[0]).val() != "") {
|
||||
validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input.");
|
||||
validate_sr_time('#sr_duration_no_' + String(index));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
//Edit short-range activity type
|
||||
$("body").on("click", ".edit_node_btn_frm_field", function() {
|
||||
$(this).closest(".form_field_outer_row").removeClass("row_validated");
|
||||
$(this).hide();
|
||||
let id = $(this).attr('id').split('_').slice(-1)[0];
|
||||
document.getElementById('sr_expiration_no_' + String(id)).disabled = false;
|
||||
document.getElementById('sr_start_no_' + String(id)).disabled = false;
|
||||
document.getElementById('sr_duration_no_' + String(id)).disabled = false;
|
||||
document.getElementById('validate_row_no_' + String(id)).style.cssText = 'display:inline !important';
|
||||
})
|
||||
|
||||
//Remove short-range interaction (modal field row).
|
||||
$("body").on("click", ".remove_node_btn_frm_field", function() {
|
||||
$(this).closest(".form_field_outer_row").remove();
|
||||
// On delete, check open/unvalidated rows.
|
||||
$(".validate_node_btn_frm_field").not(".row_validated").not(this).each(function( index ) {
|
||||
index = $(this).attr('id').split('_').slice(-1)[0];
|
||||
if ($('#sr_start_no_' + String(index)[0]).val() != "") {
|
||||
validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input.")
|
||||
validate_sr_time('#sr_start_no_' + String(index));
|
||||
};
|
||||
if ($('#sr_duration_no_' + String(index)[0]).val() != "") {
|
||||
validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input.");
|
||||
validate_sr_time('#sr_duration_no_' + String(index));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
//Short-range modal - close and save button
|
||||
$("body").on("click", ".close_btn_frm_field", function() {
|
||||
$(".validate_node_btn_frm_field").click();
|
||||
if ($(".form_field_outer").find(".form_field_outer_row.row_validated").length == $(".form_field_outer").find(".form_field_outer_row").length) {
|
||||
$("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row.row_validated").length);
|
||||
$(".form_field_outer_row").not(".row_validated").remove();
|
||||
$('#short_range_dialog').modal('hide');
|
||||
}
|
||||
});
|
||||
|
||||
//Short-range modal - reset button
|
||||
$("body").on("click", ".dismiss_btn_frm_field", function() {
|
||||
$(".form_field_outer_row").remove();
|
||||
$("#sr_interactions").text(0);
|
||||
$('input[type=radio][id=short_range_no]').prop("checked", true);
|
||||
on_short_range_option_change();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +1,38 @@
|
|||
/* Generate the concentration plot using d3 library. */
|
||||
function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses, exposed_presence_intervals) {
|
||||
function draw_plot(svg_id) {
|
||||
|
||||
console.log(cumulative_doses)
|
||||
|
||||
var time_format = d3.timeFormat('%H:%M');
|
||||
// Used for controlling the short-range interactions
|
||||
let button_full_exposure = document.getElementById("button_full_exposure");
|
||||
let button_hide_high_concentration = document.getElementById("button_hide_high_concentration");
|
||||
let long_range_checkbox = document.getElementById('long_range_cumulative_checkbox')
|
||||
let show_sr_legend = short_range_expirations.length > 0;
|
||||
|
||||
var data_for_graphs = {
|
||||
'concentrations': [],
|
||||
'cumulative_doses': [],
|
||||
'long_range_cumulative_doses': [],
|
||||
}
|
||||
times.map((time, index) => data_for_graphs.concentrations.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': concentrations[index]}));
|
||||
times.map((time, index) => data_for_graphs.cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': cumulative_doses[index]}));
|
||||
times.map((time, index) => data_for_graphs.long_range_cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': long_range_cumulative_doses[index]}));
|
||||
|
||||
const tooltip_data_for_graphs = Object.fromEntries(Object.entries(data_for_graphs).filter(([key]) => !key.includes('long_range_cumulative_doses')));
|
||||
|
||||
// Add main SVG element
|
||||
var plot_div = document.getElementById(svg_id);
|
||||
var vis = d3.select(plot_div).append('svg');
|
||||
|
||||
var time_format = d3.timeFormat('%H:%M');
|
||||
// H:M time format for x axis.
|
||||
xRange = d3.scaleTime().domain([data_for_graphs.concentrations[0].hour, data_for_graphs.concentrations[data_for_graphs.concentrations.length - 1].hour]),
|
||||
xTimeRange = d3.scaleLinear().domain([data_for_graphs.concentrations[0].time, data_for_graphs.concentrations[data_for_graphs.concentrations.length - 1].time]),
|
||||
bisecHour = d3.bisector((d) => { return d.hour; }).left,
|
||||
|
||||
yRange = d3.scaleLinear().domain([0., Math.max(...concentrations)]),
|
||||
yCumulativeRange = d3.scaleLinear().domain([0., Math.max(...cumulative_doses)*1.1]),
|
||||
|
||||
xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
|
||||
yAxis = d3.axisLeft(yRange).ticks(4),
|
||||
yCumulativeAxis = d3.axisRight(yCumulativeRange).ticks(4);
|
||||
|
||||
// Line representing the mean concentration.
|
||||
var lineFunc = d3.line();
|
||||
var draw_line = vis.append('svg:path')
|
||||
.attr('stroke', '#1f77b4')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('fill', 'none');
|
||||
|
||||
var lineCumulative = d3.line();
|
||||
var draw_cumulative_line = vis.append('svg:path')
|
||||
.attr('stroke', '#1f77b4')
|
||||
.attr('stroke-width', 2)
|
||||
.style("stroke-dasharray", "5 5")
|
||||
.attr('fill', 'none');
|
||||
|
||||
// Area representing the presence of exposed person(s).
|
||||
var exposedArea = {};
|
||||
var drawArea = {};
|
||||
exposed_presence_intervals.forEach((b, index) => {
|
||||
exposedArea[index] = d3.area();
|
||||
drawArea[index] = vis.append('svg:path')
|
||||
.attr('fill', '#1f77b4')
|
||||
.attr('fill-opacity', '0.1');
|
||||
});
|
||||
yRange = d3.scaleLinear(),
|
||||
yCumulativeRange = d3.scaleLinear(),
|
||||
|
||||
yAxis = d3.axisLeft(),
|
||||
yCumulativeAxis = d3.axisRight();
|
||||
|
||||
// X axis declaration.
|
||||
var xAxisEl = vis.append('svg:g')
|
||||
|
|
@ -77,7 +59,6 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
// Y cumulative concentration axis declaration.
|
||||
var yAxisCumEl = vis.append('svg:g')
|
||||
.attr('class', 'y axis')
|
||||
.style('font-size', 14)
|
||||
.style("stroke-dasharray", "5 5");
|
||||
|
||||
// Y cumulated concentration axis label.
|
||||
|
|
@ -88,41 +69,76 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
.text('Mean cumulative dose (infectious virus)');
|
||||
|
||||
// Legend for the plot elements - line and area.
|
||||
|
||||
// Concentration line icon
|
||||
var legendLineIcon = vis.append('rect')
|
||||
.attr('width', 20)
|
||||
.attr('height', 3)
|
||||
.style('fill', '#1f77b4');
|
||||
// Concentration line text
|
||||
var legendLineText = vis.append('text')
|
||||
.text('Mean concentration')
|
||||
.style('font-size', '15px');
|
||||
|
||||
// Cumulative dose line icon
|
||||
var legendCumulativeIcon = vis.append('line')
|
||||
.style("stroke-dasharray", "5 5") //dashed array for line
|
||||
.attr('stroke-width', '2')
|
||||
.style("stroke", '#1f77b4');
|
||||
|
||||
var legendAreaIcon = vis.append('rect')
|
||||
.attr('width', 20)
|
||||
.attr('height', 20)
|
||||
.attr('fill', '#1f77b4')
|
||||
.attr('fill-opacity', '0.1');
|
||||
|
||||
var legendLineText = vis.append('text')
|
||||
.text('Mean concentration')
|
||||
.style('font-size', '15px')
|
||||
.attr('alignment-baseline', 'central');
|
||||
|
||||
// Cumulative dose line text
|
||||
var legendCumutiveText = vis.append('text')
|
||||
.text('Cumulative dose')
|
||||
.style('font-size', '15px')
|
||||
.attr('alignment-baseline', 'central');
|
||||
.style('font-size', '15px');
|
||||
|
||||
// Area line icon
|
||||
var legendAreaIcon = vis.append('rect')
|
||||
.attr('width', 20)
|
||||
.attr('height', 15)
|
||||
.attr('fill', '#1f77b4')
|
||||
.attr('fill-opacity', '0.1');
|
||||
// Area line text
|
||||
var legendAreaText = vis.append('text')
|
||||
.text('Presence of exposed person(s)')
|
||||
.style('font-size', '15px')
|
||||
.attr('alignment-baseline', 'central');
|
||||
.style('font-size', '15px');
|
||||
|
||||
sr_unique_activities = [...new Set(short_range_expirations)]
|
||||
if (show_sr_legend) {
|
||||
// Long range cumulative dose line legend - line and area
|
||||
var legendLongCumulativeIcon = vis.append('line')
|
||||
.style("stroke-dasharray", "5 5") //dashed array for line
|
||||
.attr('stroke-width', '2')
|
||||
.style("stroke", 'purple')
|
||||
.attr('opacity', 0);
|
||||
var legendLongCumutiveText = vis.append('text')
|
||||
.text('Long-range cumulative dose')
|
||||
.style('font-size', '15px')
|
||||
.attr('opacity', 0);
|
||||
// Short range area icon
|
||||
var legendShortRangeAreaIcon = {};
|
||||
sr_unique_activities.forEach((b, index) => {
|
||||
legendShortRangeAreaIcon[index] = vis.append('rect')
|
||||
.attr('width', 20)
|
||||
.attr('height', 15);
|
||||
// Short range area icon colors
|
||||
if (sr_unique_activities[index] == 'Breathing') legendShortRangeAreaIcon[index].attr('fill', 'red').attr('fill-opacity', '0.2');
|
||||
else if (sr_unique_activities[index] == 'Speaking') legendShortRangeAreaIcon[index].attr('fill', 'green').attr('fill-opacity', '0.1');
|
||||
else legendShortRangeAreaIcon[index].attr('fill', 'blue').attr('fill-opacity', '0.1');
|
||||
});
|
||||
// Short range area text
|
||||
var legendShortRangeText = {};
|
||||
sr_unique_activities.forEach((b, index) => {
|
||||
legendShortRangeText[index] = vis.append('text')
|
||||
.text('Short-range - ' + sr_unique_activities[index])
|
||||
.style('font-size', '15px');
|
||||
});
|
||||
}
|
||||
|
||||
// Legend bounding
|
||||
if (show_sr_legend) legendBBox_height = 68 + 20 * sr_unique_activities.length;
|
||||
else legendBBox_height = 68;
|
||||
var legendBBox = vis.append('rect')
|
||||
.attr('width', 255)
|
||||
.attr('height', 70)
|
||||
.attr('height', legendBBox_height)
|
||||
.attr('stroke', 'lightgrey')
|
||||
.attr('stroke-width', '2')
|
||||
.attr('rx', '5px')
|
||||
|
|
@ -130,9 +146,64 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
.attr('stroke-linejoin', 'round')
|
||||
.attr('fill', 'none');
|
||||
|
||||
var clip = vis.append("defs").append("svg:clipPath")
|
||||
.attr("id", "clip")
|
||||
.append("svg:rect");
|
||||
|
||||
var draw_area = vis.append('svg:g')
|
||||
.attr('clip-path', 'url(#clip)');
|
||||
// Line representing the mean concentration.
|
||||
var lineFunc = d3.line();
|
||||
draw_area.append('svg:path')
|
||||
.attr('class', 'line')
|
||||
.attr('stroke', '#1f77b4')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('fill', 'none');
|
||||
|
||||
// Line representing the cumulative concentration.
|
||||
var lineCumulative = d3.line();
|
||||
var draw_cumulative_line = draw_area.append('svg:path')
|
||||
.attr('stroke', '#1f77b4')
|
||||
.attr('stroke-width', 2)
|
||||
.style("stroke-dasharray", "5 5")
|
||||
.attr('fill', 'none');
|
||||
|
||||
// Line representing the long-range cumulative concentration.
|
||||
if (show_sr_legend) {
|
||||
var longRangeCumulative = d3.line();
|
||||
var draw_long_range_cumulative_line = draw_area.append('svg:path')
|
||||
.attr('stroke', 'purple')
|
||||
.attr('stroke-width', 2)
|
||||
.style("stroke-dasharray", "5 5")
|
||||
.attr('fill', 'none')
|
||||
.attr('opacity', 0);
|
||||
}
|
||||
|
||||
// Area representing the presence of exposed person(s).
|
||||
var exposedArea = {};
|
||||
var drawArea = {};
|
||||
exposed_presence_intervals.forEach((b, index) => {
|
||||
exposedArea[index] = d3.area();
|
||||
drawArea[index] = draw_area.append('svg:path')
|
||||
.attr('fill', '#1f77b4')
|
||||
.attr('fill-opacity', '0.1');
|
||||
});
|
||||
|
||||
// Area representing the short-range interaction(s).
|
||||
var shortRangeArea = {};
|
||||
var drawShortRangeArea = {};
|
||||
short_range_intervals.forEach((b, index) => {
|
||||
shortRangeArea[index] = d3.area();
|
||||
drawShortRangeArea[index] = draw_area.append('svg:path');
|
||||
|
||||
if (short_range_expirations[index] == 'Breathing') drawShortRangeArea[index].attr('fill', 'red').attr('fill-opacity', '0.2');
|
||||
else if (short_range_expirations[index] == 'Speaking') drawShortRangeArea[index].attr('fill', 'green').attr('fill-opacity', '0.1');
|
||||
else drawShortRangeArea[index].attr('fill', 'blue').attr('fill-opacity', '0.1');
|
||||
});
|
||||
|
||||
// Tooltip.
|
||||
var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {};
|
||||
for (const [concentration, data] of Object.entries(data_for_graphs)) {
|
||||
for (const [concentration, data] of Object.entries(tooltip_data_for_graphs)) {
|
||||
|
||||
focus[concentration] = vis.append('svg:g')
|
||||
.style('display', 'none');
|
||||
|
|
@ -145,7 +216,6 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
.attr('stroke', '#000')
|
||||
.attr('width', 85)
|
||||
.attr('height', 50)
|
||||
.attr('x', 10)
|
||||
.attr('y', -22)
|
||||
.attr('rx', 4)
|
||||
.attr('ry', 4);
|
||||
|
|
@ -165,11 +235,66 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
.attr('pointer-events', 'all')
|
||||
.on('mouseover', () => { for (const [concentration, data] of Object.entries(focus)) focus[concentration].style('display', null); })
|
||||
.on('mouseout', () => { for (const [concentration, data] of Object.entries(focus)) focus[concentration].style('display', 'none'); })
|
||||
.on('mousemove', mousemove);
|
||||
.on('mousemove', mousemove);;
|
||||
}
|
||||
|
||||
var graph_width;
|
||||
var graph_height;
|
||||
function update_concentration_plot(concentration_data, cumulative_data) {
|
||||
yRange.domain([0., Math.max(...concentration_data)*1.1]);
|
||||
yAxisEl.transition().duration(1000).call(yAxis);
|
||||
|
||||
yCumulativeRange.domain([0., Math.max(...cumulative_data)*1.1]);
|
||||
yAxisCumEl.transition().duration(1000).call(yCumulativeAxis)
|
||||
|
||||
// Concentration line
|
||||
lineFunc.defined(d => !isNaN(d.concentration))
|
||||
.x(d => xTimeRange(d.time))
|
||||
.y(d => yRange(d.concentration));
|
||||
draw_area.select('.line')
|
||||
.transition()
|
||||
.duration(1000)
|
||||
.attr("d", lineFunc(data_for_graphs.concentrations));
|
||||
|
||||
// Cumulative line.
|
||||
lineCumulative.defined(d => !isNaN(d.concentration))
|
||||
.x(d => xTimeRange(d.time))
|
||||
.y(d => yCumulativeRange(d.concentration));
|
||||
draw_cumulative_line.transition()
|
||||
.duration(1000)
|
||||
.attr("d", lineCumulative(data_for_graphs.cumulative_doses));
|
||||
|
||||
// Long-range cumulative line.
|
||||
if (show_sr_legend) {
|
||||
longRangeCumulative.defined(d => !isNaN(d.concentration))
|
||||
.x(d => xTimeRange(d.time))
|
||||
.y(d => yCumulativeRange(d.concentration));
|
||||
draw_long_range_cumulative_line.transition()
|
||||
.duration(1000)
|
||||
.attr("d", lineCumulative(data_for_graphs.long_range_cumulative_doses));
|
||||
}
|
||||
|
||||
// Area.
|
||||
exposed_presence_intervals.forEach((b, index) => {
|
||||
exposedArea[index].x(d => xTimeRange(d.time))
|
||||
.y0(graph_height - 50)
|
||||
.y1(d => yRange(d.concentration)
|
||||
);
|
||||
drawArea[index].transition().duration(1000).attr('d', exposedArea[index](data_for_graphs.concentrations.filter(d => {
|
||||
return d.time >= b[0] && d.time <= b[1]
|
||||
})));
|
||||
});
|
||||
|
||||
// Short-Range Area.
|
||||
short_range_intervals.forEach((b, index) => {
|
||||
shortRangeArea[index].x(d => xTimeRange(d.time))
|
||||
.y0(graph_height - 50)
|
||||
.y1(d => yRange(d.concentration));
|
||||
|
||||
drawShortRangeArea[index].transition().duration(1000).attr('d', shortRangeArea[index](data_for_graphs.concentrations.filter(d => {
|
||||
return d.time >= b[0] && d.time <= b[1]
|
||||
})));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
|
||||
|
|
@ -182,7 +307,7 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
var margins = { top: 30, right: 20, bottom: 50, left: 60 };
|
||||
div_width = 900;
|
||||
graph_width = div_width * (2/3);
|
||||
const svg_margins = {'margin-left': '0rem', 'margin-top': '0rem'};
|
||||
const svg_margins = {'margin-left': '0rem'};
|
||||
Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val));
|
||||
}
|
||||
else {
|
||||
|
|
@ -190,7 +315,7 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
div_width = div_width * 1.1
|
||||
graph_width = div_width * .9;
|
||||
graph_height = div_height * 0.65; // On mobile screen sizes we want the legend to be on the bottom of the graph.
|
||||
const svg_margins = {'margin-left': '-1rem', 'margin-top': '3rem'};
|
||||
const svg_margins = {'margin-left': '-1rem'};
|
||||
Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val));
|
||||
};
|
||||
|
||||
|
|
@ -200,45 +325,29 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
|
||||
// SVG components according to the width and height.
|
||||
|
||||
// clipPath: everything out of this area won't be drawn.
|
||||
clip.attr("x", margins.left)
|
||||
.attr("y", margins.top)
|
||||
.attr("width", graph_width - margins.right - margins.left)
|
||||
.attr("height", graph_height - margins.top - margins.bottom);
|
||||
|
||||
// Axis ranges.
|
||||
xRange.range([margins.left, graph_width - margins.right]);
|
||||
xTimeRange.range([margins.left, graph_width - margins.right]);
|
||||
yRange.range([graph_height - margins.bottom, margins.top]);
|
||||
yCumulativeRange.range([graph_height - margins.bottom, margins.top]);
|
||||
|
||||
// Line.
|
||||
lineFunc.defined(d => !isNaN(d.concentration))
|
||||
.x(d => xTimeRange(d.time))
|
||||
.y(d => yRange(d.concentration));
|
||||
draw_line.attr("d", lineFunc(data_for_graphs.concentrations));
|
||||
|
||||
// Cumulative line
|
||||
lineCumulative.defined(d => !isNaN(d.concentration))
|
||||
.x(d => xTimeRange(d.time))
|
||||
.y(d => yCumulativeRange(d.concentration));
|
||||
draw_cumulative_line.attr("d", lineCumulative(data_for_graphs.cumulative_doses));
|
||||
|
||||
// Area.
|
||||
exposed_presence_intervals.forEach((b, index) => {
|
||||
exposedArea[index].x(d => xTimeRange(d.time))
|
||||
.y0(graph_height - margins.bottom)
|
||||
.y1(d => yRange(d.concentration));
|
||||
|
||||
drawArea[index].attr('d', exposedArea[index](data_for_graphs.concentrations.filter(d => {
|
||||
return d.time >= b[0] && d.time <= b[1]
|
||||
})));
|
||||
});
|
||||
|
||||
// Axis.
|
||||
var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d));
|
||||
var yAxis = d3.axisLeft(yRange);
|
||||
yAxis.scale(yRange);
|
||||
yCumulativeAxis.scale(yCumulativeRange);
|
||||
|
||||
xAxisEl.attr('transform', 'translate(0,' + (graph_height - margins.bottom) + ')')
|
||||
.call(xAxis);
|
||||
xAxisLabelEl.attr('x', (graph_width + margins.right) / 2)
|
||||
.attr('y', graph_height * 0.97);
|
||||
|
||||
yAxisEl.attr('transform', 'translate(' + margins.left + ',0)').call(yAxis);
|
||||
|
||||
yAxisEl.attr('transform', 'translate(' + margins.left + ',0)');
|
||||
yAxisLabelEl.attr('x', (graph_height * 0.9 + margins.bottom) / 2)
|
||||
.attr('y', (graph_height + margins.left) * 0.9)
|
||||
.attr('transform', 'rotate(-90, 0,' + graph_height + ')');
|
||||
|
|
@ -258,64 +367,129 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
.attr('y', graph_width + 290);
|
||||
}
|
||||
|
||||
// Legend on right side.
|
||||
const size = 20;
|
||||
var legend_x_start = 50;
|
||||
const space_between_text_icon = 30;
|
||||
const text_height = 6;
|
||||
// Legend on right side.
|
||||
if (plot_div.clientWidth >= 900) {
|
||||
legendLineIcon.attr('x', graph_width + size * 2.5)
|
||||
.attr('y', margins.top + size);
|
||||
legendLineText.attr('x', graph_width + 4 * size)
|
||||
legendLineIcon.attr('x', graph_width + legend_x_start)
|
||||
.attr('y', margins.top + size);
|
||||
legendLineText.attr('x', graph_width + legend_x_start + space_between_text_icon)
|
||||
.attr('y', margins.top + size + text_height);
|
||||
|
||||
legendCumulativeIcon.attr("x1", graph_width + legend_x_start)
|
||||
.attr("x2", graph_width + legend_x_start + 20)
|
||||
.attr("y1", margins.top + 2 * size)
|
||||
.attr("y2", margins.top + 2 * size);
|
||||
legendCumutiveText.attr('x', graph_width + legend_x_start + space_between_text_icon)
|
||||
.attr('y', margins.top + 2 * size + text_height);
|
||||
|
||||
legendCumulativeIcon.attr("x1", graph_width + size + 30)
|
||||
.attr("x2", graph_width + 2 * size + 32)
|
||||
.attr("y1", 3.5 * size)
|
||||
.attr("y2", 3.5 * size);
|
||||
legendCumutiveText.attr('x', graph_width + 2.5 * size + 30)
|
||||
.attr('y', margins.top + 2 * size);
|
||||
legendAreaIcon.attr('x', graph_width + legend_x_start)
|
||||
.attr('y', margins.top + (3 * size) - 15/2);
|
||||
legendAreaText.attr('x', graph_width + legend_x_start + space_between_text_icon)
|
||||
.attr('y', margins.top + 3 * size + text_height);
|
||||
|
||||
legendAreaIcon.attr('x', graph_width + size * 2.5)
|
||||
.attr('y', margins.top + 2.5 * size);
|
||||
legendAreaText.attr('x', graph_width + 4 * size)
|
||||
.attr('y', margins.top + 3 * size);
|
||||
if (show_sr_legend) {
|
||||
sr_unique_activities.forEach((b, index) => {
|
||||
legendShortRangeAreaIcon[index].attr('x', graph_width + legend_x_start)
|
||||
.attr('y', margins.top + (4 + index) * size - 15/2);
|
||||
legendShortRangeText[index].attr('x', graph_width + legend_x_start + space_between_text_icon)
|
||||
.attr('y', margins.top + (4 + index) * size + text_height);
|
||||
});
|
||||
legendLongCumulativeIcon.attr("x1", graph_width + legend_x_start)
|
||||
.attr("x2", graph_width + legend_x_start + 20)
|
||||
.attr("y1", margins.top + (4 + sr_unique_activities.length) * size)
|
||||
.attr("y2", margins.top + (4 + sr_unique_activities.length) * size);
|
||||
legendLongCumutiveText.attr('x', graph_width + legend_x_start + space_between_text_icon)
|
||||
.attr('y', margins.top + (4 + sr_unique_activities.length) * size + + text_height);
|
||||
}
|
||||
|
||||
legendBBox.attr('x', graph_width * 1.07)
|
||||
.attr('y', margins.top * 1.2);
|
||||
}
|
||||
// Legend on the bottom.
|
||||
else {
|
||||
legendLineIcon.attr('x', size * 0.5)
|
||||
.attr('y', graph_height * 1.05);
|
||||
legendLineText.attr('x', 2 * size)
|
||||
.attr('y', graph_height * 1.05);
|
||||
legend_x_start = 10;
|
||||
|
||||
legendCumulativeIcon.attr("x1", size * 0.5)
|
||||
.attr("x2", size * 1.55)
|
||||
.attr("y1", graph_height * 1.05 + size)
|
||||
.attr("y2", graph_height * 1.05 + size);
|
||||
legendCumutiveText.attr('x', 2 * size)
|
||||
.attr('y', graph_height + 1.65 * size);
|
||||
legendLineIcon.attr('x', legend_x_start)
|
||||
.attr('y', graph_height + size);
|
||||
legendLineText.attr('x', legend_x_start + space_between_text_icon)
|
||||
.attr('y', graph_height + size + text_height);
|
||||
|
||||
legendAreaIcon.attr('x', size * 0.50)
|
||||
.attr('y', graph_height * 1.09 + size);
|
||||
legendAreaText.attr('x', 2 * size)
|
||||
.attr('y', graph_height + 2.7 * size);
|
||||
legendCumulativeIcon.attr("x1", legend_x_start)
|
||||
.attr("x2", legend_x_start + 20)
|
||||
.attr("y1", graph_height + 2 * size)
|
||||
.attr("y2", graph_height + 2 * size);
|
||||
legendCumutiveText.attr('x', legend_x_start + space_between_text_icon)
|
||||
.attr('y', graph_height + 2 * size + text_height);
|
||||
|
||||
legendAreaIcon.attr('x', legend_x_start)
|
||||
.attr('y', graph_height + 3 * size - 15/2);
|
||||
legendAreaText.attr('x', legend_x_start + space_between_text_icon)
|
||||
.attr('y', graph_height + 3 * size + text_height);
|
||||
|
||||
if (show_sr_legend) {
|
||||
sr_unique_activities.forEach((b, index) => {
|
||||
legendShortRangeAreaIcon[index].attr('x', legend_x_start)
|
||||
.attr('y', graph_height + 4 * size - 15/2);
|
||||
legendShortRangeText[index].attr('x', legend_x_start + space_between_text_icon)
|
||||
.attr('y', graph_height + 4 * size + text_height);
|
||||
});
|
||||
legendLongCumulativeIcon.attr("x1", legend_x_start)
|
||||
.attr("x2", legend_x_start + 20)
|
||||
.attr("y1", graph_height + (4 + sr_unique_activities.length) * size)
|
||||
.attr("y2", graph_height + (4 + sr_unique_activities.length) * size)
|
||||
legendLongCumutiveText.attr('x', legend_x_start + space_between_text_icon)
|
||||
.attr('y', graph_height + (4 + sr_unique_activities.length) * size + text_height);
|
||||
}
|
||||
|
||||
legendBBox.attr('x', 1)
|
||||
.attr('y', graph_height);
|
||||
.attr('y', graph_height + 6);
|
||||
}
|
||||
|
||||
|
||||
// ToolBox.
|
||||
for (const [concentration, data] of Object.entries(data_for_graphs)) {
|
||||
for (const [concentration, data] of Object.entries(tooltip_data_for_graphs)) {
|
||||
toolBox[concentration].attr('width', graph_width - margins.right)
|
||||
.attr('height', graph_height);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Draw for the first time to initialize.
|
||||
redraw();
|
||||
if (show_sr_legend) {
|
||||
long_range_checkbox.addEventListener("click", () => {
|
||||
if (long_range_checkbox.checked) {
|
||||
draw_long_range_cumulative_line.transition().duration(1000).attr("opacity", 1);
|
||||
legendBBox.transition().duration(1000).attr("height", legendBBox_height + 20);
|
||||
legendLongCumulativeIcon.transition().duration(1000).attr("opacity", 1);
|
||||
legendLongCumutiveText.transition().duration(1000).attr("opacity", 1);
|
||||
}
|
||||
else {
|
||||
draw_long_range_cumulative_line.transition().duration(1000).attr("opacity", 0);
|
||||
legendBBox.transition().duration(1000).attr("height", legendBBox_height);
|
||||
legendLongCumulativeIcon.transition().duration(1000).attr("opacity", 0);
|
||||
legendLongCumutiveText.transition().duration(1000).attr("opacity", 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (button_full_exposure) {
|
||||
button_full_exposure.addEventListener("click", () => {
|
||||
update_concentration_plot(concentrations, cumulative_doses);
|
||||
button_full_exposure.disabled = true;
|
||||
button_hide_high_concentration.disabled = false;
|
||||
});
|
||||
}
|
||||
if (button_hide_high_concentration) {
|
||||
button_hide_high_concentration.addEventListener("click", () => {
|
||||
update_concentration_plot(concentrations_zoomed, long_range_cumulative_doses);
|
||||
button_full_exposure.disabled = false;
|
||||
button_hide_high_concentration.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
function mousemove() {
|
||||
for (const [scenario, data] of Object.entries(data_for_graphs)) {
|
||||
for (const [scenario, data] of Object.entries(tooltip_data_for_graphs)) {
|
||||
if (d3.pointer(event)[0] < graph_width / 2) {
|
||||
tooltip_rect[scenario].attr('x', 10)
|
||||
tooltip_time[scenario].attr('x', 18)
|
||||
|
|
@ -351,27 +525,39 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
|
|||
}
|
||||
}
|
||||
|
||||
// Redraw based on the new size whenever the browser window is resized.
|
||||
window.addEventListener("resize", redraw);
|
||||
// Draw for the first time to initialize.
|
||||
redraw();
|
||||
update_concentration_plot(concentrations, cumulative_doses);
|
||||
|
||||
|
||||
// Redraw based on the new size whenever the browser window is resized.
|
||||
window.addEventListener("resize", e => {
|
||||
redraw();
|
||||
if (button_full_exposure && button_full_exposure.disabled) update_concentration_plot(concentrations, cumulative_doses);
|
||||
else update_concentration_plot(concentrations_zoomed, long_range_cumulative_doses)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Generate the alternative scenarios plot using d3 library.
|
||||
// 'alternative_scenarios' is a dictionary with all the alternative scenarios
|
||||
// 'times' is a list of times for all the scenarios
|
||||
function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_plot_svg_id, times, alternative_scenarios) {
|
||||
// H:M format
|
||||
// The method is prepared to consider short range interactions if needed.
|
||||
function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_plot_svg_id) {
|
||||
// H:M format
|
||||
var time_format = d3.timeFormat('%H:%M');
|
||||
// D3 array of ten categorical colors represented as RGB hexadecimal strings.
|
||||
var colors = d3.schemeAccent;
|
||||
|
||||
// Used for controlling the short-range interactions
|
||||
let button_full_exposure = document.getElementById("button_alternative_full_exposure");
|
||||
let button_hide_high_concentration = document.getElementById("button_alternative_hide_high_concentration");
|
||||
|
||||
// Variable for the highest concentration for all the scenarios
|
||||
var highest_concentration = 0.
|
||||
|
||||
var data_for_scenarios = {}
|
||||
for (scenario in alternative_scenarios) {
|
||||
scenario_concentrations = alternative_scenarios[scenario].concentrations
|
||||
scenario_concentrations = alternative_scenarios[scenario].concentrations;
|
||||
|
||||
highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations))
|
||||
|
||||
|
|
@ -393,34 +579,9 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
|
|||
var xTimeRange = d3.scaleLinear().domain([times[0], times[times.length - 1]]);
|
||||
var bisecHour = d3.bisector((d) => { return d.hour; }).left;
|
||||
|
||||
var yRange = d3.scaleLinear().domain([0., highest_concentration]);
|
||||
var yRange = d3.scaleLinear();
|
||||
var yAxis = d3.axisLeft();
|
||||
|
||||
// Line representing the mean concentration for each scenario.
|
||||
var lineFuncs = {}, draw_lines = {}, label_icons = {}, label_text = {};
|
||||
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
|
||||
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
|
||||
|
||||
// Line representing the mean concentration.
|
||||
lineFuncs[scenario_name] = d3.line();
|
||||
|
||||
draw_lines[scenario_name] = vis.append('svg:path')
|
||||
.attr("stroke", colors[scenario_index])
|
||||
.attr('stroke-width', 2)
|
||||
.attr('fill', 'none');
|
||||
|
||||
// Legend for the plot elements - lines.
|
||||
label_icons[scenario_name] = vis.append('rect')
|
||||
.attr('width', 20)
|
||||
.attr('height', 3)
|
||||
.style('fill', colors[scenario_index]);
|
||||
|
||||
label_text[scenario_name] = vis.append('text')
|
||||
.text(scenario_name)
|
||||
.style('font-size', '15px')
|
||||
.attr('alignment-baseline', 'central');
|
||||
|
||||
}
|
||||
|
||||
// X axis.
|
||||
var xAxisEl = vis.append('svg:g')
|
||||
.attr('class', 'x axis');
|
||||
|
|
@ -454,6 +615,38 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
|
|||
.attr('stroke-linejoin', 'round')
|
||||
.attr('fill', 'none');
|
||||
|
||||
var clip = vis.append("defs").append("svg:clipPath")
|
||||
.attr("id", "clip")
|
||||
.append("svg:rect");
|
||||
|
||||
var draw_area = vis.append('svg:g')
|
||||
.attr('clip-path', 'url(#clip)');
|
||||
|
||||
// Line representing the mean concentration for each scenario.
|
||||
var lineFuncs = {}, draw_lines = {}, label_icons = {}, label_text = {};
|
||||
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
|
||||
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
|
||||
|
||||
// Line representing the mean concentration.
|
||||
lineFuncs[scenario_name] = d3.line();
|
||||
|
||||
draw_lines[scenario_name] = draw_area.append('svg:path')
|
||||
.attr("stroke", colors[scenario_index])
|
||||
.attr('stroke-width', 2)
|
||||
.attr('fill', 'none');
|
||||
|
||||
// Legend for the plot elements - lines.
|
||||
label_icons[scenario_name] = vis.append('rect')
|
||||
.attr('width', 20)
|
||||
.attr('height', 3)
|
||||
.style('fill', colors[scenario_index]);
|
||||
|
||||
label_text[scenario_name] = vis.append('text')
|
||||
.text(scenario_name)
|
||||
.style('font-size', '15px');
|
||||
|
||||
}
|
||||
|
||||
// Tooltip.
|
||||
var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {};
|
||||
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
|
||||
|
|
@ -489,6 +682,26 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
|
|||
.on('mousemove', mousemove);
|
||||
}
|
||||
|
||||
function update_alternative_concentration_plot(concentration_data) {
|
||||
var highest_concentration = 0.
|
||||
|
||||
for (scenario in alternative_scenarios) {
|
||||
scenario_concentrations = alternative_scenarios[scenario][concentration_data];
|
||||
highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations));
|
||||
}
|
||||
|
||||
yRange.domain([0., highest_concentration]);
|
||||
yAxisEl.transition().duration(1000).call(yAxis);
|
||||
|
||||
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
|
||||
// Lines.
|
||||
lineFuncs[scenario_name].defined(d => !isNaN(d.concentration))
|
||||
.x(d => xTimeRange(d.time))
|
||||
.y(d => yRange(d.concentration));
|
||||
draw_lines[scenario_name].transition().duration(1000).attr("d", lineFuncs[scenario_name](data));
|
||||
}
|
||||
}
|
||||
|
||||
var graph_width;
|
||||
var graph_height;
|
||||
|
||||
|
|
@ -519,41 +732,45 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
|
|||
.attr('height', div_height);
|
||||
|
||||
// SVG components according to the width and height.
|
||||
// clipPath: everything out of this area won't be drawn.
|
||||
clip.attr("x", margins.left)
|
||||
.attr("y", margins.top)
|
||||
.attr("width", graph_width - margins.right - margins.left)
|
||||
.attr("height", graph_height - margins.top - margins.bottom);
|
||||
|
||||
// Axis ranges.
|
||||
xRange.range([margins.left, graph_width - margins.right]);
|
||||
xTimeRange.range([margins.left, graph_width - margins.right]);
|
||||
yRange.range([graph_height - margins.bottom, margins.top]);
|
||||
|
||||
var legend_x_start = 25;
|
||||
const space_between_text_icon = 30;
|
||||
const text_height = 6;
|
||||
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
|
||||
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
|
||||
// Lines.
|
||||
lineFuncs[scenario_name].defined(d => !isNaN(d.concentration))
|
||||
.x(d => xTimeRange(d.time))
|
||||
.y(d => yRange(d.concentration));
|
||||
draw_lines[scenario_name].attr("d", lineFuncs[scenario_name](data));
|
||||
|
||||
// Legend on right side.
|
||||
var size = 20 * (scenario_index + 1);
|
||||
if (document.getElementById(concentration_plot_svg_id).clientWidth >= 900) {
|
||||
label_icons[scenario_name].attr('x', graph_width + 20)
|
||||
.attr('y', margins.top + size);
|
||||
label_text[scenario_name].attr('x', graph_width + 3 * 20)
|
||||
label_icons[scenario_name].attr('x', graph_width + legend_x_start)
|
||||
.attr('y', margins.top + size);
|
||||
label_text[scenario_name].attr('x', graph_width + legend_x_start + space_between_text_icon)
|
||||
.attr('y', margins.top + size + text_height);
|
||||
}
|
||||
// Legend on the bottom.
|
||||
else {
|
||||
label_icons[scenario_name].attr('x', margins.left * 0.3)
|
||||
.attr('y', graph_height + size);
|
||||
label_text[scenario_name].attr('x', margins.left * 1.4)
|
||||
legend_x_start = 10;
|
||||
|
||||
label_icons[scenario_name].attr('x', legend_x_start)
|
||||
.attr('y', graph_height + size);
|
||||
label_text[scenario_name].attr('x', legend_x_start + space_between_text_icon)
|
||||
.attr('y', graph_height + size + text_height);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Axis.
|
||||
var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d));
|
||||
var yAxis = d3.axisLeft(yRange);
|
||||
yAxis.scale(yRange);
|
||||
|
||||
xAxisEl.attr('transform', 'translate(0,' + (graph_height - margins.bottom) + ')')
|
||||
.call(xAxis);
|
||||
|
|
@ -585,8 +802,20 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
|
|||
}
|
||||
}
|
||||
|
||||
// Draw for the first time to initialize.
|
||||
redraw();
|
||||
if (button_full_exposure) {
|
||||
button_full_exposure.addEventListener("click", () => {
|
||||
update_alternative_concentration_plot('concentrations');
|
||||
button_full_exposure.disabled = true;
|
||||
button_hide_high_concentration.disabled = false;
|
||||
});
|
||||
}
|
||||
if (button_hide_high_concentration) {
|
||||
button_hide_high_concentration.addEventListener("click", () => {
|
||||
update_alternative_concentration_plot('concentrations_zoomed');
|
||||
button_full_exposure.disabled = false;
|
||||
button_hide_high_concentration.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
function mousemove() {
|
||||
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
|
||||
|
|
@ -611,8 +840,16 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
|
|||
}
|
||||
}
|
||||
|
||||
// Draw for the first time to initialize.
|
||||
redraw();
|
||||
update_alternative_concentration_plot('concentrations');
|
||||
|
||||
// Redraw based on the new size whenever the browser window is resized.
|
||||
window.addEventListener("resize", redraw);
|
||||
window.addEventListener("resize", e => {
|
||||
redraw();
|
||||
if (button_full_exposure && button_full_exposure.disabled) update_alternative_concentration_plot('concentrations');
|
||||
else update_alternative_concentration_plot('concentrations')
|
||||
});
|
||||
}
|
||||
|
||||
function copy_clipboard(shareable_link) {
|
||||
|
|
|
|||
|
|
@ -503,6 +503,7 @@ baseline_model = models.ExposureModel(
|
|||
),
|
||||
evaporation_factor=0.3,
|
||||
),
|
||||
short_range=(),
|
||||
exposed=models.Population(
|
||||
number=10,
|
||||
presence=models.SpecificInterval(((8., 12.), (13., 17.))),
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ CARA stands for COVID Airborne Risk Assessment and was developed in the spring o
|
|||
<li><a href='/expert-app'>CARA expert app</a></li>
|
||||
</ul>
|
||||
|
||||
The mathematical and physical model simulate the long-range airborne spread of SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 airborne transmission therein. The results DO NOT include (for now) short-range airborne exposure (where the physical distance plays a factor) nor the other known modes of SARS-CoV-2 transmission. Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.<br>
|
||||
The mathematical and physical model simulate the airborne spread of SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture and a two-stage exhaled jet model, and estimates the risk of COVID-19 airborne transmission therein. The results DO NOT include other known modes of SARS-CoV-2 transmission. Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as good hand hygiene and other barrier measures.<br>
|
||||
<p>The methodology, mathematical equations and parameters of the model are published here in the CARA paper: <a href="https://doi.org/10.1098/rsfs.2021.0076"> Modelling airborne transmission of SARS-CoV-2 using CARA: risk assessment for enclosed spaces</a>.</p>
|
||||
<p><i>Note that the short-range component of the model has not yet been published.</i></p>
|
||||
|
||||
The model used is based on scientific publications relating to airborne transmission of infectious diseases, virology, epidemiology and aerosol science. It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
|
||||
|
||||
|
|
|
|||
|
|
@ -366,6 +366,47 @@
|
|||
|
||||
<hr width="80%">
|
||||
|
||||
<div class="split">
|
||||
<div style="min-width: 22em">Short-range interactions (without masks):</div>
|
||||
<div>
|
||||
<input class="ml-2" type="radio" id="short_range_no" name="short_range_option" value="short_range_no" checked="checked">
|
||||
<label for="short_range_no">No</label>
|
||||
<input class="ml-2" type="radio" id="short_range_yes" name="short_range_option" value="short_range_yes" data-enables="#DIVsr_interactions">
|
||||
<label for="short_range_yes">Yes</label>
|
||||
</div>
|
||||
</div>
|
||||
<p id="short_range_warning" class="red_text" style="margin-right: 2rem">The use of masks mitigates exposure at short-range. The analytical model with short-range interactions does not take mask wearing into account.</p>
|
||||
|
||||
<div id="DIVsr_interactions" class="none">
|
||||
<div class="d-flex">
|
||||
<button type="button" id="set_interactions_button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#short_range_dialog" data-keyboard="false" data-backdrop="static">Set interactions</button>
|
||||
<p class="align-self-center pl-4"><b id="sr_interactions">0</b> short-range interactions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="short_range_dialog" tabindex="-1" role="dialog" aria-labelledby="short_range_dialogTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="short_range_dialogTitle">Short-range interactions</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="col-md-12 p-0 form-group" id="dialog_sr"></div>
|
||||
<div class="text-center"><button type="button" class="add_node_btn_frm_field btn btn-primary btn-sm">Add row</button></div>
|
||||
<input type="text" class="form-control d-none" name="short_range_interactions">
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary dismiss_btn_frm_field" data-dismiss="modal">Clear all</button>
|
||||
<button type="button" class="btn btn-primary close_btn_frm_field">Save all</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr width="80%">
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-4"><label class="col-form-label">Which month is the event?</label></div>
|
||||
<div class="col-sm-6 align-self-center">
|
||||
|
|
@ -515,7 +556,7 @@
|
|||
|
||||
<div class="container container--padding">
|
||||
<b>Quick Guide:</b><br>
|
||||
This tool simulates the long range airborne spread SARS-CoV-2 virus in a finite volume and estimates the risk of COVID-19 infection. It is based on current scientific data and can be used to compare the effectiveness of different mitigation measures.<br>
|
||||
This tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume and estimates the risk of COVID-19 infection. It is based on current scientific data and can be used to compare the effectiveness of different mitigation measures.<br>
|
||||
<b>Virus data:</b> <br>
|
||||
SARS-CoV-2 covers the original "wild type" strain of the virus and three variants of concern (VOC):<br>
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<h2 class="header_text mb-0">REPORT</h1>
|
||||
<p class="mb-0" id="report_version"> Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-dark align-self-center" id="download-pdf" style="margin-right: -100pt" onclick="print()">Print Report</button>
|
||||
<button type="button" class="btn btn-outline-dark align-self-center" id="download-pdf" style="margin-right: -100pt" onclick="print();">Print Report</button>
|
||||
<a href="{{ permalink.link }}" style="float: left;" id="pdf_qrcode_aref" class="align-self-center invisible mr-0"><div id="pdf_qrcode"></div></a>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
<div class="col-md-8 pr-0 pl-0 d-flex">
|
||||
{% block report_summary %}
|
||||
<div class="align-self-center alert alert-dark mb-0" role="alert">
|
||||
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
|
||||
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
|
||||
</div>
|
||||
{% endblock report_summary %}
|
||||
</div>
|
||||
|
|
@ -89,71 +89,89 @@
|
|||
{% endblock report_summary_footnote %}
|
||||
</div>
|
||||
<p id="section1">* The results are based on the parameters and assumptions published in the CARA publication: <a href="https://doi.org/10.1098/rsfs.2021.0076"> doi.org/10.1098/rsfs.2021.0076</a>.</p><br>
|
||||
|
||||
{% if form.short_range_option == "short_range_yes" %}
|
||||
{% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %}
|
||||
<button class="btn btn-sm btn-primary" id="button_full_exposure" disabled>Show full exposure</button>
|
||||
<button class="btn btn-sm btn-primary ml-0" id="button_hide_high_concentration">Hide high concentration</button>
|
||||
{% endif %}
|
||||
<input type="checkbox" id="long_range_cumulative_checkbox"><label class="form-check-label ml-1" for="long_range_cumulative_checkbox" id="lr_cumulative_checkbox_label">Show doses from long-range exposure alone</label>
|
||||
{% endif %}
|
||||
<div id="concentration_plot" style="height: 400px"></div>
|
||||
<script type="application/javascript">
|
||||
var times = {{ times | JSONify }}
|
||||
var concentrations = {{ concentrations | JSONify }}
|
||||
var cumulative_doses = {{ cumulative_doses | JSONify }}
|
||||
var exposed_presence_intervals = {{ exposed_presence_intervals | JSONify }}
|
||||
draw_concentration_plot("concentration_plot", times, concentrations, cumulative_doses, exposed_presence_intervals);
|
||||
let times = {{ times | JSONify }}
|
||||
let concentrations_zoomed = {{ concentrations_zoomed | JSONify }}
|
||||
let concentrations = {{ concentrations | JSONify }}
|
||||
let cumulative_doses = {{ cumulative_doses | JSONify }}
|
||||
let long_range_cumulative_doses = {{ long_range_cumulative_doses | JSONify }}
|
||||
let exposed_presence_intervals = {{ exposed_presence_intervals | JSONify }}
|
||||
let short_range_intervals = {{ short_range_intervals | JSONify }}
|
||||
let short_range_expirations = {{ short_range_expirations | JSONify }}
|
||||
draw_plot("concentration_plot")
|
||||
</script>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header"><strong>Alternative scenarios</strong>
|
||||
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseAlternativeScenarios" role="button" aria-expanded="false" aria-controls="collapseAlternativeScenarios">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="collapseAlternativeScenarios">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<div id="alternative_scenario_plot" style="height: 400px"></div>
|
||||
<script type="application/javascript">
|
||||
var alternative_scenarios = {{ alternative_scenarios.stats | JSONify }}
|
||||
var times = {{ times | JSONify }}
|
||||
draw_alternative_scenarios_plot("concentration_plot", "alternative_scenario_plot", times, alternative_scenarios);
|
||||
</script>
|
||||
<br>
|
||||
{% block report_scenarios_summary_table %}
|
||||
<table class="table w-auto">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>Scenario</th>
|
||||
<th>P(I)</th>
|
||||
<th>Expected new cases</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
|
||||
<tr>
|
||||
<td> {{ scenario_name }}</td>
|
||||
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
|
||||
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock report_scenarios_summary_table %}
|
||||
|
||||
{% if form.short_range_option == "short_range_no" %}
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header"><strong>Alternative scenarios</strong>
|
||||
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseAlternativeScenarios" role="button" aria-expanded="false" aria-controls="collapseAlternativeScenarios">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="collapseAlternativeScenarios">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
{% if form.short_range_option == "short_range_yes" %}
|
||||
{% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %}
|
||||
<button class="btn btn-sm btn-primary" id="button_alternative_full_exposure" disabled>Show full exposure</button>
|
||||
<button class="btn btn-sm btn-primary ml-0" id="button_alternative_hide_high_concentration">Hide high concentration</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div id="alternative_scenario_plot" style="height: 400px"></div>
|
||||
<script type="application/javascript">
|
||||
let alternative_scenarios = {{ alternative_scenarios.stats | JSONify }}
|
||||
draw_alternative_scenarios_plot("concentration_plot", "alternative_scenario_plot");
|
||||
</script>
|
||||
<br>
|
||||
{% block report_scenarios_summary_table %}
|
||||
<table class="table w-auto">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>Scenario</th>
|
||||
<th>P(I)</th>
|
||||
<th>Expected new cases</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
|
||||
<tr>
|
||||
<td> {{ scenario_name }}</td>
|
||||
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
|
||||
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock report_scenarios_summary_table %}
|
||||
</div>
|
||||
<br/>
|
||||
<p class="data_text"> <strong> Notes for alternative scenarios: </strong><br>
|
||||
<ol>
|
||||
<li>This graph shows the concentration of infectious quanta in the air. The filtration of Type I and FFP2 masks, if worn, applies not only to the emission rate but also to the individual exposure (i.e. inhalation).
|
||||
For this reason, scenarios with different types of mask will show the same concentration on the graph but have different absorbed doses and infection probabilities.</li>
|
||||
<li>If you have selected more sophisticated options, such as HEPA filtration or FFP2 masks, this will be indicated in the plot as the "base scenario", representing the inputs inserted in the form.<br>
|
||||
The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.</li>
|
||||
</ol>
|
||||
<br>
|
||||
</p>
|
||||
</div>
|
||||
<br/>
|
||||
<p class="data_text"> <strong> Notes for alternative scenarios: </strong><br>
|
||||
<ol>
|
||||
<li>This graph shows the concentration of infectious quanta in the air. The filtration of Type I and FFP2 masks, if worn, applies not only to the emission rate but also to the individual exposure (i.e. inhalation).
|
||||
For this reason, scenarios with different types of mask will show the same concentration on the graph but have different absorbed doses and infection probabilities.</li>
|
||||
<li>If you have selected more sophisticated options, such as HEPA filtration or FFP2 masks, this will be indicated in the plot as the "base scenario", representing the inputs inserted in the form.<br>
|
||||
The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.</li>
|
||||
</ol>
|
||||
<br>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock report_results %}
|
||||
|
||||
{% block report_footer %}
|
||||
|
|
@ -311,6 +329,18 @@
|
|||
Gym = For comparison only, all persons doing heavy physical exercise, breathing and not speaking.
|
||||
{% endif %}
|
||||
</p></li>
|
||||
{% if form.short_range_option == "short_range_yes" %}
|
||||
<li><p class="data_text">
|
||||
Short-range interactions: {{ form.short_range_interactions|length }}
|
||||
</p></li>
|
||||
<ul>
|
||||
{% for interaction in form.short_range_interactions %}
|
||||
<li>Expiratory activity {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.expiration }} </li>
|
||||
<li>Start time {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.start_time }} </li>
|
||||
<li>Duration {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<li><p class="data_text">Exposed occupant(s) activity time:</p></li>
|
||||
<ul>
|
||||
<li><p class="data_subtext">Start time: {{ form.exposed_start | minutes_to_time }}</p></li>
|
||||
|
|
|
|||
|
|
@ -28,22 +28,22 @@
|
|||
{% endblock warning_animation %}
|
||||
|
||||
{% block report_summary %}
|
||||
{% set report_message = "Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally, the <b>probability of one exposed occupant getting infected is " + prob_inf | non_zero_percentage + "</b> and the <b>expected number of new cases is " + expected_new_cases | float_format + "</b>*." %}
|
||||
<div class="flex-row align-self-center">
|
||||
{% if scale_warning == 'red' %}
|
||||
<div class="alert alert-danger mb-0" role="alert">
|
||||
<strong>Not Acceptable:</strong>
|
||||
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
|
||||
|
||||
{{ report_message }}
|
||||
</div>
|
||||
{% elif scale_warning == 'orange' %}
|
||||
<div class="alert alert-warning mb-0" role="alert">
|
||||
<strong>Attention:</strong>
|
||||
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
|
||||
{{ report_message }}
|
||||
</div>
|
||||
{% elif scale_warning == 'green' %}
|
||||
<div class="alert alert-success mb-0" role="alert">
|
||||
<strong>Acceptable:</strong>
|
||||
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
|
||||
{{ report_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
243
cara/models.py
243
cara/models.py
|
|
@ -37,7 +37,6 @@ import typing
|
|||
|
||||
import numpy as np
|
||||
from scipy.interpolate import interp1d
|
||||
import scipy.integrate
|
||||
|
||||
if not typing.TYPE_CHECKING:
|
||||
from memoization import cached
|
||||
|
|
@ -634,6 +633,12 @@ class _ExpirationBase:
|
|||
"""
|
||||
raise NotImplementedError("Subclass must implement")
|
||||
|
||||
def jet_origin_concentration(self):
|
||||
"""
|
||||
concentration of viruses at the jet origin (mL/m3).
|
||||
"""
|
||||
raise NotImplementedError("Subclass must implement")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Expiration(_ExpirationBase):
|
||||
|
|
@ -667,6 +672,14 @@ class Expiration(_ExpirationBase):
|
|||
return self.cn * (volume(self.diameter) *
|
||||
(1 - mask.exhale_efficiency(self.diameter))) * 1e-12
|
||||
|
||||
@cached()
|
||||
def jet_origin_concentration(self):
|
||||
def volume(d):
|
||||
return (np.pi * d**3) / 6.
|
||||
|
||||
# final result converted from microns^3/cm3 to mL/m3
|
||||
return self.cn * volume(self.diameter) * 1e-6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MultipleExpiration(_ExpirationBase):
|
||||
|
|
@ -886,7 +899,7 @@ class InfectedPopulation(_PopulationWithVirus):
|
|||
class ConcentrationModel:
|
||||
room: Room
|
||||
ventilation: _VentilationBase
|
||||
infected: _PopulationWithVirus
|
||||
infected: InfectedPopulation
|
||||
|
||||
#: evaporation factor: the particles' diameter is multiplied by this
|
||||
# factor as soon as they are in the air (but AFTER going out of the,
|
||||
|
|
@ -987,7 +1000,7 @@ class ConcentrationModel:
|
|||
|
||||
def _normed_concentration(self, time: float) -> _VectorisedFloat:
|
||||
"""
|
||||
Virus exposure concentration, as a function of time, and
|
||||
Virus long-range exposure concentration, as a function of time, and
|
||||
normalized by the emission rate.
|
||||
The formulas used here assume that all parameters (ventilation,
|
||||
emission rate) are constant between two state changes - only
|
||||
|
|
@ -1013,18 +1026,18 @@ class ConcentrationModel:
|
|||
|
||||
def concentration(self, time: float) -> _VectorisedFloat:
|
||||
"""
|
||||
Virus exposure concentration, as a function of time.
|
||||
Virus long-range exposure concentration, as a function of time.
|
||||
|
||||
Note that time is not vectorised. You can only pass a single float
|
||||
to this method.
|
||||
"""
|
||||
return (self._normed_concentration(time) *
|
||||
return (self._normed_concentration_cached(time) *
|
||||
self.infected.emission_rate_when_present())
|
||||
|
||||
@method_cache
|
||||
def normed_integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
|
||||
"""
|
||||
Get the integrated concentration of viruses in the air between the times start and stop,
|
||||
Get the integrated long-range concentration of viruses in the air between the times start and stop,
|
||||
normalized by the emission rate.
|
||||
"""
|
||||
if stop <= self._first_presence_time():
|
||||
|
|
@ -1059,6 +1072,117 @@ class ConcentrationModel:
|
|||
self.infected.emission_rate_when_present())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShortRangeModel:
|
||||
#: Expiration type
|
||||
expiration: _ExpirationBase
|
||||
|
||||
#: Activity type
|
||||
activity: Activity
|
||||
|
||||
#: Short-range expiration and respective presence
|
||||
presence: SpecificInterval
|
||||
|
||||
#: Interpersonal distances
|
||||
distance: _VectorisedFloat
|
||||
|
||||
def dilution_factor(self) -> _VectorisedFloat:
|
||||
'''
|
||||
The dilution factor for the respective expiratory activity type.
|
||||
'''
|
||||
# Average mouth diameter
|
||||
D = 0.02
|
||||
# Convert Breathing rate from m3/h to m3/s
|
||||
BR = np.array(self.activity.exhalation_rate/3600.)
|
||||
# Area of the mouth assuming a perfect circle
|
||||
Am = np.pi*(D**2)/4
|
||||
# Initial velocity from the division of the Breathing rate with the area
|
||||
u0 = np.array(BR/Am)
|
||||
|
||||
tstar = 2.0
|
||||
Cr1 = 0.18
|
||||
Cr2 = 0.2
|
||||
Cx1 = 2.4
|
||||
|
||||
# The expired flow rate during the expiration period, m^3/s
|
||||
Q0 = u0 * np.pi/4*D**2
|
||||
# Parameters in the jet-like stage
|
||||
x01 = D/2/Cr1
|
||||
# Time of virtual origin
|
||||
t01 = (x01/Cx1)**2 * (Q0*u0)**(-0.5)
|
||||
# The transition point, m
|
||||
xstar = np.array(Cx1*(Q0*u0)**0.25*(tstar + t01)**0.5 - x01)
|
||||
# Dilution factor at the transition point xstar
|
||||
Sxstar = np.array(2*Cr1*(xstar+x01)/D)
|
||||
|
||||
distances = np.array(self.distance)
|
||||
|
||||
factors = np.empty(distances.shape, dtype=np.float64)
|
||||
factors[distances < xstar] = 2*Cr1*(distances[distances < xstar]
|
||||
+ x01)/D
|
||||
factors[distances >= xstar] = Sxstar[distances >= xstar]*(1 +
|
||||
Cr2*(distances[distances >= xstar] -
|
||||
xstar[distances >= xstar])/Cr1/(xstar[distances >= xstar]
|
||||
+ x01))**3
|
||||
return factors
|
||||
|
||||
def _normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
|
||||
"""
|
||||
Virus short-range exposure concentration, as a function of time.
|
||||
|
||||
If the given time falls within a short-range interval it returns the
|
||||
short-range concentration normalized by the virus viral load. Otherwise
|
||||
it returns 0.
|
||||
"""
|
||||
start, stop = self.presence.boundaries()[0]
|
||||
# Verifies if the given time falls within a short-range interaction
|
||||
if start <= time <= stop:
|
||||
dilution = self.dilution_factor()
|
||||
jet_origin_concentration = self.expiration.jet_origin_concentration()
|
||||
# Long-range concentration normalized by the virus viral load
|
||||
long_range_normed_concentration = (concentration_model.concentration(time) /
|
||||
concentration_model.virus.viral_load_in_sputum)
|
||||
|
||||
# The long-range concentration values are then approximated using interpolation:
|
||||
# The set of points where we want the interpolated values are the short-range particle diameters (given the current expiration);
|
||||
# The set of points with a known value are the long-range particle diameters (given the initial expiration);
|
||||
# The set of known values are the long-range concentration values normalized by the viral load.
|
||||
long_range_normed_concentration_interpolated=np.interp(self.expiration.particle.diameter,
|
||||
concentration_model.infected.particle.diameter, long_range_normed_concentration)
|
||||
|
||||
# Short-range concentration formula. The long-range concentration is added in the concentration method (ExposureModel).
|
||||
return ((1/dilution)*(jet_origin_concentration - long_range_normed_concentration_interpolated))
|
||||
return 0.
|
||||
|
||||
def short_range_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
|
||||
"""
|
||||
Virus short-range exposure concentration, as a function of time.
|
||||
"""
|
||||
return (self._normed_concentration(concentration_model, time) *
|
||||
concentration_model.virus.viral_load_in_sputum)
|
||||
|
||||
@method_cache
|
||||
def _normed_short_range_concentration_cached(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
|
||||
# A cached version of the _normed_concentration method. Use this
|
||||
# method if you expect that there may be multiple short-range concentration
|
||||
# calculations for the same time (e.g. at state change times).
|
||||
return self._normed_concentration(concentration_model, time)
|
||||
|
||||
def normed_exposure_between_bounds(self, concentration_model: ConcentrationModel, time1: float, time2: float):
|
||||
"""
|
||||
Get the integrated short-range concentration of viruses in the air between the times start and stop,
|
||||
normalized by the virus viral load.
|
||||
"""
|
||||
start_bound, stop_bound = self.presence.boundaries()[0]
|
||||
|
||||
jet_origin_integrated = self.expiration.jet_origin_concentration()
|
||||
dilution = self.dilution_factor()
|
||||
|
||||
total_normed_concentration = -(concentration_model.integrated_concentration(start_bound, stop_bound)/concentration_model.virus.viral_load_in_sputum/dilution)
|
||||
total_normed_concentration_interpolated = np.interp(self.expiration.particle.diameter, concentration_model.infected.particle.diameter, total_normed_concentration)
|
||||
return (jet_origin_integrated/dilution * (stop_bound - start_bound)) + total_normed_concentration_interpolated
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExposureModel:
|
||||
"""
|
||||
|
|
@ -1071,13 +1195,16 @@ class ExposureModel:
|
|||
#: The virus concentration model which this exposure model should consider.
|
||||
concentration_model: ConcentrationModel
|
||||
|
||||
#: The list of short-range models which this exposure model should consider.
|
||||
short_range: typing.Tuple[ShortRangeModel, ...]
|
||||
|
||||
#: The population of non-infected people to be used in the model.
|
||||
exposed: Population
|
||||
|
||||
#: The number of times the exposure event is repeated (default 1).
|
||||
repeats: int = 1
|
||||
|
||||
def fraction_deposited(self) -> _VectorisedFloat:
|
||||
def long_range_fraction_deposited(self) -> _VectorisedFloat:
|
||||
"""
|
||||
The fraction of particles actually deposited in the respiratory
|
||||
tract (over the total number of particles). It depends on the
|
||||
|
|
@ -1086,7 +1213,7 @@ class ExposureModel:
|
|||
return self.concentration_model.infected.particle.fraction_deposited(
|
||||
self.concentration_model.evaporation_factor)
|
||||
|
||||
def _normed_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
||||
def _long_range_normed_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
||||
"""The number of virions per meter^3 between any two times, normalized
|
||||
by the emission rate of the infected population"""
|
||||
exposure = 0.
|
||||
|
|
@ -1104,28 +1231,26 @@ class ExposureModel:
|
|||
elif time1 <= start and stop < time2:
|
||||
exposure += self.concentration_model.normed_integrated_concentration(start, stop)
|
||||
return exposure
|
||||
|
||||
def _normed_exposure(self) -> _VectorisedFloat:
|
||||
"""
|
||||
The number of virions per meter^3, normalized by the emission rate
|
||||
of the infected population.
|
||||
"""
|
||||
normed_exposure = 0.0
|
||||
|
||||
for start, stop in self.exposed.presence.boundaries():
|
||||
normed_exposure += self.concentration_model.normed_integrated_concentration(start, stop)
|
||||
|
||||
return normed_exposure * self.repeats
|
||||
|
||||
def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
||||
def concentration(self, time: float) -> _VectorisedFloat:
|
||||
"""
|
||||
The number of virus per m^3 deposited on the respiratory tract
|
||||
between any two times.
|
||||
Virus exposure concentration, as a function of time.
|
||||
|
||||
It considers the long-range concentration with the
|
||||
contribution of the short-range concentration.
|
||||
"""
|
||||
concentration = self.concentration_model.concentration(time)
|
||||
for interaction in self.short_range:
|
||||
concentration += interaction.short_range_concentration(self.concentration_model, time)
|
||||
return concentration
|
||||
|
||||
def long_range_deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
||||
deposited_exposure = 0.
|
||||
|
||||
emission_rate_per_aerosol = self.concentration_model.infected.emission_rate_per_aerosol_when_present()
|
||||
aerosols = self.concentration_model.infected.aerosols()
|
||||
fdep = self.fraction_deposited()
|
||||
f_inf = self.concentration_model.infected.fraction_of_infectious_virus()
|
||||
fdep = self.long_range_fraction_deposited()
|
||||
|
||||
diameter = self.concentration_model.infected.particle.diameter
|
||||
|
||||
|
|
@ -1134,20 +1259,74 @@ class ExposureModel:
|
|||
# to perform properly the Monte-Carlo integration over
|
||||
# particle diameters (doing things in another order would
|
||||
# lead to wrong results).
|
||||
dep_exposure_integrated = np.array(self._normed_exposure_between_bounds(time1, time2) *
|
||||
aerosols *
|
||||
fdep).mean()
|
||||
dep_exposure_integrated = np.array(self._long_range_normed_exposure_between_bounds(time1, time2) *
|
||||
aerosols *
|
||||
fdep).mean()
|
||||
else:
|
||||
# in the case of a single diameter or no diameter defined,
|
||||
# one should not take any mean at this stage.
|
||||
dep_exposure_integrated = self._normed_exposure_between_bounds(time1, time2)*aerosols*fdep
|
||||
dep_exposure_integrated = self._long_range_normed_exposure_between_bounds(time1, time2)*aerosols*fdep
|
||||
|
||||
# then we multiply by the diameter-independent quantity emission_rate_per_aerosol,
|
||||
# and parameters of the vD equation (i.e. f_inf, BR_k and n_in).
|
||||
return (dep_exposure_integrated * emission_rate_per_aerosol *
|
||||
f_inf * self.exposed.activity.inhalation_rate *
|
||||
# and parameters of the vD equation (i.e. BR_k and n_in).
|
||||
deposited_exposure += (dep_exposure_integrated * emission_rate_per_aerosol *
|
||||
self.exposed.activity.inhalation_rate *
|
||||
(1 - self.exposed.mask.inhale_efficiency()))
|
||||
|
||||
# In the end we multiply the final results by the fraction of infectious virus of the vD equation.
|
||||
return deposited_exposure * f_inf
|
||||
|
||||
def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
||||
"""
|
||||
The number of virus per m^3 deposited on the respiratory tract
|
||||
between any two times.
|
||||
|
||||
Considers a contribution between the short-range and long-range exposures:
|
||||
It calculates the deposited exposure given a short-range interaction (if any).
|
||||
Then, the deposited exposure given the long-range interactions is added to the
|
||||
initial deposited exposure.
|
||||
"""
|
||||
deposited_exposure = 0.
|
||||
for interaction in self.short_range:
|
||||
start, stop = interaction.presence.boundaries()[0]
|
||||
if stop < time1:
|
||||
continue
|
||||
elif start > time2:
|
||||
break
|
||||
elif start <= time1 and time2<= stop:
|
||||
start_bound, stop_bound = time1, time2
|
||||
elif start <= time1 and stop < time2:
|
||||
start_bound, stop_bound = time1, stop
|
||||
elif time1 < start and time2 <= stop:
|
||||
start_bound, stop_bound = start, time2
|
||||
elif time1 <= start and stop < time2:
|
||||
start_bound, stop_bound = start, stop
|
||||
short_range_exposure = interaction.normed_exposure_between_bounds(self.concentration_model, start_bound, stop_bound)
|
||||
|
||||
fdep = interaction.expiration.particle.fraction_deposited(evaporation_factor=1.0)
|
||||
diameter = interaction.expiration.particle.diameter
|
||||
|
||||
# Aerosols not considered given the formula for the initial concentration at mouth/nose.
|
||||
if diameter is not None and not np.isscalar(diameter):
|
||||
# we compute first the mean of all diameter-dependent quantities
|
||||
# to perform properly the Monte-Carlo integration over
|
||||
# particle diameters (doing things in another order would
|
||||
# lead to wrong results).
|
||||
deposited_exposure += np.array(short_range_exposure *
|
||||
fdep).mean()
|
||||
else:
|
||||
# in the case of a single diameter or no diameter defined,
|
||||
# one should not take any mean at this stage.
|
||||
deposited_exposure += short_range_exposure*fdep
|
||||
|
||||
# then we multiply by the diameter-independent quantity virus viral load
|
||||
deposited_exposure *= self.concentration_model.virus.viral_load_in_sputum
|
||||
# long-range concentration
|
||||
f_inf = self.concentration_model.infected.fraction_of_infectious_virus()
|
||||
deposited_exposure += self.long_range_deposited_exposure_between_bounds(time1, time2)/f_inf
|
||||
|
||||
return deposited_exposure * f_inf
|
||||
|
||||
def deposited_exposure(self) -> _VectorisedFloat:
|
||||
"""
|
||||
The number of virus per m^3 deposited on the respiratory tract.
|
||||
|
|
@ -1162,7 +1341,7 @@ class ExposureModel:
|
|||
def infection_probability(self) -> _VectorisedFloat:
|
||||
# viral dose (vD)
|
||||
vD = self.deposited_exposure()
|
||||
|
||||
|
||||
# oneoverln2 multiplied by ID_50 corresponds to ID_63.
|
||||
infectious_dose = oneoverln2 * self.concentration_model.virus.infectious_dose
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import numpy as np
|
|||
from scipy import special as sp
|
||||
|
||||
import cara.monte_carlo as mc
|
||||
from cara.monte_carlo.sampleable import Normal,LogNormal,LogCustomKernel,CustomKernel,Uniform
|
||||
from cara.monte_carlo.sampleable import LogNormal,LogCustomKernel,CustomKernel,Uniform
|
||||
|
||||
|
||||
sqrt2pi = np.sqrt(2.*np.pi)
|
||||
sqrt2 = np.sqrt(2.)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BLOmodel:
|
||||
"""
|
||||
|
|
@ -65,7 +67,7 @@ class BLOmodel:
|
|||
return result
|
||||
|
||||
|
||||
# From https://doi.org/10.1101/2021.10.14.21264988 and refererences therein
|
||||
# From https://doi.org/10.1101/2021.10.14.21264988 and references therein
|
||||
activity_distributions = {
|
||||
'Seated': mc.Activity(LogNormal(-0.6872121723362303, 0.10498338229297108),
|
||||
LogNormal(-0.6872121723362303, 0.10498338229297108)),
|
||||
|
|
@ -84,7 +86,7 @@ activity_distributions = {
|
|||
}
|
||||
|
||||
|
||||
# From https://doi.org/10.1101/2021.10.14.21264988 and refererences therein
|
||||
# From https://doi.org/10.1101/2021.10.14.21264988 and references therein
|
||||
symptomatic_vl_frequencies = LogCustomKernel(
|
||||
np.array((2.46032, 2.67431, 2.85434, 3.06155, 3.25856, 3.47256, 3.66957, 3.85979, 4.09927, 4.27081,
|
||||
4.47631, 4.66653, 4.87204, 5.10302, 5.27456, 5.46478, 5.6533, 5.88428, 6.07281, 6.30549,
|
||||
|
|
@ -157,7 +159,10 @@ mask_distributions = {
|
|||
}
|
||||
|
||||
|
||||
def expiration_distribution(BLO_factors):
|
||||
def expiration_distribution(
|
||||
BLO_factors,
|
||||
d_max=30.,
|
||||
) -> mc.Expiration:
|
||||
"""
|
||||
Returns an Expiration with an aerosol diameter distribution, defined
|
||||
by the BLO factors (a length-3 tuple).
|
||||
|
|
@ -166,10 +171,15 @@ def expiration_distribution(BLO_factors):
|
|||
an historical choice based on previous implementations of the model
|
||||
(it limits the influence of the O-mode).
|
||||
"""
|
||||
dscan = np.linspace(0.1, 30. ,3000)
|
||||
return mc.Expiration(CustomKernel(dscan,
|
||||
BLOmodel(BLO_factors).distribution(dscan),kernel_bandwidth=0.1),
|
||||
cn=BLOmodel(BLO_factors).integrate(0.1, 30.))
|
||||
dscan = np.linspace(0.1, d_max, 3000)
|
||||
return mc.Expiration(
|
||||
CustomKernel(
|
||||
dscan,
|
||||
BLOmodel(BLO_factors).distribution(dscan),
|
||||
kernel_bandwidth=0.1,
|
||||
),
|
||||
cn=BLOmodel(BLO_factors).integrate(0.1, d_max),
|
||||
)
|
||||
|
||||
|
||||
expiration_BLO_factors = {
|
||||
|
|
@ -182,5 +192,15 @@ expiration_BLO_factors = {
|
|||
|
||||
expiration_distributions = {
|
||||
exp_type: expiration_distribution(BLO_factors)
|
||||
for exp_type,BLO_factors in expiration_BLO_factors.items()
|
||||
for exp_type, BLO_factors in expiration_BLO_factors.items()
|
||||
}
|
||||
|
||||
|
||||
short_range_expiration_distributions = {
|
||||
exp_type: expiration_distribution(BLO_factors, d_max=100)
|
||||
for exp_type, BLO_factors in expiration_BLO_factors.items()
|
||||
}
|
||||
|
||||
|
||||
# Fit from Fig 8 a) "stand-stand" in https://www.mdpi.com/1660-4601/17/4/1445/htm
|
||||
short_range_distances = LogNormal(-0.269359136417347, 0.4728300188814934)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class MCModelBase(typing.Generic[_ModelType]):
|
|||
|
||||
def build_model(self, size: int) -> _ModelType:
|
||||
"""
|
||||
Turn this MCModelBase subclass into a cara.models Model instance
|
||||
Turn this MCModelBase subclass into a cara.model Model instance
|
||||
from which you can then run the model.
|
||||
|
||||
"""
|
||||
|
|
@ -72,6 +72,9 @@ def _build_mc_model(model: _ModelType) -> typing.Type[MCModelBase[_ModelType]]:
|
|||
elif new_field.type == typing.Tuple[cara.models._ExpirationBase, ...]:
|
||||
EB = getattr(sys.modules[__name__], "_ExpirationBase")
|
||||
field_type = typing.Tuple[typing.Union[cara.models._ExpirationBase, EB], ...]
|
||||
elif new_field.type == typing.Tuple[cara.models.SpecificInterval, ...]:
|
||||
SI = getattr(sys.modules[__name__], "SpecificInterval")
|
||||
field_type = typing.Tuple[typing.Union[cara.models.SpecificInterval, SI], ...]
|
||||
else:
|
||||
# Check that we don't need to do anything with this type.
|
||||
for item in new_field.type.__args__:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import pytest
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def baseline_model():
|
||||
def baseline_concentration_model():
|
||||
model = models.ConcentrationModel(
|
||||
room=models.Room(volume=75),
|
||||
ventilation=models.AirChange(
|
||||
|
|
@ -30,14 +30,20 @@ def baseline_model():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def baseline_exposure_model(baseline_model):
|
||||
def baseline_sr_model():
|
||||
return ()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def baseline_exposure_model(baseline_concentration_model, baseline_sr_model):
|
||||
return models.ExposureModel(
|
||||
baseline_model,
|
||||
baseline_concentration_model,
|
||||
baseline_sr_model,
|
||||
exposed=models.Population(
|
||||
number=1000,
|
||||
presence=baseline_model.infected.presence,
|
||||
activity=baseline_model.infected.activity,
|
||||
mask=baseline_model.infected.mask,
|
||||
presence=baseline_concentration_model.infected.presence,
|
||||
activity=baseline_concentration_model.infected.activity,
|
||||
mask=baseline_concentration_model.infected.mask,
|
||||
host_immunity=0.,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ def known_concentrations(func):
|
|||
np.array([40.91708675, 91.46172332]), np.array([51.6749232285, 80.3196524031])],
|
||||
])
|
||||
def test_exposure_model_ndarray(population, cm,
|
||||
expected_exposure, expected_probability):
|
||||
model = ExposureModel(cm, population)
|
||||
expected_exposure, expected_probability, sr_model):
|
||||
model = ExposureModel(cm, sr_model, population)
|
||||
np.testing.assert_almost_equal(
|
||||
model.deposited_exposure(), expected_exposure
|
||||
)
|
||||
|
|
@ -110,10 +110,10 @@ def test_exposure_model_ndarray(population, cm,
|
|||
[populations[1], np.array([2.13410688, 1.98167067])],
|
||||
[populations[2], np.array([1.36390289, 1.52436206])],
|
||||
])
|
||||
def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exposure):
|
||||
def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exposure, sr_model):
|
||||
cm = known_concentrations(
|
||||
lambda t: 0. if np.floor(t) % 2 else np.array([1.2, 1.2]))
|
||||
model = ExposureModel(cm, population)
|
||||
model = ExposureModel(cm, sr_model, population)
|
||||
|
||||
np.testing.assert_almost_equal(
|
||||
model.deposited_exposure(), expected_deposited_exposure
|
||||
|
|
@ -128,17 +128,17 @@ def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exp
|
|||
[populations[1], np.array([2.13410688, 1.98167067])],
|
||||
[populations[2], np.array([1.36390289, 1.52436206])],
|
||||
])
|
||||
def test_exposure_model_vector(population, expected_deposited_exposure):
|
||||
def test_exposure_model_vector(population, expected_deposited_exposure, sr_model):
|
||||
cm_array = known_concentrations(lambda t: np.array([1.2, 1.2]))
|
||||
model_array = ExposureModel(cm_array, population)
|
||||
model_array = ExposureModel(cm_array, sr_model, population)
|
||||
np.testing.assert_almost_equal(
|
||||
model_array.deposited_exposure(), np.array(expected_deposited_exposure)
|
||||
)
|
||||
|
||||
|
||||
def test_exposure_model_scalar():
|
||||
def test_exposure_model_scalar(sr_model):
|
||||
cm_scalar = known_concentrations(lambda t: 1.2)
|
||||
model_scalar = ExposureModel(cm_scalar, populations[0])
|
||||
model_scalar = ExposureModel(cm_scalar, sr_model, populations[0])
|
||||
expected_deposited_exposure = 1.52436206
|
||||
np.testing.assert_almost_equal(
|
||||
model_scalar.deposited_exposure(), expected_deposited_exposure
|
||||
|
|
@ -169,6 +169,11 @@ def conc_model():
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sr_model():
|
||||
return ()
|
||||
|
||||
|
||||
# Expected deposited exposure were computed with a trapezoidal integration, using
|
||||
# a mesh of 10'000 pts per exposed presence interval.
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -183,17 +188,17 @@ def conc_model():
|
|||
]
|
||||
)
|
||||
def test_exposure_model_integral_accuracy(exposed_time_interval,
|
||||
expected_deposited_exposure, conc_model):
|
||||
expected_deposited_exposure, conc_model, sr_model):
|
||||
presence_interval = models.SpecificInterval((exposed_time_interval,))
|
||||
population = models.Population(
|
||||
10, presence_interval, models.Mask.types['Type I'],
|
||||
models.Activity.types['Standing'], 0.,
|
||||
)
|
||||
model = ExposureModel(conc_model, population)
|
||||
model = ExposureModel(conc_model, sr_model, population)
|
||||
np.testing.assert_allclose(model.deposited_exposure(), expected_deposited_exposure)
|
||||
|
||||
|
||||
def test_infectious_dose_vectorisation():
|
||||
def test_infectious_dose_vectorisation(sr_model):
|
||||
infected_population = models.InfectedPopulation(
|
||||
number=1,
|
||||
presence=halftime,
|
||||
|
|
@ -216,7 +221,7 @@ def test_infectious_dose_vectorisation():
|
|||
10, presence_interval, models.Mask.types['Type I'],
|
||||
models.Activity.types['Standing'], 0.,
|
||||
)
|
||||
model = ExposureModel(cm, population)
|
||||
model = ExposureModel(cm, sr_model, population)
|
||||
inf_probability = model.infection_probability()
|
||||
assert isinstance(inf_probability, np.ndarray)
|
||||
assert inf_probability.shape == (3, )
|
||||
|
|
|
|||
105
cara/tests/models/test_short_range_model.py
Normal file
105
cara/tests/models/test_short_range_model.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import typing
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from cara import models
|
||||
import cara.monte_carlo as mc_models
|
||||
from cara.apps.calculator.model_generator import build_expiration
|
||||
from cara.monte_carlo.data import short_range_expiration_distributions, short_range_distances, activity_distributions
|
||||
|
||||
# TODO: seed better the random number generators
|
||||
np.random.seed(2000)
|
||||
|
||||
@pytest.fixture
|
||||
def concentration_model() -> mc_models.ConcentrationModel:
|
||||
return mc_models.ConcentrationModel(
|
||||
room=models.Room(volume=75),
|
||||
ventilation=models.AirChange(
|
||||
active=models.SpecificInterval(present_times=((8.5, 12.5), (13.5, 17.5))),
|
||||
air_exch=10_000_000.,
|
||||
),
|
||||
infected=mc_models.InfectedPopulation(
|
||||
number=1,
|
||||
virus=models.Virus.types['SARS_CoV_2'],
|
||||
presence=models.SpecificInterval(present_times=((8.5, 12.5), (13.5, 17.5))),
|
||||
mask=models.Mask.types['No mask'],
|
||||
activity=models.Activity.types['Light activity'],
|
||||
expiration=build_expiration({'Speaking': 0.33, 'Breathing': 0.67}),
|
||||
host_immunity=0.,
|
||||
),
|
||||
evaporation_factor=0.3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def short_range_model():
|
||||
return mc_models.ShortRangeModel(expiration=short_range_expiration_distributions['Breathing'],
|
||||
activity=activity_distributions['Seated'],
|
||||
presence=models.SpecificInterval(present_times=((10.5, 11.0),)),
|
||||
distance=short_range_distances)
|
||||
|
||||
|
||||
def test_short_range_model_ndarray(concentration_model, short_range_model):
|
||||
concentration_model = concentration_model.build_model(250_000)
|
||||
model = short_range_model.build_model(250_000)
|
||||
assert isinstance(model._normed_concentration(concentration_model, 10.75), np.ndarray)
|
||||
assert isinstance(model.short_range_concentration(concentration_model, 10.75), np.ndarray)
|
||||
assert isinstance(model.normed_exposure_between_bounds(concentration_model, 10.75, 10.85), np.ndarray)
|
||||
assert isinstance(model.short_range_concentration(concentration_model, 14.0), float)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"activity, expected_dilution", [
|
||||
["Seated", 176.04075727780327],
|
||||
["Standing", 157.12965288170005],
|
||||
["Light activity", 69.06672998536413],
|
||||
["Moderate activity", 47.165817446310115],
|
||||
["Heavy exercise", 23.759992220217875],
|
||||
]
|
||||
)
|
||||
def test_dilution_factor(activity, expected_dilution):
|
||||
model = models.ShortRangeModel(expiration="Breathing",
|
||||
activity=models.Activity.types[activity],
|
||||
presence=models.SpecificInterval(present_times=((10.5, 11.0),)),
|
||||
distance=0.854)
|
||||
assert isinstance(model.dilution_factor(), np.ndarray)
|
||||
np.testing.assert_almost_equal(
|
||||
model.dilution_factor(), expected_dilution, decimal=10
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time, expected_short_range_concentration", [
|
||||
[8.5, 0.],
|
||||
[10.5, 15.24806213],
|
||||
[10.6, 15.24806213],
|
||||
[11.0, 15.24806213],
|
||||
[12.0, 0.],
|
||||
]
|
||||
)
|
||||
def test_short_range_concentration(time, expected_short_range_concentration, concentration_model, short_range_model):
|
||||
concentration_model = concentration_model.build_model(250_000)
|
||||
model = short_range_model.build_model(250_000)
|
||||
np.testing.assert_allclose(
|
||||
np.array(model.short_range_concentration(concentration_model, time)).mean(),
|
||||
expected_short_range_concentration, rtol=0.01
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start, stop, expected_exposure", [
|
||||
[8.5, 12.5, 7.875963317294013e-09],
|
||||
[10.5, 11.0, 7.875963317294013e-09],
|
||||
[10.4, 11.1, 7.875963317294013e-09],
|
||||
[10.5, 11.1, 7.875963317294013e-09],
|
||||
[10.6, 11.1, 7.66539809488759e-09],
|
||||
[10.4, 10.9, 7.66539809488759e-09],
|
||||
|
||||
]
|
||||
)
|
||||
def test_normed_exposure_between_bounds(start, stop, expected_exposure, concentration_model, short_range_model):
|
||||
concentration_model = concentration_model.build_model(250_000)
|
||||
model = short_range_model.build_model(250_000)
|
||||
np.testing.assert_almost_equal(
|
||||
model.normed_exposure_between_bounds(concentration_model, start, stop).mean(), expected_exposure
|
||||
)
|
||||
383
cara/tests/test_full_algorithm.py
Normal file
383
cara/tests/test_full_algorithm.py
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
import numpy as np
|
||||
from scipy.integrate import quad
|
||||
from scipy.special import erf
|
||||
import numpy.testing as npt
|
||||
import pytest
|
||||
|
||||
import cara.monte_carlo as mc
|
||||
from cara import models,data
|
||||
from cara.utils import method_cache
|
||||
from cara.models import _VectorisedFloat,Interval,SpecificInterval
|
||||
from cara.monte_carlo.sampleable import LogNormal
|
||||
from cara.monte_carlo.data import (expiration_distributions,
|
||||
expiration_BLO_factors,short_range_expiration_distributions,
|
||||
short_range_distances)
|
||||
|
||||
# TODO: seed better the random number generators
|
||||
np.random.seed(2000)
|
||||
SAMPLE_SIZE = 500000
|
||||
TOLERANCE = 0.05
|
||||
|
||||
sqrt2pi = np.sqrt(2.*np.pi)
|
||||
sqrt2 = np.sqrt(2.)
|
||||
ln2 = np.log(2)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimpleConcentrationModel:
|
||||
"""
|
||||
Simple model for the background (long-range) concentration, without
|
||||
all the flexibility of cara.models.ConcentrationModel.
|
||||
For independent, end-to-end testing purposes.
|
||||
This assumes no mask wearing, and the same ventilation rate at all
|
||||
times.
|
||||
"""
|
||||
|
||||
#: infected people presence interval
|
||||
infected_presence: Interval
|
||||
|
||||
#: viral load (RNA copies / mL)
|
||||
viral_load: _VectorisedFloat
|
||||
|
||||
#: breathing rate (m^3/h)
|
||||
breathing_rate: _VectorisedFloat
|
||||
|
||||
#: room volume (m^3)
|
||||
room_volume: _VectorisedFloat
|
||||
|
||||
#: ventilation rate (air changes per hour) - including HEPA
|
||||
lambda_ventilation: _VectorisedFloat
|
||||
|
||||
#: BLO factors
|
||||
BLO_factors: typing.Tuple[float, float, float]
|
||||
|
||||
#: number of infected people
|
||||
num_infected: int = 1
|
||||
|
||||
#: relative humidity RH
|
||||
humidity: float = 0.3
|
||||
|
||||
#: minimum particle diameter considered (microns)
|
||||
diameter_min: float = 0.1
|
||||
|
||||
#: maximum particle diameter considered (microns)
|
||||
diameter_max: float = 30.
|
||||
|
||||
#: evaporation factor
|
||||
evaporation: float = 0.3
|
||||
|
||||
#: cn (cm^-3) for resp. the B, L and O modes. Corresponds to the
|
||||
# total concentration of aerosols for each mode.
|
||||
cn: typing.Tuple[float, float, float] = (0.06, 0.2, 0.0010008)
|
||||
|
||||
# mean of the underlying normal distributions (represents the log of a
|
||||
# diameter in microns), for resp. the B, L and O modes.
|
||||
mu: typing.Tuple[float, float, float] = (0.989541, 1.38629, 4.97673)
|
||||
|
||||
# std deviation of the underlying normal distribution, for resp.
|
||||
# the B, L and O modes.
|
||||
sigma: typing.Tuple[float, float, float] = (0.262364, 0.506818, 0.585005)
|
||||
|
||||
def removal_rate(self) -> _VectorisedFloat:
|
||||
"""
|
||||
removal rate lambda in h^-1, excluding the deposition rate.
|
||||
"""
|
||||
return (self.lambda_ventilation
|
||||
+ ln2/(6.43 if self.humidity<=0.4 else 1.1) )
|
||||
|
||||
@method_cache
|
||||
def deposition_removal_coefficient(self) -> float:
|
||||
"""
|
||||
coefficient in front of gravitational deposition rate, in h^-1.microns^-2
|
||||
Note: 0.4512 = 1.88e-4 * 3600 / 1.5
|
||||
"""
|
||||
return 0.4512*(self.evaporation/2.5)**2
|
||||
|
||||
@method_cache
|
||||
def aerosol_volume(self,diameter: float) -> float:
|
||||
"""
|
||||
particle volume in microns^3
|
||||
"""
|
||||
return 4*np.pi/3. * (diameter/2.)**3
|
||||
|
||||
@method_cache
|
||||
def Np(self,diameter: float) -> float:
|
||||
"""
|
||||
number of emitted particles per unit volume (BLO model)
|
||||
in cm^-3.ln(micron)^-1
|
||||
"""
|
||||
result = 0.
|
||||
for cn,mu,sigma,famp in zip(self.cn,self.mu,self.sigma,
|
||||
self.BLO_factors):
|
||||
result += ( (cn * famp)/sigma *
|
||||
np.exp(-(np.log(diameter)-mu)**2/(2*sigma**2)))
|
||||
return result/(diameter*sqrt2pi)
|
||||
|
||||
def vR(self,diameter: float) -> float:
|
||||
"""
|
||||
emission rate per unit diameter, in RNA copies / h / micron
|
||||
"""
|
||||
return (self.viral_load * self.breathing_rate * self.Np(diameter)
|
||||
* self.aerosol_volume(diameter) * 1e-6)
|
||||
|
||||
@method_cache
|
||||
def f(self, removal_rate: _VectorisedFloat, deltat: float) -> _VectorisedFloat:
|
||||
"""
|
||||
A general function to compute the main integral over diameters
|
||||
"""
|
||||
def integrand(diameter):
|
||||
# function to return the integrand
|
||||
a = self.deposition_removal_coefficient()
|
||||
a_dsquare = a*diameter**2
|
||||
return (self.vR(diameter)/(a_dsquare + removal_rate)
|
||||
* np.exp(-a_dsquare*deltat))
|
||||
|
||||
return quad(integrand,self.diameter_min,self.diameter_max)[0]
|
||||
|
||||
def concentration(self,t: float) -> _VectorisedFloat:
|
||||
"""
|
||||
concentration at a given time t
|
||||
"""
|
||||
trans_times = sorted(self.infected_presence.transition_times())
|
||||
if t==trans_times[0]:
|
||||
return 0.
|
||||
|
||||
lambda_rate = self.removal_rate()
|
||||
# transition_times[i] < t <= transition_times[i]+1
|
||||
i: int = np.searchsorted(trans_times,t) - 1 # type: ignore
|
||||
ti = trans_times[i]
|
||||
Pim1 = self.infected_presence.triggered((trans_times[i-1]+ti)/2.)
|
||||
Pi = self.infected_presence.triggered((ti+trans_times[i+1])/2.)
|
||||
|
||||
result = (0 if not Pim1 else self.f(lambda_rate,t-ti))
|
||||
result -= (0 if not Pi else self.f(lambda_rate,t-ti))
|
||||
|
||||
for k,tk in enumerate(trans_times[:i]):
|
||||
Pkm1 = self.infected_presence.triggered((trans_times[k-1]+tk)/2.)
|
||||
Pk = self.infected_presence.triggered((tk+trans_times[k+1])/2.)
|
||||
s = np.sum([lambda_rate*(trans_times[l]-trans_times[l-1])
|
||||
for l in range(k+1,i+1)])
|
||||
result += ( (0 if not Pkm1 else self.f(lambda_rate,t-tk))
|
||||
-(0 if not Pk else self.f(lambda_rate,t-tk))
|
||||
) * np.exp(-s)
|
||||
|
||||
return ( ( (0 if not self.infected_presence.triggered(t)
|
||||
else self.f(lambda_rate,0))
|
||||
+ result * np.exp(-lambda_rate*(t-ti)) )
|
||||
* self.num_infected/self.room_volume)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimpleShortRangeModel:
|
||||
"""
|
||||
Simple model for the short-range concentration, without
|
||||
all the flexibility of cara.models.ShortRangeModel.
|
||||
For independent, end-to-end testing purposes.
|
||||
This assumes no mask wearing.
|
||||
"""
|
||||
|
||||
#: time intervals in which a short-range interaction occurs
|
||||
interaction_interval: SpecificInterval
|
||||
|
||||
#: tuple with interpersonal distanced from infected person (m)
|
||||
distance : _VectorisedFloat = 0.854
|
||||
|
||||
#: breathing rate (m^3/h)
|
||||
breathing_rate: _VectorisedFloat = 0.51
|
||||
|
||||
#: tuple with BLO factors
|
||||
BLO_factors: typing.Tuple[float, float, float] = (1,0,0)
|
||||
|
||||
#: minimum diameter for integration (short-range only) (microns)
|
||||
diameter_min: float = 0.1
|
||||
|
||||
#: maximum diameter for integration (short-range only) (microns)
|
||||
diameter_max: float = 100.
|
||||
|
||||
#: mouth opening diameter (m)
|
||||
D: float = 0.02
|
||||
|
||||
#: duration of the expiration (s)
|
||||
tstar: float = 2.
|
||||
|
||||
#: Streamwise and radial penetration coefficients
|
||||
Cr1: float = 0.18
|
||||
Cx1: float = 2.4
|
||||
Cr2: float = 0.2
|
||||
Cx2: float = 2.2
|
||||
|
||||
@method_cache
|
||||
def dilution_factor(self) -> _VectorisedFloat:
|
||||
"""
|
||||
computes dilution factor at a certain distance x
|
||||
based on Wei JIA matlab script.
|
||||
"""
|
||||
x = np.array(self.distance)
|
||||
dilution = np.empty(x.shape, dtype=np.float64)
|
||||
# expired flow rate during the expiration period, m^3/s
|
||||
Q0 = np.array(self.breathing_rate/3600)
|
||||
# the expired flow velocity at the noozle (mouth opening), m/s
|
||||
u0 = np.array(Q0/(np.pi/4. * self.D**2))
|
||||
# parameters in the jet-like stage
|
||||
# position of virtual origin
|
||||
x01 = self.D/2/self.Cr1
|
||||
# time of virtual origin
|
||||
t01 = (x01/self.Cx1)**2 * (Q0*u0)**(-0.5)
|
||||
# transition point (in m)
|
||||
xstar = np.array(self.Cx1*(Q0*u0)**0.25*(self.tstar + t01)**0.5
|
||||
- x01)
|
||||
# dilution factor at the transition point xstar
|
||||
Sxstar = np.array(2.*self.Cr1*(xstar+x01)/self.D)
|
||||
|
||||
# calculate dilution factor at the short-range distance x
|
||||
dilution[x <= xstar] = 2.*self.Cr1*(x[x <= xstar] + x01)/self.D
|
||||
dilution[x > xstar] = Sxstar[x > xstar]*(1. + self.Cr2*(x[x > xstar]
|
||||
- xstar[x > xstar])
|
||||
/self.Cr1/(xstar[x > xstar] + x01))**3
|
||||
|
||||
return dilution
|
||||
|
||||
@method_cache
|
||||
def jet_concentration(self,conc_model: SimpleConcentrationModel) -> _VectorisedFloat:
|
||||
"""
|
||||
virion concentration at the origin of the jet (close to
|
||||
the mouth of the infected person), in m^-3
|
||||
we perform the integral of Np(d)*V(d) over diameter analytically
|
||||
"""
|
||||
vl = conc_model.viral_load
|
||||
dmin = self.diameter_min
|
||||
dmax = self.diameter_max
|
||||
result = 0.
|
||||
for cn,mu,sigma,famp in zip(conc_model.cn,conc_model.mu,conc_model.sigma,
|
||||
self.BLO_factors):
|
||||
d0 = np.exp(mu)
|
||||
ymin = (np.log(dmin)-mu)/(sqrt2*sigma)-3.*sigma/sqrt2
|
||||
ymax = (np.log(dmax)-mu)/(sqrt2*sigma)-3.*sigma/sqrt2
|
||||
result += ( (cn * famp * d0**3)/2. * np.exp(9*sigma**2/2.) *
|
||||
(erf(ymax) - erf(ymin)) )
|
||||
return vl * 1e-6 * result * np.pi/6.
|
||||
|
||||
def concentration(self, conc_model: SimpleConcentrationModel, time: float) -> _VectorisedFloat:
|
||||
"""
|
||||
compute the short-range part of the concentration, and add it
|
||||
to the background concentration
|
||||
"""
|
||||
if self.interaction_interval.triggered(time):
|
||||
background_concentration = conc_model.concentration(time)
|
||||
S = self.dilution_factor()
|
||||
return (self.jet_concentration(conc_model)
|
||||
- background_concentration) / S
|
||||
else:
|
||||
return 0.
|
||||
|
||||
|
||||
presence = models.SpecificInterval(present_times=((8.5, 12), (13, 17.5)))
|
||||
interaction_intervals = (models.SpecificInterval(present_times=((10.5, 11.0),)),
|
||||
models.SpecificInterval(present_times=((14.5, 15.0),))
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conc_model() -> mc.ConcentrationModel:
|
||||
return mc.ConcentrationModel(
|
||||
room=models.Room(volume=50, humidity=0.3),
|
||||
ventilation=models.AirChange(active=models.PeriodicInterval(period=120, duration=120), air_exch=1.),
|
||||
infected=mc.InfectedPopulation(
|
||||
number=1,
|
||||
presence=presence,
|
||||
virus=models.Virus.types['SARS_CoV_2_DELTA'],
|
||||
mask=models.Mask.types['No mask'],
|
||||
activity=models.Activity.types['Seated'],
|
||||
expiration=expiration_distributions['Breathing'],
|
||||
host_immunity=0.,
|
||||
),
|
||||
evaporation_factor=0.3,
|
||||
).build_model(SAMPLE_SIZE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_conc_model() -> SimpleConcentrationModel:
|
||||
return SimpleConcentrationModel(
|
||||
infected_presence = presence,
|
||||
viral_load = models.Virus.types['SARS_CoV_2_DELTA'].viral_load_in_sputum,
|
||||
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
|
||||
room_volume = 50.,
|
||||
lambda_ventilation= 1.,
|
||||
BLO_factors = expiration_BLO_factors['Breathing'],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sr_models() -> typing.Tuple[mc.ShortRangeModel, ...]:
|
||||
return (
|
||||
mc.ShortRangeModel(
|
||||
expiration = short_range_expiration_distributions['Breathing'],
|
||||
activity = models.Activity.types['Seated'],
|
||||
presence = interaction_intervals[0],
|
||||
distance = 0.854,
|
||||
).build_model(SAMPLE_SIZE),
|
||||
mc.ShortRangeModel(
|
||||
expiration = short_range_expiration_distributions['Speaking'],
|
||||
activity = models.Activity.types['Seated'],
|
||||
presence = interaction_intervals[1],
|
||||
distance = 0.854,
|
||||
).build_model(SAMPLE_SIZE),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_sr_models() -> typing.Tuple[SimpleShortRangeModel, ...]:
|
||||
return (
|
||||
SimpleShortRangeModel(
|
||||
interaction_interval = interaction_intervals[0],
|
||||
distance = 0.854,
|
||||
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
|
||||
BLO_factors = expiration_BLO_factors['Breathing'],
|
||||
),
|
||||
SimpleShortRangeModel(
|
||||
interaction_interval = interaction_intervals[1],
|
||||
distance = 0.854,
|
||||
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
|
||||
BLO_factors = expiration_BLO_factors['Speaking'],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time", np.linspace(8.5,17.5,12),
|
||||
)
|
||||
def test_background_concentration(time,conc_model,simple_conc_model):
|
||||
npt.assert_allclose(
|
||||
conc_model.concentration(time).mean(),
|
||||
simple_conc_model.concentration(time), rtol=TOLERANCE
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time", [10, 10.7, 11., 12.5, 14.75, 14.9, 17]
|
||||
)
|
||||
def test_shortrange_concentration(time,conc_model,simple_conc_model,
|
||||
sr_models,simple_sr_models):
|
||||
result_sr_model = np.sum([np.array(
|
||||
sr_mod.short_range_concentration(conc_model,time)).mean()
|
||||
for sr_mod in sr_models])
|
||||
result_simple_sr_model = np.sum([np.array(
|
||||
sr_mod.concentration(simple_conc_model,time)).mean()
|
||||
for sr_mod in simple_sr_models])
|
||||
npt.assert_allclose(
|
||||
result_sr_model,result_simple_sr_model,rtol=TOLERANCE
|
||||
)
|
||||
|
||||
|
||||
#times = np.linspace(8.5,17.5,120)
|
||||
#plt.plot(times,[conc_model.concentration(t).mean() for t in times])
|
||||
#plt.plot(times,[simple_conc_model.concentration(t) for t in times])
|
||||
|
||||
#plt.plot(times,[np.sum([np.array(sr_mod.short_range_concentration(conc_model,t)).mean()
|
||||
# for sr_mod in sr_models])
|
||||
# + conc_model.concentration(t).mean() for t in times])
|
||||
#plt.plot(times,[np.sum([np.array(sr_mod.concentration(simple_conc_model,t)).mean()
|
||||
# for sr_mod in simple_sr_models])
|
||||
# + simple_conc_model.concentration(t)
|
||||
# for t in times])
|
||||
|
|
@ -6,10 +6,10 @@ import cara.models as models
|
|||
import cara.data as data
|
||||
|
||||
|
||||
def test_no_mask_superspeading_emission_rate(baseline_model):
|
||||
def test_no_mask_superspeading_emission_rate(baseline_concentration_model):
|
||||
expected_rate = 48500.
|
||||
npt.assert_allclose(
|
||||
[baseline_model.infected.emission_rate(float(t)) for t in [0, 1, 4, 4.5, 5, 8, 9]],
|
||||
[baseline_concentration_model.infected.emission_rate(float(t)) for t in [0, 1, 4, 4.5, 5, 8, 9]],
|
||||
[0, expected_rate, expected_rate, 0, 0, expected_rate, 0],
|
||||
rtol=1e-12
|
||||
)
|
||||
|
|
@ -38,10 +38,10 @@ def baseline_periodic_hepa():
|
|||
)
|
||||
|
||||
|
||||
def test_concentrations(baseline_model):
|
||||
def test_concentrations(baseline_concentration_model):
|
||||
# expected concentrations were computed analytically
|
||||
ts = [0, 4, 5, 7, 10]
|
||||
concentrations = [baseline_model.concentration(float(t)) for t in ts]
|
||||
concentrations = [baseline_concentration_model.concentration(float(t)) for t in ts]
|
||||
npt.assert_allclose(
|
||||
concentrations,
|
||||
[0.000000e+00, 20.805628, 6.602814e-13, 20.805628, 2.09545e-26],
|
||||
|
|
@ -49,13 +49,13 @@ def test_concentrations(baseline_model):
|
|||
)
|
||||
|
||||
|
||||
def test_smooth_concentrations(baseline_model):
|
||||
def test_smooth_concentrations(baseline_concentration_model):
|
||||
# We don't care about the actual concentrations in this test, but rather
|
||||
# that the curve itself is smooth.
|
||||
dx = 0.002
|
||||
dy_limit = 0.2 # Anything more than this (in relative) is a bit steep.
|
||||
ts = np.arange(0, 10, dx)
|
||||
concentrations = [baseline_model.concentration(float(t)) for t in ts]
|
||||
concentrations = [baseline_concentration_model.concentration(float(t)) for t in ts]
|
||||
assert np.abs(np.diff(concentrations)).max()/np.mean(concentrations) < dy_limit
|
||||
|
||||
|
||||
|
|
@ -367,10 +367,11 @@ def test_concentrations_refine_times(time):
|
|||
npt.assert_allclose(m1.concentration(time), m2.concentration(time), rtol=1e-8)
|
||||
|
||||
|
||||
def build_exposure_model(concentration_model):
|
||||
def build_exposure_model(concentration_model, short_range_model):
|
||||
infected = concentration_model.infected
|
||||
return models.ExposureModel(
|
||||
concentration_model=concentration_model,
|
||||
short_range=short_range_model,
|
||||
exposed=models.Population(
|
||||
number=10,
|
||||
presence=infected.presence,
|
||||
|
|
@ -390,13 +391,13 @@ def build_exposure_model(concentration_model):
|
|||
['Jun', 1721.03336729],
|
||||
],
|
||||
)
|
||||
def test_exposure_hourly_dep(month,expected_deposited_exposure):
|
||||
def test_exposure_hourly_dep(month,expected_deposited_exposure, baseline_sr_model):
|
||||
m = build_exposure_model(
|
||||
build_hourly_dependent_model(
|
||||
month,
|
||||
intervals_open=((0., 24.), ),
|
||||
intervals_presence_infected=((8., 12.), (13., 17.))
|
||||
)
|
||||
), baseline_sr_model
|
||||
)
|
||||
deposited_exposure = m.deposited_exposure()
|
||||
npt.assert_allclose(deposited_exposure, expected_deposited_exposure)
|
||||
|
|
@ -411,14 +412,14 @@ def test_exposure_hourly_dep(month,expected_deposited_exposure):
|
|||
['Jun', 1799.17597184],
|
||||
],
|
||||
)
|
||||
def test_exposure_hourly_dep_refined(month,expected_deposited_exposure):
|
||||
def test_exposure_hourly_dep_refined(month,expected_deposited_exposure, baseline_sr_model):
|
||||
m = build_exposure_model(
|
||||
build_hourly_dependent_model(
|
||||
month,
|
||||
intervals_open=((0., 24.),),
|
||||
intervals_presence_infected=((8., 12.), (13., 17.)),
|
||||
temperatures=data.GenevaTemperatures,
|
||||
)
|
||||
), baseline_sr_model
|
||||
)
|
||||
deposited_exposure = m.deposited_exposure()
|
||||
npt.assert_allclose(deposited_exposure, expected_deposited_exposure, rtol=0.02)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ def test_type_annotations():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel:
|
||||
def baseline_mc_concentration_model() -> cara.monte_carlo.ConcentrationModel:
|
||||
mc_model = cara.monte_carlo.ConcentrationModel(
|
||||
room=cara.monte_carlo.Room(volume=cara.monte_carlo.sampleable.Normal(75, 20)),
|
||||
ventilation=cara.monte_carlo.SlidingWindow(
|
||||
|
|
@ -62,21 +62,27 @@ def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel:
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def baseline_mc_exposure_model(baseline_mc_model) -> cara.monte_carlo.ExposureModel:
|
||||
def baseline_mc_sr_model() -> cara.monte_carlo.ShortRangeModel:
|
||||
return ()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def baseline_mc_exposure_model(baseline_mc_concentration_model, baseline_mc_sr_model) -> cara.monte_carlo.ExposureModel:
|
||||
return cara.monte_carlo.ExposureModel(
|
||||
baseline_mc_model,
|
||||
baseline_mc_concentration_model,
|
||||
baseline_mc_sr_model,
|
||||
exposed=cara.models.Population(
|
||||
number=10,
|
||||
presence=baseline_mc_model.infected.presence,
|
||||
activity=baseline_mc_model.infected.activity,
|
||||
mask=baseline_mc_model.infected.mask,
|
||||
presence=baseline_mc_concentration_model.infected.presence,
|
||||
activity=baseline_mc_concentration_model.infected.activity,
|
||||
mask=baseline_mc_concentration_model.infected.mask,
|
||||
host_immunity=0.,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_build_concentration_model(baseline_mc_model: cara.monte_carlo.ConcentrationModel):
|
||||
model = baseline_mc_model.build_model(7)
|
||||
def test_build_concentration_model(baseline_mc_concentration_model: cara.monte_carlo.ConcentrationModel):
|
||||
model = baseline_mc_concentration_model.build_model(7)
|
||||
assert isinstance(model, cara.models.ConcentrationModel)
|
||||
assert isinstance(model.concentration(time=0.), float)
|
||||
conc = model.concentration(time=1.)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from cara.apps.calculator.model_generator import build_expiration
|
|||
|
||||
# TODO: seed better the random number generators
|
||||
np.random.seed(2000)
|
||||
SAMPLE_SIZE = 250000
|
||||
TOLERANCE = 0.05
|
||||
SAMPLE_SIZE = 250_000
|
||||
TOLERANCE = 0.06
|
||||
|
||||
# Load the weather data (temperature in kelvin) for Toronto.
|
||||
toronto_coordinates = (43.667, 79.400)
|
||||
|
|
@ -39,7 +39,6 @@ TorontoTemperatures = {
|
|||
|
||||
# references values for infection_probability and expected new cases
|
||||
# in the following tests, were obtained from the feature/mc branch
|
||||
|
||||
@pytest.fixture
|
||||
def shared_office_mc():
|
||||
"""
|
||||
|
|
@ -72,6 +71,7 @@ def shared_office_mc():
|
|||
)
|
||||
return mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=3,
|
||||
presence=mc.SpecificInterval(present_times=((0, 3.5), (4.5, 9))),
|
||||
|
|
@ -114,6 +114,7 @@ def classroom_mc():
|
|||
)
|
||||
return mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=19,
|
||||
presence=models.SpecificInterval(((0, 2), (2.5, 4), (5, 7), (7.5, 9))),
|
||||
|
|
@ -147,6 +148,7 @@ def ski_cabin_mc():
|
|||
)
|
||||
return mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=3,
|
||||
presence=models.SpecificInterval(((0, 20/60),)),
|
||||
|
|
@ -186,6 +188,7 @@ def skagit_chorale_mc():
|
|||
)
|
||||
return mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=60,
|
||||
presence=models.SpecificInterval(((0, 2.5), )),
|
||||
|
|
@ -225,6 +228,7 @@ def bus_ride_mc():
|
|||
)
|
||||
return mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=67,
|
||||
presence=models.SpecificInterval(((0, 1.67), )),
|
||||
|
|
@ -259,6 +263,7 @@ def gym_mc():
|
|||
)
|
||||
return mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=28,
|
||||
presence=concentration_mc.infected.presence,
|
||||
|
|
@ -293,6 +298,7 @@ def waiting_room_mc():
|
|||
)
|
||||
return mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=14,
|
||||
presence=concentration_mc.infected.presence,
|
||||
|
|
@ -370,6 +376,7 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi,
|
|||
)
|
||||
exposure_mc = mc.ExposureModel(
|
||||
concentration_model=concentration_mc,
|
||||
short_range=(),
|
||||
exposed=mc.Population(
|
||||
number=1,
|
||||
presence=concentration_mc.infected.presence,
|
||||
|
|
|
|||
Loading…
Reference in a new issue