From e4d5066c876efc063c00cd1a33f976a85907a8d6 Mon Sep 17 00:00:00 2001 From: gaazzopa Date: Wed, 4 Nov 2020 22:18:08 +0100 Subject: [PATCH 1/3] Added foundations of calculator report page --- cara/apps/calculator/__init__.py | 33 +++++++ cara/apps/calculator/static/css/report.css | 43 +++++++++ .../calculator/static/images/disclaimer.jpg | Bin 0 -> 32622 bytes cara/apps/calculator/templates/report.html.j2 | 91 ++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 cara/apps/calculator/static/css/report.css create mode 100644 cara/apps/calculator/static/images/disclaimer.jpg create mode 100644 cara/apps/calculator/templates/report.html.j2 diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index e099853b..09023af4 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -1,10 +1,12 @@ import json from pathlib import Path +import jinja2 from tornado.web import Application, RequestHandler, StaticFileHandler import cara.models +from datetime import datetime def build_model(request: dict) -> cara.models.Model: return None @@ -34,6 +36,34 @@ class ConcentrationModel(RequestHandler): self.write(response_json) +class StaticModel(RequestHandler): + def get(self): + + import cara.apps.expert + model = cara.apps.expert.baseline_model + + 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') + self.write(template.render(**context)) + + def make_app(debug=False, prefix='/calculator'): static_dir = Path(__file__).absolute().parent / 'static' urls = [ @@ -43,6 +73,9 @@ def make_app(debug=False, prefix='/calculator'): ( prefix + r'/api/calculator', ConcentrationModel ), + ( + prefix + r'/baseline-model/result', StaticModel + ), ( prefix + r'/static/(.*)', StaticFileHandler, 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/images/disclaimer.jpg b/cara/apps/calculator/static/images/disclaimer.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bb12e7988d6c7d600888951f052a7a2906d8844b GIT binary patch literal 32622 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 literal 0 HcmV?d00001 diff --git a/cara/apps/calculator/templates/report.html.j2 b/cara/apps/calculator/templates/report.html.j2 new file mode 100644 index 00000000..c714ec60 --- /dev/null +++ b/cara/apps/calculator/templates/report.html.j2 @@ -0,0 +1,91 @@ + + + + + + + + + + + +

+ VERY IMPORTANT DISCLAIMER

+ +

Output from CARA - COVID Airborne Risk Assessment tool

+ +

Created {{ creation_date }} using model version {{ model_version }}


+ +

Simulation Name: {{ simulation_name }}

+

Room Number: {{ room_number }}

+ +

Input data:

+
    +
  • Room Volume: {{ room_volume }} m³

  • +
+ +

Ventilation data:

+
    +
  • Mechanical ventilation: {{ mechanical_ventilation }}

  • + +
      +
    • Air supply flow rate: {{ air_supply }}

    • +
    • Air changes per hour: {{ air_changes }}

    • +
    + +
  • Natural ventilation: No

  • + +
  • HEPA Filtration: {{ hepa_filtration }}

  • + +
+ +

Event data:

+
    +
  • Number of attendees and infected people: {{ total_people }} in attendance, of whom {{ infected_people }} are infected.

  • +
  • Activity type: {{ activity_type }}

  • +
      +
    • Start time: {{ activity_start }} End time: {{ activity_finish }}

    • +
    +
  • Exposure time (presence of infected person):

  • +
      +
    • Start time: {{ exposure_start }} End time: {{ exposure_finish }}

    • +
    +
  • Single event on {{ single_event_date }}

  • +
+ +

Break data:

+
    +
  • Lunchbreak: {{ lunch_option }} Start: {{ lunch_start }} End: {{ lunch_end }}

  • +
  • Coffee breaks: {{ coffee_option }} Number: {{ coffee_number }} Duration: {{ coffee_duration }}

  • +
      +
    • Coffee break 1: Start: {{ coffee_start1 }} End: {{ coffee_finish1 }}

    • +
    • Coffee break 2: Start: {{ coffee_start2 }} End: {{ coffee_finish2 }}

    • +
    • Coffee break 3: Start: {{ coffee_start3 }} End: {{ coffee_finish3 }}

    • +
    • Coffee break 4: Start: {{ coffee_start4 }} End: {{ coffee_finish4 }}

    • +
    +
+ + + +

Mask wearing:

+
    +
  • Masks worn at workstations? {{ mask_wearing }}

  • +
  • Mask type: Type 1 (default, for now)

  • +
+ +

Results:

+

In this scenario, the estimated probability of one exposed occupant getting infected (Pi) is {{ infection_probability }} % and the estimated basic reproduction rate (R0) rate is {{ reproduction_rate }}. If you have selected a recurrent event, this is the probability per day, and is cumulative over the number of days.

+

Exposure graph: + + + \ No newline at end of file From cecd0291422ae9df7585b8e9254792f99501da22 Mon Sep 17 00:00:00 2001 From: markus Date: Thu, 5 Nov 2020 08:57:08 +0100 Subject: [PATCH 2/3] Initial cara.calculator.model_generator implementation from @mrognlie. --- cara/apps/calculator/model_generator.py | 163 ++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 cara/apps/calculator/model_generator.py diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py new file mode 100644 index 00000000..e661771d --- /dev/null +++ b/cara/apps/calculator/model_generator.py @@ -0,0 +1,163 @@ +from typing import Dict, Any +from cara import models +from numpy import linspace + + +def dict_from_json(file: str) -> Dict[str, str]: + raise NotImplementedError + + +def model_from_dict(d: Dict[str, str]) -> models.Model: + # 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']) * float(d['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': (('Standing', '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.Activity.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 generate_data_from_model(model: models.Model) -> Dict[str, Any]: + resolution = 600 + times = list(linspace(0, 10, resolution)) + concentrations = [model.concentration(time) for time in times] + highest_const = max(concentrations) + prob = model.infection_probability() + er = model.infected.emission_rate(0) + exposed_occupants = model.exposed_occupants + r0 = prob * exposed_occupants / 100 + return {'times': times, + 'concentrations': concentrations, + 'highest_const': highest_const, + 'prob_inf': prob, + 'emission_rate': er, + 'exposed_occupants': exposed_occupants, + 'R0': r0} + + +def create_test_model(d: Dict[str, str]) -> models.Model: + assert 'room_volume' in d + return models.Model( + room=models.Room(volume=int(d['room_volume'])), + ventilation=models.WindowOpening( + active=models.PeriodicInterval(period=120, duration=120), + inside_temp=293, outside_temp=283, cd_b=0.6, + window_height=1.6, opening_length=0.6, + ), + infected=models.InfectedPerson( + virus=models.Virus.types['SARS_CoV_2'], + presence=models.SpecificInterval(((0, 4), (5, 8))), + mask=models.Mask.types['No mask'], + activity=models.Activity.types['Light exercise'], + expiration=models.Expiration.types['Unmodulated Vocalization'], + ), + infected_occupants=1, + exposed_occupants=10, + exposed_activity=models.Activity.types['Light exercise'], + ) From f7853feb6f482f618cbc271d3d7ab295661bb967 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 5 Nov 2020 10:55:19 +0100 Subject: [PATCH 3/3] Restructure the calculator app to have a strong separation between the form input and the form processing. --- cara/apps/calculator/__init__.py | 64 ++++------ cara/apps/calculator/model_generator.py | 119 +++++++++++------- cara/apps/calculator/report_generator.py | 30 +++++ cara/apps/calculator/static/form.html | 50 ++++---- cara/tests/apps/__init__.py | 0 cara/tests/apps/calculator/__init__.py | 0 .../apps/calculator/test_model_generator.py | 25 ++++ .../{test_apps.py => apps/test_expert_app.py} | 0 8 files changed, 179 insertions(+), 109 deletions(-) create mode 100644 cara/apps/calculator/report_generator.py create mode 100644 cara/tests/apps/__init__.py create mode 100644 cara/tests/apps/calculator/__init__.py create mode 100644 cara/tests/apps/calculator/test_model_generator.py rename cara/tests/{test_apps.py => apps/test_expert_app.py} (100%) diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index 09023af4..e64278ea 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -1,19 +1,13 @@ import json from pathlib import Path -import jinja2 from tornado.web import Application, RequestHandler, StaticFileHandler -import cara.models - -from datetime import datetime - -def build_model(request: dict) -> cara.models.Model: - return None +from . import model_generator +from .report_generator import build_report -def build_response(model: cara.models.Model): - return {'items': 'foobar'} +DEBUG = True class ConcentrationModel(RequestHandler): @@ -21,47 +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): - - import cara.apps.expert - model = cara.apps.expert.baseline_model - - 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') - self.write(template.render(**context)) + 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'): @@ -71,7 +59,7 @@ 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 diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index e661771d..23744597 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -1,18 +1,50 @@ -from typing import Dict, Any +from cara.models import Model +from dataclasses import dataclass +import typing + from cara import models -from numpy import linspace -def dict_from_json(file: str) -> Dict[str, str]: - raise NotImplementedError +@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_dict(d: Dict[str, str]) -> models.Model: +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']) * float(d['ceiling_height'])) + 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 @@ -94,13 +126,13 @@ def model_from_dict(d: Dict[str, str]) -> models.Model: # I.e. (infected_activity, infected_expiration), (exposed_activity, exposed_expiration) activity_dict = {'Office/Meeting': (('Seated', 'Talking'), ('Seated', 'Talking')), - 'Training': (('Standing', 'Talking'), ('Seated', 'Whispering')), + '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.Activity.types[exposed_expiration] + 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 @@ -123,41 +155,36 @@ def model_from_dict(d: Dict[str, str]) -> models.Model: ) -def generate_data_from_model(model: models.Model) -> Dict[str, Any]: - resolution = 600 - times = list(linspace(0, 10, resolution)) - concentrations = [model.concentration(time) for time in times] - highest_const = max(concentrations) - prob = model.infection_probability() - er = model.infected.emission_rate(0) - exposed_occupants = model.exposed_occupants - r0 = prob * exposed_occupants / 100 - return {'times': times, - 'concentrations': concentrations, - 'highest_const': highest_const, - 'prob_inf': prob, - 'emission_rate': er, - 'exposed_occupants': exposed_occupants, - 'R0': r0} - - -def create_test_model(d: Dict[str, str]) -> models.Model: - assert 'room_volume' in d - return models.Model( - room=models.Room(volume=int(d['room_volume'])), - ventilation=models.WindowOpening( - active=models.PeriodicInterval(period=120, duration=120), - inside_temp=293, outside_temp=283, cd_b=0.6, - window_height=1.6, opening_length=0.6, - ), - infected=models.InfectedPerson( - virus=models.Virus.types['SARS_CoV_2'], - presence=models.SpecificInterval(((0, 4), (5, 8))), - mask=models.Mask.types['No mask'], - activity=models.Activity.types['Light exercise'], - expiration=models.Expiration.types['Unmodulated Vocalization'], - ), - infected_occupants=1, - exposed_occupants=10, - exposed_activity=models.Activity.types['Light exercise'], - ) +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/form.html b/cara/apps/calculator/static/form.html index 5ceb664f..7b1f9501 100644 --- a/cara/apps/calculator/static/form.html +++ b/cara/apps/calculator/static/form.html @@ -12,11 +12,11 @@ - +

CARA Covid Calculator

-
+
@@ -190,7 +190,7 @@ var request; function on_submit(form){ // Prevent default posting of form - put here to work in case of errors - event.preventDefault(); + // event.preventDefault(); // Abort any pending request if (request) @@ -199,32 +199,32 @@ function on_submit(form){ // Let's select and cache all the fields var $inputs = $(form).find("input, select, button, textarea"); - // Serialize the data in the form - var serializedData = objectifyForm($(form).serializeArray()); + // // Serialize the data in the form + // var serializedData = objectifyForm($(form).serializeArray()); - console.log(['Sending over', JSON.stringify(serializedData)]) + // console.log(['Sending over', JSON.stringify(serializedData)]) - // Fire off the request to the calculator. - request = $.ajax({ - url: "/calculator/api/calculator", - type: "post", - data: serializedData, - dataType: "json", - }); + // // Fire off the request to the calculator. + // request = $.ajax({ + // url: "/calculator/report", + // type: "post", + // data: serializedData, + // dataType: "json", + // }); - // Callback handler that will be called on success - request.done(function (response, textStatus, jqXHR){ - build_report(response); - }); + // // Callback handler that will be called on success + // request.done(function (response, textStatus, jqXHR){ + // build_report(response); + // }); - // Callback handler that will be called on failure - request.fail(function (jqXHR, textStatus, errorThrown){ - // Log the error to the console - console.error( - "The following error occurred: "+ - textStatus, errorThrown - ); - }); + // // Callback handler that will be called on failure + // request.fail(function (jqXHR, textStatus, errorThrown){ + // // Log the error to the console + // console.error( + // "The following error occurred: "+ + // textStatus, errorThrown + // ); + // }); } // Convert all type int in form diff --git a/cara/tests/apps/__init__.py b/cara/tests/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cara/tests/apps/calculator/__init__.py b/cara/tests/apps/calculator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cara/tests/apps/calculator/test_model_generator.py b/cara/tests/apps/calculator/test_model_generator.py new file mode 100644 index 00000000..05203273 --- /dev/null +++ b/cara/tests/apps/calculator/test_model_generator.py @@ -0,0 +1,25 @@ +import pytest + +from cara.apps.calculator import model_generator + + +@pytest.fixture +def baseline_form_data(): + return model_generator.baseline_raw_form_data() + + +@pytest.fixture +def baseline_form(baseline_form_data): + return model_generator.FormData.from_dict(baseline_form_data) + + +def test_model_from_dict(baseline_form_data): + model = model_generator.FormData.from_dict(baseline_form_data) + # TODO: + # assert model.ventilation == cara.models.Ventilation() + + +def test_ventilation(baseline_form): + ventilation = baseline_form.ventilation() + # TODO: + # assert ventilation == cara.models.Ventilation() diff --git a/cara/tests/test_apps.py b/cara/tests/apps/test_expert_app.py similarity index 100% rename from cara/tests/test_apps.py rename to cara/tests/apps/test_expert_app.py