diff --git a/README.md b/README.md index e358a978..b6354ce1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,11 @@ Andre Henriques1, Luis Aleixo1, Marco Andreini1 ### Reference and Citation **For the use of the CARA web app** + CARA – COVID Airborne Risk Assessment tool + +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.6520432.svg)](https://doi.org/10.5281/zenodo.6520432) + © Copyright 2020-2021 CERN. All rights not expressly granted are reserved. **For use of the model** diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index c119d91c..8d4e5e45 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -22,7 +22,7 @@ import tornado.log from . import markdown_tools from . import model_generator -from .report_generator import ReportGenerator +from .report_generator import ReportGenerator, calculate_report_data from .user import AuthenticatedUser, AnonymousUser @@ -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.1.1" +__version__ = "4.1.2" class BaseRequestHandler(RequestHandler): @@ -129,6 +129,44 @@ class ConcentrationModel(BaseRequestHandler): self.finish(report) +class ConcentrationModelJsonResponse(BaseRequestHandler): + def check_xsrf_cookie(self): + """ + This request handler implements a stateless API that returns report data in JSON format. + Thus, XSRF cookies are disabled by overriding base class implementation of this method with a pass statement. + """ + pass + + async def post(self): + """ + Expects algorithm input in HTTP POST request body in JSON format. + Returns report data (algorithm output) in HTTP POST response body in JSON format. + """ + requested_model_config = json.loads(self.request.body) + if self.settings.get("debug", False): + from pprint import pprint + pprint(requested_model_config) + + try: + form = model_generator.FormData.from_dict(requested_model_config) + except Exception as err: + if self.settings.get("debug", False): + import traceback + print(traceback.format_exc()) + response_json = {'code': 400, 'error': f'Your request was invalid {html.escape(str(err))}'} + self.set_status(400) + await self.finish(json.dumps(response_json)) + return + + executor = loky.get_reusable_executor( + max_workers=self.settings['handler_worker_pool_size'], + timeout=300, + ) + report_data_task = executor.submit(calculate_report_data, form, form.build_model()) + report_data: dict = await asyncio.wrap_future(report_data_task) + await self.finish(report_data) + + class StaticModel(BaseRequestHandler): async def get(self): form = model_generator.FormData.from_dict(model_generator.baseline_raw_form_data()) @@ -226,6 +264,7 @@ def make_app( (r'/static/(.*)', StaticFileHandler, {'path': static_dir}), (calculator_prefix + r'/?', CalculatorForm), (calculator_prefix + r'/report', ConcentrationModel), + (calculator_prefix + r'/report-json', ConcentrationModelJsonResponse), (calculator_prefix + r'/baseline-model/result', StaticModel), (calculator_prefix + r'/user-guide', ReadmeHandler), (calculator_prefix + r'/static/(.*)', StaticFileHandler, {'path': calculator_static_dir}), diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index ab90fab8..c7ebd410 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -45,6 +45,7 @@ class FormData: floor_area: float hepa_amount: float hepa_option: bool + humidity: str infected_coffee_break_option: str #Used if infected_dont_have_breaks_with_exposed infected_coffee_duration: int #Used if infected_dont_have_breaks_with_exposed infected_dont_have_breaks_with_exposed: bool @@ -54,6 +55,7 @@ class FormData: infected_lunch_start: minutes_since_midnight #Used if infected_dont_have_breaks_with_exposed infected_people: int infected_start: minutes_since_midnight + inside_temp: float location_name: str location_latitude: float location_longitude: float @@ -100,6 +102,7 @@ class FormData: 'floor_area': 0., 'hepa_amount': 0., 'hepa_option': False, + 'humidity': '', 'infected_coffee_break_option': 'coffee_break_0', 'infected_coffee_duration': 5, 'infected_dont_have_breaks_with_exposed': False, @@ -109,6 +112,7 @@ class FormData: 'infected_lunch_start': '12:30', 'infected_people': _NO_DEFAULT, 'infected_start': '08:30', + 'inside_temp': 293., 'location_latitude': _NO_DEFAULT, 'location_longitude': _NO_DEFAULT, 'location_name': _NO_DEFAULT, @@ -240,11 +244,14 @@ class FormData: volume = self.room_volume else: volume = self.floor_area * self.ceiling_height - if self.room_heating_option: - humidity = 0.3 + if self.humidity == '': + if self.room_heating_option: + humidity = 0.3 + else: + humidity = 0.5 else: - humidity = 0.5 - room = models.Room(volume=volume, humidity=humidity) + humidity = float(self.humidity) + room = models.Room(volume=volume, inside_temp=models.PiecewiseConstant((0, 24), (self.inside_temp,)), humidity=humidity) infected_population = self.infected_population() @@ -324,18 +331,16 @@ class FormData: # 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)) + window_interval = models.PeriodicInterval(self.windows_frequency, self.windows_duration, min(self.infected_start, self.exposed_start)/60) else: window_interval = always_on outside_temp = self.outside_temp() - inside_temp = models.PiecewiseConstant((0, 24), (293,)) ventilation: models.Ventilation if self.window_type == 'window_sliding': ventilation = models.SlidingWindow( active=window_interval, - inside_temp=inside_temp, outside_temp=outside_temp, window_height=self.window_height, opening_length=self.opening_distance, @@ -344,7 +349,6 @@ class FormData: elif self.window_type == 'window_hinged': ventilation = models.HingedWindow( active=window_interval, - inside_temp=inside_temp, outside_temp=outside_temp, window_height=self.window_height, window_width=self.window_width, @@ -672,7 +676,7 @@ def build_expiration(expiration_definition) -> mc._ExpirationBase: return expiration_distribution(BLO_factors=tuple(BLO_factors)) -def baseline_raw_form_data(): +def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]: # Note: This isn't a special "baseline". It can be updated as required. return { 'activity_type': 'office', @@ -689,6 +693,7 @@ def baseline_raw_form_data(): 'floor_area': '', 'hepa_amount': '250', 'hepa_option': '0', + 'humidity': '', 'infected_coffee_break_option': 'coffee_break_4', 'infected_coffee_duration': '10', 'infected_dont_have_breaks_with_exposed': '1', @@ -698,6 +703,7 @@ def baseline_raw_form_data(): 'infected_lunch_start': '12:30', 'infected_people': '1', 'infected_start': '09:00', + 'inside_temp': 293., 'location_latitude': 46.20833, 'location_longitude': 6.14275, 'location_name': 'Geneva', diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index b526d5bf..e3113dcb 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -109,7 +109,7 @@ def concentrations_with_sr_breathing(form: FormData, model: models.ExposureModel return lower_concentrations -def calculate_report_data(form: FormData, model: models.ExposureModel): +def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing.Dict[str, typing.Any]: 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 [] diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index fd4f3c73..e4597acc 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -783,6 +783,9 @@ $(document).ready(function () { templateSelection: formatLocationSelection }); + // Logic for the API requests. Always set humity input as the empty string so that we can profit from the "room_heating_option default" values for humidity. + $("[name='humidity']").val(""); + function formatlocation(suggestedLocation) { // Function is called for each location from the geocoding API. diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 5571f07c..8a4e49df 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -7,7 +7,6 @@ import ipywidgets as widgets import matplotlib import matplotlib.figure import numpy as np -import mplcursors from matplotlib import pyplot as plt from cara import data, models, state import matplotlib.lines as mlines @@ -166,7 +165,6 @@ class ExposureModelResult(View): else: self.ax.ignore_existing_data_limits = False self.concentration_line.set_data(ts, concentration) - mplcursors.cursor(self.ax, hover=True) if self.concentration_area is None: self.concentration_area = self.ax.fill_between(x = ts, y1=0, y2=concentration, color="#96cbff", label="Exposed person presence", @@ -205,7 +203,7 @@ class ExposureModelResult(View): figure_legends = [mlines.Line2D([], [], color='#3530fe', markersize=15, label='Mean concentration'), mlines.Line2D([], [], color='#0000c8', markersize=15, ls="dotted", label='Cumulative dose'), patches.Patch(edgecolor="#96cbff", facecolor='#96cbff', label='Presence of exposed person(s)')] - self.figure.legend(handles=figure_legends) + self.ax.legend(handles=figure_legends) self.figure.canvas.draw() @@ -350,14 +348,13 @@ class ModelWidgets(View): ])], title="Infected") def _build_room_volume(self, node): - room_volume = widgets.IntText(value=node.volume, min=10, max=500 - , step=5) + room_volume = widgets.IntText(value=node.volume, min=10, max=500, step=5) - def on_value_change(change): + def on_volume_change(change): node.volume = change['new'] # TODO: Link the state back to the widget, not just the other way around. - room_volume.observe(on_value_change, names=['value']) + room_volume.observe(on_volume_change, names=['value']) return widgets.HBox([widgets.Label('Room volume (m³)'), room_volume], layout=widgets.Layout(justify_content='space-between')) @@ -367,22 +364,22 @@ class ModelWidgets(View): room_ceiling_height = widgets.IntText(value=3, min=1, max=20, step=1) displayed_volume=widgets.Label('1') - def room_surface_change(change): + def on_room_surface_change(change): node.volume = change['new']*room_ceiling_height.value displayed_volume.value=str(node.volume) - def room_ceiling_height_change(change): + def on_room_ceiling_height_change(change): node.volume = change['new']*room_surface.value displayed_volume.value=str(node.volume) - room_surface.observe(room_surface_change, names=['value']) - room_ceiling_height.observe(room_ceiling_height_change, names=['value']) + room_surface.observe(on_room_surface_change, names=['value']) + room_ceiling_height.observe(on_room_ceiling_height_change, names=['value']) - return widgets.VBox([widgets.HBox([widgets.Label('Room surface area (m²) '), room_surface] - , layout=widgets.Layout(justify_content='space-between', width='100%')) - , widgets.HBox([widgets.Label('Room ceiling height (m)'), room_ceiling_height] - , layout=widgets.Layout(justify_content='space-between', width='100%')) - , widgets.HBox([widgets.Label('Total volume :'), displayed_volume, widgets.Label('m³')])]) + return widgets.VBox([widgets.HBox([widgets.Label('Room surface area (m²) '), room_surface], + layout=widgets.Layout(justify_content='space-between', width='100%')), + widgets.HBox([widgets.Label('Room ceiling height (m)'), room_ceiling_height], + layout=widgets.Layout(justify_content='space-between', width='100%')), + widgets.HBox([widgets.Label('Total volume :'), displayed_volume, widgets.Label('m³')])]) def _build_room(self,node): room_number = widgets.Text(value='', placeholder='653/R-004', disabled=False) #not linked to volume yet @@ -414,20 +411,27 @@ class ModelWidgets(View): toggle_room(room_w.value) humidity = widgets.FloatSlider(value = node.humidity, min=0, max=1, step=0.01) + inside_temp = widgets.IntSlider(value=node.inside_temp.values[0]-273.15, min=15., max=25.) - def humidity_change(change): + def on_humidity_change(change): node.humidity = change['new'] - humidity.observe(humidity_change, names=['value']) + def on_insidetemp_change(change): + node.inside_temp.values = (change['new']+273.15,) + + humidity.observe(on_humidity_change, names=['value']) + inside_temp.observe(on_insidetemp_change, names=['value']) widget = collapsible( [ widgets.VBox([ widgets.HBox([ - widgets.Label('Room number '), room_number] - , layout=widgets.Layout(width='100%', justify_content='space-between')) - , room_w, widgets.VBox(list(room_widgets.values())) - , widgets.HBox([widgets.Label('Indoor relative humidity '),humidity] - , layout=widgets.Layout(width='100%', justify_content='space-between')) + widgets.Label('Room number'), room_number], + layout=widgets.Layout(width='100%', justify_content='space-between')), + room_w, widgets.VBox(list(room_widgets.values())), + widgets.HBox([widgets.Label('Inside temperature (℃)'), inside_temp], + layout=widgets.Layout(width='100%', justify_content='space-between')), + widgets.HBox([widgets.Label('Indoor relative humidity'), humidity], + layout=widgets.Layout(width='100%', justify_content='space-between')), ])] , title="Specification of workspace" ) @@ -437,10 +441,10 @@ class ModelWidgets(View): def _build_outsidetemp(self, node) -> WidgetGroup: outside_temp = widgets.IntSlider(value=10, min=-10, max=30) - def outsidetemp_change(change): + def on_outsidetemp_change(change): node.values = (change['new'] + 273.15, ) - outside_temp.observe(outsidetemp_change, names=['value']) + outside_temp.observe(on_outsidetemp_change, names=['value']) auto_width = widgets.Layout(width='auto') return WidgetGroup( ( @@ -454,11 +458,11 @@ class ModelWidgets(View): def _build_hinged_window(self, node): hinged_window = widgets.FloatSlider(value=node.window_width, min=0.1, max=2, step=0.1) - def hinged_window_change(change): + def on_hinged_window_change(change): node.window_width = change['new'] # TODO: Link the state back to the widget, not just the other way around. - hinged_window.observe(hinged_window_change, names=['value']) + hinged_window.observe(on_hinged_window_change, names=['value']) return widgets.HBox([widgets.Label('Window width (meters) '), hinged_window], layout=widgets.Layout(justify_content='space-between', width='100%')) @@ -510,22 +514,18 @@ class ModelWidgets(View): def on_interval_change(change): node.active.duration = change['new'] - def insidetemp_change(change): - node.inside_temp.values = (change['new']+273.15,) - - def opening_length_change(change): + def on_opening_length_change(change): node.opening_length = change['new'] - def window_height_change(change): + def on_window_height_change(change): node.window_height = change['new'] # TODO: Link the state back to the widget, not just the other way around. number_of_windows.observe(on_value_change, names=['value']) period.observe(on_period_change, names=['value']) interval.observe(on_interval_change, names=['value']) - inside_temp.observe(insidetemp_change, names=['value']) - opening_length.observe(opening_length_change, names=['value']) - window_height.observe(window_height_change, names=['value']) + opening_length.observe(on_opening_length_change, names=['value']) + window_height.observe(on_window_height_change, names=['value']) outsidetemp_widgets = { 'Fixed': self._build_outsidetemp(node.outside_temp), @@ -569,10 +569,6 @@ class ModelWidgets(View): widgets.Label('Duration of opening (minutes)', layout=auto_width), interval, ), - ( - widgets.Label('Inside temperature (℃)', layout=auto_width), - inside_temp, - ), ( widgets.Label('Outside temperature scheme', layout=auto_width), outsidetemp_w, @@ -586,22 +582,22 @@ class ModelWidgets(View): def _build_q_air_mech(self, node): q_air_mech = widgets.FloatSlider(value=node.q_air_mech, min=0, max=1000, step=5) - def q_air_mech_change(change): + def on_q_air_mech_change(change): node.q_air_mech = change['new'] # TODO: Link the state back to the widget, not just the other way around. - q_air_mech.observe(q_air_mech_change, names=['value']) + q_air_mech.observe(on_q_air_mech_change, names=['value']) return widgets.HBox([q_air_mech, widgets.Label('m³/h')]) def _build_ach(self, node): air_exch = widgets.IntSlider(value=node.air_exch, min=0, max=50, step=5) - def air_exch_change(change): + def on_air_exch_change(change): node.air_exch = change['new'] # TODO: Link the state back to the widget, not just the other way around. - air_exch.observe(air_exch_change, names=['value']) + air_exch.observe(on_air_exch_change, names=['value']) return widgets.HBox([air_exch, widgets.Label('h⁻¹')]) @@ -679,10 +675,10 @@ class ModelWidgets(View): def _build_exposed_number(self, node): number = widgets.IntSlider(value=node.number, min=1, max=200, step=1) - def exposed_number_change(change): + def on_exposed_number_change(change): node.number = change['new'] # TODO: Link the state back to the widget, not just the other way around. - number.observe(exposed_number_change, names=['value']) + number.observe(on_exposed_number_change, names=['value']) return widgets.HBox([widgets.Label('Number of exposed people in the room '), number], layout=widgets.Layout(justify_content='space-between')) @@ -704,10 +700,10 @@ class ModelWidgets(View): def _build_infected_number(self, node): number = widgets.IntSlider(value=node.number, min=1, max=200, step=1) - def infected_number_change(change): + def on_infected_number_change(change): node.number = change['new'] # TODO: Link the state back to the widget, not just the other way around. - number.observe(infected_number_change, names=['value']) + number.observe(on_infected_number_change, names=['value']) return widgets.HBox([widgets.Label('Number of infected people in the room '), number], layout=widgets.Layout(justify_content='space-between')) @@ -729,11 +725,11 @@ class ModelWidgets(View): def _build_viral_load(self, node): viral_load_in_sputum = widgets.Text(continuous_update=False, value=("{:.2e}".format(node.viral_load_in_sputum))) - def viral_load_change(change): + def on_viral_load_change(change): viral_load_in_sputum.value = "{:.2e}".format(float(change['new'])) node.viral_load_in_sputum = float(viral_load_in_sputum.value) - viral_load_in_sputum.observe(viral_load_change, names=['value']) + viral_load_in_sputum.observe(on_viral_load_change, names=['value']) return widgets.HBox([widgets.Label("Viral load (copies/ml)"), viral_load_in_sputum], layout=widgets.Layout(justify_content='space-between')) @@ -832,14 +828,14 @@ class ModelWidgets(View): transmissibility_factor.value = virus.transmissibility_factor infectious_dose.value = virus.infectious_dose - def transmissibility_change(change): + def on_transmissibility_change(change): virus = models.SARSCoV2(viral_load_in_sputum=ModelWidgets._build_viral_load(self, node).children[1].value, infectious_dose=infectious_dose.value, viable_to_RNA_ratio=0.5, transmissibility_factor=change['new']) node.dcs_update_from(virus) if (transmissibility_factor.value != models.Virus.types[virus_choice.value].transmissibility_factor): virus_choice.options = list(models.Virus.types.keys()) + ["Custom"] virus_choice.value = "Custom" - def infectious_dose_change(change): + def on_infectious_dose_change(change): virus = models.SARSCoV2(viral_load_in_sputum=ModelWidgets._build_viral_load(self, node).children[1].value, infectious_dose=change['new'], viable_to_RNA_ratio=0.5, transmissibility_factor=transmissibility_factor.value) node.dcs_update_from(virus) if (infectious_dose.value != models.Virus.types[virus_choice.value].infectious_dose): @@ -847,8 +843,8 @@ class ModelWidgets(View): virus_choice.value = "Custom" virus_choice.observe(on_virus_change, names=['value']) - transmissibility_factor.observe(transmissibility_change, names=['value']) - infectious_dose.observe(infectious_dose_change, names=['value']) + transmissibility_factor.observe(on_transmissibility_change, names=['value']) + infectious_dose.observe(on_infectious_dose_change, names=['value']) space_between=widgets.Layout(justify_content='space-between') return widgets.VBox([ @@ -861,10 +857,9 @@ class ModelWidgets(View): baseline_model = models.ExposureModel( concentration_model=models.ConcentrationModel( - room=models.Room(volume=75, humidity=0.5), + room=models.Room(volume=75, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))), ventilation=models.SlidingWindow( - active=models.PeriodicInterval(period= 120, duration= 15, start=8.0), - inside_temp=models.PiecewiseConstant((0., 24.), (293.15,)), + active=models.PeriodicInterval(period=120, duration=15), outside_temp=models.PiecewiseConstant((0., 24.), (283.15,)), window_height=1.6, opening_length=0.6, ), @@ -923,7 +918,6 @@ class CARAStateBuilder(state.StateBuilder): #Initialise the "Hinged window" state s._states['Hinged window'].dcs_update_from( models.HingedWindow(active=models.PeriodicInterval(period=120, duration=15), - inside_temp=models.PiecewiseConstant((0,24.), (293.15,)), outside_temp=models.PiecewiseConstant((0,24.), (283.15,)), window_height=1.6, opening_length=0.6, window_width=10. @@ -1138,4 +1132,4 @@ def models_start_end(models: typing.Sequence[models.ExposureModel]) -> typing.Tu """ infected_start = min(model.concentration_model.infected.presence.boundaries()[0][0] for model in models) infected_finish = min(model.concentration_model.infected.presence.boundaries()[-1][1] for model in models) - return infected_start, infected_finish + return infected_start, infected_finish \ No newline at end of file diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 499fc761..e1b66074 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -137,6 +137,8 @@ + +
diff --git a/cara/apps/templates/base/index.html.j2 b/cara/apps/templates/base/index.html.j2 index d6ee53d6..69a6511c 100644 --- a/cara/apps/templates/base/index.html.j2 +++ b/cara/apps/templates/base/index.html.j2 @@ -64,6 +64,7 @@ For use of the CARA web app: