diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index e099853b..e64278ea 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -3,15 +3,11 @@ from pathlib import Path from tornado.web import Application, RequestHandler, StaticFileHandler -import cara.models +from . import model_generator +from .report_generator import build_report -def build_model(request: dict) -> cara.models.Model: - return None - - -def build_response(model: cara.models.Model): - return {'items': 'foobar'} +DEBUG = True class ConcentrationModel(RequestHandler): @@ -19,19 +15,41 @@ class ConcentrationModel(RequestHandler): requested_model_config = { name: self.get_argument(name) for name in self.request.arguments } + if DEBUG: + from pprint import pprint + pprint(requested_model_config) + try: - model = build_model(requested_model_config) + form = model_generator.FormData.from_dict(requested_model_config) + model = form.build_model( + # TODO: This argument to be removed. + tmp_raw_form_data=requested_model_config, + ) except (KeyboardInterrupt, SystemExit): raise except Exception as err: + if DEBUG: + import traceback + traceback.print_last() response_json = {'code': 400, 'error': f'Your request was invalid {err}'} self.set_status(400) self.finish(json.dumps(response_json)) return - response_json = build_response(model) - response_json['room_name'] = requested_model_config.get('room_name', 'unknown') - self.write(response_json) + report = build_report(model, form) + self.finish(report) + + +class StaticModel(RequestHandler): + def get(self): + requested_model_config = model_generator.baseline_raw_form_data() + form = model_generator.FormData.from_dict(model_generator.baseline_raw_form_data()) + model = form.build_model( + # TODO: This argument to be removed. + tmp_raw_form_data=requested_model_config, + ) + report = build_report(model, form) + self.finish(report) def make_app(debug=False, prefix='/calculator'): @@ -41,7 +59,10 @@ def make_app(debug=False, prefix='/calculator'): prefix + r'()', StaticFileHandler, {'path': static_dir / 'form.html'} ), ( - prefix + r'/api/calculator', ConcentrationModel + prefix + r'/report', ConcentrationModel + ), + ( + prefix + r'/baseline-model/result', StaticModel ), ( prefix + r'/static/(.*)', diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py new file mode 100644 index 00000000..23744597 --- /dev/null +++ b/cara/apps/calculator/model_generator.py @@ -0,0 +1,190 @@ +from cara.models import Model +from dataclasses import dataclass +import typing + +from cara import models + + +@dataclass +class FormData: + ceiling_height: float + + @classmethod + def from_dict(cls, form_data: typing.Dict) -> "FormData": + # TODO: This fixup is a problem with the form.html. + form_data['ceiling_height'] = 1 + + return cls( + ceiling_height=float(form_data['ceiling_height']), + ) + + # TODO: Remove the tmp_raw_form_data usage. + def build_model(self, tmp_raw_form_data) -> Model: + return model_from_form(self, tmp_raw_form_data) + + def ventilation(self) -> models.Ventilation: + # TODO + pass + + def present_interval(self) -> models.Interval: + # TODO + pass + + +def model_from_form(form: FormData, tmp_raw_form_data) -> models.Model: + d = tmp_raw_form_data + + # TODO: This fixup is a problem with the form.html. + d['coffee_breaks'] = 1 + d['activity_type'] = 'Training' + d['lunch_start'] = '12:00' + d['lunch_finish'] = '13:00' + + # Initializes room with volume either given directly or as product of area and height + if d['volume_type'] == 'room_volume': + volume = int(d['room_volume']) + else: + volume = int(float(d['floor_area']) * form.ceiling_height) + room = models.Room(volume=volume) + + # Initializes a ventilation instance as a window if 'natural' is selected, or as a HEPA-filter otherwise + if d['ventilation_type'] == 'natural': + if d['windows_open'] == 'always': + period, duration = 120, 120 + else: + period, duration = 15, 120 + # I multiply the opening width by the number of windows to simulate the correct window area + ventilation = models.WindowOpening(active=models.PeriodicInterval(period=period, duration=duration), + inside_temp=293, outside_temp=283, cd_b=0.6, + window_height=float(d['window_height']), + opening_length=float(d['opening_distance']) * int(d['windows_number'])) + else: + q_air_mech = float(d['air_changes']) if d['air_type'] == 'air_changes' else float(d['air_supply']) + ventilation = models.HEPAFilter(active=models.PeriodicInterval(period=120, duration=120), + q_air_mech=q_air_mech) + + # Initializes the virus as SARS_Cov_2 + virus = models.Virus.types['SARS_CoV_2'] + + # Defines all of the parameters required to construct a list of intervals where the infected person is present in + # the room + activity_start = int(d['activity_start'][:2]) * 60 + int(d['activity_start'][3:]) + activity_finish = int(d['activity_finish'][:2]) * 60 + int(d['activity_finish'][3:]) + lunch_start = int(d['lunch_start'][:2]) * 60 + int(d['lunch_start'][3:]) + lunch_finish = int(d['lunch_finish'][:2]) * 60 + int(d['lunch_finish'][3:]) + coffee_duration = int(d['coffee_duration']) + coffee_breaks = int(d['coffee_breaks']) + coffee_period = (activity_finish - activity_start) // coffee_breaks + 1 + leave_times = [lunch_start] + enter_times = [lunch_finish] + for minute in range(activity_start, activity_finish, coffee_period): + leave_times.append(minute) + enter_times.append(minute + coffee_duration) + + # These lists represent the times where the infected person leaves or enters the room, respectively, sorted in + # reverse order. Note that these lists allows the person to "leave" when they should not even be present in the room + # The following loop handles this. + leave_times.sort(reverse=True) + enter_times.sort(reverse=True) + + # This loop iterates through the lists above, populating present_intervals with (enter, leave) intervals + # representing the infected person entering and leaving the room. Note that if one of the evenly spaced coffee- + # breaks happens to coincide with the lunch-break, it is simply ignored. + is_present = True + present_intervals = [] + time = activity_start + while time < activity_finish: + if is_present: + if not leave_times: + present_intervals.append((time / 60, activity_finish / 60)) + break + + if leave_times[-1] < time: + leave_times.pop() + else: + new_time = leave_times.pop() + present_intervals.append((time / 60, min(new_time, activity_finish) / 60)) + is_present = False + time = new_time + + else: + if not enter_times: + break + + if enter_times[-1] < time: + enter_times.pop() + else: + is_present = True + time = enter_times.pop() + + # Initializes a mask of type 1 if mask wearing is "continuous", otherwise instantiates the mask attribute as + # the "No mask"-mask + mask = models.Mask.types['Type I' if d['mask_wearing'] == "Continuous" else 'No mask'] + + # A dictionary containing the mapping of activities listed in the UI to the activity level and expiration level + # of the infected and exposed occupants respectively. + # I.e. (infected_activity, infected_expiration), (exposed_activity, exposed_expiration) + + activity_dict = {'Office/Meeting': (('Seated', 'Talking'), ('Seated', 'Talking')), + 'Training': (('Light exercise', 'Talking'), ('Seated', 'Whispering')), + 'Workshop': (('Light exercise', 'Talking'), ('Light exercise', 'Talking'))} + + (infected_activity, infected_expiration), (exposed_activity, exposed_expiration) = activity_dict[d['activity_type']] + # Converts these strings to Activity and Expiration instances + infected_activity, exposed_activity = models.Activity.types[infected_activity], models.Activity.types[exposed_activity] + infected_expiration, exposed_expiration = models.Expiration.types[infected_expiration], models.Expiration.types[exposed_expiration] + + infected_occupants = int(d['infected_people']) + # Defines the number of exposed occupants as the total number of occupants minus the number of infected occupants + exposed_occupants = int(d['total_people']) - infected_occupants + + # Initializes and returns a model with the attributes defined above + return models.Model( + room=room, + ventilation=ventilation, + infected=models.InfectedPerson( + virus=virus, + presence=models.SpecificInterval(tuple(present_intervals)), + mask=mask, + activity=infected_activity, + expiration=infected_expiration + ), + infected_occupants=infected_occupants, + exposed_occupants=exposed_occupants, + exposed_activity=exposed_activity + ) + + +def baseline_raw_form_data(): + # Note: This isn't a special "baseline". It can be updated as required. + return { + 'activity_finish': '17:00', + 'activity_start': '09:00', + 'activity_type': 'training', + 'air_changes': '', + 'air_supply': '', + 'ceiling_height': '', + 'coffee_breaks': '', + 'coffee_duration': '1', + 'coffee_option': '0', + 'event_type': 'single_event', + 'floor_area': '', + 'infected_people': '1', + 'lunch_finish': '13:30', + 'lunch_option': '1', + 'lunch_start': '12:30', + 'mask_wearing': 'removed', + 'opening_distance': '15', + 'recurrent_event_month': 'January', + 'room_number': 'baseline room', + 'room_volume': '75', + 'simulation_name': 'Baseline simulation', + 'single_event_date': '11/02/2020', + 'total_people': '10', + 'ventilation_type': 'natural', + 'volume_type': 'room_volume', + 'window_height': '2', + 'window_width': '2', + 'windows_number': '1', + 'windows_open': 'interval' + } diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py new file mode 100644 index 00000000..94ece764 --- /dev/null +++ b/cara/apps/calculator/report_generator.py @@ -0,0 +1,30 @@ +from datetime import datetime +from pathlib import Path + +import jinja2 + +from cara import models +from .model_generator import FormData + + +def build_report(model: models.Model, form: FormData): + now = datetime.now() + time = now.strftime("%d/%m/%Y %H:%M:%S") + request = {'the': 'form', 'request': 'data'} + context = {'model': model, 'request': request, 'creation_date': time, 'model_version': 'Beta v1.0.0', + 'simulation_name': 'SAMPLE', 'room_number': '40/1-02A', 'room_volume': 30, 'mechanical_ventilation': 'Yes', + 'air_supply': 1, 'air_changes': 2, 'windows_number': 5, 'window_height': 2, 'window_width': 1, + 'opening_distance': 0.05, 'windows_open': '20 minutes every 2 hours', 'hepa_filtration': 'No', 'total_people': 8, + 'infected_people': 7, 'activity_type': 'Office work – typical scenario with all persons seated, talking', + 'activity_start': '00:00', 'activity_finish': '01:15', 'exposure_start': '00:00', 'exposure_finish': '01:15', + 'single_event_date': '5th November', 'lunch_option': 'Yes', 'lunch_start': '00:00', 'lunch_finish': '01:15', + 'coffee_option': 'Yes', 'coffee_number': 4,'coffee_duration': 15, 'coffee_start1': '00:00', 'coffee_finish1': '00:00', + 'coffee_start2': '00:00','coffee_finish2': '00:00', 'coffee_start3': '00:00', 'coffee_finish3': '00:00', + 'coffee_start4': '00:00', 'coffee_finish4': '00:00', 'mask_wearing': 'Yes', + 'infection_probability': round(model.infection_probability(), 2), 'reproduction_rate': 2} + + p = Path(__file__).parent / 'templates' + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(Path(p))) + template = env.get_template('report.html.j2') + return template.render(**context) \ No newline at end of file diff --git a/cara/apps/calculator/static/css/report.css b/cara/apps/calculator/static/css/report.css new file mode 100644 index 00000000..eb4363a5 --- /dev/null +++ b/cara/apps/calculator/static/css/report.css @@ -0,0 +1,43 @@ +#body { + top: 10px; + left: 20px; + bottom: 20px; + right: 20px; + padding: 20px; +} + +.image { + display: flex; + align-items: center; + justify-content: center; + font-size: 13pt; +} + +h1{ + text-align: center; +} + +.subtitle { + text-align: center; + font-size: 13pt; + padding-bottom: 15pt; +} + +p.data_title { + font-weight: bold; +} + +p.data_text { + padding-left: 30px; + padding-left:1em; +} + +p.data_subtext { + padding-left: 60px; + margin-left:-3em; +} + +p.result_title { + font-weight: bold; + font-size: 15pt; +} diff --git a/cara/apps/calculator/static/form.html b/cara/apps/calculator/static/form.html index 71d824d4..7d992d64 100644 --- a/cara/apps/calculator/static/form.html +++ b/cara/apps/calculator/static/form.html @@ -12,11 +12,13 @@ -
+ + Beta v1.0.0 Please send feedback to CARA-dev@cern.chCARA Covid Airborne Risk Assessment tool
-