From 041f03c06e8fb219bdfb6cc18a97a238dfa50a26 Mon Sep 17 00:00:00 2001 From: jdevine Date: Thu, 12 Nov 2020 12:20:39 +0100 Subject: [PATCH 1/4] Created new conversation exp. profile, modified office/meeting activity and created call centre activity --- cara/apps/calculator/model_generator.py | 5 +++-- cara/apps/calculator/templates/calculator.form.html.j2 | 4 +++- cara/models.py | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 9b1d2ad4..1da57809 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -247,7 +247,8 @@ def model_from_form(form: FormData) -> models.ExposureModel: # of the infected and exposed occupants respectively. # I.e. (infected_activity, infected_expiration), (exposed_activity, exposed_expiration) - activity_dict = {'office': (('Seated', 'Talking'), ('Seated', 'Talking')), + activity_dict = {'office': (('Seated', 'Conversation'), ('Seated', 'Conversation')), + 'callcentre': (('Seated', 'Talking'), ('Seated', 'Talking')), 'training': (('Light exercise', 'Talking'), ('Seated', 'Whispering')), 'workshop': (('Light exercise', 'Talking'), ('Light exercise', 'Talking'))} @@ -323,7 +324,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/models.py b/cara/models.py index 12f85c7c..ed2f3c83 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 @@ -375,8 +375,10 @@ class Expiration: Expiration.types = { + #added new profile - Conversation = weighting of 1/3rd talking, 2/3 breathing. 'Breathing': Expiration((0.084, 0.009, 0.003, 0.002)), 'Whispering': Expiration((0.11, 0.014, 0.004, 0.002)), + 'Conversation': Expiration((0.135, 0.029, 0.004, 0.005)), 'Talking': Expiration((0.236, 0.068, 0.007, 0.011)), 'Unmodulated Vocalization': Expiration((0.751, 0.139, 0.0139, 0.059)), 'Superspreading event': Expiration((np.inf, np.inf, np.inf, np.inf)), From dee2c0d0ab182555b0426df08d029039e632f082 Mon Sep 17 00:00:00 2001 From: jdevine Date: Thu, 12 Nov 2020 15:05:46 +0100 Subject: [PATCH 2/4] fixed reporting for call centre type --- cara/apps/calculator/templates/report.html.j2 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cara/apps/calculator/templates/report.html.j2 b/cara/apps/calculator/templates/report.html.j2 index be04900f..ce9c6151 100644 --- a/cara/apps/calculator/templates/report.html.j2 +++ b/cara/apps/calculator/templates/report.html.j2 @@ -69,8 +69,10 @@ infected.

  • Activity type: {% if form.activity_type == "office" %} - Office/Meeting – typical scenario with all persons seated, talking. - {% elif form.activity_type == "workshop" %} + 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" %} Training – one person (the trainer) standing, talking, all others seated, talking quietly (whispering). It is assumed the trainer is the infected person, for the worst case scenario. From 842b5418f21d4f2a518f83d2ae8d931dddc6d896 Mon Sep 17 00:00:00 2001 From: jdevine Date: Thu, 12 Nov 2020 16:21:59 +0100 Subject: [PATCH 3/4] Updated readme.md with changed activities --- cara/apps/calculator/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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. From b4ebad785f2be7dce08484fafab19e9a6d8e4d39 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 12 Nov 2020 20:07:38 +0100 Subject: [PATCH 4/4] Allow the COVID calculator to have a blended Expiration type to represent conversations. --- cara/apps/calculator/model_generator.py | 136 +++++++++++++----- cara/apps/calculator/templates/report.html.j2 | 5 +- cara/models.py | 2 - .../apps/calculator/test_model_generator.py | 9 ++ 4 files changed, 109 insertions(+), 43 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 1da57809..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,51 +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', 'Conversation'), ('Seated', 'Conversation')), - 'callcentre': (('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() ) diff --git a/cara/apps/calculator/templates/report.html.j2 b/cara/apps/calculator/templates/report.html.j2 index ce9c6151..a373d786 100644 --- a/cara/apps/calculator/templates/report.html.j2 +++ b/cara/apps/calculator/templates/report.html.j2 @@ -67,12 +67,13 @@

  • 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 occasionally. {% elif form.activity_type == "callcentre" %} Call Centre = typical office scenario with all persons seated, all talking continuously. - {% elif form.activity_type == "workshop" %} + {% elif form.activity_type == "workshop" %} Workshop = assembly workshop environment, all persons doing light exercise, talking. {% elif form.activity_type == "training" %} Training – one person (the trainer) standing, talking, all others seated, talking quietly (whispering). It is assumed the trainer is the infected person, for the worst case scenario. diff --git a/cara/models.py b/cara/models.py index ed2f3c83..fdfb774e 100644 --- a/cara/models.py +++ b/cara/models.py @@ -375,10 +375,8 @@ class Expiration: Expiration.types = { - #added new profile - Conversation = weighting of 1/3rd talking, 2/3 breathing. 'Breathing': Expiration((0.084, 0.009, 0.003, 0.002)), 'Whispering': Expiration((0.11, 0.014, 0.004, 0.002)), - 'Conversation': Expiration((0.135, 0.029, 0.004, 0.005)), 'Talking': Expiration((0.236, 0.068, 0.007, 0.011)), 'Unmodulated Vocalization': Expiration((0.751, 0.139, 0.0139, 0.059)), 'Superspreading event': Expiration((np.inf, np.inf, np.inf, np.inf)), 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(