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:
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(