diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index 51d716da..970cf289 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -5,6 +5,7 @@ import logging import typing import ast import json +import re import numpy as np @@ -32,6 +33,8 @@ class FormData: air_changes: float air_supply: float arve_sensors_option: bool + specific_breaks: list + precise_activity: dict ceiling_height: float exposed_coffee_break_option: str exposed_coffee_duration: int @@ -97,6 +100,8 @@ class FormData: 'air_changes': 0., 'air_supply': 0., 'arve_sensors_option': False, + 'specific_breaks': '[]', + 'precise_activity': '{}', 'calculator_version': _NO_DEFAULT, 'ceiling_height': 0., 'exposed_coffee_break_option': 'coffee_break_0', @@ -307,6 +312,55 @@ class FormData: raise ValueError("mechanical_ventilation_type cannot be 'not-applicable' if " "ventilation_type is 'mechanical_ventilation'") + # Validate specific inputs - breaks + if self.specific_breaks != []: + if type(self.specific_breaks) is not list: + raise TypeError(f'All breaks should be in a list. Got {type(self.specific_breaks)}.') + for input_break in self.specific_breaks: + # Input validations. + if type(input_break) is not dict: + raise TypeError(f'Each break should be a dictionary. Got {type(input_break)}.') + dict_keys = list(input_break.keys()) + if "start_time" not in input_break: + raise TypeError(f'Unable to fetch "start_time" key. Got "{dict_keys[0]}".') + if "finish_time" not in input_break: + raise TypeError(f'Unable to fetch "finish_time" key. Got "{dict_keys[1]}".') + for time in input_break.values(): + if not re.compile("^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$").match(time): + raise TypeError(f'Wrong time format - "HH:MM". Got "{time}".') + + # Validate specific inputs - precise activity + if self.precise_activity != {}: + if type(self.precise_activity) is not dict: + raise TypeError('The precise activities should be in a dictionary.') + + dict_keys = list(self.precise_activity.keys()) + if "physical_activity" not in dict_keys: + raise TypeError(f'Unable to fetch "physical_activity" key. Got "{dict_keys[0]}".') + if "respiratory_activity" not in dict_keys: + raise TypeError(f'Unable to fetch "respiratory_activity" key. Got "{dict_keys[1]}".') + + if type(self.precise_activity['physical_activity']) is not str: + raise TypeError('The physical activities should be a single string.') + + if type(self.precise_activity['respiratory_activity']) is not list: + raise TypeError('The respiratory activities should be in a list.') + + total_percentage = 0 + for respiratory_activity in self.precise_activity['respiratory_activity']: + if type(respiratory_activity) is not dict: + raise TypeError('Each respiratory activity should be defined in a dictionary.') + dict_keys = list(respiratory_activity.keys()) + if "type" not in dict_keys: + raise TypeError(f'Unable to fetch "type" key. Got "{dict_keys[0]}".') + if "percentage" not in dict_keys: + raise TypeError(f'Unable to fetch "percentage" key. Got "{dict_keys[1]}".') + total_percentage += respiratory_activity['percentage'] + + if total_percentage != 100: + raise ValueError(f'The sum of all respiratory activities should be 100. Got {total_percentage}.') + + def build_mc_model(self) -> mc.ExposureModel: # Initializes room with volume either given directly or as product of area and height if self.volume_type == 'room_volume_explicit': @@ -468,6 +522,15 @@ class FormData: mask = models.Mask.types['No mask'] return mask + def generate_precise_activity_expiration(self) -> typing.Tuple[typing.Any, ...]: + if self.precise_activity == {}: # It means the precise activity is not defined by a specific input. + return () + respiratory_dict = {} + for respiratory_activity in self.precise_activity['respiratory_activity']: + respiratory_dict[respiratory_activity['type']] = respiratory_activity['percentage'] + + return (self.precise_activity['physical_activity'], respiratory_dict) + def infected_population(self) -> mc.InfectedPopulation: # Initializes the virus virus = virus_distributions[self.virus_type] @@ -511,8 +574,34 @@ class FormData: #Model 1/2 of time spent speaking in a workshop. {'Speaking': 1, 'Breathing': 1}), 'gym':('Heavy exercise', 'Breathing'), + # Other activity types + 'household-day': ( + 'Light activity', + {'Breathing': 5, 'Speaking': 5} + ), + 'household-night': ( + 'Seated', + {'Breathing': 7, 'Speaking': 3} + ), + 'primary-school': ( + 'Light activity', + {'Breathing': 5, 'Speaking': 5} + ), + 'secondary-school': ( + 'Light activity', + {'Breathing': 7, 'Speaking': 3} + ), + 'university': ( + 'Seated', + {'Breathing': 9, 'Speaking': 1} + ), + 'restaurant': ( + 'Seated', + {'Breathing': 1, 'Speaking': 9} + ), + 'precise': self.generate_precise_activity_expiration(), } - + [activity_defn, expiration_defn] = scenario_activity_and_expiration[self.activity_type] activity = activity_distributions[activity_defn] expiration = build_expiration(expiration_defn) @@ -544,6 +633,13 @@ class FormData: 'workshop': 'Moderate activity', 'lab':'Light activity', 'gym':'Heavy exercise', + 'household-day': 'Light activity', + 'household-night': 'Seated', + 'primary-school': 'Light activity', + 'secondary-school': 'Light activity', + 'university': 'Seated', + 'restaurant': 'Seated', + 'precise': 'Seated', } activity_defn = scenario_activity[self.activity_type] @@ -642,6 +738,20 @@ class FormData: else: return self.exposed_coffee_break_times() + def generate_specific_break_times(self) -> models.BoundarySequence_t: + break_times = [] + for n in self.specific_breaks: + # Parse break times. + begin = time_string_to_minutes(n["start_time"]) + end = time_string_to_minutes(n["finish_time"]) + for time in [begin, end]: + # For a specific break, the infected and exposed presence is the same. + if not getattr(self, 'infected_start') < time < getattr(self, 'infected_finish'): + raise ValueError(f'All breaks should be within the simulation time. Got {time_minutes_to_string(time)}.') + + break_times.append((begin, end)) + return tuple(break_times) + def present_interval( self, start: int, @@ -735,9 +845,13 @@ class FormData: return models.SpecificInterval(tuple(present_intervals)) def infected_present_interval(self) -> models.Interval: + if self.specific_breaks != []: # It means the breaks are specific and not predefined + breaks = self.generate_specific_break_times() + else: + breaks = self.infected_lunch_break_times() + self.infected_coffee_break_times() return self.present_interval( self.infected_start, self.infected_finish, - breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(), + breaks=breaks, ) def short_range_interval(self, interaction) -> models.SpecificInterval: @@ -746,9 +860,13 @@ class FormData: return models.SpecificInterval(present_times=((start_time/60, (start_time + duration)/60),)) def exposed_present_interval(self) -> models.Interval: + if self.specific_breaks != []: # It means the breaks are specific and not predefined + breaks = self.generate_specific_break_times() + else: + breaks = self.exposed_lunch_break_times() + self.exposed_coffee_break_times() return self.present_interval( self.exposed_start, self.exposed_finish, - breaks=self.exposed_lunch_break_times() + self.exposed_coffee_break_times(), + breaks=breaks, ) @@ -828,7 +946,10 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]: } -ACTIVITY_TYPES = {'office', 'smallmeeting', 'largemeeting', 'training', 'training_attendee', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym'} +ACTIVITY_TYPES = { + 'office', 'smallmeeting', 'largemeeting', 'training', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym', + 'household-day', 'household-night', 'primary-school', 'secondary-school', 'university', 'restaurant', 'precise', +} MECHANICAL_VENTILATION_TYPES = {'mech_type_air_changes', 'mech_type_air_supply', 'not-applicable'} MASK_TYPES = {'Type I', 'FFP2', 'Cloth'} MASK_WEARING_OPTIONS = {'mask_on', 'mask_off'} @@ -873,12 +994,20 @@ def time_minutes_to_string(time: int) -> str: return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time%60) -def string_to_list(l: str) -> list: - return list(ast.literal_eval(l.replace(""", "\""))) +def string_to_list(s: str) -> list: + return list(ast.literal_eval(s.replace(""", "\""))) -def list_to_string(s: list) -> str: - return json.dumps(s) +def list_to_string(l: list) -> str: + return json.dumps(l) + + +def string_to_dict(s: str) -> dict: + return dict(ast.literal_eval(s.replace(""", "\""))) + + +def dict_to_string(d: dict) -> str: + return json.dumps(d) def _safe_int_cast(value) -> int: @@ -915,3 +1044,6 @@ for _field in dataclasses.fields(FormData): elif _field.type is list: _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = string_to_list _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = list_to_string + elif _field.type is dict: + _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = string_to_dict + _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = dict_to_string diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2 index bff35ef8..e6326a12 100644 --- a/caimira/apps/templates/base/calculator.form.html.j2 +++ b/caimira/apps/templates/base/calculator.form.html.j2 @@ -18,6 +18,13 @@ {% block main %} +{% if DEBUG %} +