From 61fc627e35cd1d9c03a9c89a1688b316d905fb16 Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Wed, 17 May 2023 17:25:48 +0200
Subject: [PATCH 01/61] added CO2 data class
---
caimira/models.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 73 insertions(+)
diff --git a/caimira/models.py b/caimira/models.py
index 3d8d5a3f..a9e1c892 100644
--- a/caimira/models.py
+++ b/caimira/models.py
@@ -38,6 +38,7 @@ import typing
import numpy as np
from scipy.interpolate import interp1d
import scipy.stats as sct
+from scipy.optimize import minimize
if not typing.TYPE_CHECKING:
from memoization import cached
@@ -440,6 +441,18 @@ class AirChange(Ventilation):
return self.air_exch
+@dataclass(frozen=True)
+class CustomVentilation(_VentilationBase):
+ # The ventilation value for a given time
+ ventilation_value: PiecewiseConstant
+
+ def transition_times(self, room: Room) -> typing.Set[float]:
+ return self.ventilation_value.transition_times
+
+ def air_exchange(self, room: Room, time: float) -> _VectorisedFloat:
+ return self.ventilation_value.value(time)
+
+
@dataclass(frozen=True)
class Virus:
#: RNA copies / mL
@@ -1472,6 +1485,63 @@ class ShortRangeModel:
return normed_int_concentration_interpolated
+@dataclass(frozen=True)
+class CO2Data:
+ # TODO - docstring
+ room_volume: float
+ number: typing.Union[int, IntPiecewiseConstant]
+ presence: typing.Optional[Interval]
+ ventilation_transition_times: typing.Tuple[float, ...]
+ times: typing.Sequence[float]
+ CO2_concentrations: typing.Sequence[float]
+
+ def CO2_concentrations_from_params(self,
+ exhalation_rate: float,
+ ventilation_values: typing.Tuple[float, ...]) -> typing.List[float]:
+
+ CO2_concentrations = CO2ConcentrationModel(
+ room=Room(volume=self.room_volume),
+ ventilation=CustomVentilation(PiecewiseConstant(
+ self.ventilation_transition_times, ventilation_values)),
+ CO2_emitters=Population(
+ number=self.number,
+ presence=self.presence,
+ mask=Mask.types['No mask'],
+ activity=Activity(
+ exhalation_rate=exhalation_rate, inhalation_rate=exhalation_rate),
+ host_immunity=0.
+ )
+ )
+
+ return [CO2_concentrations.concentration(time) for time in self.times]
+
+ def CO2_fit_params(self):
+ if len(self.times) != len(self.CO2_concentrations):
+ raise ValueError('times and CO2_concentrations must have same length.')
+
+ if len(self.times) < 2:
+ raise ValueError(
+ 'times and CO2_concentrations must contain at last two elements')
+
+ def fun(x):
+ exhalation_rate = x[0]
+ ventilation_values = tuple(x[1:])
+ the_concentrations = self.CO2_concentrations_from_params(
+ exhalation_rate=exhalation_rate,
+ ventilation_values=ventilation_values
+ )
+ return np.sqrt(np.sum((np.array(self.CO2_concentrations) - np.array(the_concentrations))**2))
+
+ # The goal is to minimize the difference between the two different curves (known concentrations vs. predicted concentrations)
+ res_dict = minimize(fun=fun, x0=np.ones(len(self.ventilation_transition_times)), method='powell', bounds=[
+ (0, None) for _ in range(len(self.ventilation_transition_times))], options={'xtol': 1e-3})
+
+ exhalation_rate = res_dict['x'][0]
+ ventilation_values = res_dict['x'][1:]
+
+ return exhalation_rate, ventilation_values
+
+
@dataclass(frozen=True)
class ExposureModel:
"""
@@ -1489,6 +1559,9 @@ class ExposureModel:
#: Geographical data
geographical_data: Cases
+ #: CO2 data
+ CO2_profile: CO2Data = ()
+
#: The number of times the exposure event is repeated (default 1).
repeats: int = config.exposure_model['repeats'] # type: ignore
From 2faeabf05c2690a1b5097dbc91d62f5e8c5837ac Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Wed, 17 May 2023 17:27:05 +0200
Subject: [PATCH 02/61] ui modifications
---
caimira/apps/calculator/defaults.py | 2 +
caimira/apps/calculator/model_generator.py | 2 +
caimira/apps/calculator/static/js/form.js | 20 ++
.../templates/base/calculator.form.html.j2 | 212 ++++++++++--------
4 files changed, 139 insertions(+), 97 deletions(-)
diff --git a/caimira/apps/calculator/defaults.py b/caimira/apps/calculator/defaults.py
index 8bae7e23..08318c7f 100644
--- a/caimira/apps/calculator/defaults.py
+++ b/caimira/apps/calculator/defaults.py
@@ -22,6 +22,8 @@ DEFAULTS = {
'ceiling_height': 0.,
'conditional_probability_plot': False,
'conditional_probability_viral_loads': False,
+ 'CO2_data': '{}',
+ 'CO2_data_option': '{}',
'exposed_coffee_break_option': 'coffee_break_0',
'exposed_coffee_duration': 5,
'exposed_finish': '17:30',
diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py
index b844aa68..d52b569b 100644
--- a/caimira/apps/calculator/model_generator.py
+++ b/caimira/apps/calculator/model_generator.py
@@ -37,6 +37,8 @@ class FormData:
ceiling_height: float
conditional_probability_plot: bool
conditional_probability_viral_loads: bool
+ CO2_data: dict
+ CO2_data_option: bool
exposed_coffee_break_option: str
exposed_coffee_duration: int
exposed_finish: minutes_since_midnight
diff --git a/caimira/apps/calculator/static/js/form.js b/caimira/apps/calculator/static/js/form.js
index a88c5ccc..1d1e39f1 100644
--- a/caimira/apps/calculator/static/js/form.js
+++ b/caimira/apps/calculator/static/js/form.js
@@ -493,6 +493,20 @@ function on_coffee_break_option_change() {
}
}
+function on_CO2_data_option_change() {
+ CO2_data_options = $('input[type=radio][name=CO2_data_option]');
+ CO2_data_options.each(function (index){
+ if (this.checked) {
+ getChildElement($(this)).show();
+ require_fields(this);
+ }
+ else {
+ getChildElement($(this)).hide();
+ require_fields(this);
+ }
+ })
+}
+
/* -------UI------- */
function show_disclaimer() {
@@ -1039,6 +1053,12 @@ $(document).ready(function () {
// Call the function now to handle forward/back button presses in the browser.
on_coffee_break_option_change();
+ // When the CO2_data_option changes we want to make its respective
+ // children show/hide.
+ $("input[type=radio][name=CO2_data_option]").change(on_CO2_data_option_change);
+ // Call the function now to handle forward/back button presses in the browser.
+ on_CO2_data_option_change();
+
// Setup the maximum number of people at page load (to handle back/forward),
// and update it when total people is changed.
validateMaxInfectedPeople();
diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2
index 8277f829..83b30069 100644
--- a/caimira/apps/templates/base/calculator.form.html.j2
+++ b/caimira/apps/templates/base/calculator.form.html.j2
@@ -185,127 +185,145 @@
+
Ventilation data:
?
-
+
+
Use CO₂ concentration values:
+
+
+
+
+
+
+
-
Ventilation type:
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
Ventilation type:
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
Natural Ventilation
-
-
-
- Single-sided ventilation is assumed in the model and is typically effective for room depths up to a depth 2.5 x the ceiling height.
- If these conditions are not met, the air exchange might not be homogenous producing an artificially lower risk further away from the window.
-
-
-
+
+
+
+
+
+
Natural Ventilation
+
+
+
+ Single-sided ventilation is assumed in the model and is typically effective for room depths up to a depth 2.5 x the ceiling height.
+ If these conditions are not met, the air exchange might not be homogenous producing an artificially lower risk further away from the window.
+
+
+
+
From 1217201a4db1e4b01702f28fa6555a35b5278a0b Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Wed, 7 Jun 2023 17:01:21 +0200
Subject: [PATCH 11/61] added missing script file
---
caimira/apps/calculator/defaults.py | 3 ++-
caimira/apps/templates/base/calculator.form.html.j2 | 1 +
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/caimira/apps/calculator/defaults.py b/caimira/apps/calculator/defaults.py
index 08318c7f..c230e4a9 100644
--- a/caimira/apps/calculator/defaults.py
+++ b/caimira/apps/calculator/defaults.py
@@ -23,7 +23,8 @@ DEFAULTS = {
'conditional_probability_plot': False,
'conditional_probability_viral_loads': False,
'CO2_data': '{}',
- 'CO2_data_option': '{}',
+ 'CO2_data_option': False,
+ 'CO2_fitting_result': '{}',
'exposed_coffee_break_option': 'coffee_break_0',
'exposed_coffee_duration': 5,
'exposed_finish': '17:30',
diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2
index 3b5cb371..a11dfb2a 100644
--- a/caimira/apps/templates/base/calculator.form.html.j2
+++ b/caimira/apps/templates/base/calculator.form.html.j2
@@ -14,6 +14,7 @@
+
{% endblock body_scripts %}
From 210275fc3d1fd06bc79b9330745ae8f71a2de43f Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Wed, 14 Jun 2023 16:43:12 +0200
Subject: [PATCH 12/61] added fixed intervals for all the ventilation types
---
caimira/apps/calculator/co2_model_generator.py | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/caimira/apps/calculator/co2_model_generator.py b/caimira/apps/calculator/co2_model_generator.py
index 5f3393a9..51f34d25 100644
--- a/caimira/apps/calculator/co2_model_generator.py
+++ b/caimira/apps/calculator/co2_model_generator.py
@@ -37,8 +37,10 @@ class CO2FormData:
infected_start: minutes_since_midnight
room_volume: float
total_people: int
+ ventilation_type: str
windows_duration: float
windows_frequency: float
+ window_opening_regime: str
#: The default values for undefined fields. Note that the defaults here
#: and the defaults in the html form must not be contradictory.
@@ -63,8 +65,10 @@ class CO2FormData:
'infected_start': '08:30',
'room_volume': _NO_DEFAULT,
'total_people': _NO_DEFAULT,
+ 'ventilation_type': 'no_ventilation',
'windows_duration': 10.,
'windows_frequency': 60.,
+ 'window_opening_regime': 'windows_open_permanently',
}
@classmethod
@@ -307,5 +311,8 @@ class CO2FormData:
)
def ventilation_transition_times(self) -> typing.Tuple[float, ...]:
- return tuple(sorted(set(models.PeriodicInterval(self.windows_frequency,
- self.windows_duration, min(self.infected_start, self.exposed_start)/60).transition_times())))
+ if self.ventilation_type == 'natural_ventilation' and self.window_opening_regime == 'windows_open_periodically':
+ return tuple(sorted(set(models.PeriodicInterval(self.windows_frequency,
+ self.windows_duration, min(self.infected_start, self.exposed_start)/60).transition_times())))
+ else:
+ return (min(self.infected_start/60, self.exposed_start/60), max(self.infected_finish/60, self.exposed_finish/60)) # all day long
From 15d28e1711ce26e841a2aef83bb0dd518035d068 Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Wed, 14 Jun 2023 16:43:48 +0200
Subject: [PATCH 13/61] added a way to download an excel template; added a
preview feature to the display CO2 table
---
caimira/apps/calculator/static/js/co2_form.js | 38 ++++++++++++++-----
.../templates/base/calculator.form.html.j2 | 9 +++--
2 files changed, 35 insertions(+), 12 deletions(-)
diff --git a/caimira/apps/calculator/static/js/co2_form.js b/caimira/apps/calculator/static/js/co2_form.js
index 594b7079..2a68e0b9 100644
--- a/caimira/apps/calculator/static/js/co2_form.js
+++ b/caimira/apps/calculator/static/js/co2_form.js
@@ -19,8 +19,10 @@ const CO2_data = [
'infected_start',
'room_volume',
'total_people',
+ 'ventilation_type',
'windows_duration',
'windows_frequency',
+ 'window_opening_regime',
]
// Method to upload a valid excel file
@@ -48,7 +50,6 @@ function excelFileToJSON(file) {
reader.onload = function (e) {
var data = e.target.result;
var workbook = XLSX.read(data, { type: "binary" });
- var result = {};
var firstSheetName = workbook.SheetNames[0];
//reading only first sheet data
var jsonData = XLSX.utils.sheet_to_json(workbook.Sheets[firstSheetName]);
@@ -67,17 +68,22 @@ function displayJsonToHtmlTable(jsonData) {
let structure = { times: [], CO2: [] };
if (jsonData.length > 0) {
var htmlData = "
Time
CO2 Value
";
- for (var i = 0; i < jsonData.length; i++) {
+ let jsonLength = jsonData.length;
+ for (var i = 0; i < jsonLength; i++) {
var row = jsonData[i];
- htmlData +=
- "
+ For the CO₂ fitting algorithm, the following input values will be considered:
+
+
Room volume (m³)
+
Ventilation transition times
+
Total number of occupants
+
Presence transition times
+
+
+
+
+
+
+
-
+
@@ -701,24 +716,25 @@
-
The following data will be considered:
-
+ {#
The following data will be considered:
+ #}
-
-
Exhalation rate:
-
Air exchange:
+
Fitting result
+
+
+
Ventilation values in the plot (ACH)
-
+
From 8329376a58f7b230889c12e55aa98aa995bd4484 Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Thu, 22 Jun 2023 12:14:19 +0200
Subject: [PATCH 17/61] rebased and made respective changes
---
caimira/apps/calculator/model_generator.py | 29 ++++++----------------
1 file changed, 7 insertions(+), 22 deletions(-)
diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py
index 230582e3..7ccf8395 100644
--- a/caimira/apps/calculator/model_generator.py
+++ b/caimira/apps/calculator/model_generator.py
@@ -326,7 +326,10 @@ class FormData:
humidity = float(self.humidity)
inside_temp = self.inside_temp
- room = models.Room(volume=volume, inside_temp=models.PiecewiseConstant((0, 24), (inside_temp,)), humidity=humidity)
+ return models.Room(volume=volume, inside_temp=models.PiecewiseConstant((0, 24), (inside_temp,)), humidity=humidity)
+
+ def build_mc_model(self) -> mc.ExposureModel:
+ room = self.initialize_room()
ventilation: models._VentilationBase = self.ventilation()
infected_population = self.infected_population()
@@ -353,7 +356,7 @@ class FormData:
geographic_population=self.geographic_population,
geographic_cases=self.geographic_cases,
ascertainment_bias=CONFIDENCE_LEVEL_OPTIONS[self.ascertainment_bias],
- )
+ ),
)
def build_model(self, sample_size=DEFAULT_MC_SAMPLE_SIZE) -> models.ExposureModel:
@@ -436,24 +439,10 @@ class FormData:
def ventilation(self) -> models._VentilationBase:
always_on = models.PeriodicInterval(period=120, duration=120)
- periodic_interval = models.PeriodicInterval(self.windows_frequency, self.windows_duration, min(self.infected_start, self.exposed_start)/60)
-
- if self.CO2_data_option:
- ventilations = []
- if self.ventilation_type == 'natural_ventilation' and self.window_opening_regime == 'windows_open_periodically':
- for index, time in enumerate(sorted(list(periodic_interval.transition_times()))[:-1]):
- if index < len(self.CO2_fitting_result['ventilation_values']):
- ventilations.append(models.AirChange(active=models.SpecificInterval(present_times=((time, time + self.windows_duration/60), )),
- air_exch=self.CO2_fitting_result['ventilation_values'][index]))
- else: break
- else:
- ventilations.append(models.AirChange(active=always_on, air_exch=self.CO2_fitting_result['ventilation_values'][0]))
- return models.MultipleVentilation(tuple(ventilations))
-
# Initializes a ventilation instance as a window if 'natural_ventilation' is selected, or as a HEPA-filter otherwise
if self.ventilation_type == 'natural_ventilation':
if self.window_opening_regime == 'windows_open_periodically':
- window_interval = periodic_interval
+ window_interval = models.PeriodicInterval(self.windows_frequency, self.windows_duration, min(self.infected_start, self.exposed_start)/60)
else:
window_interval = always_on
@@ -535,11 +524,7 @@ class FormData:
elif (self.activity_type == 'precise'):
activity_defn, expiration_defn = self.generate_precise_activity_expiration()
- if self.CO2_data_option:
- activity = mc.Activity(self.CO2_fitting_result['exhalation_rate'], self.CO2_fitting_result['exhalation_rate'])
- else:
- activity = activity_distributions[activity_defn]
-
+ activity = activity_distributions[activity_defn]
expiration = build_expiration(expiration_defn)
infected_occupants = self.infected_people
From 7a01d39fbaa382b49491d442254896bcbbdf3d54 Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Thu, 22 Jun 2023 17:39:52 +0200
Subject: [PATCH 18/61] added additional ventilation option
---
.../apps/calculator/co2_model_generator.py | 2 +-
caimira/apps/calculator/model_generator.py | 16 +++++++++++-
caimira/apps/calculator/static/js/form.js | 22 +++++-----------
.../templates/base/calculator.form.html.j2 | 25 +++++++++++--------
4 files changed, 37 insertions(+), 28 deletions(-)
diff --git a/caimira/apps/calculator/co2_model_generator.py b/caimira/apps/calculator/co2_model_generator.py
index 3aa13118..f0341338 100644
--- a/caimira/apps/calculator/co2_model_generator.py
+++ b/caimira/apps/calculator/co2_model_generator.py
@@ -315,7 +315,7 @@ class CO2FormData:
)
def ventilation_transition_times(self, last_present_time) -> typing.Tuple[float, ...]:
- if self.ventilation_type == 'natural_ventilation' and self.window_opening_regime == 'windows_open_periodically':
+ if self.ventilation_type == 'from_fitting' and self.window_opening_regime == 'windows_open_periodically':
transition_times = sorted(models.PeriodicInterval(self.windows_frequency,
self.windows_duration, min(self.infected_start, self.exposed_start)/60).transition_times())
return tuple(filter(lambda x: x < last_present_time, transition_times))
diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py
index 7ccf8395..58ddc8e8 100644
--- a/caimira/apps/calculator/model_generator.py
+++ b/caimira/apps/calculator/model_generator.py
@@ -439,10 +439,24 @@ class FormData:
def ventilation(self) -> models._VentilationBase:
always_on = models.PeriodicInterval(period=120, duration=120)
+ periodic_interval = models.PeriodicInterval(self.windows_frequency, self.windows_duration, min(self.infected_start, self.exposed_start)/60)
+
+ if self.CO2_data_option:
+ ventilations = []
+ if self.ventilation_type == 'from_fitting' and self.window_opening_regime == 'windows_open_periodically':
+ for index, time in enumerate(sorted(list(periodic_interval.transition_times()))[:-1]):
+ if index < len(self.CO2_fitting_result['ventilation_values']):
+ ventilations.append(models.AirChange(active=models.SpecificInterval(present_times=((time, time + self.windows_duration/60), )),
+ air_exch=self.CO2_fitting_result['ventilation_values'][index]))
+ else: break
+ else:
+ ventilations.append(models.AirChange(active=always_on, air_exch=self.CO2_fitting_result['ventilation_values'][0]))
+ return models.MultipleVentilation(tuple(ventilations))
+
# Initializes a ventilation instance as a window if 'natural_ventilation' is selected, or as a HEPA-filter otherwise
if self.ventilation_type == 'natural_ventilation':
if self.window_opening_regime == 'windows_open_periodically':
- window_interval = models.PeriodicInterval(self.windows_frequency, self.windows_duration, min(self.infected_start, self.exposed_start)/60)
+ window_interval = periodic_interval
else:
window_interval = always_on
diff --git a/caimira/apps/calculator/static/js/form.js b/caimira/apps/calculator/static/js/form.js
index 63ac8673..02c2408a 100644
--- a/caimira/apps/calculator/static/js/form.js
+++ b/caimira/apps/calculator/static/js/form.js
@@ -244,6 +244,9 @@ function removeInvalid(id) {
}
function on_ventilation_type_change() {
+ if ($('input[type=radio][id=from_fitting').is(':checked')) $('#DIVfrom_fitting').after($('#window_opening_regime'));
+ else $('#DIVopening_distance').after($('#window_opening_regime'));
+
ventilation_types = $('input[type=radio][name=ventilation_type]');
ventilation_types.each(function (index) {
if (this.checked) {
@@ -494,25 +497,12 @@ function on_coffee_break_option_change() {
}
function ventilation_from_fitting(condition) {
- let CO2_data_options = $('input[type=radio][name=ventilation_type]');
- let hepa_options = $('input[type=radio][name=hepa_option]');
if (condition) {
- $('input[type=radio][id=no_ventilation]').click();
- CO2_data_options.each(function (index){
- $(this).prop("disabled", true);
- });
- hepa_options.each(function (index){
- $(this).prop("disabled", true);
- });
- $('#DIVhepa_amount').after($('#window_opening_regime'));
+ $('input[type=radio][id=from_fitting]').click();
+ $('#DIVfrom_fitting').after($('#window_opening_regime'));
}
else {
- CO2_data_options.each(function (index){
- $(this).prop("disabled", false);
- });
- hepa_options.each(function (index){
- $(this).prop("disabled", false);
- });
+ $('input[type=radio][id=no_ventilation]').click();
$('#DIVopening_distance').after($('#window_opening_regime'));
}
}
diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2
index 47d3368c..7561d4a6 100644
--- a/caimira/apps/templates/base/calculator.form.html.j2
+++ b/caimira/apps/templates/base/calculator.form.html.j2
@@ -197,16 +197,18 @@
Ventilation type:
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -296,6 +298,9 @@
+
+
+
HEPA filtration:
From 1b8aa4f2c1b02d8fb08f9df6a56e7375dadf7d4e Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Tue, 27 Jun 2023 15:11:17 +0200
Subject: [PATCH 19/61] fixed download template by adding file to CERNBox
resources
---
caimira/apps/calculator/static/js/co2_form.js | 20 ++++++++-----------
1 file changed, 8 insertions(+), 12 deletions(-)
diff --git a/caimira/apps/calculator/static/js/co2_form.js b/caimira/apps/calculator/static/js/co2_form.js
index b48c5d90..c2a03447 100644
--- a/caimira/apps/calculator/static/js/co2_form.js
+++ b/caimira/apps/calculator/static/js/co2_form.js
@@ -92,18 +92,14 @@ function displayJsonToHtmlTable(jsonData) {
}
}
-function downloadTemplate() {
- let final_export = [["Times", "CO2"], [8.5, 440.44]];
- // Prepare the CSV file.
- let csvContent = "data:text/csv;charset=utf8,"
- + final_export.map(e => e.join(",")).join("\n");
- var encodedUri = encodeURI(csvContent);
- // Set a name for the file.
- var link = document.createElement("a");
- link.setAttribute("href", encodedUri);
- link.setAttribute("download", "CO2_template.XLSX");
- document.body.appendChild(link);
- link.click();
+function downloadTemplate(uri = 'https://caimira-resources.web.cern.ch/CO2_template.xlsx', filename = 'CO2_template.xlsx') {
+ var link = document.createElement("a");
+ link.download = filename;
+ link.href = uri;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ delete link;
}
function insertErrorFor(referenceNode, text) {
From be684ee3a6aa4c1e2f3fe82cc346bd11860361c1 Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Tue, 27 Jun 2023 17:03:27 +0200
Subject: [PATCH 20/61] added back compatibility (form report -> calculator)
---
caimira/apps/calculator/__init__.py | 1 -
caimira/apps/calculator/static/js/co2_form.js | 27 ++++++++++++-------
caimira/apps/calculator/static/js/form.js | 18 +++++++++++++
.../templates/base/calculator.form.html.j2 | 2 +-
4 files changed, 36 insertions(+), 12 deletions(-)
diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py
index 12b8d885..668a91bf 100644
--- a/caimira/apps/calculator/__init__.py
+++ b/caimira/apps/calculator/__init__.py
@@ -379,7 +379,6 @@ class CO2Data(BaseRequestHandler):
fig = plt.figure(figsize=(7, 4), dpi=110)
plt.plot(form.CO2_data['times'], form.CO2_data['CO2'])
for index, time in enumerate(transition_times[:-1]):
- print(time)
plt.axvline(x = time, color = 'grey', linewidth=0.5, linestyle='--')
y_location = (form.CO2_data['CO2'][min(range(len(form.CO2_data['times'])), key=lambda i: abs(form.CO2_data['times'][i]-time))])
plt.text(x = time + 0.04, y = y_location, s=round(ventilation_values[index], 2))
diff --git a/caimira/apps/calculator/static/js/co2_form.js b/caimira/apps/calculator/static/js/co2_form.js
index c2a03447..5315379b 100644
--- a/caimira/apps/calculator/static/js/co2_form.js
+++ b/caimira/apps/calculator/static/js/co2_form.js
@@ -1,3 +1,4 @@
+// JS file to handle manipulation on CO2 Fitting Algorithm Dialog.
const CO2_data = [
'CO2_data',
'specific_breaks',
@@ -92,6 +93,7 @@ function displayJsonToHtmlTable(jsonData) {
}
}
+// Method to download Excel template available on CERNBox
function downloadTemplate(uri = 'https://caimira-resources.web.cern.ch/CO2_template.xlsx', filename = 'CO2_template.xlsx') {
var link = document.createElement("a");
link.download = filename;
@@ -114,15 +116,26 @@ function validate() {
$('span.' + "error_text").remove();
let submit = true;
for (var i = 0; i < CO2_data.length; i++) {
- let element = $(`[name=${CO2_data[i]}]`);
- if (element[0].value === '') {
- insertErrorFor($('#CO2_input_data_div'), `'${element[0].name}' must be defined.`); // raise error for total number and room volume.
+ let element = $(`[name=${CO2_data[i]}]`)[0];
+ if (element.value === '') {
+ insertErrorFor($('#CO2_input_data_div'), `'${element.name}' must be defined.`); // raise error for total number and room volume.
submit = false;
};
}
return submit;
}
+function display_fitting_data(json_response) {
+ $("#DIV_CO2_fitting_result").show();
+ $("#CO2_data_plot").attr("src", json_response['CO2_plot']);
+ delete json_response['CO2_plot'];
+ $("#CO2_fitting_result").val(JSON.stringify(json_response));
+ $("#exhalation_rate_fit").html('Exhalation rate: ' + String(json_response['exhalation_rate'].toFixed(2)) + ' m³/h');
+ // $("#ventilation_rate_fit").html(json_response['ventilation_values']);
+ $("#generate_fitting_data").html('Fit data');
+ $("#save_and_dismiss_dialog").show();
+}
+
function submit_fitting_algorithm(url) {
if (validate()) {
let CO2_mapping = {};
@@ -143,13 +156,7 @@ function submit_fitting_algorithm(url) {
})
.then((response) => response.json())
.then((json_response) => {
- $("#DIV_CO2_fitting_result").show();
- $("#CO2_fitting_result").val(JSON.stringify(json_response));
- $("#exhalation_rate_fit").html('Exhalation rate: ' + String(json_response['exhalation_rate'].toFixed(2)) + ' m³/h');
- // $("#ventilation_rate_fit").html(json_response['ventilation_values']);
- $("#CO2_data_plot").attr("src", json_response['CO2_plot']);
- $("#generate_fitting_data").html('Fit data');
- $("#save_and_dismiss_dialog").show();
+ display_fitting_data(json_response);
});
}
}
diff --git a/caimira/apps/calculator/static/js/form.js b/caimira/apps/calculator/static/js/form.js
index 02c2408a..3b7ce7bf 100644
--- a/caimira/apps/calculator/static/js/form.js
+++ b/caimira/apps/calculator/static/js/form.js
@@ -946,6 +946,12 @@ $(document).ready(function () {
// Validation after
}
+
+ // Read CO2 Fitting Algorithms result
+ else if (name == 'CO2_fitting_result' || name == 'CO2_data') {
+ // Validation after
+ }
+
//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;
@@ -956,6 +962,18 @@ $(document).ready(function () {
// Handle default URL values if they are not explicitly defined.
+ // Populate CO2 Fitting Algorithm Dialog
+ let CO2_data = url.searchParams.has('CO2_data') ? url.searchParams.get('CO2_data') : null;
+ if (CO2_data) {
+ let CO2_inputs = JSON.parse(CO2_data);
+ let input_for_table = [];
+ for (let i = 0; i < CO2_inputs['times'].length; i++) {
+ input_for_table.push({'Times': CO2_inputs['times'][i], 'CO2': CO2_inputs['CO2'][i]});
+ };
+ displayJsonToHtmlTable(input_for_table);
+ submit_fitting_algorithm(`${$('#url_prefix').data().calculator_prefix}/co2-fit`);
+ }
+
// Populate primary vaccine dropdown
$("#vaccine_type option").remove();
let primary_vaccine = url.searchParams.has('vaccine_type') ? url.searchParams.get('vaccine_type') : null;
diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2
index 7561d4a6..bb04819b 100644
--- a/caimira/apps/templates/base/calculator.form.html.j2
+++ b/caimira/apps/templates/base/calculator.form.html.j2
@@ -708,7 +708,7 @@
-
Upload an excel file to display in HTML Table
+
Upload an Excel file to preview in HTML Table
From 6c8623874337aaf97e773161d22750205a9303e0 Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Thu, 29 Jun 2023 12:48:24 +0100
Subject: [PATCH 21/61] added ventilation option validations on form
---
caimira/apps/calculator/static/js/co2_form.js | 1 +
caimira/apps/calculator/static/js/form.js | 19 ++++++++++++-------
2 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/caimira/apps/calculator/static/js/co2_form.js b/caimira/apps/calculator/static/js/co2_form.js
index 5315379b..b9e7c50b 100644
--- a/caimira/apps/calculator/static/js/co2_form.js
+++ b/caimira/apps/calculator/static/js/co2_form.js
@@ -170,6 +170,7 @@ function clear_fitting_algorithm() {
$('#DIV_CO2_fitting_result').hide();
$('#CO2_input_data_div').hide();
$('#CO2_data_no').click();
+ ventilation_from_fitting(false);
}
function dismiss_co2_dialog() {
diff --git a/caimira/apps/calculator/static/js/form.js b/caimira/apps/calculator/static/js/form.js
index 3b7ce7bf..d2b0dbf1 100644
--- a/caimira/apps/calculator/static/js/form.js
+++ b/caimira/apps/calculator/static/js/form.js
@@ -244,9 +244,6 @@ function removeInvalid(id) {
}
function on_ventilation_type_change() {
- if ($('input[type=radio][id=from_fitting').is(':checked')) $('#DIVfrom_fitting').after($('#window_opening_regime'));
- else $('#DIVopening_distance').after($('#window_opening_regime'));
-
ventilation_types = $('input[type=radio][name=ventilation_type]');
ventilation_types.each(function (index) {
if (this.checked) {
@@ -496,15 +493,23 @@ function on_coffee_break_option_change() {
}
}
-function ventilation_from_fitting(condition) {
- if (condition) {
- $('input[type=radio][id=from_fitting]').click();
+function ventilation_from_fitting(condition_from_fitting) {
+ $('input[type=radio][id=no_ventilation]').prop("disabled", condition_from_fitting);
+ $('input[type=radio][id=mechanical_ventilation]').prop("disabled", condition_from_fitting);
+ $('input[type=radio][id=natural_ventilation]').prop("disabled", condition_from_fitting);
+ $('input[type=radio][id=from_fitting]').prop("disabled", !condition_from_fitting);
+ if (condition_from_fitting) {
+ $('input[type=radio][id=from_fitting]').prop('checked',true);
$('#DIVfrom_fitting').after($('#window_opening_regime'));
}
else {
- $('input[type=radio][id=no_ventilation]').click();
+ // Select the URL ventilation option, if any (from back-navigation)
+ var url = new URL(decodeURIComponent(window.location.href));
+ let ventilation_from_url = url.searchParams.has('ventilation_type') ? url.searchParams.get('ventilation_type') : "no_ventilation";
+ $(`input[type=radio][id=${ventilation_from_url}]`).prop('checked',true);
$('#DIVopening_distance').after($('#window_opening_regime'));
}
+ on_ventilation_type_change();
}
function on_CO2_data_option_change() {
From be732da4eaf8328069cd98faae1fa526327bab94 Mon Sep 17 00:00:00 2001
From: Luis Aleixo
Date: Thu, 29 Jun 2023 16:24:00 +0100
Subject: [PATCH 22/61] form modifications - table format
---
caimira/apps/calculator/__init__.py | 6 ++--
.../apps/calculator/co2_model_generator.py | 35 ++++++++++++++++---
caimira/apps/calculator/defaults.py | 1 -
caimira/apps/calculator/model_generator.py | 1 -
caimira/apps/calculator/static/js/co2_form.js | 35 ++++++++++---------
caimira/apps/calculator/static/js/form.js | 19 +++++-----
.../templates/base/calculator.form.html.j2 | 8 +++--
7 files changed, 66 insertions(+), 39 deletions(-)
diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py
index 668a91bf..97b20fca 100644
--- a/caimira/apps/calculator/__init__.py
+++ b/caimira/apps/calculator/__init__.py
@@ -354,7 +354,6 @@ class CO2Data(BaseRequestHandler):
async def post(self) -> None:
requested_model_config = tornado.escape.json_decode(self.request.body)
-
try:
form = co2_model_generator.CO2FormData.from_dict(requested_model_config)
except Exception as err:
@@ -375,7 +374,7 @@ class CO2Data(BaseRequestHandler):
)
report = await asyncio.wrap_future(report_task)
- def generate_image(transition_times: tuple, ventilation_values: tuple):
+ def generate_ventilation_plot(transition_times: tuple, ventilation_values: tuple):
fig = plt.figure(figsize=(7, 4), dpi=110)
plt.plot(form.CO2_data['times'], form.CO2_data['CO2'])
for index, time in enumerate(transition_times[:-1]):
@@ -387,7 +386,8 @@ class CO2Data(BaseRequestHandler):
return fig
result = dict(report.CO2_fit_params())
- result['CO2_plot'] = img2base64(_figure2bytes(generate_image(report.ventilation_transition_times, result['ventilation_values'])))
+ result['transition_times'] = report.ventilation_transition_times
+ result['CO2_plot'] = img2base64(_figure2bytes(generate_ventilation_plot(report.ventilation_transition_times, result['ventilation_values'])))
self.finish(result)
diff --git a/caimira/apps/calculator/co2_model_generator.py b/caimira/apps/calculator/co2_model_generator.py
index f0341338..39e61d29 100644
--- a/caimira/apps/calculator/co2_model_generator.py
+++ b/caimira/apps/calculator/co2_model_generator.py
@@ -46,7 +46,7 @@ class CO2FormData:
#: and the defaults in the html form must not be contradictory.
_DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = {
'CO2_data': '{}',
- 'specific_breaks': '{}', # CHECK INTEGRATION WITH WHO
+ 'specific_breaks': '{}',
'exposed_coffee_break_option': 'coffee_break_0',
'exposed_coffee_duration': 5,
'exposed_finish': '17:30',
@@ -89,8 +89,8 @@ class CO2FormData:
form_data[key] = default_value
for key, value in form_data.items():
- if key in model_generator._CAST_RULES_FORM_ARG_TO_NATIVE:
- form_data[key] = model_generator._CAST_RULES_FORM_ARG_TO_NATIVE[key](value)
+ if key in _CAST_RULES_FORM_ARG_TO_NATIVE:
+ form_data[key] = _CAST_RULES_FORM_ARG_TO_NATIVE[key](value)
if key not in cls._DEFAULTS:
raise ValueError(f'Invalid argument "{html.escape(key)}" given')
@@ -318,6 +318,33 @@ class CO2FormData:
if self.ventilation_type == 'from_fitting' and self.window_opening_regime == 'windows_open_periodically':
transition_times = sorted(models.PeriodicInterval(self.windows_frequency,
self.windows_duration, min(self.infected_start, self.exposed_start)/60).transition_times())
- return tuple(filter(lambda x: x < last_present_time, transition_times))
+ return tuple(filter(lambda x: x <= last_present_time, transition_times))
else:
return tuple((min(self.infected_start, self.exposed_start)/60, max(self.infected_finish, self.exposed_finish)/60), ) # all day long
+
+#: Mapping of field name to a callable which can convert values from form
+#: input (URL encoded arguments / string) into the correct type.
+_CAST_RULES_FORM_ARG_TO_NATIVE: typing.Dict[str, typing.Callable] = {}
+
+#: Mapping of field name to callable which can convert native type to values
+#: that can be encoded to URL arguments.
+_CAST_RULES_NATIVE_TO_FORM_ARG: typing.Dict[str, typing.Callable] = {}
+
+
+for _field in dataclasses.fields(CO2FormData):
+ if _field.type is minutes_since_midnight:
+ _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = model_generator.time_string_to_minutes
+ _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = model_generator.time_minutes_to_string
+ elif _field.type is int:
+ _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = model_generator._safe_int_cast
+ elif _field.type is float:
+ _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = float
+ 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] = model_generator.string_to_list
+ _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = model_generator.list_to_string
+ elif _field.type is dict:
+ _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = model_generator.string_to_dict
+ _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = model_generator.dict_to_string
diff --git a/caimira/apps/calculator/defaults.py b/caimira/apps/calculator/defaults.py
index c230e4a9..35ced634 100644
--- a/caimira/apps/calculator/defaults.py
+++ b/caimira/apps/calculator/defaults.py
@@ -22,7 +22,6 @@ DEFAULTS = {
'ceiling_height': 0.,
'conditional_probability_plot': False,
'conditional_probability_viral_loads': False,
- 'CO2_data': '{}',
'CO2_data_option': False,
'CO2_fitting_result': '{}',
'exposed_coffee_break_option': 'coffee_break_0',
diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py
index 58ddc8e8..97749fd2 100644
--- a/caimira/apps/calculator/model_generator.py
+++ b/caimira/apps/calculator/model_generator.py
@@ -37,7 +37,6 @@ class FormData:
ceiling_height: float
conditional_probability_plot: bool
conditional_probability_viral_loads: bool
- CO2_data: dict
CO2_data_option: bool
CO2_fitting_result: dict
exposed_coffee_break_option: str
diff --git a/caimira/apps/calculator/static/js/co2_form.js b/caimira/apps/calculator/static/js/co2_form.js
index b9e7c50b..e5277f3f 100644
--- a/caimira/apps/calculator/static/js/co2_form.js
+++ b/caimira/apps/calculator/static/js/co2_form.js
@@ -1,5 +1,5 @@
// JS file to handle manipulation on CO2 Fitting Algorithm Dialog.
-const CO2_data = [
+const CO2_data_form = [
'CO2_data',
'specific_breaks',
'exposed_coffee_break_option',
@@ -74,11 +74,11 @@ function displayJsonToHtmlTable(jsonData) {
var row = jsonData[i];
if (i < 5) {
htmlData +=
- "
" +
- row["Times"].toFixed(2) +
- "
" +
- row["CO2"].toFixed(2) +
- "
";
+ `
+ ${row["Times"].toFixed(2)}
+
+ ${row["CO2"].toFixed(2)}
+
`;
}
structure["times"].push(row["Times"]);
structure["CO2"].push(row["CO2"]);
@@ -115,8 +115,8 @@ function insertErrorFor(referenceNode, text) {
function validate() {
$('span.' + "error_text").remove();
let submit = true;
- for (var i = 0; i < CO2_data.length; i++) {
- let element = $(`[name=${CO2_data[i]}]`)[0];
+ for (var i = 0; i < CO2_data_form.length; i++) {
+ let element = $(`[name=${CO2_data_form[i]}]`)[0];
if (element.value === '') {
insertErrorFor($('#CO2_input_data_div'), `'${element.name}' must be defined.`); // raise error for total number and room volume.
submit = false;
@@ -128,10 +128,17 @@ function validate() {
function display_fitting_data(json_response) {
$("#DIV_CO2_fitting_result").show();
$("#CO2_data_plot").attr("src", json_response['CO2_plot']);
- delete json_response['CO2_plot'];
+ // Not needed for the form submit
+ delete json_response['CO2_plot'];
$("#CO2_fitting_result").val(JSON.stringify(json_response));
$("#exhalation_rate_fit").html('Exhalation rate: ' + String(json_response['exhalation_rate'].toFixed(2)) + ' m³/h');
- // $("#ventilation_rate_fit").html(json_response['ventilation_values']);
+ let ventilation_table = "