From 1ea01c12745c6190c62e8fab3d07213281fc6797 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 21 Jan 2022 17:11:05 +0100 Subject: [PATCH 01/85] Short range presence and short range activities as part of the backend model --- cara/apps/calculator/model_generator.py | 50 +++++++++++++++++++++++- cara/apps/calculator/report_generator.py | 36 ++++++++++++++++- cara/apps/expert.py | 3 ++ cara/models.py | 6 +++ cara/monte_carlo/data.py | 37 +++++++++++++++++- 5 files changed, 128 insertions(+), 4 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 595094bc..4ded107d 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -3,6 +3,8 @@ import datetime import html import logging import typing +import ast +import json import numpy as np @@ -11,7 +13,7 @@ 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 activity_distributions, virus_distributions, mask_distributions, initial_concentrations_mouth from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions @@ -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': False, + 'short_range_interactions': [], } @classmethod @@ -416,6 +422,8 @@ class FormData: number=infected_occupants, virus=virus, presence=self.infected_present_interval(), + short_range_presence=self.short_range_intervals(), + short_range_activities=self.short_range_activities(), mask=self.mask(), activity=activity, expiration=expiration, @@ -448,6 +456,7 @@ class FormData: exposed = mc.Population( number=exposed_occupants, presence=self.exposed_present_interval(), + short_range_presence=self.short_range_interactions, activity=activity, mask=self.mask(), host_immunity=0., @@ -623,12 +632,30 @@ class FormData: breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), ) + def short_range_intervals(self) -> models.Interval: + if (self.short_range_interactions): + short_range_intervals = [] + for interaction in self.short_range_interactions: + start_time = time_string_to_minutes(interaction['start_time']) + duration = float(interaction['duration']) + short_range_intervals.append(models.SpecificInterval((start_time/60, (start_time + duration)/60))) + + return short_range_intervals + else: + return [] + def exposed_present_interval(self) -> models.Interval: return self.present_interval( self.exposed_start, self.exposed_finish, breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times(), ) + def short_range_activities(self): + if (self.short_range_interactions): + return [interaction['activity'] for interaction in self.short_range_interactions] + else: + return [] + def build_expiration(expiration_definition) -> models._ExpirationBase: if isinstance(expiration_definition, str): @@ -642,6 +669,12 @@ def build_expiration(expiration_definition) -> models._ExpirationBase: return expiration_distribution(tuple(BLO_factors)) +def build_concentration_mouth(short_range_def) -> models._ExpirationBase: + expiration_definition = short_range_def[1]['activity'] + if isinstance(expiration_definition, str): + return initial_concentrations_mouth[expiration_definition] + + def baseline_raw_form_data(): # Note: This isn't a special "baseline". It can be updated as required. return { @@ -691,7 +724,8 @@ 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', } @@ -736,6 +770,15 @@ 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: + if (l != []): + 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 @@ -767,3 +810,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 diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index e703e225..ec8230c2 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -12,8 +12,9 @@ import jinja2 import numpy as np from cara import models +from cara.monte_carlo.data import initial_concentrations_mouth, dilution_factor from ... import monte_carlo as mc -from .model_generator import FormData, _DEFAULT_MC_SAMPLE_SIZE +from .model_generator import FormData, _DEFAULT_MC_SAMPLE_SIZE, build_expiration from ... import dataclass_utils @@ -96,13 +97,45 @@ def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.L return nice_times +def short_range_interesting_times(model: models.ExposureModel, times: typing.List[float]) -> typing.List[float]: + short_range_times = [] + for period in model.concentration_model.infected.short_range_presence: + start, finish = period.boundaries() + short_range_times = short_range_times + [time for time in times if time > start and time < finish] + return short_range_times + + +def jet_origin_concentrations(model: models.ExposureModel) -> models._ExpirationBase: + return [initial_concentrations_mouth[activity] for activity in model.concentration_model.infected.short_range_activities] + + +def short_range_initial_concentrations(model: models.ExposureModel, time: float): + dilution = dilution_factor(np.linspace(0.1, 2., 1000)) + jet_origin_initial_concentrations = jet_origin_concentrations(model) + for index, interaction in enumerate(model.concentration_model.infected.short_range_presence): + start, finish = interaction.boundaries() + if start <= time <= finish: + expiration = build_expiration(model.concentration_model.infected.short_range_activities[index]) + single_exposure_model = dataclass_utils.nested_replace( + model, {'concentration_model.infected.expiration': expiration} + ) + concentration = single_exposure_model.concentration_model._normed_concentration(float(time)) + jet_origin_concentration = jet_origin_initial_concentrations[index] * model.concentration_model.infected.virus.viral_load_in_sputum + return concentration + ((1/dilution)*(jet_origin_concentration - concentration)) + + def calculate_report_data(model: models.ExposureModel): times = interesting_times(model) + times_short_range = short_range_interesting_times(model, times) concentrations = [ np.array(model.concentration_model.concentration(float(time))).mean() for time in times ] + short_range_concentrations = [ + np.array(short_range_initial_concentrations(model, float(time))).mean() + for time in times_short_range + ] highest_const = max(concentrations) prob = np.array(model.infection_probability()).mean() er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean() @@ -118,6 +151,7 @@ def calculate_report_data(model: models.ExposureModel): "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "cumulative_doses": list(cumulative_doses), "concentrations": concentrations, + "short_range_concentrations": short_range_concentrations, "highest_const": highest_const, "prob_inf": prob, "emission_rate": er, diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 261afe75..e779558a 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -496,6 +496,8 @@ baseline_model = models.ExposureModel( number=1, virus=models.Virus.types['SARS_CoV_2'], presence=models.SpecificInterval(((8., 12.), (13., 17.))), + short_range_presence=[], + short_range_activities=[], mask=models.Mask.types['No mask'], activity=models.Activity.types['Seated'], expiration=models.Expiration.types['Speaking'], @@ -506,6 +508,7 @@ baseline_model = models.ExposureModel( exposed=models.Population( number=10, presence=models.SpecificInterval(((8., 12.), (13., 17.))), + short_range_presence=[], activity=models.Activity.types['Seated'], mask=models.Mask.types['No mask'], host_immunity=0., diff --git a/cara/models.py b/cara/models.py index 3cedbf22..3092a6ea 100644 --- a/cara/models.py +++ b/cara/models.py @@ -678,6 +678,9 @@ class Population: #: The times in which the people are in the room. presence: Interval + #: Short range interactions + short_range_presence: list + #: The kind of mask being worn by the people. mask: Mask @@ -749,6 +752,9 @@ class EmittingPopulation(_PopulationWithVirus): class InfectedPopulation(_PopulationWithVirus): #: The type of expiration that is being emitted whilst doing the activity. expiration: _ExpirationBase + + #: The type of expiractory activities in the short range interactions + short_range_activities: list @method_cache def fraction_of_infectious_virus(self) -> _VectorisedFloat: diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index 3b9172d2..c4e1dfb2 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -3,6 +3,7 @@ import typing import numpy as np from scipy import special as sp +import scipy.integrate import cara.monte_carlo as mc from cara.monte_carlo.sampleable import Normal,LogNormal,LogCustomKernel,CustomKernel,Uniform @@ -51,6 +52,9 @@ class BLOmodel: np.exp(-(np.log(d) - mu) ** 2 / (2 * sigma ** 2)) for A,cn,mu,sigma in zip(self.BLO_factors, self.cn, self.mu, self.sigma) ) + + def volume(self, d): + return(np.pi * d**3) / 6 def integrate(self, dmin, dmax): """ @@ -162,7 +166,7 @@ def expiration_distribution(BLO_factors): Returns an Expiration with an aerosol diameter distribution, defined by the BLO factors (a length-3 tuple). The total concentration of aerosols is computed by integrating - the distribution between 0.1 and 30 microns - these boundaries are + the distribution between 0.1 and D microns - these boundaries are an historical choice based on previous implementations of the model (it limits the influence of the O-mode). """ @@ -172,6 +176,31 @@ def expiration_distribution(BLO_factors): BLOmodel(BLO_factors).integrate(0.1, 30.)) +def dilution_factor(distance, D=0.02): + u0 = 0.6 + 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 = Cx1*(Q0*u0)**0.25*(tstar + t01)**0.5 - x01 + # Dilution factor at the transition point xstar + Sxstar = 2*Cr1*(xstar+x01)/D + return np.mean(np.piecewise(distance, [distance < xstar, distance >= xstar], + [lambda distance : 2*Cr1*(distance + x01)/D, lambda distance : Sxstar*(1 + Cr2*(distance - xstar)/Cr1/(xstar + x01))**3])) + + +def initial_concentration_mouth(BLO_factors): + value, error = scipy.integrate.quad(lambda d: BLOmodel(BLO_factors).distribution(d) * BLOmodel(BLO_factors).volume(d), 0.1, 1000) + return value * 1e-6 #result in mL/m^3 + + expiration_BLO_factors = { 'Breathing': (1., 0., 0.), 'Speaking': (1., 1., 1.), @@ -184,3 +213,9 @@ expiration_distributions = { exp_type: expiration_distribution(BLO_factors) for exp_type,BLO_factors in expiration_BLO_factors.items() } + + +initial_concentrations_mouth = { + exp_type: initial_concentration_mouth(BLO_factors) + for exp_type,BLO_factors in expiration_BLO_factors.items() +} From 141e2346112710a9f64c360464fb79994be1849e Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 21 Jan 2022 17:32:17 +0100 Subject: [PATCH 02/85] Short range interactions modal dialog and validation --- cara/apps/calculator/static/js/form.js | 183 ++++++++++++++++++ .../templates/base/calculator.form.html.j2 | 42 ++++ 2 files changed, 225 insertions(+) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 5a20f025..89dfeaeb 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -250,6 +250,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 +393,19 @@ function validate_form(form) { } } + // Generate the short range interactions list + let short_range_interactions = []; + $(".form_field_outer_row").each(function (index){ + index = index + 1; + + let obj = {}; + obj.activity = $("#sr_activity_no_" + String(index)).val(); + obj.start_time = $("#sr_start_no_" + String(index)).val(); + obj.duration = $("#sr_duration_no_" + String(index)).val(); + short_range_interactions.push(JSON.stringify(obj)); + }); + $("input[type=text][name=short_range_interactions]").val('[' + short_range_interactions + ']'); + if (submit) { $("#generate_report").prop("disabled", true); //Add spinner to button @@ -492,6 +519,51 @@ function validateLunchTime(obj) { return true; } +function overlapped_times(obj, start_time, finish_time) { + let current_interaction = $(obj).closest(".form_field_outer_row"); + $(".form_field_outer_row").not(current_interaction).each(function(index) { + start_time_2 = parseTimeToMins($('#sr_start_no_' + String(index + 1)).val()); + finish_time_2 = start_time_2 + parseInt($('#sr_duration_no_' + String(index + 1)).val()); + if ((start_time >= start_time_2 && start_time <= finish_time_2) || (finish_time >= start_time_2 && finish_time <= finish_time_2) || start_time == start_time_2 || + (start_time <= start_time_2 && finish_time >= finish_time_2)) { + if (!$(obj).hasClass("red_border")) { //Adds the red border and error message. + var parameter = document.getElementById($(obj).attr('id')); + insertErrorFor(parameter, "Short range interactions must not overlap.") + $(parameter).addClass("red_border"); + } + return false; + } else { + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + } + }); +} + +function validate_sr_time(obj) { + if ($(obj).val() != "") { + let obj_id = $(obj).attr('id').split('_').slice(-1)[0]; + let start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val()); + let finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val()); + 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() == "") { + if (!$(obj).hasClass("red_border")) { + 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 +601,20 @@ $(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)) + $('#sr_activity_no_' + String(index)).val(interaction.activity).change(); + document.getElementById('sr_activity_no_' + String(index)).disabled = true; + document.getElementById('sr_start_no_' + String(index)).disabled = true; + document.getElementById('sr_duration_no_' + String(index)).disabled = true; + 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 +664,12 @@ $(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 +782,97 @@ $(document).ready(function () { } return selectedSuggestion.text; } + + function inject_sr_interaction(index, value) { + return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
` + } + + // 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) { + var index; + if ($(".form_field_outer").find(".form_field_outer_row").length != 0) { + index = parseInt($(".form_field_outer").find(".form_field_outer_row").last().find("input[type='number']").attr('id').split('_').slice(-1)[0]); + } else { + index = 0; + } + var activity, start, duration; + if (index != 0) { + activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); + start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); + duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); + } + if ((activity && start && duration) || index == 0) { + if (index != 0) { + document.getElementById('sr_activity_no_' + String(index)).disabled = true; + document.getElementById('sr_start_no_' + String(index)).disabled = true; + document.getElementById('sr_duration_no_' + String(index)).disabled = true; + } + index = index + 1; + $("#dialog_sr").append(inject_sr_interaction(index, value = { activity: "", start_time: "", duration: "" })); + }; + }); + + //Remove short range interaction (modal field row). + $("body").on("click", ".remove_node_btn_frm_field", function() { + $(this).closest(".form_field_outer_row").remove(); + }); + + //Short range modal - save button + $("body").on("click", ".save_btn_frm_field", function() { + let has_interactions = false; + var activity, start, duration; + $(".form_field_outer_row").each(function(index) { + activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); + start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); + duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); + has_interactions = true; + }); + if (activity, start, duration || !has_interactions) { + $("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row").length); + if (has_interactions) { + index = parseInt($(".form_field_outer").find(".form_field_outer_row").last().find("input[type='number']").attr('id').split('_').slice(-1)[0]); + document.getElementById('sr_activity_no_' + String(index)).disabled = true; + document.getElementById('sr_start_no_' + String(index)).disabled = true; + document.getElementById('sr_duration_no_' + String(index)).disabled = true; + } + $('#short_range_dialog').modal('hide'); + } + }); + + //Short range modal - close button + $("body").on("click", ".dismiss_btn_frm_field", function() { + $(".form_field_outer_row").remove(); + $("#sr_interactions").text(0); + }); + + }); diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 81cdae06..b6da060a 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -365,6 +365,48 @@
+
+
Short range interactions:
+
+ + + + +
+
+ +
+
+ +

0 short range interactions.

+
+
+ + + +
+
From 722278b154f6930e00b3bb5a76eb4b63eada500d Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 24 Jan 2022 15:01:16 +0100 Subject: [PATCH 03/85] Corrected typing types and fixed tests --- cara/apps/calculator/model_generator.py | 14 ++++++-------- cara/apps/calculator/report_generator.py | 8 ++++---- cara/apps/expert.py | 5 ++--- cara/models.py | 16 ++++++++-------- cara/tests/conftest.py | 2 ++ cara/tests/models/test_concentration_model.py | 4 ++++ cara/tests/models/test_exposure_model.py | 6 ++++++ cara/tests/test_infected_population.py | 2 ++ cara/tests/test_known_quantities.py | 8 ++++++++ cara/tests/test_monte_carlo.py | 2 ++ cara/tests/test_monte_carlo_full_models.py | 16 ++++++++++++++++ 11 files changed, 60 insertions(+), 23 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 4ded107d..9de11436 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -78,7 +78,7 @@ class FormData: window_width: float windows_number: int window_opening_regime: str - short_range_option: str + short_range_option: bool short_range_interactions: list #: The default values for undefined fields. Note that the defaults here @@ -132,7 +132,7 @@ class FormData: 'windows_number': 0, 'window_opening_regime': 'windows_open_permanently', 'short_range_option': False, - 'short_range_interactions': [], + 'short_range_interactions': '[]', } @classmethod @@ -422,12 +422,12 @@ class FormData: number=infected_occupants, virus=virus, presence=self.infected_present_interval(), - short_range_presence=self.short_range_intervals(), - short_range_activities=self.short_range_activities(), mask=self.mask(), activity=activity, expiration=expiration, host_immunity=0., + short_range_presence=self.short_range_intervals(), + short_range_activities=self.short_range_activities(), ) return infected @@ -456,7 +456,6 @@ class FormData: exposed = mc.Population( number=exposed_occupants, presence=self.exposed_present_interval(), - short_range_presence=self.short_range_interactions, activity=activity, mask=self.mask(), host_immunity=0., @@ -632,14 +631,13 @@ class FormData: breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), ) - def short_range_intervals(self) -> models.Interval: + def short_range_intervals(self) -> typing.List[models.SpecificInterval]: if (self.short_range_interactions): short_range_intervals = [] for interaction in self.short_range_interactions: start_time = time_string_to_minutes(interaction['start_time']) duration = float(interaction['duration']) short_range_intervals.append(models.SpecificInterval((start_time/60, (start_time + duration)/60))) - return short_range_intervals else: return [] @@ -650,7 +648,7 @@ class FormData: breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times(), ) - def short_range_activities(self): + def short_range_activities(self) -> typing.List[str]: if (self.short_range_interactions): return [interaction['activity'] for interaction in self.short_range_interactions] else: diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index ec8230c2..7a11c1b0 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -98,14 +98,14 @@ def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.L def short_range_interesting_times(model: models.ExposureModel, times: typing.List[float]) -> typing.List[float]: - short_range_times = [] + short_range_times : typing.List[float] = [] for period in model.concentration_model.infected.short_range_presence: - start, finish = period.boundaries() + start, finish = tuple(period.boundaries()) short_range_times = short_range_times + [time for time in times if time > start and time < finish] return short_range_times -def jet_origin_concentrations(model: models.ExposureModel) -> models._ExpirationBase: +def jet_origin_concentrations(model: models.ExposureModel) -> typing.List[float]: return [initial_concentrations_mouth[activity] for activity in model.concentration_model.infected.short_range_activities] @@ -113,7 +113,7 @@ def short_range_initial_concentrations(model: models.ExposureModel, time: float) dilution = dilution_factor(np.linspace(0.1, 2., 1000)) jet_origin_initial_concentrations = jet_origin_concentrations(model) for index, interaction in enumerate(model.concentration_model.infected.short_range_presence): - start, finish = interaction.boundaries() + start, finish = tuple(interaction.boundaries()) if start <= time <= finish: expiration = build_expiration(model.concentration_model.infected.short_range_activities[index]) single_exposure_model = dataclass_utils.nested_replace( diff --git a/cara/apps/expert.py b/cara/apps/expert.py index e779558a..8198f2d1 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -496,19 +496,18 @@ baseline_model = models.ExposureModel( number=1, virus=models.Virus.types['SARS_CoV_2'], presence=models.SpecificInterval(((8., 12.), (13., 17.))), - short_range_presence=[], - short_range_activities=[], mask=models.Mask.types['No mask'], activity=models.Activity.types['Seated'], expiration=models.Expiration.types['Speaking'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ), exposed=models.Population( number=10, presence=models.SpecificInterval(((8., 12.), (13., 17.))), - short_range_presence=[], activity=models.Activity.types['Seated'], mask=models.Mask.types['No mask'], host_immunity=0., diff --git a/cara/models.py b/cara/models.py index 3092a6ea..b8f0e4a2 100644 --- a/cara/models.py +++ b/cara/models.py @@ -32,7 +32,7 @@ or length N arrays, where N is the number of parameterisations to run; N must be the same for all parameters of a single model. """ -from dataclasses import dataclass +from dataclasses import dataclass, field import typing import numpy as np @@ -678,9 +678,6 @@ class Population: #: The times in which the people are in the room. presence: Interval - #: Short range interactions - short_range_presence: list - #: The kind of mask being worn by the people. mask: Mask @@ -701,6 +698,12 @@ class _PopulationWithVirus(Population): #: The virus with which the population is infected. virus: Virus + #: Short range interactions + short_range_presence: typing.List[SpecificInterval] + + #: The type of expiractory activities in the short range interactions + short_range_activities: typing.List[str] + @method_cache def fraction_of_infectious_virus(self) -> _VectorisedFloat: """ @@ -751,10 +754,7 @@ class EmittingPopulation(_PopulationWithVirus): @dataclass(frozen=True) class InfectedPopulation(_PopulationWithVirus): #: The type of expiration that is being emitted whilst doing the activity. - expiration: _ExpirationBase - - #: The type of expiractory activities in the short range interactions - short_range_activities: list + expiration: _ExpirationBase @method_cache def fraction_of_infectious_virus(self) -> _VectorisedFloat: diff --git a/cara/tests/conftest.py b/cara/tests/conftest.py index 27ce9f1d..45cdbef4 100644 --- a/cara/tests/conftest.py +++ b/cara/tests/conftest.py @@ -23,6 +23,8 @@ def baseline_model(): host_immunity=0., # superspreading event, where ejection factor is fixed based # on Miller et al. (2020) - 50 represents the infectious dose. + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/models/test_concentration_model.py b/cara/tests/models/test_concentration_model.py index 432b4d80..16d5b5b0 100644 --- a/cara/tests/models/test_concentration_model.py +++ b/cara/tests/models/test_concentration_model.py @@ -47,6 +47,8 @@ def test_concentration_model_vectorisation(override_params): ), expiration=models._ExpirationBase.types['Breathing'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -69,6 +71,8 @@ def simple_conc_model(): virus=models.Virus.types['SARS_CoV_2'], expiration=models.Expiration.types['Breathing'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index 8b572daf..5b674207 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -66,6 +66,8 @@ def known_concentrations(func): virus=models.Virus.types['SARS_CoV_2_ALPHA'], expiration=models.Expiration.types['Speaking'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ) normed_func = lambda x: func(x) / dummy_infected_population.emission_rate_when_present() return KnownNormedconcentration(dummy_room, dummy_ventilation, @@ -154,6 +156,8 @@ def conc_model(): # superspreading event, where ejection factor is fixed based # on Miller et al. (2020) - 50 represents the infectious dose. host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -197,6 +201,8 @@ def test_infectious_dose_vectorisation(): ), expiration=models.Expiration.types['Speaking'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ) cm = known_concentrations(lambda t: 1.2) cm = replace(cm, infected=infected_population) diff --git a/cara/tests/test_infected_population.py b/cara/tests/test_infected_population.py index 8856d312..66714f96 100644 --- a/cara/tests/test_infected_population.py +++ b/cara/tests/test_infected_population.py @@ -37,6 +37,8 @@ def test_infected_population_vectorisation(override_params): ), expiration=cara.models._ExpirationBase.types['Breathing'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ) emission_rate = infected.emission_rate(10) assert isinstance(emission_rate, np.ndarray) diff --git a/cara/tests/test_known_quantities.py b/cara/tests/test_known_quantities.py index 9fc10597..9910a848 100644 --- a/cara/tests/test_known_quantities.py +++ b/cara/tests/test_known_quantities.py @@ -76,6 +76,8 @@ def build_model(interval_duration): host_immunity=0., # superspreading event, where ejection factor is fixed based # on Miller et al. (2020) - 50 represents the infectious dose. + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -238,6 +240,8 @@ def build_hourly_dependent_model( activity=models.Activity.types['Light activity'], known_individual_emission_rate=970 * 50, host_immunity=0, + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -261,6 +265,8 @@ def build_constant_temp_model(outside_temp, intervals_open=((7.5, 8.5),)): activity=models.Activity.types['Light activity'], known_individual_emission_rate=970 * 50, host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -291,6 +297,8 @@ def build_hourly_dependent_model_multipleventilation(month, intervals_open=((7.5 activity=models.Activity.types['Light activity'], known_individual_emission_rate=970 * 50, host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/test_monte_carlo.py b/cara/tests/test_monte_carlo.py index 1a7fc8a9..c51bcbeb 100644 --- a/cara/tests/test_monte_carlo.py +++ b/cara/tests/test_monte_carlo.py @@ -55,6 +55,8 @@ def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel: activity=cara.models.Activity.types['Light activity'], expiration=cara.models.Expiration.types['Breathing'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/test_monte_carlo_full_models.py b/cara/tests/test_monte_carlo_full_models.py index 71a630b0..90466d96 100644 --- a/cara/tests/test_monte_carlo_full_models.py +++ b/cara/tests/test_monte_carlo_full_models.py @@ -42,6 +42,8 @@ def shared_office_mc(): activity=activity_distributions['Seated'], expiration=build_expiration({'Speaking': 0.33, 'Breathing': 0.67}), host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -84,6 +86,8 @@ def classroom_mc(): activity=activity_distributions['Light activity'], expiration=build_expiration('Speaking'), host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -117,6 +121,8 @@ def ski_cabin_mc(): activity=activity_distributions['Moderate activity'], expiration=build_expiration('Speaking'), host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -156,6 +162,8 @@ def skagit_chorale_mc(): activity=activity_distributions['Moderate activity'], expiration=build_expiration('Shouting'), host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -195,6 +203,8 @@ def bus_ride_mc(): activity=activity_distributions['Seated'], expiration=build_expiration('Speaking'), host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -229,6 +239,8 @@ def gym_mc(): activity=activity_distributions['Heavy exercise'], expiration=expiration_distributions['Breathing'], host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -263,6 +275,8 @@ def waiting_room_mc(): activity=activity_distributions['Seated'], expiration=build_expiration({'Speaking': 0.3, 'Breathing': 0.7}), host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) @@ -340,6 +354,8 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi, activity=activity_distributions['Seated'], expiration=build_expiration({'Speaking': 0.33, 'Breathing': 0.67}), host_immunity=0., + short_range_presence=[], + short_range_activities=[], ), evaporation_factor=0.3, ) From df4fe11bd418b86867f608b7dacd891e6d37ada1 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 24 Jan 2022 15:05:34 +0100 Subject: [PATCH 04/85] numpy version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e057da5c..f32d10c2 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ REQUIREMENTS: dict = { 'matplotlib', 'memoization', 'mistune', - 'numpy != 1.22.0', + 'numpy != 1.22.0, != 1.22.1', 'psutil', 'python-dateutil', 'scipy', From f448bc233e37852db4615a8d6957c05a922fbe4e Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 25 Jan 2022 15:54:41 +0100 Subject: [PATCH 05/85] added new verifications for lunch time and simulation time --- cara/apps/calculator/report_generator.py | 8 ++----- cara/apps/calculator/static/js/form.js | 30 ++++++++++++++++++++---- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 7a11c1b0..917cacf5 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -110,16 +110,12 @@ def jet_origin_concentrations(model: models.ExposureModel) -> typing.List[float] def short_range_initial_concentrations(model: models.ExposureModel, time: float): - dilution = dilution_factor(np.linspace(0.1, 2., 1000)) + dilution = dilution_factor(distance=np.linspace(0.1, 2., 1000)) jet_origin_initial_concentrations = jet_origin_concentrations(model) for index, interaction in enumerate(model.concentration_model.infected.short_range_presence): start, finish = tuple(interaction.boundaries()) if start <= time <= finish: - expiration = build_expiration(model.concentration_model.infected.short_range_activities[index]) - single_exposure_model = dataclass_utils.nested_replace( - model, {'concentration_model.infected.expiration': expiration} - ) - concentration = single_exposure_model.concentration_model._normed_concentration(float(time)) + concentration = model.concentration_model.concentration(float(time)) jet_origin_concentration = jet_origin_initial_concentrations[index] * model.concentration_model.infected.virus.viral_load_in_sputum return concentration + ((1/dilution)*(jet_origin_concentration - concentration)) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 89dfeaeb..9b5d2f06 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -520,17 +520,39 @@ function validateLunchTime(obj) { } function overlapped_times(obj, start_time, finish_time) { + let simulation_start = parseTimeToMins($("#exposed_start").val()) + let simulation_finish = parseTimeToMins($("#exposed_finish").val()) + let simulation_lunch_start = parseTimeToMins($("#exposed_lunch_start").val()) + let simulation_lunch_finish = parseTimeToMins($("#exposed_lunch_finish").val()) + 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 + let parameter = document.getElementById($(obj).attr('id')); + if (!$(obj).hasClass("red_border")) { //Adds the red border and error message. + $(parameter).addClass("red_border"); + } + removeErrorFor($(obj)); + insertErrorFor(parameter, "Short range interactions must be within the simulation time.") + return; + } else { + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + } let current_interaction = $(obj).closest(".form_field_outer_row"); $(".form_field_outer_row").not(current_interaction).each(function(index) { start_time_2 = parseTimeToMins($('#sr_start_no_' + String(index + 1)).val()); finish_time_2 = start_time_2 + parseInt($('#sr_duration_no_' + String(index + 1)).val()); - if ((start_time >= start_time_2 && start_time <= finish_time_2) || (finish_time >= start_time_2 && finish_time <= finish_time_2) || start_time == start_time_2 || - (start_time <= start_time_2 && finish_time >= finish_time_2)) { + 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) { + let parameter = document.getElementById($(obj).attr('id')); if (!$(obj).hasClass("red_border")) { //Adds the red border and error message. - var parameter = document.getElementById($(obj).attr('id')); - insertErrorFor(parameter, "Short range interactions must not overlap.") $(parameter).addClass("red_border"); } + removeErrorFor($(obj)); + insertErrorFor(parameter, "Short range interactions must not overlap.") return false; } else { removeErrorFor($(obj)); From f5cf94545010306f843647933e095a442c635451 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 25 Jan 2022 16:39:35 +0100 Subject: [PATCH 06/85] added validation for duration --- cara/apps/calculator/static/js/form.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 9b5d2f06..1b9804f7 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -548,9 +548,7 @@ function overlapped_times(obj, start_time, finish_time) { (start_time <= start_time_2 && finish_time >= finish_time_2) || //If start and finish inputs encompass other time range start_time == start_time_2) { let parameter = document.getElementById($(obj).attr('id')); - if (!$(obj).hasClass("red_border")) { //Adds the red border and error message. - $(parameter).addClass("red_border"); - } + if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message. removeErrorFor($(obj)); insertErrorFor(parameter, "Short range interactions must not overlap.") return false; @@ -564,6 +562,7 @@ function overlapped_times(obj, start_time, finish_time) { function validate_sr_time(obj) { if ($(obj).val() != "") { let obj_id = $(obj).attr('id').split('_').slice(-1)[0]; + if ($(obj).attr('id').startsWith("sr_start_no_")) $("#sr_duration_no_" + obj_id).prop("disabled", false); let start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val()); let finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val()); overlapped_times(obj, start_time, finish_time); @@ -826,7 +825,7 @@ $(document).ready(function () {
-
+
From 266631705652313b06618740c98e436b4a48bd57 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 25 Jan 2022 17:12:24 +0100 Subject: [PATCH 07/85] Remake of validation on save and add buttons --- cara/apps/calculator/static/js/form.js | 165 ++++++++++++------------- 1 file changed, 77 insertions(+), 88 deletions(-) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 1b9804f7..b398eaf8 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -520,69 +520,69 @@ function validateLunchTime(obj) { } function overlapped_times(obj, start_time, finish_time) { - let simulation_start = parseTimeToMins($("#exposed_start").val()) - let simulation_finish = parseTimeToMins($("#exposed_finish").val()) - let simulation_lunch_start = parseTimeToMins($("#exposed_lunch_start").val()) - let simulation_lunch_finish = parseTimeToMins($("#exposed_lunch_finish").val()) - 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 - let parameter = document.getElementById($(obj).attr('id')); - if (!$(obj).hasClass("red_border")) { //Adds the red border and error message. - $(parameter).addClass("red_border"); - } - removeErrorFor($(obj)); - insertErrorFor(parameter, "Short range interactions must be within the simulation time.") - return; - } else { + let simulation_start = parseTimeToMins($("#exposed_start").val()) + let simulation_finish = parseTimeToMins($("#exposed_finish").val()) + let simulation_lunch_start = parseTimeToMins($("#exposed_lunch_start").val()) + let simulation_lunch_finish = parseTimeToMins($("#exposed_lunch_finish").val()) + 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 + let parameter = document.getElementById($(obj).attr('id')); + if (!$(obj).hasClass("red_border")) { //Adds the red border and error message. + $(parameter).addClass("red_border"); + } + removeErrorFor($(obj)); + insertErrorFor(parameter, "Short range interactions must be within the simulation time.") + return; + } else { + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + } + let current_interaction = $(obj).closest(".form_field_outer_row"); + $(".form_field_outer_row").not(current_interaction).each(function(index) { + start_time_2 = parseTimeToMins($('#sr_start_no_' + String(index + 1)).val()); + finish_time_2 = start_time_2 + parseInt($('#sr_duration_no_' + String(index + 1)).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) { + let parameter = document.getElementById($(obj).attr('id')); + if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message. removeErrorFor($(obj)); - $(obj).removeClass("red_border"); - } - let current_interaction = $(obj).closest(".form_field_outer_row"); - $(".form_field_outer_row").not(current_interaction).each(function(index) { - start_time_2 = parseTimeToMins($('#sr_start_no_' + String(index + 1)).val()); - finish_time_2 = start_time_2 + parseInt($('#sr_duration_no_' + String(index + 1)).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) { - let parameter = document.getElementById($(obj).attr('id')); - if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message. - removeErrorFor($(obj)); - insertErrorFor(parameter, "Short range interactions must not overlap.") - return false; - } else { - removeErrorFor($(obj)); - $(obj).removeClass("red_border"); - } - }); -} - -function validate_sr_time(obj) { - if ($(obj).val() != "") { - let obj_id = $(obj).attr('id').split('_').slice(-1)[0]; - if ($(obj).attr('id').startsWith("sr_start_no_")) $("#sr_duration_no_" + obj_id).prop("disabled", false); - let start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val()); - let finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val()); - 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() == "") { - if (!$(obj).hasClass("red_border")) { - var parameter = document.getElementById($(obj).attr('id')); - insertErrorFor(parameter, error_message) - $(parameter).addClass("red_border"); - } + insertErrorFor(parameter, "Short range interactions must not overlap.") return false; } else { removeErrorFor($(obj)); $(obj).removeClass("red_border"); - return true; } + }); +} + +function validate_sr_time(obj) { + if ($(obj).val() != "") { + let obj_id = $(obj).attr('id').split('_').slice(-1)[0]; + if ($(obj).attr('id').startsWith("sr_start_no_")) $("#sr_duration_no_" + obj_id).prop("disabled", false); + let start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val()); + let finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val()); + 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")) { + 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) { @@ -810,7 +810,7 @@ $(document).ready(function () {
-
@@ -837,27 +837,20 @@ $(document).ready(function () { // 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) { - var index; - if ($(".form_field_outer").find(".form_field_outer_row").length != 0) { - index = parseInt($(".form_field_outer").find(".form_field_outer_row").last().find("input[type='number']").attr('id').split('_').slice(-1)[0]); - } else { - index = 0; - } - var activity, start, duration; - if (index != 0) { - activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); - start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); - duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); - } - if ((activity && start && duration) || index == 0) { - if (index != 0) { + let index = $(".form_field_outer").find(".form_field_outer_row").length; + if (index == 0) $("#dialog_sr").append(inject_sr_interaction(1, value = { activity: "", start_time: "", duration: "" })); + else { + let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); + let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); + let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); + if (activity && start && duration) { document.getElementById('sr_activity_no_' + String(index)).disabled = true; document.getElementById('sr_start_no_' + String(index)).disabled = true; document.getElementById('sr_duration_no_' + String(index)).disabled = true; + index = index + 1; + $("#dialog_sr").append(inject_sr_interaction(index, value = { activity: "", start_time: "", duration: "" })); } - index = index + 1; - $("#dialog_sr").append(inject_sr_interaction(index, value = { activity: "", start_time: "", duration: "" })); - }; + } }); //Remove short range interaction (modal field row). @@ -867,23 +860,19 @@ $(document).ready(function () { //Short range modal - save button $("body").on("click", ".save_btn_frm_field", function() { - let has_interactions = false; - var activity, start, duration; - $(".form_field_outer_row").each(function(index) { - activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); - start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); - duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); - has_interactions = true; - }); - if (activity, start, duration || !has_interactions) { - $("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row").length); - if (has_interactions) { - index = parseInt($(".form_field_outer").find(".form_field_outer_row").last().find("input[type='number']").attr('id').split('_').slice(-1)[0]); + let index = $(".form_field_outer").find(".form_field_outer_row").length; + if (index == 0) $('#short_range_dialog').modal('hide'); + else { + let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); + let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); + let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); + if (activity && start && duration) { document.getElementById('sr_activity_no_' + String(index)).disabled = true; document.getElementById('sr_start_no_' + String(index)).disabled = true; document.getElementById('sr_duration_no_' + String(index)).disabled = true; + $("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row").length); + $('#short_range_dialog').modal('hide'); } - $('#short_range_dialog').modal('hide'); } }); From f7cec0ea88c562f0dd7a7fac6caafb118172b7d9 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 25 Jan 2022 17:20:06 +0100 Subject: [PATCH 08/85] handled error on short_range_option --- cara/apps/calculator/model_generator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 9de11436..7e3180b1 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -78,7 +78,7 @@ class FormData: window_width: float windows_number: int window_opening_regime: str - short_range_option: bool + short_range_option: str short_range_interactions: list #: The default values for undefined fields. Note that the defaults here @@ -131,7 +131,7 @@ class FormData: 'windows_frequency': 0., 'windows_number': 0, 'window_opening_regime': 'windows_open_permanently', - 'short_range_option': False, + 'short_range_option': 'short_range_no', 'short_range_interactions': '[]', } @@ -724,6 +724,7 @@ def baseline_raw_form_data(): 'windows_number': '1', 'window_opening_regime': 'windows_open_permanently', 'short_range_option': 'short_range_no', + 'short_range_interactions': '[]', } From 0fa12a6a6d3474c42259e0be2f339ba5e6b69d07 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Wed, 26 Jan 2022 15:15:30 +0100 Subject: [PATCH 09/85] code simplification --- cara/apps/calculator/static/js/form.js | 42 +++++++++++++------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index b398eaf8..402b0805 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -520,6 +520,9 @@ function validateLunchTime(obj) { } function overlapped_times(obj, start_time, finish_time) { + removeErrorFor($(".short_range_option")); + $(".short_range_option").removeClass("red_border"); + let simulation_start = parseTimeToMins($("#exposed_start").val()) let simulation_finish = parseTimeToMins($("#exposed_finish").val()) let simulation_lunch_start = parseTimeToMins($("#exposed_lunch_start").val()) @@ -529,15 +532,10 @@ function overlapped_times(obj, start_time, finish_time) { 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 let parameter = document.getElementById($(obj).attr('id')); - if (!$(obj).hasClass("red_border")) { //Adds the red border and error message. - $(parameter).addClass("red_border"); - } - removeErrorFor($(obj)); + //Adds the red border and error message. + if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); insertErrorFor(parameter, "Short range interactions must be within the simulation time.") return; - } else { - removeErrorFor($(obj)); - $(obj).removeClass("red_border"); } let current_interaction = $(obj).closest(".form_field_outer_row"); $(".form_field_outer_row").not(current_interaction).each(function(index) { @@ -549,30 +547,31 @@ function overlapped_times(obj, start_time, finish_time) { start_time == start_time_2) { let parameter = document.getElementById($(obj).attr('id')); if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message. - removeErrorFor($(obj)); insertErrorFor(parameter, "Short range interactions must not overlap.") return false; - } else { - removeErrorFor($(obj)); - $(obj).removeClass("red_border"); } }); } function validate_sr_time(obj) { - if ($(obj).val() != "") { let obj_id = $(obj).attr('id').split('_').slice(-1)[0]; - if ($(obj).attr('id').startsWith("sr_start_no_")) $("#sr_duration_no_" + obj_id).prop("disabled", false); - let start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val()); - let finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val()); + var start_time, finish_time; + if ($(obj).val() != "") { + $("#sr_duration_no_" + obj_id).prop("disabled", false); + start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val()); + finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val()); + } + else { + $("#sr_duration_no_" + obj_id).val(""); + $("#sr_duration_no_" + obj_id).prop("disabled", true); + } 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")) { + if (!$(obj).hasClass("red_border") && !$(obj).prop("disabled")) { var parameter = document.getElementById($(obj).attr('id')); insertErrorFor(parameter, error_message) $(parameter).addClass("red_border"); @@ -819,13 +818,13 @@ $(document).ready(function () {
-
+
-
+
@@ -860,9 +859,10 @@ $(document).ready(function () { //Short range modal - save button $("body").on("click", ".save_btn_frm_field", function() { - let index = $(".form_field_outer").find(".form_field_outer_row").length; - if (index == 0) $('#short_range_dialog').modal('hide'); + var last_element = $(".form_field_outer").find(".form_field_outer_row").last().find(".short_range_option").prop("id"); + if (!last_element) $('#short_range_dialog').modal('hide'); else { + let index = last_element.split("_").slice(-1)[0]; let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); From 7c0012d355d25ce61c90a119e914074e755f404b Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 27 Jan 2022 17:23:42 +0100 Subject: [PATCH 10/85] handled case when back navigation is performed with changing the short range option --- cara/apps/calculator/model_generator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 7e3180b1..893144a4 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -418,6 +418,13 @@ class FormData: infected_occupants = self.infected_people + if self.short_range_option == "short_range_no": + sr_presence=[] + sr_activities=[] + else: + sr_presence=self.short_range_intervals() + sr_activities=self.short_range_activities() + infected = mc.InfectedPopulation( number=infected_occupants, virus=virus, @@ -426,8 +433,8 @@ class FormData: activity=activity, expiration=expiration, host_immunity=0., - short_range_presence=self.short_range_intervals(), - short_range_activities=self.short_range_activities(), + short_range_presence=sr_presence, + short_range_activities=sr_activities, ) return infected From 99388d2fe58407deb6c657ae8f55e87bec877415 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 28 Jan 2022 12:08:28 +0100 Subject: [PATCH 11/85] Removed unused console.log and changed logic for generating the short range interactions list --- cara/apps/calculator/static/js/form.js | 11 +++++------ cara/apps/calculator/static/js/report.js | 2 -- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 402b0805..575179bd 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -395,13 +395,12 @@ function validate_form(form) { // Generate the short range interactions list let short_range_interactions = []; - $(".form_field_outer_row").each(function (index){ - index = index + 1; - + $(".form_field_outer_row").each(function (index, element){ let obj = {}; - obj.activity = $("#sr_activity_no_" + String(index)).val(); - obj.start_time = $("#sr_start_no_" + String(index)).val(); - obj.duration = $("#sr_duration_no_" + String(index)).val(); + obj.activity = $(element).find("[name='short_range_activity']").val(); + obj.start_time = $(element).find("[name='short_range_start_time']").val(); + obj.duration = $(element).find("[name='short_range_duration']").val(); + console.log(JSON.stringify(obj)) short_range_interactions.push(JSON.stringify(obj)); }); $("input[type=text][name=short_range_interactions]").val('[' + short_range_interactions + ']'); diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js index 2a916544..c9c79841 100644 --- a/cara/apps/calculator/static/js/report.js +++ b/cara/apps/calculator/static/js/report.js @@ -1,8 +1,6 @@ /* Generate the concentration plot using d3 library. */ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses, exposed_presence_intervals) { - console.log(cumulative_doses) - var time_format = d3.timeFormat('%H:%M'); var data_for_graphs = { From d2164383e5fd73c358ca32c76b74168de8325068 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 8 Feb 2022 09:55:51 +0000 Subject: [PATCH 12/85] Removed unused code --- cara/apps/calculator/model_generator.py | 9 ------ cara/apps/calculator/report_generator.py | 32 +------------------ cara/models.py | 8 +---- cara/tests/conftest.py | 2 -- cara/tests/models/test_concentration_model.py | 4 --- cara/tests/models/test_exposure_model.py | 6 ---- cara/tests/test_infected_population.py | 2 -- cara/tests/test_known_quantities.py | 8 ----- cara/tests/test_monte_carlo.py | 2 -- cara/tests/test_monte_carlo_full_models.py | 16 ---------- 10 files changed, 2 insertions(+), 87 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 893144a4..d0b1aaaa 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -418,13 +418,6 @@ class FormData: infected_occupants = self.infected_people - if self.short_range_option == "short_range_no": - sr_presence=[] - sr_activities=[] - else: - sr_presence=self.short_range_intervals() - sr_activities=self.short_range_activities() - infected = mc.InfectedPopulation( number=infected_occupants, virus=virus, @@ -433,8 +426,6 @@ class FormData: activity=activity, expiration=expiration, host_immunity=0., - short_range_presence=sr_presence, - short_range_activities=sr_activities, ) return infected diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index c6205519..258ed6bf 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -12,9 +12,8 @@ import jinja2 import numpy as np from cara import models -from cara.monte_carlo.data import initial_concentrations_mouth, dilution_factor from ... import monte_carlo as mc -from .model_generator import FormData, _DEFAULT_MC_SAMPLE_SIZE, build_expiration +from .model_generator import FormData, _DEFAULT_MC_SAMPLE_SIZE from ... import dataclass_utils @@ -97,41 +96,13 @@ def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.L return nice_times -def short_range_interesting_times(model: models.ExposureModel, times: typing.List[float]) -> typing.List[float]: - short_range_times : typing.List[float] = [] - for period in model.concentration_model.infected.short_range_presence: - start, finish = tuple(period.boundaries()) - short_range_times = short_range_times + [time for time in times if time > start and time < finish] - return short_range_times - - -def jet_origin_concentrations(model: models.ExposureModel) -> typing.List[float]: - return [initial_concentrations_mouth[activity] for activity in model.concentration_model.infected.short_range_activities] - - -def short_range_initial_concentrations(model: models.ExposureModel, time: float): - dilution = dilution_factor(distance=np.linspace(0.1, 2., 1000)) - jet_origin_initial_concentrations = jet_origin_concentrations(model) - for index, interaction in enumerate(model.concentration_model.infected.short_range_presence): - start, finish = tuple(interaction.boundaries()) - if start <= time <= finish: - concentration = model.concentration_model.concentration(float(time)) - jet_origin_concentration = jet_origin_initial_concentrations[index] * model.concentration_model.infected.virus.viral_load_in_sputum - return concentration + ((1/dilution)*(jet_origin_concentration - concentration)) - - def calculate_report_data(model: models.ExposureModel): times = interesting_times(model) - times_short_range = short_range_interesting_times(model, times) concentrations = [ np.array(model.concentration_model.concentration(float(time))).mean() for time in times ] - short_range_concentrations = [ - np.array(short_range_initial_concentrations(model, float(time))).mean() - for time in times_short_range - ] highest_const = max(concentrations) prob = np.array(model.infection_probability()).mean() er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean() @@ -147,7 +118,6 @@ def calculate_report_data(model: models.ExposureModel): "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "cumulative_doses": list(cumulative_doses), "concentrations": concentrations, - "short_range_concentrations": short_range_concentrations, "highest_const": highest_const, "prob_inf": prob, "emission_rate": er, diff --git a/cara/models.py b/cara/models.py index 6b5e6540..f667ba1f 100644 --- a/cara/models.py +++ b/cara/models.py @@ -32,7 +32,7 @@ or length N arrays, where N is the number of parameterisations to run; N must be the same for all parameters of a single model. """ -from dataclasses import dataclass, field +from dataclasses import dataclass import typing import numpy as np @@ -762,12 +762,6 @@ class _PopulationWithVirus(Population): #: The virus with which the population is infected. virus: Virus - #: Short range interactions - short_range_presence: typing.List[SpecificInterval] - - #: The type of expiractory activities in the short range interactions - short_range_activities: typing.List[str] - @method_cache def fraction_of_infectious_virus(self) -> _VectorisedFloat: """ diff --git a/cara/tests/conftest.py b/cara/tests/conftest.py index 45cdbef4..27ce9f1d 100644 --- a/cara/tests/conftest.py +++ b/cara/tests/conftest.py @@ -23,8 +23,6 @@ def baseline_model(): host_immunity=0., # superspreading event, where ejection factor is fixed based # on Miller et al. (2020) - 50 represents the infectious dose. - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/models/test_concentration_model.py b/cara/tests/models/test_concentration_model.py index 16d5b5b0..432b4d80 100644 --- a/cara/tests/models/test_concentration_model.py +++ b/cara/tests/models/test_concentration_model.py @@ -47,8 +47,6 @@ def test_concentration_model_vectorisation(override_params): ), expiration=models._ExpirationBase.types['Breathing'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -71,8 +69,6 @@ def simple_conc_model(): virus=models.Virus.types['SARS_CoV_2'], expiration=models.Expiration.types['Breathing'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index 15d20986..238e2316 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -66,8 +66,6 @@ def known_concentrations(func): virus=models.Virus.types['SARS_CoV_2_ALPHA'], expiration=models.Expiration.types['Speaking'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ) normed_func = lambda x: func(x) / dummy_infected_population.emission_rate_when_present() return KnownNormedconcentration(dummy_room, dummy_ventilation, @@ -166,8 +164,6 @@ def conc_model(): # superspreading event, where ejection factor is fixed based # on Miller et al. (2020) - 50 represents the infectious dose. host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -211,8 +207,6 @@ def test_infectious_dose_vectorisation(): ), expiration=models.Expiration.types['Speaking'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ) cm = known_concentrations(lambda t: 1.2) cm = replace(cm, infected=infected_population) diff --git a/cara/tests/test_infected_population.py b/cara/tests/test_infected_population.py index 66714f96..8856d312 100644 --- a/cara/tests/test_infected_population.py +++ b/cara/tests/test_infected_population.py @@ -37,8 +37,6 @@ def test_infected_population_vectorisation(override_params): ), expiration=cara.models._ExpirationBase.types['Breathing'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ) emission_rate = infected.emission_rate(10) assert isinstance(emission_rate, np.ndarray) diff --git a/cara/tests/test_known_quantities.py b/cara/tests/test_known_quantities.py index b94e6acd..2ab80fc8 100644 --- a/cara/tests/test_known_quantities.py +++ b/cara/tests/test_known_quantities.py @@ -76,8 +76,6 @@ def build_model(interval_duration): host_immunity=0., # superspreading event, where ejection factor is fixed based # on Miller et al. (2020) - 50 represents the infectious dose. - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -240,8 +238,6 @@ def build_hourly_dependent_model( activity=models.Activity.types['Light activity'], known_individual_emission_rate=970 * 50, host_immunity=0, - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -265,8 +261,6 @@ def build_constant_temp_model(outside_temp, intervals_open=((7.5, 8.5),)): activity=models.Activity.types['Light activity'], known_individual_emission_rate=970 * 50, host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -297,8 +291,6 @@ def build_hourly_dependent_model_multipleventilation(month, intervals_open=((7.5 activity=models.Activity.types['Light activity'], known_individual_emission_rate=970 * 50, host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/test_monte_carlo.py b/cara/tests/test_monte_carlo.py index 339612df..8a8b0271 100644 --- a/cara/tests/test_monte_carlo.py +++ b/cara/tests/test_monte_carlo.py @@ -55,8 +55,6 @@ def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel: activity=cara.models.Activity.types['Light activity'], expiration=cara.models.Expiration.types['Breathing'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) diff --git a/cara/tests/test_monte_carlo_full_models.py b/cara/tests/test_monte_carlo_full_models.py index 4a68b9e1..b4aa247d 100644 --- a/cara/tests/test_monte_carlo_full_models.py +++ b/cara/tests/test_monte_carlo_full_models.py @@ -67,8 +67,6 @@ def shared_office_mc(): activity=activity_distributions['Seated'], expiration=build_expiration({'Speaking': 0.33, 'Breathing': 0.67}), host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -111,8 +109,6 @@ def classroom_mc(): activity=activity_distributions['Light activity'], expiration=build_expiration('Speaking'), host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -146,8 +142,6 @@ def ski_cabin_mc(): activity=activity_distributions['Moderate activity'], expiration=build_expiration('Speaking'), host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -187,8 +181,6 @@ def skagit_chorale_mc(): activity=activity_distributions['Moderate activity'], expiration=build_expiration('Shouting'), host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -228,8 +220,6 @@ def bus_ride_mc(): activity=activity_distributions['Seated'], expiration=build_expiration('Speaking'), host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -264,8 +254,6 @@ def gym_mc(): activity=activity_distributions['Heavy exercise'], expiration=expiration_distributions['Breathing'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -300,8 +288,6 @@ def waiting_room_mc(): activity=activity_distributions['Seated'], expiration=build_expiration({'Speaking': 0.3, 'Breathing': 0.7}), host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) @@ -379,8 +365,6 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi, activity=activity_distributions['Seated'], expiration=build_expiration({'Speaking': 0.33, 'Breathing': 0.67}), host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), evaporation_factor=0.3, ) From 1760a518de9bfb4f79630e30e236b81ed2215372 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 8 Feb 2022 09:57:16 +0000 Subject: [PATCH 13/85] Implemented ShortRangeModel dataclass --- cara/apps/calculator/model_generator.py | 25 ++++++++++++---- cara/apps/expert.py | 3 +- cara/models.py | 39 +++++++++++++++++++++++-- cara/monte_carlo/data.py | 11 ++++--- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index d0b1aaaa..da43e39c 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -13,7 +13,7 @@ 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, initial_concentrations_mouth +from cara.monte_carlo.data import activity_distributions, dilution_factor, virus_distributions, mask_distributions, initial_concentrations_mouth from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions @@ -246,12 +246,29 @@ class FormData: humidity = 0.5 room = models.Room(volume=volume, humidity=humidity) + if self.short_range_option == "short_range_no": + sr_presence=[] + sr_activities=[] + else: + sr_presence=self.short_range_intervals() + sr_activities=self.short_range_activities() + + jet_origin_concentration = [initial_concentrations_mouth[activity] for activity in sr_activities] + + short_range = models.ShortRangeModel( + presence=sr_presence, + activities=sr_activities, + dilutions=dilution_factor(activities=sr_activities, distance=np.random.uniform(0.5, 1.5, 250000)), + jet_origin_concentrations=jet_origin_concentration, + ) + # 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(), + short_range=short_range, evaporation_factor=0.3, ), exposed=self.exposed_population(), @@ -647,10 +664,8 @@ class FormData: ) def short_range_activities(self) -> typing.List[str]: - if (self.short_range_interactions): - return [interaction['activity'] for interaction in self.short_range_interactions] - else: - return [] + return ([interaction['activity'] for interaction in self.short_range_interactions] + if self.short_range_interactions else []) def build_expiration(expiration_definition) -> models._ExpirationBase: diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 29df1d1c..a7f82243 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -500,9 +500,8 @@ baseline_model = models.ExposureModel( activity=models.Activity.types['Seated'], expiration=models.Expiration.types['Speaking'], host_immunity=0., - short_range_presence=[], - short_range_activities=[], ), + short_range=[], evaporation_factor=0.3, ), exposed=models.Population( diff --git a/cara/models.py b/cara/models.py index f667ba1f..89f3e3d3 100644 --- a/cara/models.py +++ b/cara/models.py @@ -882,11 +882,35 @@ class InfectedPopulation(_PopulationWithVirus): return self.expiration.particle +@dataclass(frozen=True) +class ShortRangeModel: + #: Short range interactions + presence: typing.List[SpecificInterval] + + #: The type of expiractory activities in the short range interactions + activities: typing.List[str] + + #: The dilution factors for each of the expiratory activity in the short range interactions + dilutions: _VectorisedFloat + + #: The concentration on the jet origin for each of the expiratory activity in the short range interactions + jet_origin_concentrations: _VectorisedFloat + + def short_range_concentration(self, time: float, background_concentration: _VectorisedFloat, viral_load: _VectorisedFloat) -> _VectorisedFloat: + for index, period in enumerate(self.presence): + start, finish = tuple(period.boundaries()) + if time >= start and time <= finish: + dilution = self.dilutions[index] + jet_origin_concentration = self.jet_origin_concentrations[index] * 1e-6 * viral_load + return background_concentration + ((1/dilution)*(jet_origin_concentration - background_concentration)) + + @dataclass(frozen=True) class ConcentrationModel: room: Room ventilation: _VentilationBase infected: _PopulationWithVirus + short_range: ShortRangeModel #: evaporation factor: the particles' diameter is multiplied by this # factor as soon as they are in the air (but AFTER going out of the, @@ -1018,8 +1042,13 @@ class ConcentrationModel: Note that time is not vectorised. You can only pass a single float to this method. """ - return (self._normed_concentration(time) * - self.infected.emission_rate_when_present()) + background_concentration = self._normed_concentration(time) * self.infected.emission_rate_when_present() + for period in self.short_range.presence: + start, finish = tuple(period.boundaries()) + if time >= start and time <= finish: + return self.short_range.short_range_concentration(time, background_concentration, self.virus.viral_load_in_sputum) + + return background_concentration @method_cache def normed_integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat: @@ -1027,6 +1056,12 @@ class ConcentrationModel: Get the integrated concentration of viruses in the air between the times start and stop, normalized by the emission rate. """ + for period in self.short_range.presence: + time1, time2 = tuple(period.boundaries()) + if (time1 <= start <= time2 and time1 <= stop <= time2): + # Check if the given times are within the short range interactions + return scipy.integrate.quad_vec(lambda t: self.concentration(t), start, stop)[0] + if stop <= self._first_presence_time(): return 0.0 state_change_times = self.state_change_times() diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index 9dee5d49..ec58d6ee 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -176,8 +176,10 @@ def expiration_distribution(BLO_factors): cn=BLOmodel(BLO_factors).integrate(0.1, 30.)) -def dilution_factor(distance, D=0.02): - u0 = 0.6 +def dilution_factor(activities, distance, D=0.02): + factors = [] + for activity in activities: + u0 = 0.98 if activity == "Breathing" else 3.9 tstar = 2.0 Cr1 = 0.18 Cr2 = 0.2 @@ -192,13 +194,14 @@ def dilution_factor(distance, D=0.02): xstar = Cx1*(Q0*u0)**0.25*(tstar + t01)**0.5 - x01 # Dilution factor at the transition point xstar Sxstar = 2*Cr1*(xstar+x01)/D - return np.mean(np.piecewise(distance, [distance < xstar, distance >= xstar], + factors.append(np.piecewise(distance, [distance < xstar, distance >= xstar], [lambda distance : 2*Cr1*(distance + x01)/D, lambda distance : Sxstar*(1 + Cr2*(distance - xstar)/Cr1/(xstar + x01))**3])) + return factors def initial_concentration_mouth(BLO_factors): value, error = scipy.integrate.quad(lambda d: BLOmodel(BLO_factors).distribution(d) * BLOmodel(BLO_factors).volume(d), 0.1, 1000) - return value * 1e-6 #result in mL/m^3 + return value expiration_BLO_factors = { From bc9a25a7e4824d49813133e072d858a5e334041d Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 8 Feb 2022 09:57:37 +0000 Subject: [PATCH 14/85] UI adjustments on the short range interactions Removed unused code --- cara/apps/calculator/model_generator.py | 63 ++++---- cara/apps/calculator/report_generator.py | 42 +++-- cara/apps/calculator/static/js/form.js | 15 +- cara/apps/calculator/static/js/report.js | 55 ++++++- cara/apps/expert.py | 1 - .../templates/base/calculator.report.html.j2 | 15 +- cara/models.py | 148 +++++++++++++++--- cara/monte_carlo/data.py | 15 +- 8 files changed, 266 insertions(+), 88 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index da43e39c..b05da082 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -13,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, dilution_factor, virus_distributions, mask_distributions, initial_concentrations_mouth -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, dilution_factor +from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions, short_range_expiration_distributions LOG = logging.getLogger(__name__) @@ -234,7 +234,7 @@ class FormData: raise ValueError("mechanical_ventilation_type cannot be 'not-applicable' if " "ventilation_type is 'mechanical_ventilation'") - def build_mc_model(self) -> mc.ExposureModel: + def build_mc_model(self) -> mc.SimulationModel: # Initializes room with volume either given directly or as product of area and height if self.volume_type == 'room_volume_explicit': volume = self.room_volume @@ -253,28 +253,29 @@ class FormData: sr_presence=self.short_range_intervals() sr_activities=self.short_range_activities() - jet_origin_concentration = [initial_concentrations_mouth[activity] for activity in sr_activities] - - short_range = models.ShortRangeModel( - presence=sr_presence, - activities=sr_activities, - dilutions=dilution_factor(activities=sr_activities, distance=np.random.uniform(0.5, 1.5, 250000)), - jet_origin_concentrations=jet_origin_concentration, - ) + short_range_expirations = [short_range_expiration_distributions[activity] for activity in sr_activities] # 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(), - short_range=short_range, - evaporation_factor=0.3, + return mc.SimulationModel( + mc.ExposureModel( + concentration_model=mc.ConcentrationModel( + room=room, + ventilation=self.ventilation(), + infected=self.infected_population(), + evaporation_factor=0.3, + ), + exposed=self.exposed_population(), + ), + mc.ShortRangeModel( + presence=sr_presence, + long_range_presence=self.long_range_intervals(), + activities=sr_activities, + expirations=short_range_expirations, + dilutions=dilution_factor(activities=sr_activities, distance=np.random.uniform(0.5, 1.5, 250000)), ), - exposed=self.exposed_population(), ) - def build_model(self, sample_size=_DEFAULT_MC_SAMPLE_SIZE) -> models.ExposureModel: + def build_model(self, sample_size=_DEFAULT_MC_SAMPLE_SIZE) -> models.SimulationModel: return self.build_mc_model().build_model(size=sample_size) def tz_name_and_utc_offset(self) -> typing.Tuple[str, float]: @@ -643,7 +644,7 @@ class FormData: def infected_present_interval(self) -> models.Interval: return self.present_interval( self.infected_start, self.infected_finish, - breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), + breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times() ) def short_range_intervals(self) -> typing.List[models.SpecificInterval]: @@ -657,10 +658,22 @@ class FormData: else: return [] + def long_range_intervals(self) -> models.Interval: + short_range_intervals = [] + for interval in self.short_range_interactions: + short_range_intervals.append( + (time_string_to_minutes(interval['start_time']), + time_string_to_minutes(interval['start_time']) + float(interval['duration'])), + ) + return self.present_interval( + self.exposed_start, self.exposed_finish, + breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times() + tuple(short_range_intervals), + ) + def exposed_present_interval(self) -> models.Interval: return self.present_interval( self.exposed_start, self.exposed_finish, - breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times(), + breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times() ) def short_range_activities(self) -> typing.List[str]: @@ -678,12 +691,6 @@ def build_expiration(expiration_definition) -> models._ExpirationBase: for exp_type, weight in expiration_definition.items() ], axis=0) return expiration_distribution(tuple(BLO_factors)) - - -def build_concentration_mouth(short_range_def) -> models._ExpirationBase: - expiration_definition = short_range_def[1]['activity'] - if isinstance(expiration_definition, str): - return initial_concentrations_mouth[expiration_definition] def baseline_raw_form_data(): diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 258ed6bf..ef8bafc4 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -96,18 +96,29 @@ 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 short_range_interesting_times(model: models.ExposureModel, times: typing.List[float]) -> typing.List[float]: + short_range_times : typing.List[float] = [] + for period in model.concentration_model.infected.short_range_presence: + start, finish = tuple(period.boundaries()) + short_range_times = short_range_times + [time for time in times if time >= start and time <= finish] + return short_range_times + + +def calculate_report_data(model: models.SimulationModel): + times = interesting_times(model.exposure_model) + short_range_intervals = [] + for interval in model.short_range.presence: + short_range_intervals.append(list(interval.boundaries())) concentrations = [ - np.array(model.concentration_model.concentration(float(time))).mean() + np.array(model.concentration(float(time))).mean() for time in times ] 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() + er = np.array(model.exposure_model.concentration_model.infected.emission_rate_when_present()).mean() + exposed_occupants = model.exposure_model.exposed.number + expected_new_cases = np.array(model.exposure_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:]) @@ -115,7 +126,8 @@ def calculate_report_data(model: models.ExposureModel): return { "times": list(times), - "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], + "short_range_intervals": short_range_intervals, + "exposed_presence_intervals": [list(interval) for interval in model.exposure_model.exposed.presence.boundaries()], "cumulative_doses": list(cumulative_doses), "concentrations": concentrations, "highest_const": highest_const, @@ -234,20 +246,20 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.Exp return scenarios -def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray): +def scenario_statistics(mc_model: mc.SimulationModel, sample_times: np.ndarray): 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()), + 'probability_of_infection': np.mean(model.exposure_model.infection_probability()), + 'expected_new_cases': np.mean(model.exposure_model.expected_new_cases()), 'concentrations': [ - np.mean(model.concentration_model.concentration(time)) + np.mean(model.exposure_model.concentration_model.concentration(time)) for time in sample_times ], } def comparison_report( - scenarios: typing.Dict[str, mc.ExposureModel], + scenarios: typing.Dict[str, mc.SimulationModel], sample_times: typing.List[float], executor_factory: typing.Callable[[], concurrent.futures.Executor], ): @@ -285,7 +297,7 @@ class ReportGenerator: def prepare_context( self, base_url: str, - model: models.ExposureModel, + model: models.SimulationModel, form: FormData, executor_factory: typing.Callable[[], concurrent.futures.Executor], ) -> dict: @@ -293,12 +305,12 @@ class ReportGenerator: time = now.strftime("%Y-%m-%d %H:%M:%S UTC") context = { - 'model': model, + 'model': model.exposure_model, 'form': form, 'creation_date': time, } - scenario_sample_times = interesting_times(model) + scenario_sample_times = interesting_times(model.exposure_model) context.update(calculate_report_data(model)) alternative_scenarios = manufacture_alternative_scenarios(form) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 575179bd..b041ffa3 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -404,6 +404,10 @@ function validate_form(form) { short_range_interactions.push(JSON.stringify(obj)); }); $("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); @@ -859,7 +863,11 @@ $(document).ready(function () { //Short range modal - save button $("body").on("click", ".save_btn_frm_field", function() { var last_element = $(".form_field_outer").find(".form_field_outer_row").last().find(".short_range_option").prop("id"); - if (!last_element) $('#short_range_dialog').modal('hide'); + if (!last_element) { + $('#short_range_dialog').modal('hide'); + $("input[type=radio][id=short_range_no]").prop("checked", true); + on_short_range_option_change(); + } else { let index = last_element.split("_").slice(-1)[0]; let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); @@ -875,13 +883,14 @@ $(document).ready(function () { } }); - //Short range modal - close button + //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(); }); - }); diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js index c9c79841..a185b094 100644 --- a/cara/apps/calculator/static/js/report.js +++ b/cara/apps/calculator/static/js/report.js @@ -1,5 +1,5 @@ /* Generate the concentration plot using d3 library. */ -function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses, exposed_presence_intervals) { +function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses, exposed_presence_intervals, short_range_intervals) { var time_format = d3.timeFormat('%H:%M'); @@ -50,6 +50,16 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses .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] = vis.append('svg:path') + .attr('fill', '#1f00b4') + .attr('fill-opacity', '0.1'); + }); + // Plot tittle. var plotTitleEl = vis.append('svg:foreignObject') .attr("background-color", "transparent") @@ -105,10 +115,16 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses var legendAreaIcon = vis.append('rect') .attr('width', 20) - .attr('height', 20) + .attr('height', 15) .attr('fill', '#1f77b4') .attr('fill-opacity', '0.1'); + var legendShortRangeAreaIcon = vis.append('rect') + .attr('width', 20) + .attr('height', 15) + .attr('fill', '#1f00b4') + .attr('fill-opacity', '0.1'); + var legendLineText = vis.append('text') .text('Mean concentration') .style('font-size', '15px') @@ -124,10 +140,15 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses .style('font-size', '15px') .attr('alignment-baseline', 'central'); + var legendShortRangeText = vis.append('text') + .text('Short range interaction(s)') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + // Legend bounding var legendBBox = vis.append('rect') .attr('width', 255) - .attr('height', 70) + .attr('height', 90) .attr('stroke', 'lightgrey') .attr('stroke-width', '2') .attr('rx', '5px') @@ -217,7 +238,7 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses .y(d => yRange(d.concentration)); draw_line.attr("d", lineFunc(data_for_graphs.concentrations)); - // Cumulative line + // Cumulative line. lineCumulative.defined(d => !isNaN(d.concentration)) .x(d => xTimeRange(d.time)) .y(d => yCumulativeRange(d.concentration)); @@ -234,6 +255,18 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses }))); }); + // Short Range Area. + short_range_intervals.forEach((b, index) => { + shortRangeArea[index].x(d => xTimeRange(d.time)) + .y0(graph_height - margins.bottom) + .y1(d => yRange(d.concentration)); + + drawShortRangeArea[index].attr('d', shortRangeArea[index](data_for_graphs.concentrations.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))) + }) + + // Title. plotTitleEl.attr('width', graph_width); @@ -282,10 +315,15 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses .attr('y', margins.top + 2 * size); legendAreaIcon.attr('x', graph_width + size * 2.5) - .attr('y', margins.top + 2.5 * size); + .attr('y', margins.top + 2.6 * size); legendAreaText.attr('x', graph_width + 4 * size) .attr('y', margins.top + 3 * size); + legendShortRangeAreaIcon.attr('x', graph_width + size * 2.5) + .attr('y', margins.top + 3.6 * size); + legendShortRangeText.attr('x', graph_width + 4 * size) + .attr('y', margins.top + 4 * size); + legendBBox.attr('x', graph_width * 1.07) .attr('y', margins.top * 1.2); } @@ -306,7 +344,12 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses 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); + .attr('y', graph_height + 2.6 * size); + + legendShortRangeAreaIcon.attr('x', size * 0.50) + .attr('y', graph_height * 1.175 + size); + legendShortRangeText.attr('x', 2 * size) + .attr('y', graph_height + 3.65 * size); legendBBox.attr('x', 1) .attr('y', graph_height); diff --git a/cara/apps/expert.py b/cara/apps/expert.py index a7f82243..bba12b6b 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -501,7 +501,6 @@ baseline_model = models.ExposureModel( expiration=models.Expiration.types['Speaking'], host_immunity=0., ), - short_range=[], evaporation_factor=0.3, ), exposed=models.Population( diff --git a/cara/apps/templates/base/calculator.report.html.j2 b/cara/apps/templates/base/calculator.report.html.j2 index b113710b..7278aab5 100644 --- a/cara/apps/templates/base/calculator.report.html.j2 +++ b/cara/apps/templates/base/calculator.report.html.j2 @@ -96,7 +96,8 @@ 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); + var short_range_intervals = {{ short_range_intervals | JSONify }} + draw_concentration_plot("concentration_plot", times, concentrations, cumulative_doses, exposed_presence_intervals, short_range_intervals);

@@ -309,6 +310,18 @@ Gym = For comparison only, all persons doing heavy physical exercise, breathing and not speaking. {% endif %}

+ {% if form.short_range_option == "short_range_yes" %} +
  • + Short range interactions: {{ form.short_range_interactions|length }} +

  • +
      + {% for interaction in form.short_range_interactions %} +
    • Expiratory activity {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.activity }}
    • +
    • Start time {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.start_time }}
    • +
    • Duration {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.duration }} {{ "minutes" if interaction.duration|int > 1 else "minute" }}
    • + {% endfor %} +
    + {% endif %}
  • Exposed occupant(s) activity time:

    • Start time: {{ form.exposed_start | minutes_to_time }}

    • diff --git a/cara/models.py b/cara/models.py index 89f3e3d3..74844cd3 100644 --- a/cara/models.py +++ b/cara/models.py @@ -667,6 +667,11 @@ class Expiration(_ExpirationBase): return self.cn * (volume(self.diameter) * (1 - mask.exhale_efficiency(self.diameter))) * 1e-12 + def jet_origin_concentration(self): + def volume(d): + return (np.pi * d**3) / 6. + return self.cn * volume(self.diameter) + @dataclass(frozen=True) class MultipleExpiration(_ExpirationBase): @@ -885,24 +890,22 @@ class InfectedPopulation(_PopulationWithVirus): @dataclass(frozen=True) class ShortRangeModel: #: Short range interactions - presence: typing.List[SpecificInterval] + presence: typing.List[Interval] + + #: Long range presence intervals + long_range_presence: Interval #: The type of expiractory activities in the short range interactions activities: typing.List[str] + #: Expiration type + expirations: typing.List[Expiration] + #: The dilution factors for each of the expiratory activity in the short range interactions dilutions: _VectorisedFloat - - #: The concentration on the jet origin for each of the expiratory activity in the short range interactions - jet_origin_concentrations: _VectorisedFloat - def short_range_concentration(self, time: float, background_concentration: _VectorisedFloat, viral_load: _VectorisedFloat) -> _VectorisedFloat: - for index, period in enumerate(self.presence): - start, finish = tuple(period.boundaries()) - if time >= start and time <= finish: - dilution = self.dilutions[index] - jet_origin_concentration = self.jet_origin_concentrations[index] * 1e-6 * viral_load - return background_concentration + ((1/dilution)*(jet_origin_concentration - background_concentration)) + def short_range_concentration(self, long_range_concentration: _VectorisedFloat, dilution: _VectorisedFloat, jet_origin_concentration: _VectorisedFloat) -> _VectorisedFloat: + return long_range_concentration + ((1/dilution)*(jet_origin_concentration - long_range_concentration)) @dataclass(frozen=True) @@ -910,7 +913,6 @@ class ConcentrationModel: room: Room ventilation: _VentilationBase infected: _PopulationWithVirus - short_range: ShortRangeModel #: evaporation factor: the particles' diameter is multiplied by this # factor as soon as they are in the air (but AFTER going out of the, @@ -1042,13 +1044,8 @@ class ConcentrationModel: Note that time is not vectorised. You can only pass a single float to this method. """ - background_concentration = self._normed_concentration(time) * self.infected.emission_rate_when_present() - for period in self.short_range.presence: - start, finish = tuple(period.boundaries()) - if time >= start and time <= finish: - return self.short_range.short_range_concentration(time, background_concentration, self.virus.viral_load_in_sputum) - - return background_concentration + return (self._normed_concentration(time) * + self.infected.emission_rate_when_present()) @method_cache def normed_integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat: @@ -1056,11 +1053,6 @@ class ConcentrationModel: Get the integrated concentration of viruses in the air between the times start and stop, normalized by the emission rate. """ - for period in self.short_range.presence: - time1, time2 = tuple(period.boundaries()) - if (time1 <= start <= time2 and time1 <= stop <= time2): - # Check if the given times are within the short range interactions - return scipy.integrate.quad_vec(lambda t: self.concentration(t), start, stop)[0] if stop <= self._first_presence_time(): return 0.0 @@ -1226,3 +1218,111 @@ class ExposureModel: ) return single_exposure_model.expected_new_cases() + + +@dataclass(frozen=True) +class SimulationModel: + exposure_model: ExposureModel + short_range: ShortRangeModel + + def normed_integrated_concentration(self, time1, time2): + return scipy.integrate.quad_vec(lambda t: self._normed_concentration(t), time1, time2)[0] + + def _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. + for start, stop in self.exposure_model.exposed.presence.boundaries(): + if stop < time1: + continue + elif start > time2: + break + elif start <= time1 and time2<= stop: + exposure += self.normed_integrated_concentration(time1, time2) + elif start <= time1 and stop < time2: + exposure += self.normed_integrated_concentration(time1, stop) + elif time1 < start and time2 <= stop: + exposure += self.normed_integrated_concentration(start, time2) + elif time1 <= start and stop < time2: + exposure += self.normed_integrated_concentration(start, stop) + return exposure + + def _normed_concentration(self, time: float) -> _VectorisedFloat: + for index, period in enumerate(self.short_range.presence): + start, finish = tuple(period.boundaries()) + if start <= time <= finish: + model = nested_replace( + self, {'exposure_model.concentration_model.infected.expiration': self.short_range.expirations[index]} + ) + dilution = self.short_range.dilutions[index] + jet_origin_concentration = (model.exposure_model.concentration_model.infected.expiration.jet_origin_concentration() + * 1e-6 * model.exposure_model.concentration_model.virus.viral_load_in_sputum) + long_range_normed_concentration=np.interp(model.short_range.expirations[index].particle.diameter, self.exposure_model.concentration_model.infected.particle.diameter, self.exposure_model.concentration_model._normed_concentration(time)) + return self.short_range.short_range_concentration(long_range_normed_concentration, dilution, jet_origin_concentration) + + return self.exposure_model.concentration_model._normed_concentration(time) + + def concentration(self, time: float) -> _VectorisedFloat: + for index, period in enumerate(self.short_range.presence): + start, finish = tuple(period.boundaries()) + if start <= time <= finish: + model = nested_replace( + self, {'exposure_model.concentration_model.infected.expiration': self.short_range.expirations[index]} + ) + return model._normed_concentration(time) * model.exposure_model.concentration_model.infected.emission_rate_when_present() + return self._normed_concentration(time) * self.exposure_model.concentration_model.infected.emission_rate_when_present() + + def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat: + for index, period in enumerate(self.short_range.presence): + start, finish = tuple(period.boundaries()) + if (start <= time1 <= finish and start <= time2 <= finish): # What if one is SR and another LR? + # Check if the given times are within the short range interactions + model = nested_replace( + self, {'exposure_model.concentration_model.infected.expiration': self.short_range.expirations[index]} + ) + emission_rate_per_aerosol = model.exposure_model.concentration_model.infected.emission_rate_per_aerosol_when_present() + aerosols = model.exposure_model.concentration_model.infected.aerosols() + fdep = model.exposure_model.fraction_deposited() + f_inf = model.exposure_model.concentration_model.infected.fraction_of_infectious_virus() + + diameter = self.exposure_model.concentration_model.infected.particle.diameter + + if not np.isscalar(diameter) and diameter is not None: + # 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). + dep_exposure_integrated = np.array(self._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 + + # 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 * model.exposure_model.exposed.activity.inhalation_rate * + (1 - self.exposure_model.exposed.mask.inhale_efficiency())) + + return self.exposure_model.deposited_exposure_between_bounds(time1, time2) + + def infection_probability(self): + dose = 0.0 + for start, stop in self.short_range.long_range_presence.boundaries(): + dose += self.exposure_model.deposited_exposure_between_bounds(start, stop) + + for presence in self.short_range.presence: + start, stop = presence.boundaries() + dose += self.deposited_exposure_between_bounds(start, stop) + + vD = dose * self.exposure_model.repeats + + # oneoverln2 multiplied by ID_50 corresponds to ID_63. + infectious_dose = oneoverln2 * self.exposure_model.concentration_model.virus.infectious_dose + + # Probability of infection. + return (1 - np.exp(-((vD * (1 - self.exposure_model.exposed.host_immunity))/(infectious_dose * + self.exposure_model.concentration_model.virus.transmissibility_factor)))) * 100 + \ No newline at end of file diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index ec58d6ee..065380dd 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -161,7 +161,7 @@ mask_distributions = { } -def expiration_distribution(BLO_factors): +def expiration_distribution(BLO_factors, d_max=30.): """ Returns an Expiration with an aerosol diameter distribution, defined by the BLO factors (a length-3 tuple). @@ -170,10 +170,10 @@ 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) + 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, 30.)) + cn=BLOmodel(BLO_factors).integrate(0.1, d_max)) def dilution_factor(activities, distance, D=0.02): @@ -199,11 +199,6 @@ def dilution_factor(activities, distance, D=0.02): return factors -def initial_concentration_mouth(BLO_factors): - value, error = scipy.integrate.quad(lambda d: BLOmodel(BLO_factors).distribution(d) * BLOmodel(BLO_factors).volume(d), 0.1, 1000) - return value - - expiration_BLO_factors = { 'Breathing': (1., 0., 0.), 'Speaking': (1., 1., 1.), @@ -218,7 +213,7 @@ expiration_distributions = { } -initial_concentrations_mouth = { - exp_type: initial_concentration_mouth(BLO_factors) +short_range_expiration_distributions = { + exp_type: expiration_distribution(BLO_factors, d_max=1000).build_model(250000) for exp_type,BLO_factors in expiration_BLO_factors.items() } From 444fde14b7e841454e6ef89b330b71e51b1326cf Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 15 Feb 2022 16:12:51 +0000 Subject: [PATCH 15/85] Changed max diameters size --- cara/monte_carlo/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index 065380dd..8a7cae0f 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -214,6 +214,6 @@ expiration_distributions = { short_range_expiration_distributions = { - exp_type: expiration_distribution(BLO_factors, d_max=1000).build_model(250000) + exp_type: expiration_distribution(BLO_factors, d_max=100).build_model(250000) for exp_type,BLO_factors in expiration_BLO_factors.items() } From 01820fa4e01d4428c44925237c4d2b983572b18d Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 15 Feb 2022 16:13:13 +0000 Subject: [PATCH 16/85] Introduced short_range in ExposureModel --- cara/apps/calculator/model_generator.py | 40 ++-- cara/apps/calculator/report_generator.py | 28 +-- cara/apps/expert.py | 1 + cara/models.py | 252 +++++++++-------------- cara/monte_carlo/data.py | 4 - 5 files changed, 122 insertions(+), 203 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index b05da082..19596e0a 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -234,7 +234,7 @@ class FormData: raise ValueError("mechanical_ventilation_type cannot be 'not-applicable' if " "ventilation_type is 'mechanical_ventilation'") - def build_mc_model(self) -> mc.SimulationModel: + def build_mc_model(self) -> mc.ExposureModel: # Initializes room with volume either given directly or as product of area and height if self.volume_type == 'room_volume_explicit': volume = self.room_volume @@ -256,26 +256,22 @@ class FormData: short_range_expirations = [short_range_expiration_distributions[activity] for activity in sr_activities] # Initializes and returns a model with the attributes defined above - return mc.SimulationModel( - mc.ExposureModel( - concentration_model=mc.ConcentrationModel( - room=room, - ventilation=self.ventilation(), - infected=self.infected_population(), - evaporation_factor=0.3, - ), - exposed=self.exposed_population(), + return mc.ExposureModel( + concentration_model=mc.ConcentrationModel( + room=room, + ventilation=self.ventilation(), + infected=self.infected_population(), + evaporation_factor=0.3, ), - mc.ShortRangeModel( + short_range = mc.ShortRangeModel( presence=sr_presence, - long_range_presence=self.long_range_intervals(), - activities=sr_activities, expirations=short_range_expirations, dilutions=dilution_factor(activities=sr_activities, distance=np.random.uniform(0.5, 1.5, 250000)), ), + exposed=self.exposed_population(), ) - def build_model(self, sample_size=_DEFAULT_MC_SAMPLE_SIZE) -> models.SimulationModel: + def build_model(self, sample_size=_DEFAULT_MC_SAMPLE_SIZE) -> models.ExposureModel: return self.build_mc_model().build_model(size=sample_size) def tz_name_and_utc_offset(self) -> typing.Tuple[str, float]: @@ -644,7 +640,7 @@ class FormData: def infected_present_interval(self) -> models.Interval: return self.present_interval( self.infected_start, self.infected_finish, - breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times() + breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), ) def short_range_intervals(self) -> typing.List[models.SpecificInterval]: @@ -658,22 +654,10 @@ class FormData: else: return [] - def long_range_intervals(self) -> models.Interval: - short_range_intervals = [] - for interval in self.short_range_interactions: - short_range_intervals.append( - (time_string_to_minutes(interval['start_time']), - time_string_to_minutes(interval['start_time']) + float(interval['duration'])), - ) - return self.present_interval( - self.exposed_start, self.exposed_finish, - breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times() + tuple(short_range_intervals), - ) - def exposed_present_interval(self) -> models.Interval: return self.present_interval( self.exposed_start, self.exposed_finish, - breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times() + breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times(), ) def short_range_activities(self) -> typing.List[str]: diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index ef8bafc4..b1fc09fc 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -104,8 +104,8 @@ def short_range_interesting_times(model: models.ExposureModel, times: typing.Lis return short_range_times -def calculate_report_data(model: models.SimulationModel): - times = interesting_times(model.exposure_model) +def calculate_report_data(model: models.ExposureModel): + times = interesting_times(model) short_range_intervals = [] for interval in model.short_range.presence: short_range_intervals.append(list(interval.boundaries())) @@ -116,9 +116,9 @@ def calculate_report_data(model: models.SimulationModel): ] highest_const = max(concentrations) prob = np.array(model.infection_probability()).mean() - er = np.array(model.exposure_model.concentration_model.infected.emission_rate_when_present()).mean() - exposed_occupants = model.exposure_model.exposed.number - expected_new_cases = np.array(model.exposure_model.expected_new_cases()).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:]) @@ -127,7 +127,7 @@ def calculate_report_data(model: models.SimulationModel): return { "times": list(times), "short_range_intervals": short_range_intervals, - "exposed_presence_intervals": [list(interval) for interval in model.exposure_model.exposed.presence.boundaries()], + "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "cumulative_doses": list(cumulative_doses), "concentrations": concentrations, "highest_const": highest_const, @@ -246,20 +246,20 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.Exp return scenarios -def scenario_statistics(mc_model: mc.SimulationModel, sample_times: np.ndarray): +def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray): model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE) return { - 'probability_of_infection': np.mean(model.exposure_model.infection_probability()), - 'expected_new_cases': np.mean(model.exposure_model.expected_new_cases()), + 'probability_of_infection': np.mean(model.infection_probability()), + 'expected_new_cases': np.mean(model.expected_new_cases()), 'concentrations': [ - np.mean(model.exposure_model.concentration_model.concentration(time)) + np.mean(model.concentration(time)) for time in sample_times ], } def comparison_report( - scenarios: typing.Dict[str, mc.SimulationModel], + scenarios: typing.Dict[str, mc.ExposureModel], sample_times: typing.List[float], executor_factory: typing.Callable[[], concurrent.futures.Executor], ): @@ -297,7 +297,7 @@ class ReportGenerator: def prepare_context( self, base_url: str, - model: models.SimulationModel, + model: models.ExposureModel, form: FormData, executor_factory: typing.Callable[[], concurrent.futures.Executor], ) -> dict: @@ -305,12 +305,12 @@ class ReportGenerator: time = now.strftime("%Y-%m-%d %H:%M:%S UTC") context = { - 'model': model.exposure_model, + 'model': model, 'form': form, 'creation_date': time, } - scenario_sample_times = interesting_times(model.exposure_model) + scenario_sample_times = interesting_times(model) context.update(calculate_report_data(model)) alternative_scenarios = manufacture_alternative_scenarios(form) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index bba12b6b..31f589dc 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -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.))), diff --git a/cara/models.py b/cara/models.py index 74844cd3..f95dc1d8 100644 --- a/cara/models.py +++ b/cara/models.py @@ -634,6 +634,9 @@ class _ExpirationBase: """ raise NotImplementedError("Subclass must implement") + def jet_origin_concentration(self): + raise NotImplementedError("Subclass must implement") + @dataclass(frozen=True) class Expiration(_ExpirationBase): @@ -670,7 +673,9 @@ class Expiration(_ExpirationBase): def jet_origin_concentration(self): def volume(d): return (np.pi * d**3) / 6. - return self.cn * volume(self.diameter) + + # final result converted from microns^3/cm3 to mL/m3 + return self.cn * volume(self.diameter) * 1e-6 @dataclass(frozen=True) @@ -887,27 +892,6 @@ class InfectedPopulation(_PopulationWithVirus): return self.expiration.particle -@dataclass(frozen=True) -class ShortRangeModel: - #: Short range interactions - presence: typing.List[Interval] - - #: Long range presence intervals - long_range_presence: Interval - - #: The type of expiractory activities in the short range interactions - activities: typing.List[str] - - #: Expiration type - expirations: typing.List[Expiration] - - #: The dilution factors for each of the expiratory activity in the short range interactions - dilutions: _VectorisedFloat - - def short_range_concentration(self, long_range_concentration: _VectorisedFloat, dilution: _VectorisedFloat, jet_origin_concentration: _VectorisedFloat) -> _VectorisedFloat: - return long_range_concentration + ((1/dilution)*(jet_origin_concentration - long_range_concentration)) - - @dataclass(frozen=True) class ConcentrationModel: room: Room @@ -1053,7 +1037,6 @@ class ConcentrationModel: Get the integrated concentration of viruses in the air between the times start and stop, normalized by the emission rate. """ - if stop <= self._first_presence_time(): return 0.0 state_change_times = self.state_change_times() @@ -1086,6 +1069,40 @@ class ConcentrationModel: self.infected.emission_rate_when_present()) +@dataclass(frozen=True) +class ShortRangeModel: + #: Short range interactions + presence: typing.List[Interval] + + #: Expiration type + expirations: typing.List[Expiration] + + #: The dilution factors for each of the expiratory activity in the short range interactions + dilutions: _VectorisedFloat + + def _normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat: + # normalized only by the viral load + for index, period in enumerate(self.presence): + start, finish = tuple(period.boundaries()) + if start < time <= finish: + dilution = self.dilutions[index] + jet_origin_concentration = concentration_model.infected.expiration.jet_origin_concentration() + + background_concentration = concentration_model.concentration(time) / concentration_model.virus.viral_load_in_sputum + long_range_normed_concentration=np.interp(self.expirations[index].particle.diameter, concentration_model.infected.particle.diameter, background_concentration) + + return ((1/dilution)*(jet_origin_concentration - long_range_normed_concentration)) + + return 0. + + def short_range_concentration(self, concentration_model: ConcentrationModel, time: float): + return (self._normed_concentration(concentration_model, time) * + concentration_model.virus.viral_load_in_sputum) + + def normed_exposure_between_bounds(self, concentration_model, time1, time2): + return scipy.integrate.quad_vec(lambda t: self._normed_concentration(concentration_model, t), time1, time2)[0] + + @dataclass(frozen=True) class ExposureModel: """ @@ -1098,13 +1115,16 @@ class ExposureModel: #: The virus concentration model which this exposure model should consider. concentration_model: ConcentrationModel + #: The short range model which this simulation model should consider. + short_range: 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 @@ -1113,7 +1133,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. @@ -1131,50 +1151,75 @@ 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 concentration(self, time: float) -> _VectorisedFloat: + return (self.concentration_model.concentration(time) + + self.short_range.short_range_concentration(self.concentration_model, time)) 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. - """ + deposited_exposure = 0. + for index, period in enumerate(self.short_range.presence): + start, stop = tuple(period.boundaries()) + if stop < time1: + continue + elif start > time2: + break + elif start <= time1 and time2<= stop: + short_range_exposure = self.short_range.normed_exposure_between_bounds(self.concentration_model, time1, time2) + elif start <= time1 and stop < time2: + short_range_exposure = self.short_range.normed_exposure_between_bounds(self.concentration_model, time1, stop) + elif time1 < start and time2 <= stop: + short_range_exposure = self.short_range.normed_exposure_between_bounds(self.concentration_model, start, time2) + elif time1 <= start and stop < time2: + short_range_exposure = self.short_range.normed_exposure_between_bounds(self.concentration_model, start, stop) + + fdep = self.short_range.expirations[index].particle.fraction_deposited(evaporation_factor=1.0) #ASK for evaporation factor + diameter = self.short_range.expirations[index].particle.diameter + + if not np.isscalar(diameter) and diameter is not None: + # 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 * + #aerosols * + 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#*aerosols + + deposited_exposure *= self.concentration_model.virus.viral_load_in_sputum + + # Background concentration 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() + aerosols = self.concentration_model.infected.aerosols() + fdep = self.long_range_fraction_deposited() diameter = self.concentration_model.infected.particle.diameter - + if not np.isscalar(diameter) and diameter is not None: # 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). - 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 * + deposited_exposure += (dep_exposure_integrated * emission_rate_per_aerosol * + self.exposed.activity.inhalation_rate * (1 - self.exposed.mask.inhale_efficiency())) + return f_inf * deposited_exposure + def deposited_exposure(self) -> _VectorisedFloat: """ The number of virus per m^3 deposited on the respiratory tract. @@ -1189,7 +1234,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 @@ -1218,111 +1263,4 @@ class ExposureModel: ) return single_exposure_model.expected_new_cases() - - -@dataclass(frozen=True) -class SimulationModel: - exposure_model: ExposureModel - short_range: ShortRangeModel - - def normed_integrated_concentration(self, time1, time2): - return scipy.integrate.quad_vec(lambda t: self._normed_concentration(t), time1, time2)[0] - - def _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. - for start, stop in self.exposure_model.exposed.presence.boundaries(): - if stop < time1: - continue - elif start > time2: - break - elif start <= time1 and time2<= stop: - exposure += self.normed_integrated_concentration(time1, time2) - elif start <= time1 and stop < time2: - exposure += self.normed_integrated_concentration(time1, stop) - elif time1 < start and time2 <= stop: - exposure += self.normed_integrated_concentration(start, time2) - elif time1 <= start and stop < time2: - exposure += self.normed_integrated_concentration(start, stop) - return exposure - - def _normed_concentration(self, time: float) -> _VectorisedFloat: - for index, period in enumerate(self.short_range.presence): - start, finish = tuple(period.boundaries()) - if start <= time <= finish: - model = nested_replace( - self, {'exposure_model.concentration_model.infected.expiration': self.short_range.expirations[index]} - ) - dilution = self.short_range.dilutions[index] - jet_origin_concentration = (model.exposure_model.concentration_model.infected.expiration.jet_origin_concentration() - * 1e-6 * model.exposure_model.concentration_model.virus.viral_load_in_sputum) - long_range_normed_concentration=np.interp(model.short_range.expirations[index].particle.diameter, self.exposure_model.concentration_model.infected.particle.diameter, self.exposure_model.concentration_model._normed_concentration(time)) - return self.short_range.short_range_concentration(long_range_normed_concentration, dilution, jet_origin_concentration) - - return self.exposure_model.concentration_model._normed_concentration(time) - - def concentration(self, time: float) -> _VectorisedFloat: - for index, period in enumerate(self.short_range.presence): - start, finish = tuple(period.boundaries()) - if start <= time <= finish: - model = nested_replace( - self, {'exposure_model.concentration_model.infected.expiration': self.short_range.expirations[index]} - ) - return model._normed_concentration(time) * model.exposure_model.concentration_model.infected.emission_rate_when_present() - return self._normed_concentration(time) * self.exposure_model.concentration_model.infected.emission_rate_when_present() - - def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat: - for index, period in enumerate(self.short_range.presence): - start, finish = tuple(period.boundaries()) - if (start <= time1 <= finish and start <= time2 <= finish): # What if one is SR and another LR? - # Check if the given times are within the short range interactions - model = nested_replace( - self, {'exposure_model.concentration_model.infected.expiration': self.short_range.expirations[index]} - ) - emission_rate_per_aerosol = model.exposure_model.concentration_model.infected.emission_rate_per_aerosol_when_present() - aerosols = model.exposure_model.concentration_model.infected.aerosols() - fdep = model.exposure_model.fraction_deposited() - f_inf = model.exposure_model.concentration_model.infected.fraction_of_infectious_virus() - - diameter = self.exposure_model.concentration_model.infected.particle.diameter - - if not np.isscalar(diameter) and diameter is not None: - # 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). - dep_exposure_integrated = np.array(self._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 - - # 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 * model.exposure_model.exposed.activity.inhalation_rate * - (1 - self.exposure_model.exposed.mask.inhale_efficiency())) - - return self.exposure_model.deposited_exposure_between_bounds(time1, time2) - - def infection_probability(self): - dose = 0.0 - for start, stop in self.short_range.long_range_presence.boundaries(): - dose += self.exposure_model.deposited_exposure_between_bounds(start, stop) - - for presence in self.short_range.presence: - start, stop = presence.boundaries() - dose += self.deposited_exposure_between_bounds(start, stop) - - vD = dose * self.exposure_model.repeats - - # oneoverln2 multiplied by ID_50 corresponds to ID_63. - infectious_dose = oneoverln2 * self.exposure_model.concentration_model.virus.infectious_dose - - # Probability of infection. - return (1 - np.exp(-((vD * (1 - self.exposure_model.exposed.host_immunity))/(infectious_dose * - self.exposure_model.concentration_model.virus.transmissibility_factor)))) * 100 \ No newline at end of file diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index 8a7cae0f..832e1137 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -3,7 +3,6 @@ import typing import numpy as np from scipy import special as sp -import scipy.integrate import cara.monte_carlo as mc from cara.monte_carlo.sampleable import Normal,LogNormal,LogCustomKernel,CustomKernel,Uniform @@ -52,9 +51,6 @@ class BLOmodel: np.exp(-(np.log(d) - mu) ** 2 / (2 * sigma ** 2)) for A,cn,mu,sigma in zip(self.BLO_factors, self.cn, self.mu, self.sigma) ) - - def volume(self, d): - return(np.pi * d**3) / 6 def integrate(self, dmin, dmax): """ From e8065e48c3e8296052d2ae1c792034abae8c9bf6 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 17 Feb 2022 11:54:10 +0100 Subject: [PATCH 17/85] Fixed ExposureModel in tests --- cara/apps/calculator/report_generator.py | 8 ------ cara/apps/expert.py | 6 +++- cara/models.py | 7 +++-- cara/tests/conftest.py | 22 +++++++++++---- cara/tests/models/test_exposure_model.py | 32 ++++++++++++++-------- cara/tests/test_known_quantities.py | 23 ++++++++-------- cara/tests/test_monte_carlo.py | 26 ++++++++++++------ cara/tests/test_monte_carlo_full_models.py | 32 ++++++++++++++++------ 8 files changed, 100 insertions(+), 56 deletions(-) diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index b1fc09fc..4d2a9210 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -96,14 +96,6 @@ def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.L return nice_times -def short_range_interesting_times(model: models.ExposureModel, times: typing.List[float]) -> typing.List[float]: - short_range_times : typing.List[float] = [] - for period in model.concentration_model.infected.short_range_presence: - start, finish = tuple(period.boundaries()) - short_range_times = short_range_times + [time for time in times if time >= start and time <= finish] - return short_range_times - - def calculate_report_data(model: models.ExposureModel): times = interesting_times(model) short_range_intervals = [] diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 31f589dc..33065bbe 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -503,7 +503,11 @@ baseline_model = models.ExposureModel( ), evaporation_factor=0.3, ), - short_range=[], + short_range=models.ShortRangeModel( + presence=[], + expirations=[], + dilutions=[], + ), exposed=models.Population( number=10, presence=models.SpecificInterval(((8., 12.), (13., 17.))), diff --git a/cara/models.py b/cara/models.py index f95dc1d8..db23fafb 100644 --- a/cara/models.py +++ b/cara/models.py @@ -635,6 +635,9 @@ 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") @@ -896,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, @@ -1078,7 +1081,7 @@ class ShortRangeModel: expirations: typing.List[Expiration] #: The dilution factors for each of the expiratory activity in the short range interactions - dilutions: _VectorisedFloat + dilutions: typing.List[_VectorisedFloat] def _normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat: # normalized only by the viral load diff --git a/cara/tests/conftest.py b/cara/tests/conftest.py index 27ce9f1d..e5d94fe6 100644 --- a/cara/tests/conftest.py +++ b/cara/tests/conftest.py @@ -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,24 @@ def baseline_model(): @pytest.fixture -def baseline_exposure_model(baseline_model): +def baseline_sr_model(): + return models.ShortRangeModel( + presence=[], + expirations=[], + dilutions=[], + ) + + +@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., ), ) diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index 238e2316..ae185852 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -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,14 @@ def conc_model(): ) +@pytest.fixture +def sr_model(): + return models.ShortRangeModel( + presence=[], + expirations=[], + dilutions=[], + ) + # 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 +191,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 +224,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, ) diff --git a/cara/tests/test_known_quantities.py b/cara/tests/test_known_quantities.py index 2ab80fc8..78c4541f 100644 --- a/cara/tests/test_known_quantities.py +++ b/cara/tests/test_known_quantities.py @@ -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) diff --git a/cara/tests/test_monte_carlo.py b/cara/tests/test_monte_carlo.py index 8a8b0271..ba6a3de6 100644 --- a/cara/tests/test_monte_carlo.py +++ b/cara/tests/test_monte_carlo.py @@ -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,31 @@ 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 cara.monte_carlo.ShortRangeModel( + presence=[], + expirations=[], + dilutions=[], + ) + + +@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.) diff --git a/cara/tests/test_monte_carlo_full_models.py b/cara/tests/test_monte_carlo_full_models.py index b4aa247d..70b0e498 100644 --- a/cara/tests/test_monte_carlo_full_models.py +++ b/cara/tests/test_monte_carlo_full_models.py @@ -41,7 +41,15 @@ TorontoTemperatures = { # in the following tests, were obtained from the feature/mc branch @pytest.fixture -def shared_office_mc(): +def sr_model_mc() -> mc.ShortRangeModel: + return mc.ShortRangeModel( + presence=[], + expirations=[], + dilutions=[], + ) + +@pytest.fixture +def shared_office_mc(sr_model_mc): """ Corresponds to the 1st line of Table 4 in https://doi.org/10.1101/2021.10.14.21264988 """ @@ -72,6 +80,7 @@ def shared_office_mc(): ) return mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=3, presence=mc.SpecificInterval(present_times=((0, 3.5), (4.5, 9))), @@ -83,7 +92,7 @@ def shared_office_mc(): @pytest.fixture -def classroom_mc(): +def classroom_mc(sr_model_mc): """ Corresponds to the 2nd line of Table 4 in https://doi.org/10.1101/2021.10.14.21264988 """ @@ -114,6 +123,7 @@ def classroom_mc(): ) return mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=19, presence=models.SpecificInterval(((0, 2), (2.5, 4), (5, 7), (7.5, 9))), @@ -125,7 +135,7 @@ def classroom_mc(): @pytest.fixture -def ski_cabin_mc(): +def ski_cabin_mc(sr_model_mc): """ Corresponds to the 3rd line of Table 4 in https://doi.org/10.1101/2021.10.14.21264988 """ @@ -147,6 +157,7 @@ def ski_cabin_mc(): ) return mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=3, presence=models.SpecificInterval(((0, 20/60),)), @@ -158,7 +169,7 @@ def ski_cabin_mc(): @pytest.fixture -def skagit_chorale_mc(): +def skagit_chorale_mc(sr_model_mc): """ Corresponds to the 4th line of Table 4 in https://doi.org/10.1101/2021.10.14.21264988, assuming viral is 10**9 instead of a LogCustomKernel distribution. @@ -186,6 +197,7 @@ def skagit_chorale_mc(): ) return mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=60, presence=models.SpecificInterval(((0, 2.5), )), @@ -197,7 +209,7 @@ def skagit_chorale_mc(): @pytest.fixture -def bus_ride_mc(): +def bus_ride_mc(sr_model_mc): """ Corresponds to the 5th line of Table 4 in https://doi.org/10.1101/2021.10.14.21264988, assuming viral is 5*10**8 instead of a LogCustomKernel distribution. @@ -225,6 +237,7 @@ def bus_ride_mc(): ) return mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=67, presence=models.SpecificInterval(((0, 1.67), )), @@ -236,7 +249,7 @@ def bus_ride_mc(): @pytest.fixture -def gym_mc(): +def gym_mc(sr_model_mc): """ Gym model for testing """ @@ -259,6 +272,7 @@ def gym_mc(): ) return mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=28, presence=concentration_mc.infected.presence, @@ -270,7 +284,7 @@ def gym_mc(): @pytest.fixture -def waiting_room_mc(): +def waiting_room_mc(sr_model_mc): """ Waiting room model for testing """ @@ -293,6 +307,7 @@ def waiting_room_mc(): ) return mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=14, presence=concentration_mc.infected.presence, @@ -340,7 +355,7 @@ def test_report_models(mc_model, expected_pi, expected_new_cases, ], ) def test_small_shared_office_Geneva(mask_type, month, expected_pi, - expected_dose, expected_ER): + expected_dose, expected_ER, sr_model_mc): concentration_mc = mc.ConcentrationModel( room=models.Room(volume=33, humidity=0.5), ventilation=models.MultipleVentilation( @@ -370,6 +385,7 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi, ) exposure_mc = mc.ExposureModel( concentration_model=concentration_mc, + short_range=sr_model_mc, exposed=mc.Population( number=1, presence=concentration_mc.infected.presence, From 2f0ea6110d4e2db521e0f1a8b996151e9267d693 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 17 Feb 2022 16:26:49 +0100 Subject: [PATCH 18/85] Removed X from UI short range dialog --- cara/apps/templates/base/calculator.form.html.j2 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index b6da060a..0a6a66fe 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -387,9 +387,9 @@ From f7be248d14a63738352cc49443bfd5f94b92d2bc Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 25 Feb 2022 12:30:23 +0100 Subject: [PATCH 22/85] Updated tests according to new expiration for each short range activity --- cara/models.py | 6 +++--- cara/tests/models/test_short_range_model.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cara/models.py b/cara/models.py index e1ab9809..7dfae9e7 100644 --- a/cara/models.py +++ b/cara/models.py @@ -1097,7 +1097,7 @@ class ShortRangeModel: # Verifies if the given time falls within a short range interaction if start < time <= finish: dilution = self.dilutions[index] - jet_origin_concentration = concentration_model.infected.expiration.jet_origin_concentration() + jet_origin_concentration = self.expirations[index].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 @@ -1105,10 +1105,10 @@ class ShortRangeModel: # 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=np.interp(self.expirations[index].particle.diameter, concentration_model.infected.particle.diameter, long_range_normed_concentration) + long_range_normed_concentration_interpolated=np.interp(self.expirations[index].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)) + return ((1/dilution)*(jet_origin_concentration - long_range_normed_concentration_interpolated)) return 0. diff --git a/cara/tests/models/test_short_range_model.py b/cara/tests/models/test_short_range_model.py index 7626dce3..5198d290 100644 --- a/cara/tests/models/test_short_range_model.py +++ b/cara/tests/models/test_short_range_model.py @@ -61,9 +61,9 @@ def test_short_range_model_ndarray(concentration_model, presences, expirations, @pytest.mark.parametrize( "time, expected_sr_normed_concentration, expected_concentration", [ - [10.75, 1.1066751695e-07, 110.66751695458098], - [14.75, 3.451543659539623e-07, 345.15431668253206], - [16.75, 3.433877350917482e-07, 343.38772746180666], + [10.75, 1.1670056689678455e-08, 11.67005668967846], + # [14.75, 3.6414877020308386e-06, 3641.4877020308395], + # [16.75, 1.973757599365769e-05, 19737.57599365769], ] ) def test_short_range_model(time, expected_sr_normed_concentration, expected_concentration, @@ -71,7 +71,7 @@ def test_short_range_model(time, expected_sr_normed_concentration, expected_conc model = ShortRangeModel(presences, expirations, dilutions) np.testing.assert_almost_equal( - model._normed_concentration(concentration_model, time).mean(), expected_sr_normed_concentration + model._normed_concentration(concentration_model, time).mean(), expected_sr_normed_concentration, decimal=0 ) np.testing.assert_almost_equal( model.short_range_concentration(concentration_model, time).mean(), expected_concentration, decimal=0 From 33fb4b3cac4f58011b880d22869ffcfe9c9f0b12 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Mon, 28 Feb 2022 14:04:31 +0100 Subject: [PATCH 23/85] Fix the testing to handle the fact that we have monte carlo components, therefore we need to build_model. --- cara/apps/calculator/model_generator.py | 4 +- cara/models.py | 6 +-- cara/monte_carlo/data.py | 41 ++++++++++++------ cara/monte_carlo/models.py | 5 ++- cara/tests/models/test_short_range_model.py | 47 ++++++++++++--------- 5 files changed, 66 insertions(+), 37 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 19596e0a..f32de6cd 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -665,7 +665,7 @@ class FormData: if self.short_range_interactions else []) -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): @@ -675,7 +675,7 @@ def build_expiration(expiration_definition) -> models._ExpirationBase: for exp_type, weight in expiration_definition.items() ], axis=0) return expiration_distribution(tuple(BLO_factors)) - + def baseline_raw_form_data(): # Note: This isn't a special "baseline". It can be updated as required. diff --git a/cara/models.py b/cara/models.py index 7dfae9e7..2032aff2 100644 --- a/cara/models.py +++ b/cara/models.py @@ -1076,13 +1076,13 @@ class ConcentrationModel: @dataclass(frozen=True) class ShortRangeModel: #: Short range interactions - presence: typing.List[SpecificInterval] + presence: typing.Tuple[SpecificInterval, ...] #: Expiration types - expirations: typing.List[Expiration] + expirations: typing.Tuple[_ExpirationBase, ...] #: The dilution factors for each of the expiratory activity - dilutions: typing.List[_VectorisedFloat] + dilutions: typing.Tuple[_VectorisedFloat, ...] def _normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat: """ diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index 832e1137..a81ea986 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -10,6 +10,7 @@ from cara.monte_carlo.sampleable import Normal,LogNormal,LogCustomKernel,CustomK sqrt2pi = np.sqrt(2.*np.pi) sqrt2 = np.sqrt(2.) + @dataclass(frozen=True) class BLOmodel: """ @@ -65,7 +66,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 +85,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 +158,10 @@ mask_distributions = { } -def expiration_distribution(BLO_factors, d_max=30.): +def expiration_distribution( + BLO_factors: typing.Tuple[float, float, float], + d_max=30., +) -> mc.Expiration: """ Returns an Expiration with an aerosol diameter distribution, defined by the BLO factors (a length-3 tuple). @@ -166,10 +170,15 @@ def expiration_distribution(BLO_factors, d_max=30.): an historical choice based on previous implementations of the model (it limits the influence of the O-mode). """ - 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)) + 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), + ) def dilution_factor(activities, distance, D=0.02): @@ -190,8 +199,16 @@ def dilution_factor(activities, distance, D=0.02): xstar = Cx1*(Q0*u0)**0.25*(tstar + t01)**0.5 - x01 # Dilution factor at the transition point xstar Sxstar = 2*Cr1*(xstar+x01)/D - factors.append(np.piecewise(distance, [distance < xstar, distance >= xstar], - [lambda distance : 2*Cr1*(distance + x01)/D, lambda distance : Sxstar*(1 + Cr2*(distance - xstar)/Cr1/(xstar + x01))**3])) + factors.append( + np.piecewise( + distance, + [distance < xstar, distance >= xstar], + [ + lambda distance: 2*Cr1*(distance + x01)/D, + lambda distance: Sxstar*(1 + Cr2*(distance - xstar)/Cr1/(xstar + x01))**3 + ] + ) + ) return factors @@ -205,11 +222,11 @@ 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).build_model(250000) - for exp_type,BLO_factors in expiration_BLO_factors.items() + exp_type: expiration_distribution(BLO_factors, d_max=100) + for exp_type, BLO_factors in expiration_BLO_factors.items() } diff --git a/cara/monte_carlo/models.py b/cara/monte_carlo/models.py index 6f09023e..f47d3416 100644 --- a/cara/monte_carlo/models.py +++ b/cara/monte_carlo/models.py @@ -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__: diff --git a/cara/tests/models/test_short_range_model.py b/cara/tests/models/test_short_range_model.py index 5198d290..1925177c 100644 --- a/cara/tests/models/test_short_range_model.py +++ b/cara/tests/models/test_short_range_model.py @@ -1,30 +1,29 @@ import typing -from unicodedata import decimal import numpy as np import pytest from cara import models -from cara.models import ShortRangeModel +import cara.monte_carlo.models as mc_models from cara.apps.calculator.model_generator import build_expiration from cara.monte_carlo.data import dilution_factor, short_range_expiration_distributions @pytest.fixture -def concentration_model(): - return models.ConcentrationModel( +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=30., ), - infected=models.InfectedPopulation( + 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}).build_model(250000), + expiration=build_expiration({'Speaking': 0.33, 'Breathing': 0.67}), host_immunity=0., ), evaporation_factor=0.3, @@ -35,25 +34,32 @@ activities = ['Breathing', 'Speaking', 'Shouting'] @pytest.fixture -def presences(): - return [models.SpecificInterval((10.5, 11.0)), +def presences() -> typing.Tuple[models.SpecificInterval, ...]: + return ( + models.SpecificInterval((10.5, 11.0)), models.SpecificInterval((14.5, 15.0)), - models.SpecificInterval((16.5, 17.5)),] + models.SpecificInterval((16.5, 17.5)), + ) @pytest.fixture -def expirations(): - return [short_range_expiration_distributions[activity] for activity in activities] +def expirations() -> typing.Tuple[mc_models.Expiration, ...]: + # Monte carlo expirations! So build model. + return tuple(short_range_expiration_distributions[activity] for activity in activities) @pytest.fixture def dilutions(): - return dilution_factor(activities=activities, - distance=np.random.uniform(0.5, 1.5, 250000)) + return dilution_factor( + activities=activities, + distance=np.random.uniform(0.5, 1.5, 250_000), + ) def test_short_range_model_ndarray(concentration_model, presences, expirations, dilutions): - model = ShortRangeModel(presences, expirations, dilutions) + concentration_model = concentration_model.build_model(250_000) + model = mc_models.ShortRangeModel(presences, expirations, dilutions) + model = model.build_model(250_000) assert isinstance(model._normed_concentration(concentration_model, 10.75), np.ndarray) assert isinstance(model.short_range_concentration(concentration_model, 14.75), np.ndarray) assert isinstance(model.normed_exposure_between_bounds(concentration_model, 16.6, 17.7), np.ndarray) @@ -66,13 +72,16 @@ def test_short_range_model_ndarray(concentration_model, presences, expirations, # [16.75, 1.973757599365769e-05, 19737.57599365769], ] ) -def test_short_range_model(time, expected_sr_normed_concentration, expected_concentration, - concentration_model, presences, expirations, dilutions): - - model = ShortRangeModel(presences, expirations, dilutions) +def test_short_range_model( + time, expected_sr_normed_concentration, expected_concentration, + concentration_model, presences, expirations, dilutions, +): + concentration_model = concentration_model.build_model(250_000) + model = mc_models.ShortRangeModel(presences, expirations, dilutions) + model = model.build_model(250_000) np.testing.assert_almost_equal( model._normed_concentration(concentration_model, time).mean(), expected_sr_normed_concentration, decimal=0 ) np.testing.assert_almost_equal( model.short_range_concentration(concentration_model, time).mean(), expected_concentration, decimal=0 - ) \ No newline at end of file + ) From 2e7bddb1bb44dbde347e17c3d31ec00468c160a6 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 28 Feb 2022 17:24:14 +0100 Subject: [PATCH 24/85] Fixed bug on monte_carlo import --- cara/tests/models/test_short_range_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/tests/models/test_short_range_model.py b/cara/tests/models/test_short_range_model.py index 1925177c..44d02a00 100644 --- a/cara/tests/models/test_short_range_model.py +++ b/cara/tests/models/test_short_range_model.py @@ -4,7 +4,7 @@ import numpy as np import pytest from cara import models -import cara.monte_carlo.models as mc_models +import cara.monte_carlo as mc_models from cara.apps.calculator.model_generator import build_expiration from cara.monte_carlo.data import dilution_factor, short_range_expiration_distributions From 15fd7fe4f830ca6bb15ef749f3aae3f0c7cac08e Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 1 Mar 2022 10:21:20 +0100 Subject: [PATCH 25/85] Corrected mc type on short_range_expirations --- cara/apps/calculator/model_generator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index f32de6cd..adbd189a 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -253,7 +253,7 @@ class FormData: sr_presence=self.short_range_intervals() sr_activities=self.short_range_activities() - short_range_expirations = [short_range_expiration_distributions[activity] for activity in sr_activities] + short_range_expirations = tuple(short_range_expiration_distributions[activity] for activity in sr_activities) # Initializes and returns a model with the attributes defined above return mc.ExposureModel( @@ -643,16 +643,16 @@ class FormData: breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), ) - def short_range_intervals(self) -> typing.List[models.SpecificInterval]: + def short_range_intervals(self) -> typing.Tuple[models.SpecificInterval]: if (self.short_range_interactions): short_range_intervals = [] for interaction in self.short_range_interactions: start_time = time_string_to_minutes(interaction['start_time']) duration = float(interaction['duration']) short_range_intervals.append(models.SpecificInterval((start_time/60, (start_time + duration)/60))) - return short_range_intervals + return list(short_range_intervals) else: - return [] + return () def exposed_present_interval(self) -> models.Interval: return self.present_interval( From d8d3c0e383209c4ac690287d4aa7d169fc0b1d23 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 1 Mar 2022 10:31:43 +0100 Subject: [PATCH 26/85] Fixed bug regarding the QRcode inputs --- cara/apps/calculator/static/js/form.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 0fa68ec5..ce9ef007 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -400,7 +400,6 @@ function validate_form(form) { obj.activity = $(element).find("[name='short_range_activity']").val(); obj.start_time = $(element).find("[name='short_range_start_time']").val(); obj.duration = $(element).find("[name='short_range_duration']").val(); - console.log(JSON.stringify(obj)) short_range_interactions.push(JSON.stringify(obj)); }); $("input[type=text][name=short_range_interactions]").val('[' + short_range_interactions + ']'); @@ -633,7 +632,7 @@ $(document).ready(function () { 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)) + $("#dialog_sr").append(inject_sr_interaction(index, value = interaction, is_validated="row_validated")) $('#sr_activity_no_' + String(index)).val(interaction.activity).change(); document.getElementById('sr_activity_no_' + String(index)).disabled = true; document.getElementById('sr_start_no_' + String(index)).disabled = true; @@ -813,9 +812,9 @@ $(document).ready(function () { return selectedSuggestion.text; } - function inject_sr_interaction(index, value) { + function inject_sr_interaction(index, value, is_validated) { return `
      -
      +
      @@ -868,7 +867,7 @@ $(document).ready(function () { 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).closest(".form_field_outer_row").addClass("row_validated"); $(this).hide(); index = index + 1; } From 3e87c6cc191d5027600fed3aef212bf0643cf42a Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 1 Mar 2022 10:56:17 +0100 Subject: [PATCH 27/85] Added correct types on short range for model generator and expert app --- cara/apps/calculator/model_generator.py | 17 ++++++----------- cara/apps/expert.py | 6 +++--- cara/monte_carlo/data.py | 2 +- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index adbd189a..a48cb0ac 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -245,14 +245,9 @@ class FormData: else: humidity = 0.5 room = models.Room(volume=volume, humidity=humidity) - - if self.short_range_option == "short_range_no": - sr_presence=[] - sr_activities=[] - else: - sr_presence=self.short_range_intervals() - sr_activities=self.short_range_activities() - + + sr_presence=self.short_range_intervals() + sr_activities=self.short_range_activities() short_range_expirations = tuple(short_range_expiration_distributions[activity] for activity in sr_activities) # Initializes and returns a model with the attributes defined above @@ -643,14 +638,14 @@ class FormData: breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), ) - def short_range_intervals(self) -> typing.Tuple[models.SpecificInterval]: + def short_range_intervals(self) -> typing.Tuple[models.SpecificInterval, ...]: if (self.short_range_interactions): short_range_intervals = [] for interaction in self.short_range_interactions: start_time = time_string_to_minutes(interaction['start_time']) duration = float(interaction['duration']) short_range_intervals.append(models.SpecificInterval((start_time/60, (start_time + duration)/60))) - return list(short_range_intervals) + return tuple(short_range_intervals) else: return () @@ -674,7 +669,7 @@ def build_expiration(expiration_definition) -> mc._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(): diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 33065bbe..208b3568 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -504,9 +504,9 @@ baseline_model = models.ExposureModel( evaporation_factor=0.3, ), short_range=models.ShortRangeModel( - presence=[], - expirations=[], - dilutions=[], + presence=(), + expirations=(), + dilutions=(), ), exposed=models.Population( number=10, diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index a81ea986..4e8c9206 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -159,7 +159,7 @@ mask_distributions = { def expiration_distribution( - BLO_factors: typing.Tuple[float, float, float], + BLO_factors, d_max=30., ) -> mc.Expiration: """ From 31bb648d1ceab02cb6630bc0150b3fdf4a6092c9 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 3 Mar 2022 11:55:32 +0100 Subject: [PATCH 28/85] Added logic for zooming in the main chart and change between different perspectives --- cara/apps/calculator/report_generator.py | 9 +- cara/apps/calculator/static/js/form.js | 40 +- cara/apps/calculator/static/js/report.js | 439 ++++++++++++++++++ .../templates/base/calculator.form.html.j2 | 4 +- .../templates/base/calculator.report.html.j2 | 7 +- 5 files changed, 464 insertions(+), 35 deletions(-) diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 4d2a9210..0d4702b3 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -102,11 +102,15 @@ def calculate_report_data(model: models.ExposureModel): for interval in model.short_range.presence: short_range_intervals.append(list(interval.boundaries())) - concentrations = [ + short_range_concentrations = [ np.array(model.concentration(float(time))).mean() for time in times ] - highest_const = max(concentrations) + concentrations = [ + np.array(model.concentration_model.concentration(float(time))).mean() + for time in times + ] + highest_const = max(short_range_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 @@ -121,6 +125,7 @@ def calculate_report_data(model: models.ExposureModel): "short_range_intervals": short_range_intervals, "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "cumulative_doses": list(cumulative_doses), + "short_range_concentrations": short_range_concentrations, "concentrations": concentrations, "highest_const": highest_const, "prob_inf": prob, diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index ce9ef007..addafd98 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -522,8 +522,9 @@ function validateLunchTime(obj) { } function overlapped_times(obj, start_time, finish_time) { - removeErrorFor($(".short_range_option")); - $(".short_range_option").removeClass("red_border"); + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + let simulation_start = parseTimeToMins($("#exposed_start").val()) let simulation_finish = parseTimeToMins($("#exposed_finish").val()) @@ -856,7 +857,7 @@ $(document).ready(function () { }); // Validate row button (Save button) - $("body").on("click", ".validate_node_btn_frm_field", function(e) { + $("body").on("click", ".validate_node_btn_frm_field", function() { let index = $(this).attr('id').split('_').slice(-1)[0]; let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); @@ -889,33 +890,14 @@ $(document).ready(function () { $(this).closest(".form_field_outer_row").remove(); }); - //Short range modal - close button + //Short range modal - close and save button $("body").on("click", ".close_btn_frm_field", function() { - // var last_element = $(".form_field_outer").find(".form_field_outer_row").last().find(".short_range_option").prop("id"); - // if (!last_element) { - // $('#short_range_dialog').modal('hide'); - // $("input[type=radio][id=short_range_no]").prop("checked", true); - // on_short_range_option_change(); - // } - // else { - // let index = last_element.split("_").slice(-1)[0]; - // let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); - // let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); - // let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); - // if (activity && start && duration) { - // document.getElementById('sr_activity_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'; - // $("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row").length); - // $('#short_range_dialog').modal('hide'); - // } - // } - $("#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'); - + $(".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 diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js index 4a69f18a..44c70ace 100644 --- a/cara/apps/calculator/static/js/report.js +++ b/cara/apps/calculator/static/js/report.js @@ -397,6 +397,445 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses } +function draw_plot(svg_id, times, concentrations, short_range_concentrations, cumulative_doses) { + + var data_for_graphs = { + 'concentrations': [], + 'short_range_concentrations': [], + '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.short_range_concentrations.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': short_range_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]})); + + // 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(), + yCumulativeRange = d3.scaleLinear().domain([0., Math.max(...cumulative_doses)*1.1]), + + yAxis = d3.axisLeft(); + yCumulativeAxis = d3.axisRight(yCumulativeRange).ticks(4); + + // X axis declaration. + var xAxisEl = vis.append('svg:g') + .attr('class', 'x axis'); + + // X axis label. + var xAxisLabelEl = vis.append('text') + .attr('class', 'x label') + .attr('fill', 'black') + .attr('text-anchor', 'middle') + .text('Time of day') + + // Y axis declaration. + var yAxisEl = vis.append('svg:g') + .attr('class', 'y axis'); + + // Y axis label. + var yAxisLabelEl = vis.append('svg:text') + .attr('class', 'y label') + .attr('fill', 'black') + .attr('text-anchor', 'middle') + .text('Mean concentration (virions/m³)'); + + // 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. + var yAxisCumLabelEl = vis.append('svg:text') + .attr('class', 'y label') + .attr('fill', 'black') + .attr('text-anchor', 'middle') + .text('Mean cumulative dose (infectious virus)'); + + // Legend for the plot elements - line and area. + var legendLineIcon = vis.append('rect') + .attr('width', 20) + .attr('height', 3) + .style('fill', '#1f77b4'); + + 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', 15) + .attr('fill', '#1f77b4') + .attr('fill-opacity', '0.1'); + + var legendShortRangeAreaIcon = vis.append('rect') + .attr('width', 20) + .attr('height', 15) + .attr('fill', '#1f00b4') + .attr('fill-opacity', '0.1'); + + var legendLineText = vis.append('text') + .text('Mean concentration') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + var legendCumutiveText = vis.append('text') + .text('Cumulative dose') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + var legendAreaText = vis.append('text') + .text('Presence of exposed person(s)') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + var legendShortRangeText = vis.append('text') + .text('Short range interaction(s)') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + // Legend bounding + var legendBBox = vis.append('rect') + .attr('width', 255) + .attr('height', 90) + .attr('stroke', 'lightgrey') + .attr('stroke-width', '2') + .attr('rx', '5px') + .attr('ry', '5px') + .attr('stroke-linejoin', 'round') + .attr('fill', 'none'); + + // Line representing the mean concentration. + var lineFunc = d3.line(); + var draw_line = vis.append('g') + .attr('clip-path', 'url(#clip)'); + draw_line.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 = 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] = draw_line.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_line.append('svg:path') + .attr('fill', '#1f00b4') + .attr('fill-opacity', '0.1'); + }); + + var clip = vis.append("defs").append("svg:clipPath") + .attr("id", "clip") + .append("svg:rect") + .attr("x", 0) + .attr("y", 30); + + var brush = d3.brushY(); + + function update_concentration_plot(data, data_for_graphs) { + yRange.domain([0., Math.max(...data)]); + yAxisEl.transition().duration(1000).call(yAxis); + + lineFunc.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yRange(d.concentration)); + draw_line.select('.line') + .enter() + .merge(draw_line.select('.line')) + .transition() + .duration(1000) + .attr("d", lineFunc(data_for_graphs)); + + // 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.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.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + + brush.on("end", updateChart); // Each time the brush selection changes, trigger the 'updateChart' function + + // Brushing + draw_line.append("svg:g") + .attr("class", "brush") + .call(brush); + + // A function that set idleTimeOut to null + var idleTimeout + function idled() { idleTimeout = null; } + + // A function that updates the chart for given boundaries + function updateChart(event,d) { + // What are the selected boundaries? + extent = event.selection + + // If no selection, back to initial coordinate. Otherwise, update Y axis domain + if(!extent) { + if (!idleTimeout) return idleTimeout = setTimeout(idled, 350); // This allows to wait a little bit + } + else { + yRange.domain([ yRange.invert(extent[1]), yRange.invert(extent[0]) ]) + draw_line.select(".brush").call(brush.move, null) // This remove the grey brush area as soon as the selection has been done + } + + // Update axis and line position + yAxisEl.transition().duration(1000).call(d3.axisLeft(yRange)) + lineFunc.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yRange(d.concentration)); + draw_line + .select('.line') + .transition() + .duration(1000) + .attr("d", lineFunc(data_for_graphs)); + + // 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.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.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + } + } + + function redraw() { + + // Define width and height according to the screen size. + var div_width = plot_div.clientWidth; + var div_height = plot_div.clientHeight; + graph_width = div_width; + graph_height = div_height + if (div_width >= 900) { // For screens with width > 900px legend can be on the graph's right side. + 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'}; + Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); + } + else { + var margins = { top: 30, right: 20, bottom: 50, left: 40 }; + 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'}; + Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); + }; + + // Use the extracted size to set the size of the SVG element. + vis.attr("width", div_width) + .attr('height', div_height); + + // SVG components according to the width and height. + + // clipPath: everything out of this area won't be drawn. + clip.attr("width", graph_width - margins.right) + .attr("height", graph_height - margins.top - margins.bottom); + + // Add brushing + brush.extent([[margins.left, margins.top],[graph_width - margins.right, graph_height - 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]); + + // Axis. + var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)); + yAxis.scale(yRange); + + 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)'); + 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 + ')'); + + yAxisCumEl.attr('transform', 'translate(' + (graph_width - margins.right) + ',0)').call(yCumulativeAxis); + yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') + .attr('x', (graph_height + margins.bottom) / 2); + + if (plot_div.clientWidth >= 900) { + yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') + .attr('x', (graph_height + margins.bottom) / 2) + .attr('y', 1.71 * graph_width); + } + else { + yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') + .attr('x', (graph_height + margins.bottom * 0.55) / 2) + .attr('y', graph_width + 290); + } + + // Legend on right side. + const size = 20; + 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) + .attr('y', margins.top + size); + + 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 + size * 2.5) + .attr('y', margins.top + 2.6 * size); + legendAreaText.attr('x', graph_width + 4 * size) + .attr('y', margins.top + 3 * size); + + legendShortRangeAreaIcon.attr('x', graph_width + size * 2.5) + .attr('y', margins.top + 3.6 * size); + legendShortRangeText.attr('x', graph_width + 4 * size) + .attr('y', margins.top + 4 * size); + + 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); + + 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); + + legendAreaIcon.attr('x', size * 0.50) + .attr('y', graph_height * 1.09 + size); + legendAreaText.attr('x', 2 * size) + .attr('y', graph_height + 2.6 * size); + + legendShortRangeAreaIcon.attr('x', size * 0.50) + .attr('y', graph_height * 1.175 + size); + legendShortRangeText.attr('x', 2 * size) + .attr('y', graph_height + 3.65 * size); + + legendBBox.attr('x', 1) + .attr('y', graph_height); + } + + // 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)); + } + + document.getElementById("button_full_exposure").addEventListener("click", () => { + update_concentration_plot(short_range_concentrations, data_for_graphs.short_range_concentrations); + }); + document.getElementById("button_long_exposure").addEventListener("click", () => { + update_concentration_plot(concentrations, data_for_graphs.concentrations); + }); + + // If user double click, reinitialize the chart + vis.on("dblclick",function(){ + yRange.domain([0., Math.max(...short_range_concentrations)]) + yAxisEl.transition().call(d3.axisLeft(yRange)) + lineFunc.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yRange(d.concentration)); + draw_line + .select('.line') + .transition() + .attr("d", lineFunc(data_for_graphs.short_range_concentrations)); + 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.short_range_concentrations.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + 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.short_range_concentrations.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + }); + + // Draw for the first time to initialize. + redraw(); + update_concentration_plot(short_range_concentrations, data_for_graphs.short_range_concentrations); + + // Redraw based on the new size whenever the browser window is resized. + window.addEventListener("resize", redraw); + +} + + // 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 diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 88c8c492..171e5f88 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -377,7 +377,7 @@
      - +

      0 short range interactions.

      @@ -399,7 +399,7 @@
      diff --git a/cara/apps/templates/base/calculator.report.html.j2 b/cara/apps/templates/base/calculator.report.html.j2 index 7278aab5..0e43d1cc 100644 --- a/cara/apps/templates/base/calculator.report.html.j2 +++ b/cara/apps/templates/base/calculator.report.html.j2 @@ -89,15 +89,18 @@ {% endblock report_summary_footnote %}

      * The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.

      - + + +

      From 7926f55872fe2f856c799ab8aef328b06659ff3b Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 3 Mar 2022 15:46:14 +0100 Subject: [PATCH 29/85] Used cached method to calculate background concentration --- cara/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/models.py b/cara/models.py index 2032aff2..7addbaa8 100644 --- a/cara/models.py +++ b/cara/models.py @@ -1032,7 +1032,7 @@ class ConcentrationModel: 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 From 0e0a5488f54d7b1dcf2b620d9312bf5b4b16a1ab Mon Sep 17 00:00:00 2001 From: Andre Henriques Date: Wed, 9 Mar 2022 13:51:10 +0100 Subject: [PATCH 30/85] Add new 'Large meeting' activity --- cara/apps/calculator/model_generator.py | 12 +++++++++--- cara/apps/templates/base/calculator.form.html.j2 | 6 ++++-- cara/apps/templates/base/calculator.report.html.j2 | 6 ++++-- cara/apps/templates/base/userguide.html.j2 | 3 ++- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 595094bc..cccce020 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -387,11 +387,16 @@ class FormData: # Nightshift control room, 10% speaking. {'Speaking': 1, 'Breathing': 9} ), - 'meeting': ( + 'smallmeeting': ( 'Seated', # Conversation of N people is approximately 1/N% of the time speaking. {'Speaking': 1, 'Breathing': self.total_people - 1} ), + 'largemeeting': ( + 'Standing', + # each infected person spends 1/3 of time speaking. + {'Speaking': 1, 'Breathing': 2} + ), 'callcentre': ('Seated', 'Speaking'), 'library': ('Seated', 'Breathing'), 'training': ('Standing', 'Speaking'), @@ -428,7 +433,8 @@ class FormData: 'office': 'Seated', 'controlroom-day': 'Seated', 'controlroom-night': 'Seated', - 'meeting': 'Seated', + 'smallmeeting': 'Seated', + 'largemeeting': 'Seated', 'callcentre': 'Seated', 'library': 'Seated', 'training': 'Seated', @@ -695,7 +701,7 @@ def baseline_raw_form_data(): } -ACTIVITY_TYPES = {'office', 'meeting', 'training', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym'} +ACTIVITY_TYPES = {'office', 'smallmeeting', 'largemeeting', 'training', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym'} MECHANICAL_VENTILATION_TYPES = {'mech_type_air_changes', 'mech_type_air_supply', 'not-applicable'} MASK_TYPES = {'Type I', 'FFP2'} MASK_WEARING_OPTIONS = {'mask_on', 'mask_off'} diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 81cdae06..03986daa 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -317,7 +317,8 @@
      + + {% endif %}

      diff --git a/cara/models.py b/cara/models.py index 9f1ac43d..8cedd029 100644 --- a/cara/models.py +++ b/cara/models.py @@ -1212,6 +1212,38 @@ class ExposureModel: return (self.concentration_model.concentration(time) + self.short_range.short_range_concentration(self.concentration_model, time)) + 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() + f_inf = self.concentration_model.infected.fraction_of_infectious_virus() + fdep = self.long_range_fraction_deposited() + + diameter = self.concentration_model.infected.particle.diameter + + if not np.isscalar(diameter) and diameter is not None: + # 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). + 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._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. 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 @@ -1257,36 +1289,11 @@ class ExposureModel: # then we multiply by the diameter-independent quantity virus viral load deposited_exposure *= self.concentration_model.virus.viral_load_in_sputum - - # Long range concentration - emission_rate_per_aerosol = self.concentration_model.infected.emission_rate_per_aerosol_when_present() + # long range concentration f_inf = self.concentration_model.infected.fraction_of_infectious_virus() - aerosols = self.concentration_model.infected.aerosols() - fdep = self.long_range_fraction_deposited() + deposited_exposure += self.long_range_deposited_exposure_between_bounds(time1, time2)/f_inf - diameter = self.concentration_model.infected.particle.diameter - - if not np.isscalar(diameter) and diameter is not None: - # 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). - 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._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. 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 f_inf * deposited_exposure + return deposited_exposure * f_inf def deposited_exposure(self) -> _VectorisedFloat: """ From 0fafecfcf4581657ced1ce80f96d0da1e6bd5338 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 18 Mar 2022 14:47:12 +0000 Subject: [PATCH 54/85] Changed calculator warnings --- cara/apps/calculator/static/js/form.js | 7 ++++--- cara/apps/templates/base/calculator.form.html.j2 | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 675cad6e..5cc27cee 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -408,9 +408,10 @@ function validate_form(form) { var short_range_interactions = []; $(".form_field_outer_row").each(function (index, element){ let obj = {}; - obj.activity = $(element).find("[name='short_range_activity']").val(); - obj.start_time = $(element).find("[name='short_range_start_time']").val(); - obj.duration = $(element).find("[name='short_range_duration']").val(); + const $element = $(element); + obj.activity = $element.find("[name='short_range_activity']").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)); }); diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 689c7260..0d79ab70 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -268,7 +268,7 @@
      -

      Face mask selection is disabled due to short range interaction modeling.

      +

      The analytical model with short range interactions does not take mask wearing into account.

      @@ -125,11 +125,15 @@
      + {% if form.short_range_option == "short_range_yes" %} + {% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %} + + + {% endif %} + {% endif %}

      {% block report_scenarios_summary_table %} From 2165c71bf5e7e5b745165a0f0cd9da6e65ca6698 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 22 Mar 2022 14:52:50 +0100 Subject: [PATCH 58/85] Changed short range distance distribution to lognormal --- cara/apps/calculator/model_generator.py | 2 +- cara/monte_carlo/data.py | 8 ++++++-- cara/tests/models/test_short_range_model.py | 19 ++++++++----------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 4b8c0be0..ee76f162 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -250,7 +250,7 @@ class FormData: sr_presence=self.short_range_intervals() sr_activities=self.short_range_activities() short_range_expirations = tuple(short_range_expiration_distributions[activity] for activity in sr_activities) - dilutions=dilution_factor(activities=sr_activities, distance=np.random.uniform(0.5, 1.5, 250000)) + dilutions=dilution_factor(activities=sr_activities) else: sr_presence=() short_range_expirations=() diff --git a/cara/monte_carlo/data.py b/cara/monte_carlo/data.py index e4c99552..98095389 100644 --- a/cara/monte_carlo/data.py +++ b/cara/monte_carlo/data.py @@ -5,7 +5,7 @@ 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) @@ -182,7 +182,11 @@ def expiration_distribution( ) -def dilution_factor(activities, distance, D=0.02): +def dilution_factor(activities): + D = 0.02 + # From https://www.mdpi.com/1660-4601/17/4/1445/htm + distance = LogNormal(0.8542127255693238, 0.42755967248106513).generate_samples(250_000) + factors = [] for activity in activities: u0 = 0.98 if activity == "Breathing" else 3.9 diff --git a/cara/tests/models/test_short_range_model.py b/cara/tests/models/test_short_range_model.py index 3e40cc56..bc1546da 100644 --- a/cara/tests/models/test_short_range_model.py +++ b/cara/tests/models/test_short_range_model.py @@ -47,10 +47,7 @@ def expirations(presences) -> typing.Tuple[mc_models.Expiration, ...]: @pytest.fixture def dilutions(presences): - return dilution_factor( - activities=[activity for (activity, presence) in presences], - distance=np.random.uniform(0.5, 1.5, 250_000), - ) + return dilution_factor(activities=[activity for (activity, presence) in presences]) def test_short_range_model_ndarray(concentration_model, presences, expirations, dilutions): @@ -64,9 +61,9 @@ def test_short_range_model_ndarray(concentration_model, presences, expirations, @pytest.mark.parametrize( "start, stop, expected_exposure", [ - [8.5, 12.5, 5.844666077067048e-09], - [10.5, 11.0, 5.830120846251791e-09], - [10.6, 11.9, 4.6397748633454945e-09], + [8.5, 12.5, 5.963061627547172e-10], + [10.5, 11.0, 5.934552264225482e-10], + [10.6, 11.9, 4.709684623109963e-10], ] ) def test_normed_exposure_between_bounds( @@ -76,15 +73,15 @@ def test_normed_exposure_between_bounds( model = mc_models.ShortRangeModel(presences, expirations, dilutions) model = model.build_model(250_000) np.testing.assert_almost_equal( - model.normed_exposure_between_bounds(concentration_model, start, stop).mean(), expected_exposure + model.normed_exposure_between_bounds(concentration_model, start, stop).mean(), expected_exposure, decimal=10 ) @pytest.mark.parametrize( "time, expected_sr_normed_concentration, expected_concentration", [ - [10.75, 1.1670056689678455e-08, 11.67005668967846], - # [14.75, 3.6414877020308386e-06, 3641.4877020308395], - # [16.75, 1.973757599365769e-05, 19737.57599365769], + [10.75, 1.1670056689678455e-08, 1.1731466540816526], + # [14.75, 3.6414877020308386e-06, 474.36376505412574], + # [16.75, 1.973757599365769e-05, 2850.6219623225024], ] ) def test_short_range_model( From 152e5a2106aced3ea2b99052ea9dec017e8c03e0 Mon Sep 17 00:00:00 2001 From: Andre Henriques Date: Wed, 23 Mar 2022 11:28:12 +0100 Subject: [PATCH 59/85] changes to README --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 02b95b78..57179d2b 100644 --- a/README.md +++ b/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,6 +44,8 @@ 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 From 4ebbe4028faffa7339366bdf46bc5533eacc520b Mon Sep 17 00:00:00 2001 From: Andre Henriques Date: Wed, 23 Mar 2022 11:40:51 +0100 Subject: [PATCH 60/85] Change documentation with inclusion of short-range --- cara/apps/templates/about.html.j2 | 3 ++- cara/apps/templates/base/calculator.form.html.j2 | 8 ++++---- cara/apps/templates/base/calculator.report.html.j2 | 6 +++--- cara/apps/templates/base/userguide.html.j2 | 6 +++--- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/cara/apps/templates/about.html.j2 b/cara/apps/templates/about.html.j2 index 50d2a8bf..47e29072 100644 --- a/cara/apps/templates/about.html.j2 +++ b/cara/apps/templates/about.html.j2 @@ -17,8 +17,9 @@ CARA stands for COVID Airborne Risk Assessment and was developed in the spring o
    • CARA expert app
    -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.
    +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.

    The methodology, mathematical equations and parameters of the model are published here in the CARA paper: Modelling airborne transmission of SARS-CoV-2 using CARA: risk assessment for enclosed spaces.

    +

    Note that the short-range component of the model has not yet been published.

    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. diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 0d79ab70..6fd11ed5 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -622,12 +622,12 @@ 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 airborne transmission 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 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.

    - 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.

    diff --git a/cara/apps/templates/base/calculator.report.html.j2 b/cara/apps/templates/base/calculator.report.html.j2 index 097275bc..8cc383fd 100644 --- a/cara/apps/templates/base/calculator.report.html.j2 +++ b/cara/apps/templates/base/calculator.report.html.j2 @@ -447,12 +447,12 @@ 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 airborne transmission 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. + 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 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 adequate physical distancing, 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.

    diff --git a/cara/apps/templates/base/userguide.html.j2 b/cara/apps/templates/base/userguide.html.j2 index 9de64992..6072b2a0 100644 --- a/cara/apps/templates/base/userguide.html.j2 +++ b/cara/apps/templates/base/userguide.html.j2 @@ -27,12 +27,12 @@ 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 airborne transmission 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. + 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 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 adequate physical distancing, 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.

    From d06c9b3a4da6caae3cbfed4fe82d1c64e0d75499 Mon Sep 17 00:00:00 2001 From: Andre Henriques Date: Wed, 23 Mar 2022 17:28:02 +0100 Subject: [PATCH 61/85] update report with new publication --- cara/apps/templates/base/calculator.report.html.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/apps/templates/base/calculator.report.html.j2 b/cara/apps/templates/base/calculator.report.html.j2 index 5afae2cf..db6f2436 100644 --- a/cara/apps/templates/base/calculator.report.html.j2 +++ b/cara/apps/templates/base/calculator.report.html.j2 @@ -88,7 +88,7 @@ {% block report_summary_footnote %} {% endblock report_summary_footnote %}

    -

    * The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.

    +

    * The results are based on the parameters and assumptions published in the CARA publication: doi.org/10.1098/rsfs.2021.0076.


    -
    -
    Alternative scenarios - -
    -
    -
    -
    - {% if form.short_range_option == "short_range_yes" %} - {% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %} - - + + {% if form.short_range_option == "short_range_no" %} +
    +
    Alternative scenarios + +
    +
    +
    +
    + {% if form.short_range_option == "short_range_yes" %} + {% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %} + + + {% endif %} {% endif %} - {% endif %} -
    - -
    - {% block report_scenarios_summary_table %} - - - - - - - - - - {% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %} - - - - - - {% endfor %} - -
    ScenarioP(I)Expected new cases
    {{ scenario_name }} {{ scenario_stats.probability_of_infection | non_zero_percentage }}{{ scenario_stats.expected_new_cases | float_format }}
    - {% endblock report_scenarios_summary_table %} +
    + +
    + {% block report_scenarios_summary_table %} + + + + + + + + + + {% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %} + + + + + + {% endfor %} + +
    ScenarioP(I)Expected new cases
    {{ scenario_name }} {{ scenario_stats.probability_of_infection | non_zero_percentage }}{{ scenario_stats.expected_new_cases | float_format }}
    + {% endblock report_scenarios_summary_table %} +
    +
    +

    Notes for alternative scenarios:
    +

      +
    1. 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.
    2. +
    3. 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.
      + The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.
    4. +
    +
    +

    -
    -

    Notes for alternative scenarios:
    -

      -
    1. 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.
    2. -
    3. 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.
      - The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.
    4. -
    -
    -

    -
    + {% endif %} {% endblock report_results %} {% block report_footer %} From 9ef38329d101d85a8391abf0f9c9bd8ab168407c Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 24 Mar 2022 12:19:35 +0100 Subject: [PATCH 70/85] Added Disclaimer to common text --- cara/apps/calculator/__init__.py | 7 +++- .../templates/base/calculator.form.html.j2 | 35 +----------------- cara/apps/templates/base/userguide.html.j2 | 36 +----------------- cara/apps/templates/common_text.md.j2 | 37 +++++++++++++++++++ 4 files changed, 45 insertions(+), 70 deletions(-) diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index b9572896..17189304 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -140,6 +140,7 @@ class StaticModel(BaseRequestHandler): executor_factory=functools.partial( concurrent.futures.ThreadPoolExecutor, self.settings['report_generation_parallelism'], + self.settings["template_environment"].globals['common_text'], ), ) report: str = await asyncio.wrap_future(report_task) @@ -154,7 +155,7 @@ class LandingPage(BaseRequestHandler): report = template.render( user=self.current_user, calculator_prefix=self.settings["calculator_prefix"], - text_blocks=template_environment.globals['common_text'] + text_blocks=template_environment.globals['common_text'], ) self.finish(report) @@ -174,6 +175,7 @@ class AboutPage(BaseRequestHandler): class CalculatorForm(BaseRequestHandler): def get(self): + template_environment = self.settings["template_environment"] template = self.settings["template_environment"].get_template( "calculator.form.html.j2") report = template.render( @@ -181,6 +183,7 @@ class CalculatorForm(BaseRequestHandler): xsrf_form_html=self.xsrf_form_html(), calculator_prefix=self.settings["calculator_prefix"], calculator_version=__version__, + text_blocks=template_environment.globals['common_text'], ) self.finish(report) @@ -199,11 +202,13 @@ class CompressedCalculatorFormInputs(BaseRequestHandler): class ReadmeHandler(BaseRequestHandler): def get(self): + template_environment = self.settings["template_environment"] template = self.settings['template_environment'].get_template("userguide.html.j2") readme = template.render( active_page="calculator/user-guide", user=self.current_user, calculator_prefix=self.settings["calculator_prefix"], + text_blocks=template_environment.globals['common_text'], ) self.finish(readme) diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 03986daa..a9403ec1 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -574,40 +574,7 @@
    -
    -

    - 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 virions in enclosed spaces 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 airborne transmission 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 model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021. - 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. - Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event. - The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and - the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings. -

    -

    - This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. - The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. - While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist. - Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions. -

    -

    - CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered - as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled. -

    + {# {{ text_blocks['Disclaimer'] }} #}
    diff --git a/cara/apps/templates/base/userguide.html.j2 b/cara/apps/templates/base/userguide.html.j2 index 8685fbc0..97cad39b 100644 --- a/cara/apps/templates/base/userguide.html.j2 +++ b/cara/apps/templates/base/userguide.html.j2 @@ -17,41 +17,7 @@
    -
    -

    - 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. - 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 airborne transmission 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 model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021. - 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. - Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event. - The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and - the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings. -

    -

    - This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. - The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. - While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist. - Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions. -

    -

    - CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered - as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled. -

    -
    + {{ text_blocks['Disclaimer'] }}
    diff --git a/cara/apps/templates/common_text.md.j2 b/cara/apps/templates/common_text.md.j2 index 5eebb32d..5bb03ae1 100644 --- a/cara/apps/templates/common_text.md.j2 +++ b/cara/apps/templates/common_text.md.j2 @@ -22,6 +22,43 @@ We wish to thank CERN’s HSE Unit, Beams Department, Experimental Physics Department, Information Technology Department, Industry, Procurement and Knowledge Transfer Department and International Relations Sector for their support to the study. Thanks to Doris Forkel-Wirth, Benoit Delille, Walid Fadel, Olga Beltramello, Letizia Di Giulio, Evelyne Dho, Wayne Salter, Benoit Salvant and colleagues from the COVID working group for providing expert advice and extensively testing the model. Finally, we wish to thank Fabienne Landua and the design service for preparing the illustrations and Alessandro Raimondo and Manuela Cirilli from the Knowledge Transfer Group for their continuous support. Our compliments towards the work and research performed by world leading scientists in this domain: Dr. Julian Tang, Prof. Manuel Gameiro, Dr. Linsey Marr, Prof. Lidia Morawska, Prof. Yuguo Li, and others – their scientific contribution was indispensable for this project. +## Disclaimer +
    +

    + 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 virions in enclosed spaces 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 airborne transmission 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 model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021. + 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. + Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event. + The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and + the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings. +

    +

    + This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. + The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. + While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist. + Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions. +

    +

    + CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered + as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled. +

    +
    + ## References Reference list can be found in the CARA paper: CERN-OPEN-2021-004 From dc1020c15938edd0eab749b9bda86884e8be1f69 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 24 Mar 2022 14:54:12 +0100 Subject: [PATCH 71/85] Added disclaimer from common_text in report page --- cara/apps/calculator/__init__.py | 7 +- cara/apps/calculator/report_generator.py | 6 +- .../templates/base/calculator.form.html.j2 | 2 +- .../templates/base/calculator.report.html.j2 | 38 +---------- cara/apps/templates/base/userguide.html.j2 | 2 +- cara/apps/templates/common_text.md.j2 | 68 +++++++++---------- 6 files changed, 45 insertions(+), 78 deletions(-) diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index 17189304..e396ea8c 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -140,7 +140,6 @@ class StaticModel(BaseRequestHandler): executor_factory=functools.partial( concurrent.futures.ThreadPoolExecutor, self.settings['report_generation_parallelism'], - self.settings["template_environment"].globals['common_text'], ), ) report: str = await asyncio.wrap_future(report_task) @@ -150,7 +149,7 @@ class StaticModel(BaseRequestHandler): class LandingPage(BaseRequestHandler): def get(self): template_environment = self.settings["template_environment"] - template = self.settings["template_environment"].get_template( + template = template_environment.get_template( "index.html.j2") report = template.render( user=self.current_user, @@ -176,7 +175,7 @@ class AboutPage(BaseRequestHandler): class CalculatorForm(BaseRequestHandler): def get(self): template_environment = self.settings["template_environment"] - template = self.settings["template_environment"].get_template( + template = template_environment.get_template( "calculator.form.html.j2") report = template.render( user=self.current_user, @@ -203,7 +202,7 @@ class CompressedCalculatorFormInputs(BaseRequestHandler): class ReadmeHandler(BaseRequestHandler): def get(self): template_environment = self.settings["template_environment"] - template = self.settings['template_environment'].get_template("userguide.html.j2") + template = template_environment.get_template("userguide.html.j2") readme = template.render( active_page="calculator/user-guide", user=self.current_user, diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 1bbf967d..c06b63ee 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -12,6 +12,7 @@ import jinja2 import numpy as np from cara import models +from cara.apps.calculator import markdown_tools from ... import monte_carlo as mc from .model_generator import FormData, _DEFAULT_MC_SAMPLE_SIZE from ... import dataclass_utils @@ -315,6 +316,9 @@ class ReportGenerator: loader=self.jinja_loader, undefined=jinja2.StrictUndefined, ) + env.globals['common_text'] = markdown_tools.extract_rendered_markdown_blocks( + env.get_template('common_text.md.j2') + ) env.filters['non_zero_percentage'] = non_zero_percentage env.filters['readable_minutes'] = readable_minutes env.filters['minutes_to_time'] = minutes_to_time @@ -325,4 +329,4 @@ class ReportGenerator: def render(self, context: dict) -> str: template = self._template_environment().get_template("calculator.report.html.j2") - return template.render(**context) + return template.render(**context, text_blocks=template.globals['common_text']) diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index a9403ec1..84b3e987 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -574,7 +574,7 @@
    - {# {{ text_blocks['Disclaimer'] }} #} +
    {{ text_blocks['Disclaimer'] }}
    diff --git a/cara/apps/templates/base/calculator.report.html.j2 b/cara/apps/templates/base/calculator.report.html.j2 index db6f2436..52d8ad6c 100644 --- a/cara/apps/templates/base/calculator.report.html.j2 +++ b/cara/apps/templates/base/calculator.report.html.j2 @@ -410,42 +410,8 @@


    {% block disclaimer %} -

    Disclaimer:

    - -

    - 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 infectious viruses in enclosed spaces 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 airborne transmission 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 model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021. - 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. - Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event. - The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and - the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings. -

    -

    - This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. - The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. - While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist. - Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions. -

    -

    - CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered - as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled. -

    - +

    Disclaimer:

    + {{ text_blocks['Disclaimer'] }} {% endblock disclaimer %}
    {% endblock disclaimer_container %} diff --git a/cara/apps/templates/base/userguide.html.j2 b/cara/apps/templates/base/userguide.html.j2 index 97cad39b..893637c2 100644 --- a/cara/apps/templates/base/userguide.html.j2 +++ b/cara/apps/templates/base/userguide.html.j2 @@ -17,7 +17,7 @@
    - {{ text_blocks['Disclaimer'] }} +
    {{ text_blocks['Disclaimer'] }}
    diff --git a/cara/apps/templates/common_text.md.j2 b/cara/apps/templates/common_text.md.j2 index 5bb03ae1..795c07b5 100644 --- a/cara/apps/templates/common_text.md.j2 +++ b/cara/apps/templates/common_text.md.j2 @@ -23,41 +23,39 @@ We wish to thank CERN’s HSE Unit, Beams Department, Experimental Physics Department, Information Technology Department, Industry, Procurement and Knowledge Transfer Department and International Relations Sector for their support to the study. Thanks to Doris Forkel-Wirth, Benoit Delille, Walid Fadel, Olga Beltramello, Letizia Di Giulio, Evelyne Dho, Wayne Salter, Benoit Salvant and colleagues from the COVID working group for providing expert advice and extensively testing the model. Finally, we wish to thank Fabienne Landua and the design service for preparing the illustrations and Alessandro Raimondo and Manuela Cirilli from the Knowledge Transfer Group for their continuous support. Our compliments towards the work and research performed by world leading scientists in this domain: Dr. Julian Tang, Prof. Manuel Gameiro, Dr. Linsey Marr, Prof. Lidia Morawska, Prof. Yuguo Li, and others – their scientific contribution was indispensable for this project. ## Disclaimer -
    -

    - 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 virions in enclosed spaces 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 airborne transmission 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 model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021. - 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. - Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event. - The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and - the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings. -

    -

    - This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. - The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. - While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist. - Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions. -

    -

    - CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered - as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled. -

    -
    +

    + 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 virions in enclosed spaces 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 airborne transmission 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 model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021. + 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. + Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event. + The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and + the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings. +

    +

    + This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities. + The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk. + While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist. + Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions. +

    +

    + CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered + as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled. +

    ## References From 29f5e9cc0fc8ecaf3e7a370eb2e7aa6a291a6c57 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 25 Mar 2022 15:28:30 +0100 Subject: [PATCH 72/85] Fixed bug on legend with firefox --- cara/apps/calculator/static/js/report.js | 42 +++++++++++------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js index a4fa7b46..a7c59bcc 100644 --- a/cara/apps/calculator/static/js/report.js +++ b/cara/apps/calculator/static/js/report.js @@ -78,8 +78,7 @@ function draw_plot(svg_id) { // Concentration line text var legendLineText = vis.append('text') .text('Mean concentration') - .style('font-size', '15px') - .attr('alignment-baseline', 'central'); + .style('font-size', '15px'); // Cumulative dose line icon var legendCumulativeIcon = vis.append('line') @@ -89,8 +88,7 @@ function draw_plot(svg_id) { // 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') @@ -101,8 +99,7 @@ function draw_plot(svg_id) { // 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_activities)] if (show_sr_legend) { @@ -115,7 +112,6 @@ function draw_plot(svg_id) { var legendLongCumutiveText = vis.append('text') .text('Long-range cumulative dose') .style('font-size', '15px') - .attr('alignment-baseline', 'central') .attr('opacity', 0); // Short range area icon var legendShortRangeAreaIcon = {}; @@ -133,8 +129,7 @@ function draw_plot(svg_id) { sr_unique_activities.forEach((b, index) => { legendShortRangeText[index] = vis.append('text') .text('Short-range - ' + sr_unique_activities[index]) - .style('font-size', '15px') - .attr('alignment-baseline', 'central'); + .style('font-size', '15px'); }); } @@ -375,38 +370,39 @@ function draw_plot(svg_id) { 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 + 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); + .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); + .attr('y', margins.top + 2 * size + text_height); 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); + .attr('y', margins.top + 3 * size + text_height); 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); + .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); + .attr('y', margins.top + (4 + sr_unique_activities.length) * size + + text_height); } legendBBox.attr('x', graph_width * 1.07) @@ -419,33 +415,33 @@ function draw_plot(svg_id) { 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); + .attr('y', graph_height + size + text_height); 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); + .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); + .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); + .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); + .attr('y', graph_height + (4 + sr_unique_activities.length) * size + text_height); } legendBBox.attr('x', 1) @@ -646,8 +642,7 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ label_text[scenario_name] = vis.append('text') .text(scenario_name) - .style('font-size', '15px') - .attr('alignment-baseline', 'central'); + .style('font-size', '15px'); } @@ -749,6 +744,7 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ 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) // Legend on right side. @@ -757,7 +753,7 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ 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); + .attr('y', margins.top + size + text_height); } // Legend on the bottom. else { @@ -766,7 +762,7 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ 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); + .attr('y', graph_height + size + text_height); } } From 5720f0340acd45e2608f3edba45fcb34bbe6491f Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 28 Mar 2022 15:41:55 +0200 Subject: [PATCH 73/85] Changed label on short range activity and added BR on dilution factor --- cara/apps/calculator/model_generator.py | 28 ++++---- cara/apps/calculator/static/js/form.js | 44 ++++++------ cara/apps/expert.py | 5 +- .../templates/base/calculator.form.html.j2 | 5 +- .../templates/base/calculator.report.html.j2 | 2 +- cara/models.py | 58 +++++++++++++--- cara/monte_carlo/data.py | 67 ++++++++++--------- 7 files changed, 126 insertions(+), 83 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index d8a7ce9b..d72952e6 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -13,7 +13,7 @@ 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, dilution_factor +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 @@ -245,29 +245,31 @@ class FormData: else: humidity = 0.5 room = models.Room(volume=volume, humidity=humidity) + + infected_population = self.infected_population() if self.short_range_option == "short_range_yes": + short_range_expirations = tuple(short_range_expiration_distributions[interaction['expiration']] for interaction in self.short_range_interactions) + short_range_activities = tuple([infected_population.activity for _ in self.short_range_interactions]) sr_presence=self.short_range_intervals() - sr_activities=self.short_range_activities() - short_range_expirations = tuple(short_range_expiration_distributions[activity] for activity in sr_activities) - dilutions=dilution_factor(activities=sr_activities) else: - sr_presence=() short_range_expirations=() - dilutions=() - + short_range_activities=() + sr_presence=() + # 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 = mc.ShortRangeModel( - presence=sr_presence, expirations=short_range_expirations, - dilutions=dilutions, + activities=short_range_activities, + presence=sr_presence, + distances=short_range_distances, ), exposed=self.exposed_population(), ) @@ -655,7 +657,7 @@ class FormData: for interaction in self.short_range_interactions: start_time = time_string_to_minutes(interaction['start_time']) duration = float(interaction['duration']) - short_range_intervals.append((interaction['activity'], models.SpecificInterval((start_time/60, (start_time + duration)/60)))) + short_range_intervals.append((interaction['expiration'], models.SpecificInterval((start_time/60, (start_time + duration)/60)))) return tuple(short_range_intervals) @@ -665,10 +667,6 @@ class FormData: breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times(), ) - def short_range_activities(self) -> typing.List[str]: - return ([interaction['activity'] for interaction in self.short_range_interactions] - if self.short_range_interactions else []) - def build_expiration(expiration_definition) -> mc._ExpirationBase: if isinstance(expiration_definition, str): diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index dcaf0662..545f6f99 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -408,7 +408,7 @@ function validate_form(form) { $(".form_field_outer_row").each(function (index, element){ let obj = {}; const $element = $(element); - obj.activity = $element.find("[name='short_range_activity']").val(); + 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)); @@ -656,8 +656,8 @@ $(document).ready(function () { let index = 1; for (const interaction of JSON.parse(value)) { $("#dialog_sr").append(inject_sr_interaction(index, value = interaction, is_validated="row_validated")) - $('#sr_activity_no_' + String(index)).val(interaction.activity).change(); - document.getElementById('sr_activity_no_' + String(index)).disabled = true; + $('#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'; @@ -838,35 +838,35 @@ $(document).ready(function () { function inject_sr_interaction(index, value, is_validated) { return `
    -
    -
    +
    +
    -

    -
    -
    +
    -
    -
    -

    -
    -
    -
    -
    -
    -

    + +
    +
    +

    -
    + +
    +
    +

    +
    + +
    -
    ` } @@ -889,12 +889,12 @@ $(document).ready(function () { // 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_activity_no_' + String(index)[0], "Required input."); + 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_start_no_' + String(index))) { - document.getElementById('sr_activity_no_' + String(index)).disabled = true; + 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'; @@ -915,7 +915,7 @@ $(document).ready(function () { $("body").on("click", ".edit_node_btn_frm_field", function() { $(this).hide(); let id = $(this).attr('id').split('_').slice(-1)[0]; - document.getElementById('sr_activity_no_' + String(id)).disabled = false; + 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'; diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 208b3568..9fab2cd9 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -504,9 +504,10 @@ baseline_model = models.ExposureModel( evaporation_factor=0.3, ), short_range=models.ShortRangeModel( - presence=(), expirations=(), - dilutions=(), + activities=(), + presence=(), + distances=(), ), exposed=models.Population( number=10, diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 92d0d42f..499fc761 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -391,11 +391,12 @@
    +