From 2997607e60e9674eb0aa15d6ce619126df96272b Mon Sep 17 00:00:00 2001
From: Gabriella Azzopardi
Date: Mon, 1 Mar 2021 15:19:15 +0000
Subject: [PATCH] Automatic report regeneration
---
cara/apps/calculator/README.md | 13 ++-
cara/apps/calculator/__init__.py | 6 +-
cara/apps/calculator/model_generator.py | 87 +++++++------------
cara/apps/calculator/report_generator.py | 45 ++++++++--
cara/apps/calculator/static/js/form.js | 28 +++++-
.../templates/calculator.form.html.j2 | 6 +-
cara/apps/calculator/templates/report.html.j2 | 45 +++++-----
.../apps/calculator/test_report_generator.py | 2 +-
requirements.txt | 77 ++++++++--------
setup.cfg | 3 +
setup.py | 1 +
11 files changed, 186 insertions(+), 127 deletions(-)
diff --git a/cara/apps/calculator/README.md b/cara/apps/calculator/README.md
index 9ae16f3e..ebd0c04d 100644
--- a/cara/apps/calculator/README.md
+++ b/cara/apps/calculator/README.md
@@ -33,7 +33,7 @@ A room number is included, if you do not wish to use a formal room number any re
### Room Data
-Please enter either the room volume (in m3) or both the floor area (m2) and the room height (m).
+Please enter either the room volume (in m³) or both the floor area (m²) and the room height (m).
This information is available via GIS Portal (https://gis.cern.ch/gisportal/).
### Ventilation type
@@ -99,7 +99,6 @@ As an example, for a shared office with 4 people, where one person is infected,
There are a few predefined activities in the tool at present.
**Office ** = All persons seated, talking occasionally (1/3rd of the time, with normal breathing the other 2/3rds of the time). Everyone (exposed and infected occupants) is treated the same in this model.
-
**Meeting** = All persons seated, having a conversation (approximately each occupant is 1/N % of the time talking, where N is the number of occupants). Everyone (exposed and infected occupants) is treated the same in this model.
**Library** = All persons seated, breathing only (not talking), all the time.
@@ -191,9 +190,8 @@ It contains a summary of all the input data, which will allow the simulation to
This part of the report shows the ``P(i)`` or probability of one exposed person getting infected.
It is estimated based on the emission rate of virus into the simulated volume, and the amount which is inhaled by exposed individuals.
-This probability is valid for the simulation duration - i.e. if you have simulated one day and plan to work 5 days in these conditions and the infected person emits the same amount of virus each day, the cumulative probability of infection is ``(1-(1-P(i))^5)```.
+This probability is valid for the simulation duration - i.e. if you have simulated one day and plan to work 5 days in these conditions and the infected person emits the same amount of virus each day, the cumulative probability of infection is ``(1-(1-P(i))^5)``.
If you are using the natural ventilation option, the simulation is only valid for the selected month, because the following or preceding month will have a different average temperature profile.
-
The ``expected number of new cases`` for the simulation is calculated based on the probability of infection, multiplied by the number of exposed occupants.
### Exposure graph
@@ -207,6 +205,13 @@ It is determined by:
* Under natural ventilation conditions, the effectiveness of ventilation relies upon the hourly temperature difference between the inside and outside air temperature.
* A HEPA filter removes infectious quanta from the air at a constant rate and is modelled in the same way as mechanical ventilation, however air passed through a HEPA filter is recycled (i.e. it is not fresh air).
+### QR code
+
+At the end of the report you can find a unique QR code / hyperlink for this report. This provides an automatic way to review the calculator form with the corresponding specified parameters.
+This allows for:
+* sharing reports by either scanning or clicking on the QR code to obtain a shareable link.
+* easily regenerating reports with any new versions of the CARA model released in the future.
+
# Conclusion
This tool provides informative comparisons for COVID-19 (long-range) airborne risk only - see Disclaimer
diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py
index 437fffb1..e5e02478 100644
--- a/cara/apps/calculator/__init__.py
+++ b/cara/apps/calculator/__init__.py
@@ -55,7 +55,8 @@ class ConcentrationModel(BaseRequestHandler):
self.finish(json.dumps(response_json))
return
- report = build_report(form.build_model(), form)
+ base_url = self.request.protocol + "://" + self.request.host
+ report = build_report(base_url, form.build_model(), form)
self.finish(report)
@@ -63,7 +64,8 @@ class StaticModel(BaseRequestHandler):
def get(self):
form = model_generator.FormData.from_dict(model_generator.baseline_raw_form_data())
model = form.build_model()
- report = build_report(model, form)
+ base_url = self.request.protocol + "://" + self.request.host
+ report = build_report(base_url, model, form)
self.finish(report)
diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py
index de7b9010..0bbacc69 100644
--- a/cara/apps/calculator/model_generator.py
+++ b/cara/apps/calculator/model_generator.py
@@ -82,66 +82,29 @@ class FormData:
if value == "":
form_data[key] = "0"
- time_attributes = [
- 'exposed_lunch_start', 'exposed_lunch_finish', 'exposed_start', 'exposed_finish',
- 'infected_lunch_start', 'infected_lunch_finish', 'infected_start', 'infected_finish',
- ]
- for attr_name in time_attributes:
+ for attr_name in BOOLEAN_ATTRIBUTES:
+ form_data[attr_name] = form_data[attr_name] == '1'
+ for attr_name in FLOAT_ATTRIBUTES:
+ form_data[attr_name] = float(form_data[attr_name])
+ for attr_name in INT_ATTRIBUTES:
+ form_data[attr_name] = int(form_data[attr_name])
+ for attr_name in TIME_ATTRIBUTES:
form_data[attr_name] = time_string_to_minutes(form_data[attr_name])
- boolean_attributes = [
- 'hepa_option', 'exposed_lunch_option', 'infected_lunch_option', 'infected_dont_have_breaks_with_exposed',
- ]
- for attr_name in boolean_attributes:
- form_data[attr_name] = form_data[attr_name] == '1'
-
- instance = cls(
- activity_type=form_data['activity_type'],
- air_changes=float(form_data['air_changes']),
- air_supply=float(form_data['air_supply']),
- ceiling_height=float(form_data['ceiling_height']),
- exposed_coffee_break_option=form_data['exposed_coffee_break_option'],
- exposed_coffee_duration=int(form_data['exposed_coffee_duration']),
- exposed_finish=form_data['exposed_finish'],
- exposed_lunch_finish=form_data['exposed_lunch_finish'],
- exposed_lunch_option=form_data['exposed_lunch_option'],
- exposed_lunch_start=form_data['exposed_lunch_start'],
- exposed_start=form_data['exposed_start'],
- floor_area=float(form_data['floor_area']),
- hepa_amount=float(form_data['hepa_amount']),
- hepa_option=form_data['hepa_option'],
- infected_coffee_break_option=form_data['infected_coffee_break_option'],
- infected_coffee_duration=int(form_data['infected_coffee_duration']),
- infected_dont_have_breaks_with_exposed=form_data['infected_dont_have_breaks_with_exposed'],
- infected_finish=form_data['infected_finish'],
- infected_lunch_finish=form_data['infected_lunch_finish'],
- infected_lunch_option=form_data['infected_lunch_option'],
- infected_lunch_start=form_data['infected_lunch_start'],
- infected_people=int(form_data['infected_people']),
- infected_start=form_data['infected_start'],
- mask_type=form_data['mask_type'],
- mask_wearing_option=form_data['mask_wearing_option'],
- mechanical_ventilation_type=form_data['mechanical_ventilation_type'],
- model_version=form_data['model_version'],
- opening_distance=float(form_data['opening_distance']),
- event_month=form_data['event_month'],
- room_number=form_data['room_number'],
- room_volume=float(form_data['room_volume']),
- simulation_name=form_data['simulation_name'],
- total_people=int(form_data['total_people']),
- ventilation_type=form_data['ventilation_type'],
- volume_type=form_data['volume_type'],
- windows_duration=float(form_data['windows_duration']),
- windows_frequency=float(form_data['windows_frequency']),
- window_height=float(form_data['window_height']),
- window_type=form_data['window_type'],
- window_width=float(form_data['window_width']),
- windows_number=int(form_data['windows_number']),
- window_opening_regime=form_data['window_opening_regime'],
- )
+ form_data.pop('_xsrf', None)
+ instance = cls(**form_data)
instance.validate()
return instance
+ @classmethod
+ def to_dict(self, form: "FormData") -> dict:
+ form_dict = form.__dict__.copy()
+ for attr_name in TIME_ATTRIBUTES:
+ form_dict[attr_name] = time_minutes_to_string(form_dict[attr_name])
+ for attr_name in BOOLEAN_ATTRIBUTES:
+ form_dict[attr_name] = form_dict[attr_name] & 1
+ return form_dict
+
def validate(self):
# Validate time intervals selected by user
time_intervals = [
@@ -604,6 +567,12 @@ WINDOWS_TYPES = {'window_sliding', 'window_hinged', 'not-applicable'}
COFFEE_OPTIONS_INT = {'coffee_break_0':0, 'coffee_break_1':1, 'coffee_break_2':2, 'coffee_break_4':4}
+BOOLEAN_ATTRIBUTES = {'hepa_option', 'exposed_lunch_option', 'infected_lunch_option', 'infected_dont_have_breaks_with_exposed'}
+FLOAT_ATTRIBUTES = {'air_changes', 'air_supply', 'ceiling_height', 'floor_area', 'hepa_amount', 'opening_distance',
+ 'room_volume', 'windows_duration', 'windows_frequency', 'window_height', 'window_width'}
+INT_ATTRIBUTES = {'exposed_coffee_duration', 'infected_coffee_duration', 'infected_people', 'total_people', 'windows_number'}
+TIME_ATTRIBUTES = {'exposed_lunch_start', 'exposed_lunch_finish', 'exposed_start', 'exposed_finish',
+ 'infected_lunch_start', 'infected_lunch_finish', 'infected_start', 'infected_finish'}
def time_string_to_minutes(time: str) -> minutes_since_midnight:
"""
@@ -612,3 +581,11 @@ def time_string_to_minutes(time: str) -> minutes_since_midnight:
:return: The number of minutes between 'time' and 00:00
"""
return minutes_since_midnight(60 * int(time[:2]) + int(time[3:]))
+
+def time_minutes_to_string(time: int) -> str:
+ """
+ Converts time from an integer number of minutes after 00:00 to string-format
+ :param time: The number of minutes between 'time' and 00:00
+ :return: A string of the form "HH:MM" representing a time of day
+ """
+ return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time%60)
diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py
index 3d39b741..9cfdccfd 100644
--- a/cara/apps/calculator/report_generator.py
+++ b/cara/apps/calculator/report_generator.py
@@ -5,6 +5,8 @@ import io
from pathlib import Path
import typing
+import qrcode
+import urllib
import jinja2
import matplotlib
matplotlib.use('agg')
@@ -61,16 +63,46 @@ def calculate_report_data(model: models.ExposureModel):
"emission_rate": er,
"exposed_occupants": exposed_occupants,
"expected_new_cases": expected_new_cases,
- "scenario_plot_src": embed_figure(plot(times, concentrations, model)),
+ "scenario_plot_src": img2base64(_figure2bytes(plot(times, concentrations, model))),
"repeated_events": repeated_events,
}
-def embed_figure(figure) -> str:
- # Draw the scenario graph.
- img_data = io.BytesIO()
+def generate_qr_code(prefix, form: FormData):
+ form_dict = FormData.to_dict(form)
+ url = prefix + "?" + urllib.parse.urlencode(form_dict)
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_H,
+ box_size=10,
+ border=4,
+ )
+ qr.add_data(url)
+ qr.make(fit=True)
+ img = qr.make_image(fill_color="black", back_color="white").convert('RGB')
+
+ return {
+ 'image': img2base64(_img2bytes(img)),
+ 'link': url,
+ }
+
+
+def _img2bytes(figure):
+ # Draw the image
+ img_data = io.BytesIO()
+ figure.save(img_data, format='png', bbox_inches="tight")
+ return img_data
+
+
+def _figure2bytes(figure):
+ # Draw the image
+ img_data = io.BytesIO()
figure.savefig(img_data, format='png', bbox_inches="tight")
+ return img_data
+
+
+def img2base64(img_data) -> str:
plt.close()
img_data.seek(0)
pic_hash = base64.b64encode(img_data.read()).decode('ascii')
@@ -220,12 +252,12 @@ def comparison_report(scenarios: typing.Dict[str, models.ExposureModel]):
'expected_new_cases': model.expected_new_cases(),
}
return {
- 'plot': embed_figure(comparison_plot(scenarios)),
+ 'plot': img2base64(_figure2bytes(comparison_plot(scenarios))),
'stats': statistics,
}
-def build_report(model: models.ExposureModel, form: FormData):
+def build_report(base_url: str, model: models.ExposureModel, form: FormData):
now = datetime.now()
time = now.strftime("%d/%m/%Y %H:%M:%S")
request = {"the": "form", "request": "data"}
@@ -240,6 +272,7 @@ 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)
+ context['qr_code'] = generate_qr_code(f'{base_url}/calculator', form)
cara_templates = Path(__file__).parent.parent / "templates"
calculator_templates = Path(__file__).parent / "templates"
diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js
index 99bdbbd5..92ab638b 100644
--- a/cara/apps/calculator/static/js/form.js
+++ b/cara/apps/calculator/static/js/form.js
@@ -231,7 +231,7 @@ function on_ventilation_type_change() {
unrequire_fields(this);
//Clear invalid inputs for this newly hidden child element
- removeInvalid(getChildElement($(this)).find('input').not('input[type=radio]').attr('id'));
+ removeInvalid("#"+getChildElement($(this)).find('input').not('input[type=radio]').attr('id'));
}
});
}
@@ -456,6 +456,32 @@ function parseTimeToMins(cTime) {
/* -------On Load------- */
$(document).ready(function () {
+
+ //Pre-fill form with known values
+ (new URL(decodeURIComponent(window.location.href))).searchParams.forEach((value, name) => {
+
+ //If element exists
+ if(document.getElementsByName(name).length > 0) {
+ var elemObj = document.getElementsByName(name)[0];
+
+ //Pre-select checked radios
+ if (elemObj.type === 'radio') {
+ if (value !== 'not-applicable') {
+ $('[name="'+name+'"][value="'+value+'"]').prop('checked',true);
+ }
+ }
+ //Pre-select checkboxes
+ else if (elemObj.type === 'checkbox') {
+ elemObj.checked = (value==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;
+ validateValue(elemObj);
+ }
+ }
+ });
+
// When the document is ready, deal with the fact that we may be here
// as a result of a forward/back browser action. If that is the case, update
// the visibility of some of our inputs.
diff --git a/cara/apps/calculator/templates/calculator.form.html.j2 b/cara/apps/calculator/templates/calculator.form.html.j2
index b7ab72f9..6d9f203f 100644
--- a/cara/apps/calculator/templates/calculator.form.html.j2
+++ b/cara/apps/calculator/templates/calculator.form.html.j2
@@ -1,6 +1,6 @@
{% extends "layout.html.j2" %}
-{% set MODEL_VERSION="v1.4.0" %}
+{% set MODEL_VERSION="v1.4.1" %}
{% set DEBUG=False %}
{% set active_page="calculator/" %}
@@ -92,8 +92,8 @@
- /
-
+ /
+
diff --git a/cara/apps/calculator/templates/report.html.j2 b/cara/apps/calculator/templates/report.html.j2
index 621bf919..9792e305 100644
--- a/cara/apps/calculator/templates/report.html.j2
+++ b/cara/apps/calculator/templates/report.html.j2
@@ -265,33 +265,36 @@ In this scenario, the estimated probability of one exposed occupant getting infe
{% endfor %}
-
Notes for alternative scenarios:
-
-
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.
-
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.
-
-
-
+
+
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.
+
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.
+
+
+
-
- Footnotes for ALARA:
- ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
-
-
Justification - any exposure of persons has to be justified
-
Limitation - the personal doses have to be kept below the legal limits (in this case the CERN exposure limits)
-
Optimisation - the personal doses and collective doses have to be kept as low as reasonably achievable (ALARA).
+
+ Click the QR code to regenerate the report and get a shareable link. Alternatively, scan to regenerate the report. Mobile-friendly app coming soon!
+