diff --git a/README.md b/README.md index 02b95b78..90319c63 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,11 +44,13 @@ CARA – COVID Airborne Risk Assessment tool **For use of the model** Henriques A, Mounet N, Aleixo L, Elson P, Devine J, Azzopardi G, Andreini M, Rognlien M, Tarocco N, Tang J. (2022). Modelling airborne transmission of SARS-CoV-2 using CARA: risk assessment for enclosed spaces. _Interface Focus 20210076_. https://doi.org/10.1098/rsfs.2021.0076 +_Note that the short-range component of the model has not yet been published._ + ## Applications ### COVID Calculator -A risk assessment tool which simulates the long range airborne spread of the SARS-CoV-2 virus for space managers. +A risk assessment tool which simulates the long-range airborne spread of the SARS-CoV-2 virus for space managers. ### CARA Expert App diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index e396ea8c..c0c93887 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -33,7 +33,7 @@ from .user import AuthenticatedUser, AnonymousUser # calculator version. If the calculator needs to make breaking changes (e.g. change # form attributes) then it can also increase its MAJOR version without needing to # increase the overall CARA version (found at ``cara.__version__``). -__version__ = "4.0.0" +__version__ = "4.1.0" class BaseRequestHandler(RequestHandler): diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index cccce020..ab90fab8 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,8 +13,8 @@ from cara import data import cara.data.weather import cara.monte_carlo as mc from .. import calculator -from cara.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions -from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions +from cara.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions, short_range_distances +from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions, short_range_expiration_distributions LOG = logging.getLogger(__name__) @@ -76,6 +78,8 @@ class FormData: window_width: float windows_number: int window_opening_regime: str + short_range_option: str + short_range_interactions: list #: The default values for undefined fields. Note that the defaults here #: and the defaults in the html form must not be contradictory. @@ -127,6 +131,8 @@ class FormData: 'windows_frequency': 0., 'windows_number': 0, 'window_opening_regime': 'windows_open_permanently', + 'short_range_option': 'short_range_no', + 'short_range_interactions': '[]', } @classmethod @@ -240,14 +246,27 @@ class FormData: humidity = 0.5 room = models.Room(volume=volume, humidity=humidity) + infected_population = self.infected_population() + + short_range = [] + if self.short_range_option == "short_range_yes": + for interaction in self.short_range_interactions: + short_range.append(mc.ShortRangeModel( + expiration=short_range_expiration_distributions[interaction['expiration']], + activity=infected_population.activity, + presence=self.short_range_interval(interaction), + distance=short_range_distances, + )) + # Initializes and returns a model with the attributes defined above return mc.ExposureModel( concentration_model=mc.ConcentrationModel( room=room, ventilation=self.ventilation(), - infected=self.infected_population(), + infected=infected_population, evaporation_factor=0.3, ), + short_range = tuple(short_range), exposed=self.exposed_population(), ) @@ -629,6 +648,11 @@ class FormData: breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), ) + def short_range_interval(self, interaction) -> models.SpecificInterval: + start_time = time_string_to_minutes(interaction['start_time']) + duration = float(interaction['duration']) + return models.SpecificInterval(present_times=((start_time/60, (start_time + duration)/60),)) + def exposed_present_interval(self) -> models.Interval: return self.present_interval( self.exposed_start, self.exposed_finish, @@ -636,7 +660,7 @@ class FormData: ) -def build_expiration(expiration_definition) -> models._ExpirationBase: +def build_expiration(expiration_definition) -> mc._ExpirationBase: if isinstance(expiration_definition, str): return expiration_distributions[expiration_definition] elif isinstance(expiration_definition, dict): @@ -645,7 +669,7 @@ def build_expiration(expiration_definition) -> models._ExpirationBase: np.array(expiration_BLO_factors[exp_type]) * weight/total_weight for exp_type, weight in expiration_definition.items() ], axis=0) - return expiration_distribution(tuple(BLO_factors)) + return expiration_distribution(BLO_factors=tuple(BLO_factors)) def baseline_raw_form_data(): @@ -697,7 +721,9 @@ def baseline_raw_form_data(): 'window_type': 'window_sliding', 'window_width': '2', 'windows_number': '1', - 'window_opening_regime': 'windows_open_permanently' + 'window_opening_regime': 'windows_open_permanently', + 'short_range_option': 'short_range_no', + 'short_range_interactions': '[]', } @@ -742,6 +768,14 @@ def time_minutes_to_string(time: int) -> str: return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time%60) +def string_to_list(l: str) -> list: + return list(ast.literal_eval(l.replace(""", "\""))) + + +def list_to_string(s: list) -> str: + return json.dumps(s) + + def _safe_int_cast(value) -> int: if isinstance(value, int): return value @@ -773,3 +807,6 @@ for _field in dataclasses.fields(FormData): elif _field.type is bool: _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = lambda v: v == '1' _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = int + elif _field.type is list: + _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = string_to_list + _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = list_to_string diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index c06b63ee..b526d5bf 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -97,29 +97,54 @@ def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.L return nice_times -def calculate_report_data(model: models.ExposureModel): - times = interesting_times(model) +def concentrations_with_sr_breathing(form: FormData, model: models.ExposureModel, times: typing.List[float], short_range_intervals: typing.List) -> typing.List[float]: + lower_concentrations = [] + for time in times: + for index, (start, stop) in enumerate(short_range_intervals): + # For visualization issues, add short-range breathing activity to the initial long-range concentrations + if start <= time <= stop and form.short_range_interactions[index]['expiration'] == 'Breathing': + lower_concentrations.append(np.array(model.concentration(float(time))).mean()) + break + lower_concentrations.append(np.array(model.concentration_model.concentration(float(time))).mean()) + return lower_concentrations + +def calculate_report_data(form: FormData, model: models.ExposureModel): + times = interesting_times(model) + short_range_intervals = [interaction.presence.boundaries()[0] for interaction in model.short_range] + short_range_expirations = [interaction['expiration'] for interaction in form.short_range_interactions] if form.short_range_option == "short_range_yes" else [] + concentrations = [ - np.array(model.concentration_model.concentration(float(time))).mean() + np.array(model.concentration(float(time))).mean() for time in times - ] + ] + lower_concentrations = concentrations_with_sr_breathing(form, model, times, short_range_intervals) highest_const = max(concentrations) - prob = np.array(model.infection_probability()).mean() - er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean() - exposed_occupants = model.exposed.number - expected_new_cases = np.array(model.expected_new_cases()).mean() + cumulative_doses = np.cumsum([ np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean() for time1, time2 in zip(times[:-1], times[1:]) ]) + long_range_cumulative_doses = np.cumsum([ + np.array(model.long_range_deposited_exposure_between_bounds(float(time1), float(time2))).mean() + for time1, time2 in zip(times[:-1], times[1:]) + ]) + + prob = np.array(model.infection_probability()).mean() + er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean() + exposed_occupants = model.exposed.number + expected_new_cases = np.array(model.expected_new_cases()).mean() return { "times": list(times), "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], - "cumulative_doses": list(cumulative_doses), + "short_range_intervals": short_range_intervals, + "short_range_expirations": short_range_expirations, "concentrations": concentrations, + "concentrations_zoomed": lower_concentrations, "highest_const": highest_const, + "cumulative_doses": list(cumulative_doses), + "long_range_cumulative_doses": list(long_range_cumulative_doses), "prob_inf": prob, "emission_rate": er, "exposed_occupants": exposed_occupants, @@ -197,51 +222,52 @@ def non_zero_percentage(percentage: int) -> str: def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.ExposureModel]: scenarios = {} + if (form.short_range_option == "short_range_no"): + # Two special option cases - HEPA and/or FFP2 masks. + FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2') + if FFP2_being_worn and form.hepa_option: + FFP2andHEPAalternative = dataclass_utils.replace(form, mask_type='Type I') + scenarios['Base scenario with HEPA filter and Type I masks'] = FFP2andHEPAalternative.build_mc_model() + if not FFP2_being_worn and form.hepa_option: + noHEPAalternative = dataclass_utils.replace(form, mask_type = 'FFP2') + noHEPAalternative = dataclass_utils.replace(noHEPAalternative, mask_wearing_option = 'mask_on') + noHEPAalternative = dataclass_utils.replace(noHEPAalternative, hepa_option=False) + scenarios['Base scenario without HEPA filter, with FFP2 masks'] = noHEPAalternative.build_mc_model() - # Two special option cases - HEPA and/or FFP2 masks. - FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2') - if FFP2_being_worn and form.hepa_option: - FFP2andHEPAalternative = dataclass_utils.replace(form, mask_type='Type I') - scenarios['Base scenario with HEPA filter and Type I masks'] = FFP2andHEPAalternative.build_mc_model() - if not FFP2_being_worn and form.hepa_option: - noHEPAalternative = dataclass_utils.replace(form, mask_type = 'FFP2') - noHEPAalternative = dataclass_utils.replace(noHEPAalternative, mask_wearing_option = 'mask_on') - noHEPAalternative = dataclass_utils.replace(noHEPAalternative, hepa_option=False) - scenarios['Base scenario without HEPA filter, with FFP2 masks'] = noHEPAalternative.build_mc_model() + # The remaining scenarios are based on Type I masks (possibly not worn) + # and no HEPA filtration. + form = dataclass_utils.replace(form, mask_type='Type I') + if form.hepa_option: + form = dataclass_utils.replace(form, hepa_option=False) - # The remaining scenarios are based on Type I masks (possibly not worn) - # and no HEPA filtration. - form = dataclass_utils.replace(form, mask_type='Type I') - if form.hepa_option: - form = dataclass_utils.replace(form, hepa_option=False) + with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on') + without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off') - with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on') - without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off') + if form.ventilation_type == 'mechanical_ventilation': + #scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model() + scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model() - if form.ventilation_type == 'mechanical_ventilation': - #scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model() - scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model() + elif form.ventilation_type == 'natural_ventilation': + #scenarios['Windows open with Type I masks'] = with_mask.build_mc_model() + scenarios['Windows open without masks'] = without_mask.build_mc_model() - elif form.ventilation_type == 'natural_ventilation': - #scenarios['Windows open with Type I masks'] = with_mask.build_mc_model() - scenarios['Windows open without masks'] = without_mask.build_mc_model() - - # No matter the ventilation scheme, we include scenarios which don't have any ventilation. - with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation') - without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation') - scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model() - scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model() + # No matter the ventilation scheme, we include scenarios which don't have any ventilation. + with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation') + without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation') + scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model() + scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model() return scenarios -def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray): +def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[float]): model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE) + return { 'probability_of_infection': np.mean(model.infection_probability()), 'expected_new_cases': np.mean(model.expected_new_cases()), 'concentrations': [ - np.mean(model.concentration_model.concentration(time)) + np.mean(model.concentration(time)) for time in sample_times ], } @@ -301,7 +327,7 @@ class ReportGenerator: scenario_sample_times = interesting_times(model) - context.update(calculate_report_data(model)) + context.update(calculate_report_data(form, model)) alternative_scenarios = manufacture_alternative_scenarios(form) context['alternative_scenarios'] = comparison_report( alternative_scenarios, scenario_sample_times, executor_factory=executor_factory, diff --git a/cara/apps/calculator/static/css/report.css b/cara/apps/calculator/static/css/report.css index d2b096e3..3a5a5487 100644 --- a/cara/apps/calculator/static/css/report.css +++ b/cara/apps/calculator/static/css/report.css @@ -144,6 +144,15 @@ p.notes { padding: 15px; page-break-inside: avoid; } + #button_full_exposure, #button_hide_high_concentration { + display: none!important; + } + #long_range_cumulative_checkbox, #lr_cumulative_checkbox_label { + display: none!important; + } + #button_alternative_full_exposure, #button_alternative_hide_high_concentration { + display: none!important; + } } diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 5a20f025..fd4f3c73 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -242,6 +242,16 @@ function on_wearing_mask_change() { if (this.checked) { getChildElement($(this)).show(); require_fields(this); + if (this.id == "mask_on") { + $('#short_range_no').click(); + $('input[name="short_range_option"]').attr('disabled', true); + $("#short_range_warning").show(); + } + else { + $('input[name="short_range_option"]').attr('disabled', false); + $("#short_range_warning").hide(); + } + } else { getChildElement($(this)).hide(); @@ -250,6 +260,20 @@ function on_wearing_mask_change() { }) } +function on_short_range_option_change() { + short_range = $('input[type=radio][name=short_range_option]') + short_range.each(function (index){ + if (this.checked) { + getChildElement($(this)).show(); + require_fields(this); + } + else { + getChildElement($(this)).hide(); + require_fields(this); + } + }) +} + /* -------UI------- */ function show_disclaimer() { @@ -379,6 +403,27 @@ function validate_form(form) { } } + // Generate the short-range interactions list + var short_range_interactions = []; + $(".form_field_outer_row").each(function (index, element){ + let obj = {}; + const $element = $(element); + obj.expiration = $element.find("[name='short_range_expiration']").val(); + obj.start_time = $element.find("[name='short_range_start_time']").val(); + obj.duration = $element.find("[name='short_range_duration']").val(); + short_range_interactions.push(JSON.stringify(obj)); + }); + + // Sort list by time + short_range_interactions.sort(function (a, b) { + return JSON.parse(a).start_time.localeCompare(JSON.parse(b).start_time); + }); + $("input[type=text][name=short_range_interactions]").val('[' + short_range_interactions + ']'); + if (short_range_interactions.length == 0) { + $("input[type=radio][id=short_range_no]").prop("checked", true); + on_short_range_option_change(); + } + if (submit) { $("#generate_report").prop("disabled", true); //Add spinner to button @@ -492,6 +537,84 @@ function validateLunchTime(obj) { return true; } +function overlapped_times(obj, start_time, finish_time) { + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + + let parameter = document.getElementById($(obj).attr('id')); + + if ($(obj).attr('name') == "short_range_duration" && parseFloat($(obj).val()) < 15.0) { + if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message. + insertErrorFor(parameter, "Must be ≥ 15 min.") + return false; + } + + let simulation_start = parseTimeToMins($("#exposed_start").val()) + let simulation_finish = parseTimeToMins($("#exposed_finish").val()) + var simulation_lunch_start, simulation_lunch_finish; + if ($('input[name=exposed_lunch_option]:checked').val() == 1) { + simulation_lunch_start = parseTimeToMins($("#exposed_lunch_start").val()) + simulation_lunch_finish = parseTimeToMins($("#exposed_lunch_finish").val()) + } else { + simulation_lunch_start = 0 + simulation_lunch_finish = 0 + } + if (start_time < simulation_start || start_time > simulation_finish || + finish_time < simulation_start || finish_time > simulation_finish || + start_time >= simulation_lunch_start && start_time <= simulation_lunch_finish || + finish_time >= simulation_lunch_start && finish_time <= simulation_lunch_finish ) {//If start and finish inputs are out of the simulation period + //Adds the red border and error message. + if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); + insertErrorFor(parameter, "Out of event time."); + return false; + } + let current_interaction = $(obj).closest(".form_field_outer_row"); + var toReturn = true; + $(".form_field_outer_row.row_validated").not(current_interaction).each(function(index, el) { + let current_start_el = $(el).find("input[name='short_range_start_time']"); + let current_duration_el = $(el).find("input[name='short_range_duration']") + start_time_2 = parseTimeToMins(current_start_el.val()) + finish_time_2 = parseTimeToMins(current_start_el.val()) + parseInt(current_duration_el.val()); + if ((start_time > start_time_2 && start_time < finish_time_2) || ( //If hour input is within other time range + finish_time > start_time_2 && finish_time < finish_time_2) || //If finish time input is within other time range + (start_time <= start_time_2 && finish_time >= finish_time_2) || //If start and finish inputs encompass other time range + start_time == start_time_2) { + if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message. + insertErrorFor(parameter, "Time overlap.") + toReturn = false; + return false; + } + }); + return toReturn; +} + +function validate_sr_time(obj) { + let obj_id = $(obj).attr('id').split('_').slice(-1)[0]; + var start_time, finish_time; + if ($(obj).val() != "") { + if ($('#sr_start_no_' + String(obj_id)).val()) start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val()); + else start = 0. + finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val()); + } + return overlapped_times(obj, start_time, finish_time); +}; + +// Check if short-range durations are filled, and if there is no repetitions +function validate_sr_parameter(obj, error_message) { + if ($(obj).val() == "" || $(obj).val() == null) { + if (!$(obj).hasClass("red_border") && !$(obj).prop("disabled")) { + var parameter = document.getElementById($(obj).attr('id')); + insertErrorFor(parameter, error_message) + $(parameter).addClass("red_border"); + } + return false; + } else { + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + return true; + } +} + function parseValToNumber(val) { return parseInt(val.replace(':',''), 10); } @@ -529,6 +652,22 @@ $(document).ready(function () { elemObj.checked = (value==1); } + // Read short-range from URL + else if (name == 'short_range_interactions') { + let index = 1; + for (const interaction of JSON.parse(value)) { + $("#dialog_sr").append(inject_sr_interaction(index, value = interaction, is_validated="row_validated")) + $('#sr_expiration_no_' + String(index)).val(interaction.expiration).change(); + document.getElementById('sr_expiration_no_' + String(index)).disabled = true; + document.getElementById('sr_start_no_' + String(index)).disabled = true; + document.getElementById('sr_duration_no_' + String(index)).disabled = true; + document.getElementById('edit_row_no_' + String(index)).style.cssText = 'display:inline !important'; + document.getElementById('validate_row_no_' + String(index)).style.cssText = 'display: none !important'; + index++; + } + $("#sr_interactions").text(index - 1); + } + //Ignore 0 (default) values from server side else if (!(elemObj.classList.contains("non_zero") || elemObj.classList.contains("remove_zero")) || (value != "0.0" && value != "0")) { elemObj.value = value; @@ -578,6 +717,13 @@ $(document).ready(function () { // Call the function now to handle forward/back button presses in the browser. on_wearing_mask_change(); + // When the short_range_option changes we want to make its respective + // children show/hide. + $("input[type=radio][name=short_range_option]").change(on_short_range_option_change); + + // Call the function now to handle forward/back button presses in the browser. + on_short_range_option_change(); + // Setup the maximum number of people at page load (to handle back/forward), // and update it when total people is changed. setMaxInfectedPeople(); @@ -690,6 +836,134 @@ $(document).ready(function () { } return selectedSuggestion.text; } + + function inject_sr_interaction(index, value, is_validated) { + return `
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 84b3e987..499fc761 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -366,6 +366,47 @@The use of masks mitigates exposure at short-range. The analytical model with short-range interactions does not take mask wearing into account.
+ +0 short-range interactions.
+Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}
* The results are based on the parameters and assumptions published in the CARA publication: doi.org/10.1098/rsfs.2021.0076.
| Scenario | -P(I) | -Expected new cases | -
|---|---|---|
| {{ scenario_name }} | -{{ scenario_stats.probability_of_infection | non_zero_percentage }} | -{{ scenario_stats.expected_new_cases | float_format }} | -
| Scenario | +P(I) | +Expected new cases | +
|---|---|---|
| {{ scenario_name }} | +{{ scenario_stats.probability_of_infection | non_zero_percentage }} | +{{ scenario_stats.expected_new_cases | float_format }} | +
Notes for alternative scenarios:
+
Notes for alternative scenarios:
-
+ Short-range interactions: {{ form.short_range_interactions|length }} +
Exposed occupant(s) activity time:
Start time: {{ form.exposed_start | minutes_to_time }}