2020-10-20 07:11:28 +00:00
|
|
|
import functools
|
|
|
|
|
import numpy as np
|
|
|
|
|
import typing
|
2020-10-20 12:44:29 +00:00
|
|
|
from abc import abstractmethod
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
2020-11-04 20:09:55 +00:00
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Room:
|
2020-10-20 12:44:29 +00:00
|
|
|
# The total volume of the room
|
2020-11-05 16:22:39 +00:00
|
|
|
volume: float
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Interval:
|
|
|
|
|
"""
|
|
|
|
|
Represents a collection of times in which a "thing" happens.
|
|
|
|
|
|
|
|
|
|
The "thing" may be when an action is taken, such as opening a window, or
|
|
|
|
|
entering a room.
|
|
|
|
|
|
2020-10-27 14:06:28 +00:00
|
|
|
Note that all intervals are open at the start, and closed at the end. So a
|
2020-10-27 13:47:45 +00:00
|
|
|
simple start, stop interval follows::
|
|
|
|
|
|
|
|
|
|
start < t <= end
|
|
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
"""
|
2020-10-27 14:06:28 +00:00
|
|
|
def boundaries(self) -> typing.Tuple[typing.Tuple[float, float], ...]:
|
|
|
|
|
return ()
|
|
|
|
|
|
|
|
|
|
def transition_times(self) -> typing.Set[float]:
|
|
|
|
|
transitions = set()
|
|
|
|
|
for start, end in self.boundaries():
|
|
|
|
|
transitions.update([start, end])
|
|
|
|
|
return transitions
|
|
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
def triggered(self, time: float) -> bool:
|
|
|
|
|
"""Whether the given time falls inside this interval."""
|
2020-10-27 14:06:28 +00:00
|
|
|
for start, end in self.boundaries():
|
|
|
|
|
if start < time <= end:
|
|
|
|
|
return True
|
2020-10-27 05:27:38 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2020-10-27 13:47:45 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class SpecificInterval(Interval):
|
|
|
|
|
#: A sequence of times (start, stop), in hours, that the infected person
|
|
|
|
|
#: is present. The flattened list of times must be strictly monotonically
|
|
|
|
|
#: increasing.
|
|
|
|
|
present_times: typing.Tuple[typing.Tuple[float, float], ...]
|
|
|
|
|
|
2020-10-27 14:06:28 +00:00
|
|
|
def boundaries(self):
|
|
|
|
|
return self.present_times
|
2020-10-27 13:47:45 +00:00
|
|
|
|
|
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class PeriodicInterval(Interval):
|
2020-10-27 13:34:45 +00:00
|
|
|
#: How often does the interval occur (minutes).
|
2020-10-27 05:27:38 +00:00
|
|
|
period: int
|
|
|
|
|
|
2020-10-27 13:34:45 +00:00
|
|
|
#: How long does the interval occur for (minutes).
|
2020-10-27 05:27:38 +00:00
|
|
|
#: A value greater than :data:`period` signifies the event is permanently
|
|
|
|
|
#: occurring, a value of 0 signifies that the event never happens.
|
|
|
|
|
duration: int
|
|
|
|
|
|
2020-10-27 14:06:28 +00:00
|
|
|
def boundaries(self) -> typing.Tuple[typing.Tuple[float, float], ...]:
|
2020-10-27 13:34:45 +00:00
|
|
|
result = []
|
|
|
|
|
for i in np.arange(0, 24, self.period / 60):
|
|
|
|
|
result.append((i, i+self.duration/60))
|
|
|
|
|
return tuple(result)
|
|
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
|
2020-11-04 19:57:49 +00:00
|
|
|
@dataclass(frozen=True)
|
2020-11-05 08:10:07 +00:00
|
|
|
class PiecewiseConstant:
|
2020-11-05 11:08:08 +00:00
|
|
|
|
2020-11-09 15:08:58 +00:00
|
|
|
# TODO: implement rather a periodic version (24-hour period), where
|
|
|
|
|
# transition_times and values have the same length.
|
|
|
|
|
|
2020-11-04 19:57:49 +00:00
|
|
|
#: transition times at which the function changes value (hours).
|
|
|
|
|
transition_times: typing.Tuple[float, ...]
|
|
|
|
|
|
|
|
|
|
#: values of the function between transitions
|
|
|
|
|
values: typing.Tuple[float, ...]
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
if len(self.transition_times) != len(self.values)+1:
|
|
|
|
|
raise ValueError("transition_times should contain one more element than values")
|
2020-11-04 22:35:06 +00:00
|
|
|
if tuple(sorted(set(self.transition_times))) != self.transition_times:
|
2020-11-04 19:57:49 +00:00
|
|
|
raise ValueError("transition_times should not contain duplicated elements and should be sorted")
|
|
|
|
|
|
|
|
|
|
def value(self,time) -> float:
|
2020-11-05 11:08:08 +00:00
|
|
|
if time <= self.transition_times[0]:
|
2020-11-04 19:57:49 +00:00
|
|
|
return self.values[0]
|
2020-11-05 11:08:08 +00:00
|
|
|
if time > self.transition_times[-1]:
|
|
|
|
|
return self.values[-1]
|
|
|
|
|
|
2020-11-04 19:57:49 +00:00
|
|
|
for t1,t2,value in zip(self.transition_times[:-1],
|
|
|
|
|
self.transition_times[1:],self.values):
|
|
|
|
|
if time > t1 and time <= t2:
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
def interval(self) -> Interval:
|
|
|
|
|
# build an Interval object
|
|
|
|
|
present_times = []
|
|
|
|
|
for t1,t2,value in zip(self.transition_times[:-1],
|
|
|
|
|
self.transition_times[1:],self.values):
|
|
|
|
|
if value:
|
|
|
|
|
present_times.append((t1,t2))
|
|
|
|
|
return SpecificInterval(present_times=present_times)
|
|
|
|
|
|
2020-11-09 15:08:58 +00:00
|
|
|
def refine(self,refine_factor=10):
|
|
|
|
|
# build a new PiecewiseConstant object with a refined mesh,
|
|
|
|
|
# using a linear interpolation in-between the initial mesh points
|
|
|
|
|
refined_times = np.linspace(self.transition_times[0],self.transition_times[-1],
|
|
|
|
|
(len(self.transition_times)-1)*refine_factor+1)
|
|
|
|
|
return PiecewiseConstant(tuple(refined_times),
|
|
|
|
|
tuple(np.interp(refined_times[:-1],self.transition_times,
|
|
|
|
|
self.values+(self.values[-1],) ) ) )
|
|
|
|
|
|
2020-11-04 19:57:49 +00:00
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Ventilation:
|
2020-10-20 12:44:29 +00:00
|
|
|
"""
|
2020-10-27 05:27:38 +00:00
|
|
|
Represents a mechanism by which air can be exchanged (replaced/filtered)
|
|
|
|
|
in a time dependent manner.
|
|
|
|
|
|
|
|
|
|
The nature of the various air exchange schemes means that it is expected
|
|
|
|
|
for subclasses of Ventilation to exist. Known subclasses include
|
|
|
|
|
WindowOpening for window based air exchange, and HEPAFilter, for
|
|
|
|
|
mechanical air exchange through a filter.
|
|
|
|
|
|
2020-10-20 12:44:29 +00:00
|
|
|
"""
|
2020-10-27 05:27:38 +00:00
|
|
|
#: The times at which the air exchange is taking place.
|
|
|
|
|
active: Interval
|
2020-10-20 07:11:28 +00:00
|
|
|
|
2020-11-05 21:16:03 +00:00
|
|
|
def transition_times(self) -> typing.Set[float]:
|
2020-11-05 08:52:58 +00:00
|
|
|
return self.active.transition_times()
|
|
|
|
|
|
2020-10-20 12:44:29 +00:00
|
|
|
@abstractmethod
|
|
|
|
|
def air_exchange(self, room: Room, time: float) -> float:
|
2020-10-26 07:21:31 +00:00
|
|
|
"""
|
2020-11-05 19:50:36 +00:00
|
|
|
Returns the rate at which air is being exchanged in the given room
|
|
|
|
|
at a given time (in hours).
|
2020-10-26 07:21:31 +00:00
|
|
|
|
2020-10-27 14:06:28 +00:00
|
|
|
Note that whilst the time is known inside this function, it may not
|
|
|
|
|
be used to vary the result unless the specific time used is declared
|
|
|
|
|
as part of a state change in the interval (e.g. when air_exchange == 0).
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
"""
|
2020-10-27 14:06:28 +00:00
|
|
|
return 0.
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
|
2020-11-05 19:50:58 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class MultipleVentilation:
|
|
|
|
|
"""
|
|
|
|
|
Represents a mechanism by which air can be exchanged (replaced/filtered)
|
|
|
|
|
in a time dependent manner.
|
|
|
|
|
|
|
|
|
|
Group together different sources of ventilations.
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
ventilations: typing.Tuple[Ventilation, ...]
|
|
|
|
|
|
2020-11-05 21:16:03 +00:00
|
|
|
def transition_times(self) -> typing.Set[float]:
|
2020-11-05 19:50:58 +00:00
|
|
|
transitions = set()
|
|
|
|
|
for ventilation in self.ventilations:
|
|
|
|
|
transitions.update(ventilation.transition_times())
|
2020-11-05 21:16:03 +00:00
|
|
|
return transitions
|
2020-11-05 19:50:58 +00:00
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
def air_exchange(self, room: Room, time: float) -> float:
|
|
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
for ventilation in self.ventilations])
|
|
|
|
|
|
|
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class WindowOpening(Ventilation):
|
|
|
|
|
#: The interval in which the window is open.
|
|
|
|
|
active: Interval
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-11-09 08:40:11 +00:00
|
|
|
#: The temperature inside the room (Kelvin).
|
|
|
|
|
inside_temp: PiecewiseConstant
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-11-09 08:40:11 +00:00
|
|
|
#: The temperature outside of the window (Kelvin).
|
|
|
|
|
outside_temp: PiecewiseConstant
|
2020-10-20 14:10:52 +00:00
|
|
|
|
2020-11-09 08:40:11 +00:00
|
|
|
#: The height of the window.
|
|
|
|
|
window_height: float
|
2020-10-20 14:10:52 +00:00
|
|
|
|
2020-11-09 08:40:11 +00:00
|
|
|
#: The length of the opening-gap when the window is open
|
|
|
|
|
opening_length: float
|
|
|
|
|
|
|
|
|
|
#: The number of windows of the given dimensions.
|
|
|
|
|
number_of_windows: int = 1
|
|
|
|
|
|
|
|
|
|
#: Discharge coefficient: what portion effective area is
|
|
|
|
|
#: used to exchange air (0 <= cd_b <= 1)
|
|
|
|
|
cd_b: float = 0.6
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-11-05 21:16:03 +00:00
|
|
|
def transition_times(self) -> typing.Set[float]:
|
2020-11-05 08:52:58 +00:00
|
|
|
transitions = super().transition_times()
|
2020-11-05 20:47:44 +00:00
|
|
|
transitions.update(self.inside_temp.transition_times)
|
|
|
|
|
transitions.update(self.outside_temp.transition_times)
|
2020-11-05 08:52:58 +00:00
|
|
|
return transitions
|
|
|
|
|
|
2020-10-20 13:43:48 +00:00
|
|
|
def air_exchange(self, room: Room, time: float) -> float:
|
2020-10-27 05:27:38 +00:00
|
|
|
# If the window is shut, no air is being exchanged.
|
|
|
|
|
if not self.active.triggered(time):
|
|
|
|
|
return 0.
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-11-09 08:40:11 +00:00
|
|
|
inside_temp = self.inside_temp.value(time)
|
|
|
|
|
outside_temp = self.outside_temp.value(time)
|
2020-10-27 14:06:28 +00:00
|
|
|
|
2020-11-09 08:40:11 +00:00
|
|
|
# Reminder, no dependence on time in the resulting calculation.
|
|
|
|
|
temp_delta = abs(inside_temp - outside_temp) / outside_temp
|
2020-10-27 05:27:38 +00:00
|
|
|
root = np.sqrt(9.81 * self.window_height * temp_delta)
|
2020-11-09 08:40:11 +00:00
|
|
|
window_area = self.window_height * self.opening_length * self.number_of_windows
|
|
|
|
|
return (3600 / (3 * room.volume)) * self.cd_b * window_area * root
|
2020-10-20 12:44:29 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
2020-10-27 05:27:38 +00:00
|
|
|
class HEPAFilter(Ventilation):
|
|
|
|
|
#: The interval in which the HEPA filter is operating.
|
|
|
|
|
active: Interval
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-10-27 14:06:28 +00:00
|
|
|
#: The rate at which the HEPA exchanges air (when switched on)
|
2020-11-05 17:46:32 +00:00
|
|
|
# in m^3/h
|
2020-10-27 14:06:28 +00:00
|
|
|
q_air_mech: float
|
2020-10-20 12:44:29 +00:00
|
|
|
|
2020-10-20 13:43:48 +00:00
|
|
|
def air_exchange(self, room: Room, time: float) -> float:
|
2020-10-27 05:27:38 +00:00
|
|
|
# If the HEPA is off, no air is being exchanged.
|
|
|
|
|
if not self.active.triggered(time):
|
|
|
|
|
return 0.
|
2020-10-27 14:06:28 +00:00
|
|
|
# Reminder, no dependence on time in the resulting calculation.
|
2020-10-20 12:44:29 +00:00
|
|
|
return self.q_air_mech / room.volume
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
|
|
|
|
2020-11-05 17:46:32 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class HVACMechanical(Ventilation):
|
|
|
|
|
#: The interval in which the mechanical ventilation (HVAC) is operating.
|
|
|
|
|
active: Interval
|
|
|
|
|
|
|
|
|
|
#: The rate at which the HVAC exchanges air (when switched on)
|
|
|
|
|
# in m^3/h
|
|
|
|
|
q_air_mech: float
|
|
|
|
|
|
|
|
|
|
def air_exchange(self, room: Room, time: float) -> float:
|
|
|
|
|
# If the HVAC is off, no air is being exchanged.
|
|
|
|
|
if not self.active.triggered(time):
|
|
|
|
|
return 0.
|
|
|
|
|
# Reminder, no dependence on time in the resulting calculation.
|
|
|
|
|
return self.q_air_mech / room.volume
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
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
|
|
|
|
|
# of the room (when switched on)
|
|
|
|
|
air_exch: float
|
|
|
|
|
|
|
|
|
|
def air_exchange(self, room: Room, time: float) -> float:
|
|
|
|
|
# No dependence on the room volume.
|
|
|
|
|
# If off, no air is being exchanged.
|
|
|
|
|
if not self.active.triggered(time):
|
|
|
|
|
return 0.
|
|
|
|
|
# Reminder, no dependence on time in the resulting calculation.
|
|
|
|
|
return self.air_exch
|
|
|
|
|
|
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Virus:
|
|
|
|
|
#: Biological decay (inactivation of the virus in air)
|
|
|
|
|
halflife: float
|
|
|
|
|
|
|
|
|
|
#: RNA copies / mL
|
|
|
|
|
viral_load_in_sputum: float
|
|
|
|
|
|
|
|
|
|
#: Ratio between infectious aerosols and dose to cause infection.
|
|
|
|
|
coefficient_of_infectivity: float
|
|
|
|
|
|
|
|
|
|
#: Pre-populated examples of Viruses.
|
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "Virus"]]
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def decay_constant(self):
|
|
|
|
|
# Viral inactivation per hour (h^-1)
|
|
|
|
|
return np.log(2) / self.halflife
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Virus.types = {
|
|
|
|
|
'SARS_CoV_2': Virus(
|
|
|
|
|
halflife=1.1,
|
|
|
|
|
viral_load_in_sputum=10e8,
|
|
|
|
|
# No data on coefficient for SARS-CoV-2 yet.
|
|
|
|
|
# It is somewhere between 0.001 and 0.01 to have a 50% chance
|
|
|
|
|
# to cause infection. i.e. 1000 or 100 SARS-CoV viruses to cause infection.
|
|
|
|
|
coefficient_of_infectivity=0.02,
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Mask:
|
|
|
|
|
#: Filtration efficiency. (In %/100)
|
|
|
|
|
η_exhale: float
|
|
|
|
|
|
|
|
|
|
#: Leakage through side of masks.
|
|
|
|
|
η_leaks: float
|
|
|
|
|
|
|
|
|
|
#: Filtration efficiency of masks when inhaling.
|
|
|
|
|
η_inhale: float
|
|
|
|
|
|
|
|
|
|
particle_sizes: typing.Tuple[float] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm.
|
|
|
|
|
|
|
|
|
|
#: Pre-populated examples of Masks.
|
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "Mask"]]
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def exhale_efficiency(self):
|
|
|
|
|
# Overall efficiency with the effect of the leaks for aerosol emission
|
|
|
|
|
# Gammaitoni et al (1997)
|
2020-10-26 18:10:53 +00:00
|
|
|
return self.η_exhale * (1 - self.η_leaks)
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
Mask.types = {
|
|
|
|
|
'No mask': Mask(0, 0, 0),
|
|
|
|
|
'Type I': Mask(
|
|
|
|
|
η_exhale=0.95,
|
|
|
|
|
η_leaks=0.15, # (Huang 2007)
|
|
|
|
|
η_inhale=0.3, # (Browen 2010)
|
|
|
|
|
),
|
2020-11-10 15:32:34 +00:00
|
|
|
'FFP2': Mask(
|
|
|
|
|
η_exhale=0.95, # (same outward effect as type 1 - Asadi 2020)
|
|
|
|
|
η_leaks=0.15, # (same outward effect as type 1 - Asadi 2020)
|
|
|
|
|
η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149)
|
|
|
|
|
),
|
2020-10-20 07:11:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Expiration:
|
|
|
|
|
ejection_factor: typing.Tuple[float, float, float, float]
|
|
|
|
|
particle_sizes: typing.Tuple[float, float, float, float] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm.
|
|
|
|
|
|
|
|
|
|
#: Pre-populated examples of Expiration.
|
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "Expiration"]]
|
|
|
|
|
|
|
|
|
|
def aerosols(self, mask: Mask):
|
|
|
|
|
def volume(diameter):
|
|
|
|
|
return (4 * np.pi * (diameter/2)**3) / 3
|
|
|
|
|
total = 0
|
2020-11-03 16:32:06 +00:00
|
|
|
for diameter, factor in zip(self.particle_sizes, self.ejection_factor):
|
2020-10-20 07:11:28 +00:00
|
|
|
contribution = volume(diameter) * factor
|
2020-11-03 16:32:06 +00:00
|
|
|
if diameter >= 3e-4:
|
2020-10-20 07:11:28 +00:00
|
|
|
contribution = contribution * (1 - mask.exhale_efficiency)
|
|
|
|
|
total += contribution
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Expiration.types = {
|
|
|
|
|
'Breathing': Expiration((0.084, 0.009, 0.003, 0.002)),
|
|
|
|
|
'Whispering': Expiration((0.11, 0.014, 0.004, 0.002)),
|
|
|
|
|
'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)),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Activity:
|
|
|
|
|
inhalation_rate: float
|
|
|
|
|
exhalation_rate: float
|
|
|
|
|
|
|
|
|
|
#: Pre-populated examples of activities.
|
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "Activity"]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Activity.types = {
|
|
|
|
|
'Resting': Activity(0.49, 0.49),
|
|
|
|
|
'Seated': Activity(0.54, 0.54),
|
|
|
|
|
'Light exercise': Activity(1.38, 1.38),
|
|
|
|
|
'Moderate exercise': Activity(2.35, 2.35),
|
|
|
|
|
'Heavy exercise': Activity(3.30, 3.30),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
2020-11-10 14:45:39 +00:00
|
|
|
class Population:
|
|
|
|
|
"""
|
|
|
|
|
Represents a group of people all with exactly the same behaviour and
|
|
|
|
|
situation.
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
#: How many in the population.
|
|
|
|
|
number: int
|
|
|
|
|
|
|
|
|
|
#: The times in which the people are in the room.
|
2020-10-27 13:47:45 +00:00
|
|
|
presence: Interval
|
2020-11-10 14:45:39 +00:00
|
|
|
|
|
|
|
|
#: The kind of mask being worn by the people.
|
2020-10-20 07:11:28 +00:00
|
|
|
mask: Mask
|
2020-11-10 14:45:39 +00:00
|
|
|
|
|
|
|
|
#: The physical activity being carried out by the people.
|
2020-10-20 07:11:28 +00:00
|
|
|
activity: Activity
|
|
|
|
|
|
|
|
|
|
def person_present(self, time):
|
2020-10-27 13:47:45 +00:00
|
|
|
return self.presence.triggered(time)
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class InfectedPopulation(Population):
|
|
|
|
|
#: The virus with which the population is infected.
|
|
|
|
|
virus: Virus
|
|
|
|
|
|
|
|
|
|
#: The type of expiration that is being emitted whilst doing the activity.
|
|
|
|
|
expiration: Expiration
|
|
|
|
|
|
2020-11-10 16:25:19 +00:00
|
|
|
def emission_rate_when_present(self) -> float:
|
2020-11-10 15:46:35 +00:00
|
|
|
"""
|
|
|
|
|
The emission rate if the infected population is present.
|
|
|
|
|
|
|
|
|
|
Note that the rate is not currently time-dependent.
|
|
|
|
|
|
|
|
|
|
"""
|
2020-10-20 07:11:28 +00:00
|
|
|
# Emission Rate (infectious quantum / h)
|
|
|
|
|
aerosols = self.expiration.aerosols(self.mask)
|
|
|
|
|
if np.isinf(aerosols):
|
|
|
|
|
# A superspreading event. Miller et al. (2020)
|
|
|
|
|
ER = 970
|
|
|
|
|
else:
|
|
|
|
|
ER = (self.virus.viral_load_in_sputum *
|
|
|
|
|
self.virus.coefficient_of_infectivity *
|
|
|
|
|
self.activity.exhalation_rate *
|
|
|
|
|
10**6 *
|
|
|
|
|
aerosols)
|
|
|
|
|
return ER
|
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
def individual_emission_rate(self, time) -> float:
|
|
|
|
|
"""
|
|
|
|
|
The emission rate of a single individual in the population.
|
|
|
|
|
|
|
|
|
|
"""
|
2020-10-20 07:11:28 +00:00
|
|
|
# Note: The original model avoids time dependence on the emission rate
|
|
|
|
|
# at the cost of implementing a piecewise (on time) concentration function.
|
2020-11-10 15:46:35 +00:00
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
if not self.person_present(time):
|
2020-11-10 16:25:19 +00:00
|
|
|
return 0.
|
2020-10-20 07:11:28 +00:00
|
|
|
|
2020-11-10 15:46:35 +00:00
|
|
|
# Note: It is essential that the value of the emission rate is not
|
|
|
|
|
# itself a function of time. Any change in rate must be accompanied
|
|
|
|
|
# with a declaration of state change time, as is the case for things
|
|
|
|
|
# like Ventilation.
|
|
|
|
|
|
2020-11-10 16:25:19 +00:00
|
|
|
return self.emission_rate_when_present()
|
2020-10-20 07:11:28 +00:00
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
@functools.lru_cache()
|
|
|
|
|
def emission_rate(self, time) -> float:
|
|
|
|
|
"""
|
|
|
|
|
The emission rate of the entire population.
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
return self.individual_emission_rate(time) * self.number
|
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
2020-11-10 16:19:19 +00:00
|
|
|
class ConcentrationModel:
|
2020-10-20 07:11:28 +00:00
|
|
|
room: Room
|
|
|
|
|
ventilation: Ventilation
|
2020-11-10 14:45:39 +00:00
|
|
|
infected: InfectedPopulation
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def virus(self):
|
|
|
|
|
return self.infected.virus
|
|
|
|
|
|
2020-10-20 12:41:11 +00:00
|
|
|
def infectious_virus_removal_rate(self, time: float) -> float:
|
2020-10-20 07:11:28 +00:00
|
|
|
# Particle deposition on the floor
|
|
|
|
|
vg = 1 * 10 ** -4
|
|
|
|
|
# Height of the emission source to the floor - i.e. mouth/nose (m)
|
|
|
|
|
h = 1.5
|
|
|
|
|
# Deposition rate (h^-1)
|
|
|
|
|
k = (vg * 3600) / h
|
|
|
|
|
|
2020-10-20 12:44:44 +00:00
|
|
|
return k + self.virus.decay_constant + self.ventilation.air_exchange(self.room, time)
|
2020-10-20 07:11:28 +00:00
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
@functools.lru_cache()
|
2020-10-27 14:06:28 +00:00
|
|
|
def state_change_times(self):
|
2020-10-26 19:12:54 +00:00
|
|
|
"""
|
|
|
|
|
All time dependent entities on this model must provide information about
|
|
|
|
|
the times at which their state changes.
|
|
|
|
|
|
|
|
|
|
"""
|
2020-10-27 05:27:38 +00:00
|
|
|
state_change_times = set()
|
2020-10-27 14:06:28 +00:00
|
|
|
state_change_times.update(self.infected.presence.transition_times())
|
2020-11-05 08:52:58 +00:00
|
|
|
state_change_times.update(self.ventilation.transition_times())
|
2020-11-05 00:06:36 +00:00
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
return sorted(state_change_times)
|
|
|
|
|
|
|
|
|
|
def last_state_change(self, time: float):
|
|
|
|
|
"""
|
|
|
|
|
Find the most recent state change.
|
|
|
|
|
|
|
|
|
|
"""
|
2020-10-27 14:06:28 +00:00
|
|
|
for change_time in self.state_change_times()[::-1]:
|
2020-10-27 13:34:45 +00:00
|
|
|
if change_time < time:
|
2020-10-27 05:27:38 +00:00
|
|
|
return change_time
|
|
|
|
|
return 0
|
2020-10-26 19:12:54 +00:00
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
@functools.lru_cache()
|
|
|
|
|
def concentration(self, time: float) -> float:
|
2020-10-27 13:34:45 +00:00
|
|
|
if time == 0:
|
|
|
|
|
return 0.0
|
2020-10-20 12:41:11 +00:00
|
|
|
IVRR = self.infectious_virus_removal_rate(time)
|
2020-10-20 07:11:28 +00:00
|
|
|
V = self.room.volume
|
2020-10-27 13:34:45 +00:00
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
t_last_state_change = self.last_state_change(time)
|
2020-10-27 13:34:45 +00:00
|
|
|
concentration_at_last_state_change = self.concentration(t_last_state_change)
|
2020-10-27 05:27:38 +00:00
|
|
|
|
2020-10-27 13:34:45 +00:00
|
|
|
delta_time = time - t_last_state_change
|
|
|
|
|
fac = np.exp(-IVRR * delta_time)
|
2020-11-10 14:45:39 +00:00
|
|
|
concentration_limit = (self.infected.emission_rate(time)) / (IVRR * V)
|
2020-10-27 05:27:38 +00:00
|
|
|
return concentration_limit * (1 - fac) + concentration_at_last_state_change * fac
|
2020-10-26 18:10:53 +00:00
|
|
|
|
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ExposureModel:
|
|
|
|
|
#: The virus concentration model which this exposure model should consider.
|
2020-11-10 16:19:19 +00:00
|
|
|
concentration_model: ConcentrationModel
|
2020-11-10 14:45:39 +00:00
|
|
|
|
|
|
|
|
#: The population of non-infected people to be used in the model.
|
|
|
|
|
exposed: Population
|
|
|
|
|
|
2020-11-11 08:50:54 +00:00
|
|
|
#: The number of times the exposure event is repeated (default 1).
|
|
|
|
|
repeats: int = 1
|
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
def quanta_exposure(self) -> float:
|
|
|
|
|
"""The number of virus quanta per meter^3."""
|
|
|
|
|
exposure = 0.0
|
2020-10-26 18:10:53 +00:00
|
|
|
|
|
|
|
|
def integrate(fn, start, stop):
|
|
|
|
|
values = np.linspace(start, stop)
|
|
|
|
|
return np.trapz([fn(v) for v in values], values)
|
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
for start, stop in self.exposed.presence.boundaries():
|
|
|
|
|
exposure += integrate(self.concentration_model.concentration, start, stop)
|
2020-11-11 08:50:54 +00:00
|
|
|
return exposure * self.repeats
|
2020-11-10 14:45:39 +00:00
|
|
|
|
|
|
|
|
def infection_probability(self):
|
|
|
|
|
exposure = self.quanta_exposure()
|
2020-10-26 18:10:53 +00:00
|
|
|
|
|
|
|
|
inf_aero = (
|
2020-11-10 14:45:39 +00:00
|
|
|
self.exposed.activity.inhalation_rate *
|
|
|
|
|
(1 - self.exposed.mask.η_inhale) *
|
2020-10-26 18:10:53 +00:00
|
|
|
exposure
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Probability of infection.
|
|
|
|
|
return (1 - np.exp(-inf_aero)) * 100
|
2020-11-10 14:45:39 +00:00
|
|
|
|
|
|
|
|
def reproduction_rate(self):
|
|
|
|
|
prob = self.infection_probability()
|
|
|
|
|
exposed_occupants = self.exposed.number
|
|
|
|
|
return prob * exposed_occupants / 100
|