diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 720e7179..e6cd21b7 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -403,7 +403,7 @@ def baseline_raw_form_data(): 'mask_type': 'Type I', 'mask_wearing': 'removed', 'mechanical_ventilation_type': '', - 'model_version': 'BetaV1.1.0', + 'model_version': 'v1.1.0', 'opening_distance': '0.2', 'recurrent_event_month': 'January', 'room_number': '123', diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index c98355e9..be8f8322 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -3,6 +3,7 @@ import dataclasses from datetime import datetime import io from pathlib import Path +import typing import jinja2 import matplotlib @@ -21,14 +22,18 @@ class RepeatEvents: expected_new_cases: float -def calculate_report_data(model: models.ExposureModel): - resolution = 600 - +def model_start_end(model: models.ExposureModel): t_start = min(model.exposed.presence.boundaries()[0][0], model.concentration_model.infected.presence.boundaries()[0][0]) t_end = max(model.exposed.presence.boundaries()[-1][1], model.concentration_model.infected.presence.boundaries()[-1][1]) + return t_start, t_end + +def calculate_report_data(model: models.ExposureModel): + resolution = 600 + + t_start, t_end = model_start_end(model) times = list(np.linspace(t_start, t_end, resolution)) concentrations = [model.concentration_model.concentration(time) for time in times] highest_const = max(concentrations) @@ -99,6 +104,93 @@ def minutes_to_time(minutes: int) -> str: return f"{hour_string}:{minute_string}" +def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, models.ExposureModel]: + scenarios = {} + + # Two special option cases - HEPA and/or FFP2 masks. + FFP2_being_worn = bool(form.mask_wearing == 'continuous' and form.mask_type == 'FFP2') + if FFP2_being_worn and form.hepa_option: + scenarios['Base scenario with HEPA and FFP2 masks'] = form.build_model() + elif FFP2_being_worn: + scenarios['Base scenario with FFP2 masks'] = form.build_model() + elif form.hepa_option: + scenarios['Base scenario with HEPA filter'] = form.build_model() + + # The remaining scenarios are based on Type I masks (possibly not worn) + # and no HEPA filtration. + form = dataclasses.replace(form, mask_type='Type I') + if form.hepa_option: + form = dataclasses.replace(form, hepa_option=False) + + with_mask = dataclasses.replace(form, mask_wearing='continuous') + without_mask = dataclasses.replace(form, mask_wearing='removed') + + if form.ventilation_type == 'mechanical': + scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_model() + scenarios['Mechanical ventilation without masks'] = without_mask.build_model() + + elif form.ventilation_type == 'natural': + scenarios['Windows open with Type I masks'] = with_mask.build_model() + scenarios['Windows open without masks'] = without_mask.build_model() + + # No matter the ventilation scheme, we include scenarios which don't have any ventilation. + with_mask_no_vent = dataclasses.replace(with_mask, ventilation_type='no-ventilation') + without_mask_or_vent = dataclasses.replace(without_mask, ventilation_type='no-ventilation') + scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_model() + scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_model() + + return scenarios + + +def comparison_plot(scenarios: typing.Dict[str, models.ExposureModel]): + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1) + + resolution = 350 + times = None + + dash_styled_scenarios = [ + 'Base scenario with FFP2 masks', + 'Base scenario with HEPA filter', + 'Base scenario with HEPA and FFP2 masks', + ] + + for name, model in scenarios.items(): + if times is None: + t_start, t_end = model_start_end(model) + times = np.linspace(t_start, t_end, resolution) + concentrations = [model.concentration_model.concentration(time) for time in times] + + if name in dash_styled_scenarios: + ax.plot(times, concentrations, label=name, linestyle='--') + else: + ax.plot(times, concentrations, label=name, linestyle='-', alpha=0.5) + + # Place a legend outside of the axes itself. + ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left') + ax.spines['right'].set_visible(False) + ax.spines['top'].set_visible(False) + + ax.set_xlabel('Time (hour of day)') + ax.set_ylabel('Concentration ($q/m^3$)') + ax.set_title('Concentration of infectious quanta') + + return fig + + +def comparison_report(scenarios: typing.Dict[str, models.ExposureModel]): + statistics = {} + for name, model in scenarios.items(): + statistics[name] = { + 'probability_of_infection': model.infection_probability(), + 'expected_new_cases': model.expected_new_cases(), + } + return { + 'plot': embed_figure(comparison_plot(scenarios)), + 'stats': statistics, + } + + def build_report(model: models.ExposureModel, form: FormData): now = datetime.now() time = now.strftime("%d/%m/%Y %H:%M:%S") @@ -112,6 +204,8 @@ def build_report(model: models.ExposureModel, form: FormData): } context.update(calculate_report_data(model)) + alternative_scenarios = manufacture_alternative_scenarios(form) + context['alternative_scenarios'] = comparison_report(alternative_scenarios) cara_templates = Path(__file__).parent.parent / "templates" calculator_templates = Path(__file__).parent / "templates" diff --git a/cara/apps/calculator/static/css/report.css b/cara/apps/calculator/static/css/report.css index 500ffbb3..5967818d 100644 --- a/cara/apps/calculator/static/css/report.css +++ b/cara/apps/calculator/static/css/report.css @@ -39,11 +39,15 @@ p.result_title { font-size: 15pt; } -.image { +p.image { text-align: center; font-size: 13pt; } -.discalimer { +p.disclaimer { font-size: 12pt; } + +p.notes { + font-size: 10pt; +} \ No newline at end of file diff --git a/cara/apps/calculator/templates/report.html.j2 b/cara/apps/calculator/templates/report.html.j2 index 3701f2e2..941eec4c 100644 --- a/cara/apps/calculator/templates/report.html.j2 +++ b/cara/apps/calculator/templates/report.html.j2 @@ -19,9 +19,12 @@
Created {{ creation_date }} using model version {{ form.model_version }}
Applicable rules:
+ Please ensure that this scenario conforms to current CERN HSE rules (minimum ventilation requirements, mask wearing and the maximum number of people permitted in a space).
Simulation Name: {{ form.simulation_name }}
Room Number: {{ form.room_number }}
- +Input data:
Room Volume: {{ model.concentration_model.room.volume }} m³
+
+ +Alternative scenarios:
+
+
+
| Scenario | +P(i) | +Expected new cases | +
|---|---|---|
| {{ scenario_name }} | +{{ scenario_stats.probability_of_infection | int_format }}% | +{{ scenario_stats.expected_new_cases | float_format }} | +
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 Pi values.
+ 2) 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.
+
+
Disclaimer:
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 thereto. The results DO NOT include short-range airborne exposure (where the physical distance plays a factor) nor the other know modes of transmission of SARS-CoV-2. Hence, this model implies that proper physical distancing, good hand hygiene and other barrier measures are ensured.
+
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 thereto. The results DO NOT include short-range airborne exposure (where the physical distance plays a factor) nor the other know modes of transmission of SARS-CoV-2. Hence, this model implies that proper physical distancing, good hand hygiene and other barrier measures are ensured.
It is based on current scientific data and can be used to measures the effectiveness of different mitigation measures.
Note that this model is based on a deterministic approach, i.e., at least one person is infected and shedding viruses into the 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 mostly useful to compare the impact and effectiveness of mitigation measures such as ventilation, filtration, exposure time, activity and the size of the room on long-range airborne transmission of COVID-19 in indoor settings.
This application is meant for informative and educational purposes. The user can be able to adapt different settings and measure the relative impact on the estimated infection probabilities to allow for a targeted decision making and investment. The user should acknowledge that until the virus is in circulation among the population, the notion of 'zero risk' or a 'completely safe scenario' does not exist. Each event is unique and the results are as accurate as the inputs. The app is based on our scientific understanding of infectious diseases transmission, exposure and aerosol science as of November 2020.