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:
-
- Footnotes for ALARA:
- ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
-
+ Footnotes for ALARA:
+ ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
+
Disclaimer: