diff --git a/cara/apps/calculator/README.md b/cara/apps/calculator/README.md index ef6883ae..641e3081 100644 --- a/cara/apps/calculator/README.md +++ b/cara/apps/calculator/README.md @@ -97,7 +97,9 @@ As an example, for a shared office with 4 people, where one person is infected, There are three predefined activities in the tool at present. -**Office / Meeting ** = All persons seated, talking. Everyone (occupants and infected occupants) is treated the same in this model. +**Office / Meeting ** = All persons seated, talking occasionally (1/3rd of the time, with normal breathing the other 2/3rds of the time). Everyone (occupants and infected occupants) is treated the same in this model. + +**Call Centre** = All persons seated, all talking simultaneously, all the time. This is a conservative profile (i.e. will show in increased ``P(i)`` compared to office/meeting) if used for office activity. **Workshop** = Based on a mechanical assembly workshop, all persons are doing light exercise (standing, moving around, using tools) and talking. Everyone (occupants and infected occupants) is treated the same in this model. diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 9b1d2ad4..3dd6a5e4 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -2,6 +2,8 @@ from dataclasses import dataclass import html import typing +import numpy as np + from cara import models from cara import data @@ -161,6 +163,67 @@ class FormData: else: return ventilation + def mask(self) -> models.Mask: + # Initializes the mask type if mask wearing is "continuous", otherwise instantiates the mask attribute as + # the "No mask"-mask + mask = models.Mask.types[self.mask_type if self.mask_wearing == "continuous" else 'No mask'] + return mask + + def infected_population(self) -> models.InfectedPopulation: + # Initializes the virus as SARS_Cov_2 + virus = models.Virus.types['SARS_CoV_2'] + + scenario_activity_and_expiration = { + 'office': ( + 'Seated', + # Mostly silent in the office, but 1/3rd of time talking. + {'Talking': 1, 'Breathing': 2} + ), + 'callcentre': ('Seated', 'Talking'), + 'training': ('Light exercise', 'Talking'), + 'workshop': ('Light exercise', 'Talking'), + } + + [activity_defn, expiration_defn] = scenario_activity_and_expiration[self.activity_type] + activity = models.Activity.types[activity_defn] + expiration = build_expiration(expiration_defn) + + infected_occupants = self.infected_people + + infected = models.InfectedPopulation( + number=infected_occupants, + virus=virus, + presence=self.infected_present_interval(), + mask=self.mask(), + activity=activity, + expiration=expiration + ) + return infected + + def exposed_population(self) -> models.Population: + scenario_activity = { + 'office': 'Seated', + 'callcentre': 'Seated', + 'training': 'Light exercise', + 'workshop': 'Light exercise', + } + + activity_defn = scenario_activity[self.activity_type] + activity = models.Activity.types[activity_defn] + + infected_occupants = self.infected_people + # The number of exposed occupants is the total number of occupants + # minus the number of infected occupants. + exposed_occupants = self.total_people - infected_occupants + + exposed = models.Population( + number=exposed_occupants, + presence=self.exposed_present_interval(), + activity=activity, + mask=self.mask(), + ) + return exposed + def coffee_break_times(self) -> typing.Tuple[typing.Tuple[int, int]]: if not self.coffee_breaks: return () @@ -228,6 +291,38 @@ class FormData: return self.present_interval(self.activity_start, self.activity_finish) +def build_expiration(expiration_definition) -> models.Expiration: + if isinstance(expiration_definition, str): + return models.Expiration.types[expiration_definition] + elif isinstance(expiration_definition, dict): + return expiration_blend({ + build_expiration(exp): amount + for exp, amount in expiration_definition.items() + } + ) + + +def expiration_blend(expiration_weights: typing.Dict[models.Expiration, int]) -> models.Expiration: + """ + Combine together multiple types of Expiration, using a weighted mean to + compute their ejection factor and particle sizes. + + """ + ejection_factor = np.zeros(4) + particle_sizes = np.zeros(4) + + total_weight = 0 + for expiration, weight in expiration_weights.items(): + total_weight += weight + ejection_factor += np.array(expiration.ejection_factor) * weight + particle_sizes += np.array(expiration.particle_sizes) * weight + + return models.Expiration( + ejection_factor=tuple(ejection_factor/total_weight), + particle_sizes=tuple(particle_sizes/total_weight), + ) + + def model_from_form(form: FormData) -> models.ExposureModel: # Initializes room with volume either given directly or as product of area and height if form.volume_type == 'room_volume': @@ -236,50 +331,14 @@ def model_from_form(form: FormData) -> models.ExposureModel: volume = form.floor_area * form.ceiling_height room = models.Room(volume=volume) - # Initializes the virus as SARS_Cov_2 - virus = models.Virus.types['SARS_CoV_2'] - - # Initializes the mask type if mask wearing is "continuous", otherwise instantiates the mask attribute as - # the "No mask"-mask - mask = models.Mask.types[form.mask_type if form.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': (('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[form.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 = form.infected_people - # Defines the number of exposed occupants as the total number of occupants minus the number of infected occupants - exposed_occupants = form.total_people - infected_occupants - # Initializes and returns a model with the attributes defined above return models.ExposureModel( concentration_model=models.ConcentrationModel( room=room, ventilation=form.ventilation(), - infected=models.InfectedPopulation( - number=infected_occupants, - virus=virus, - presence=form.infected_present_interval(), - mask=mask, - activity=infected_activity, - expiration=infected_expiration - ), + infected=form.infected_population(), ), - exposed=models.Population( - number=exposed_occupants, - presence=form.exposed_present_interval(), - activity=exposed_activity, - mask=mask, - ) + exposed=form.exposed_population() ) @@ -323,7 +382,7 @@ def baseline_raw_form_data(): } -ACTIVITY_TYPES = {'office', 'training', 'workshop'} +ACTIVITY_TYPES = {'office', 'training', 'callcentre', 'workshop'} EVENT_TYPES = {'single_event', 'recurrent_event'} MECHANICAL_VENTILATION_TYPES = {'air_changes', 'air_supply', 'not-applicable'} MASK_TYPES = {'Type I', 'FFP2'} diff --git a/cara/apps/calculator/templates/calculator.form.html.j2 b/cara/apps/calculator/templates/calculator.form.html.j2 index 83b0eb0c..9fd342bd 100644 --- a/cara/apps/calculator/templates/calculator.form.html.j2 +++ b/cara/apps/calculator/templates/calculator.form.html.j2 @@ -111,6 +111,7 @@ Activity type:
@@ -206,8 +207,9 @@ Activity types:
The type of activity that includes both the infected and exposed persons: Activity breaks:
diff --git a/cara/apps/calculator/templates/report.html.j2 b/cara/apps/calculator/templates/report.html.j2 index be04900f..a373d786 100644 --- a/cara/apps/calculator/templates/report.html.j2 +++ b/cara/apps/calculator/templates/report.html.j2 @@ -67,9 +67,12 @@
  • Number of attendees and infected people: {{ form.total_people }} in attendance, of whom {{ form.infected_people }} {{ "is" if form.infected_people == 1 else "are" }} infected.

  • -
  • Activity type: +

  • + Activity type: {% if form.activity_type == "office" %} - Office/Meeting – typical scenario with all persons seated, talking. + Office/Meeting – typical scenario with all persons seated, talking occasionally. + {% elif form.activity_type == "callcentre" %} + Call Centre = typical office scenario with all persons seated, all talking continuously. {% elif form.activity_type == "workshop" %} Workshop = assembly workshop environment, all persons doing light exercise, talking. {% elif form.activity_type == "training" %} diff --git a/cara/models.py b/cara/models.py index 12f85c7c..fdfb774e 100644 --- a/cara/models.py +++ b/cara/models.py @@ -172,7 +172,7 @@ class MultipleVentilation: @abstractmethod def air_exchange(self, room: Room, time: float) -> float: """ - Returns the rate at which air is being exchanged in the given room + Returns the rate at which air is being exchanged in the given room at a given time (in hours). """ return sum([ventilation.air_exchange(room,time) @@ -271,7 +271,7 @@ class AirChange(Ventilation): #: The interval in which the ventilation is operating. active: Interval - #: The rate (in h^-1) at which the ventilation exchanges all the air + #: The rate (in h^-1) at which the ventilation exchanges all the air # of the room (when switched on) air_exch: float diff --git a/cara/tests/apps/calculator/test_model_generator.py b/cara/tests/apps/calculator/test_model_generator.py index dda64995..64c741c9 100644 --- a/cara/tests/apps/calculator/test_model_generator.py +++ b/cara/tests/apps/calculator/test_model_generator.py @@ -21,6 +21,15 @@ def test_model_from_dict(baseline_form_data): # assert model.ventilation == cara.models.Ventilation() +def test_blend_expiration(): + blend = {'Breathing': 2, 'Talking': 1} + r = model_generator.build_expiration(blend) + expected = models.Expiration( + (0.13466666666666668, 0.02866666666666667, 0.004333333333333334, 0.005) + ) + assert r == expected + + def test_ventilation_window(baseline_form): room = models.Room(75) window = models.WindowOpening(