2022-09-09 14:11:53 +00:00
|
|
|
|
# This module is part of CAiMIRA. Please see the repository at
|
2022-12-15 14:30:11 +00:00
|
|
|
|
# https://gitlab.cern.ch/caimira/caimira for details of the license and terms of use.
|
2021-03-28 03:53:47 +00:00
|
|
|
|
"""
|
2022-09-09 14:11:53 +00:00
|
|
|
|
This module implements the core CAiMIRA models.
|
2021-03-28 03:53:47 +00:00
|
|
|
|
|
2022-09-09 14:11:53 +00:00
|
|
|
|
The CAiMIRA model is a flexible, object-oriented numerical model. It is designed
|
2021-03-28 03:53:47 +00:00
|
|
|
|
to allow the user to swap-out and extend its various components. One of the
|
|
|
|
|
|
major abstractions of the model is the distinction between virus concentration
|
|
|
|
|
|
(:class:`ConcentrationModel`) and virus exposure (:class:`ExposureModel`).
|
|
|
|
|
|
|
|
|
|
|
|
The concentration component is a recursive (on model time) model and therefore in order
|
|
|
|
|
|
to optimise its execution certain layers of caching are implemented. This caching
|
|
|
|
|
|
mandates that the models in this module, once instantiated, are immutable and
|
|
|
|
|
|
deterministic (i.e. running the same model twice will result in the same answer).
|
|
|
|
|
|
|
|
|
|
|
|
In order to apply stochastic / non-deterministic analyses therefore you must
|
|
|
|
|
|
introduce the randomness before constructing the models themselves; the
|
2022-09-09 14:11:53 +00:00
|
|
|
|
:mod:`caimira.monte_carlo` module is a good example of doing this - that module uses
|
2021-03-28 03:53:47 +00:00
|
|
|
|
the models defined here to allow you to construct a ConcentrationModel containing
|
|
|
|
|
|
parameters which are expressed as probability distributions. Under the hood the
|
2022-09-09 14:11:53 +00:00
|
|
|
|
``caimira.monte_carlo.ConcentrationModel`` implementation simply samples all of those
|
2021-03-28 03:53:47 +00:00
|
|
|
|
probability distributions to produce many instances of the deterministic model.
|
|
|
|
|
|
|
|
|
|
|
|
The models in this module have been designed for flexibility above performance,
|
|
|
|
|
|
particularly in the single-model case. By using the natural expressiveness of
|
|
|
|
|
|
Python we benefit from a powerful, readable and extendable implementation. A
|
|
|
|
|
|
useful feature of the implementation is that we are able to benefit from numpy
|
|
|
|
|
|
vectorisation in the case of wanting to run multiple-parameterisations of the model
|
|
|
|
|
|
at the same time. In order to benefit from this feature you must construct the models
|
|
|
|
|
|
with an array of parameter values. The values must be either scalar, length 1 arrays,
|
|
|
|
|
|
or length N arrays, where N is the number of parameterisations to run; N must be
|
|
|
|
|
|
the same for all parameters of a single model.
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
2022-02-08 09:55:51 +00:00
|
|
|
|
from dataclasses import dataclass
|
2020-10-20 07:11:28 +00:00
|
|
|
|
import typing
|
|
|
|
|
|
|
2021-04-07 08:15:48 +00:00
|
|
|
|
import numpy as np
|
|
|
|
|
|
from scipy.interpolate import interp1d
|
2022-09-20 14:00:25 +00:00
|
|
|
|
import scipy.stats as sct
|
2021-04-07 08:15:48 +00:00
|
|
|
|
|
2021-03-28 04:46:34 +00:00
|
|
|
|
if not typing.TYPE_CHECKING:
|
|
|
|
|
|
from memoization import cached
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Workaround issue https://github.com/lonelyenvoy/python-memoization/issues/18
|
|
|
|
|
|
# by providing a no-op cache decorator when type-checking.
|
|
|
|
|
|
cached = lambda *cached_args, **cached_kwargs: lambda function: function # noqa
|
|
|
|
|
|
|
2021-08-05 13:48:24 +00:00
|
|
|
|
from .utils import method_cache
|
|
|
|
|
|
|
2020-11-12 20:21:02 +00:00
|
|
|
|
from .dataclass_utils import nested_replace
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2021-09-20 13:06:35 +00:00
|
|
|
|
oneoverln2 = 1 / np.log(2)
|
2021-03-28 05:32:42 +00:00
|
|
|
|
# Define types for items supporting vectorisation. In the future this may be replaced
|
|
|
|
|
|
# by ``np.ndarray[<type>]`` once/if that syntax is supported. Note that vectorization
|
|
|
|
|
|
# implies 1d arrays: multi-dimensional arrays are not supported.
|
|
|
|
|
|
_VectorisedFloat = typing.Union[float, np.ndarray]
|
|
|
|
|
|
_VectorisedInt = typing.Union[int, np.ndarray]
|
|
|
|
|
|
|
2021-01-05 15:30:16 +00:00
|
|
|
|
Time_t = typing.TypeVar('Time_t', float, int)
|
|
|
|
|
|
BoundaryPair_t = typing.Tuple[Time_t, Time_t]
|
|
|
|
|
|
BoundarySequence_t = typing.Union[typing.Tuple[BoundaryPair_t, ...], typing.Tuple]
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
"""
|
2021-01-05 15:30:16 +00:00
|
|
|
|
def boundaries(self) -> BoundarySequence_t:
|
2020-10-27 14:06:28 +00:00
|
|
|
|
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.
|
2021-01-05 15:30:16 +00:00
|
|
|
|
present_times: BoundarySequence_t
|
2020-10-27 13:47:45 +00:00
|
|
|
|
|
2021-01-05 15:30:16 +00:00
|
|
|
|
def boundaries(self) -> BoundarySequence_t:
|
2020-10-27 14:06:28 +00:00
|
|
|
|
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).
|
2021-01-05 15:30:16 +00:00
|
|
|
|
period: float
|
2020-10-27 05:27:38 +00:00
|
|
|
|
|
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.
|
2021-01-05 15:30:16 +00:00
|
|
|
|
duration: float
|
2020-10-27 05:27:38 +00:00
|
|
|
|
|
2021-12-15 14:33:57 +00:00
|
|
|
|
#: Time at which the first person (infected or exposed) arrives at the enclosed space.
|
|
|
|
|
|
start: float = 0.0
|
|
|
|
|
|
|
2021-01-05 15:30:16 +00:00
|
|
|
|
def boundaries(self) -> BoundarySequence_t:
|
2020-11-20 10:08:02 +00:00
|
|
|
|
if self.period == 0 or self.duration == 0:
|
|
|
|
|
|
return tuple()
|
2020-10-27 13:34:45 +00:00
|
|
|
|
result = []
|
2021-12-15 14:33:57 +00:00
|
|
|
|
for i in np.arange(self.start, 24, self.period / 60):
|
2021-08-05 13:48:24 +00:00
|
|
|
|
# NOTE: It is important that the time type is float, not np.float, in
|
|
|
|
|
|
# order to allow hashability (for caching).
|
|
|
|
|
|
result.append((float(i), float(i+self.duration/60)))
|
2020-10-27 13:34:45 +00:00
|
|
|
|
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
|
|
|
|
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# TODO: Implement rather a periodic version (24-hour period), where
|
2020-11-09 15:08:58 +00:00
|
|
|
|
# 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
|
2021-04-07 08:15:48 +00:00
|
|
|
|
values: typing.Tuple[_VectorisedFloat, ...]
|
2020-11-04 19:57:49 +00:00
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
|
if len(self.transition_times) != len(self.values)+1:
|
2022-01-27 14:38:25 +00:00
|
|
|
|
raise ValueError("transition_times must contain one more element than values")
|
2020-11-04 22:35:06 +00:00
|
|
|
|
if tuple(sorted(set(self.transition_times))) != self.transition_times:
|
2022-01-27 14:38:25 +00:00
|
|
|
|
raise ValueError("transition_times must not contain duplicated elements and must be sorted")
|
2021-04-07 08:25:00 +00:00
|
|
|
|
shapes = [np.array(v).shape for v in self.values]
|
|
|
|
|
|
if not all(shapes[0] == shape for shape in shapes):
|
|
|
|
|
|
raise ValueError("All values must have the same shape")
|
2020-11-04 19:57:49 +00:00
|
|
|
|
|
2021-04-07 08:15:48 +00:00
|
|
|
|
def value(self, time) -> _VectorisedFloat:
|
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]
|
2021-01-05 15:30:16 +00:00
|
|
|
|
elif time > self.transition_times[-1]:
|
2020-11-05 11:08:08 +00:00
|
|
|
|
return self.values[-1]
|
|
|
|
|
|
|
2021-01-05 15:30:16 +00:00
|
|
|
|
for t1, t2, value in zip(self.transition_times[:-1],
|
|
|
|
|
|
self.transition_times[1:], self.values):
|
|
|
|
|
|
if t1 < time <= t2:
|
|
|
|
|
|
break
|
|
|
|
|
|
return value
|
2020-11-04 19:57:49 +00:00
|
|
|
|
|
|
|
|
|
|
def interval(self) -> Interval:
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Build an Interval object
|
2020-11-04 19:57:49 +00:00
|
|
|
|
present_times = []
|
2021-01-05 17:59:43 +00:00
|
|
|
|
for t1, t2, value in zip(self.transition_times[:-1],
|
|
|
|
|
|
self.transition_times[1:], self.values):
|
2020-11-04 19:57:49 +00:00
|
|
|
|
if value:
|
2021-04-07 08:15:48 +00:00
|
|
|
|
present_times.append((t1, t2))
|
2021-01-05 15:30:16 +00:00
|
|
|
|
return SpecificInterval(present_times=tuple(present_times))
|
2020-11-04 19:57:49 +00:00
|
|
|
|
|
2021-04-07 08:15:48 +00:00
|
|
|
|
def refine(self, refine_factor=10) -> "PiecewiseConstant":
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Build a new PiecewiseConstant object with a refined mesh,
|
2020-11-09 15:08:58 +00:00
|
|
|
|
# using a linear interpolation in-between the initial mesh points
|
2021-01-05 17:59:43 +00:00
|
|
|
|
refined_times = np.linspace(self.transition_times[0], self.transition_times[-1],
|
|
|
|
|
|
(len(self.transition_times)-1) * refine_factor+1)
|
2021-04-07 08:15:48 +00:00
|
|
|
|
interpolator = interp1d(
|
|
|
|
|
|
self.transition_times,
|
|
|
|
|
|
np.concatenate([self.values, self.values[-1:]], axis=0),
|
|
|
|
|
|
axis=0)
|
2021-01-05 17:59:43 +00:00
|
|
|
|
return PiecewiseConstant(
|
2021-08-05 13:48:24 +00:00
|
|
|
|
# NOTE: It is important that the time type is float, not np.float, in
|
|
|
|
|
|
# order to allow hashability (for caching).
|
|
|
|
|
|
tuple(float(time) for time in refined_times),
|
2021-04-07 08:15:48 +00:00
|
|
|
|
tuple(interpolator(refined_times)[:-1]),
|
2021-01-05 17:59:43 +00:00
|
|
|
|
)
|
2020-11-09 15:08:58 +00:00
|
|
|
|
|
2020-11-04 19:57:49 +00:00
|
|
|
|
|
2023-04-04 09:35:40 +00:00
|
|
|
|
@dataclass(frozen=True)
|
2023-04-28 10:05:30 +00:00
|
|
|
|
class IntPiecewiseConstant(PiecewiseConstant):
|
2023-04-04 09:35:40 +00:00
|
|
|
|
|
|
|
|
|
|
#: values of the function between transitions
|
|
|
|
|
|
values: typing.Tuple[int, ...]
|
|
|
|
|
|
|
|
|
|
|
|
def value(self, time) -> _VectorisedFloat:
|
|
|
|
|
|
if time <= self.transition_times[0] or time > self.transition_times[-1]:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
for t1, t2, value in zip(self.transition_times[:-1],
|
|
|
|
|
|
self.transition_times[1:], self.values):
|
|
|
|
|
|
if t1 < time <= t2:
|
|
|
|
|
|
break
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-04-22 13:20:20 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Room:
|
|
|
|
|
|
#: The total volume of the room
|
|
|
|
|
|
volume: _VectorisedFloat
|
|
|
|
|
|
|
|
|
|
|
|
#: The temperature inside the room (Kelvin).
|
|
|
|
|
|
inside_temp: PiecewiseConstant = PiecewiseConstant((0, 24), (293,))
|
|
|
|
|
|
|
|
|
|
|
|
#: The humidity in the room (from 0 to 1 - e.g. 0.5 is 50% humidity)
|
|
|
|
|
|
humidity: _VectorisedFloat = 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
@dataclass(frozen=True)
|
2021-01-05 17:59:43 +00:00
|
|
|
|
class _VentilationBase:
|
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
|
|
|
|
"""
|
2022-04-22 13:20:20 +00:00
|
|
|
|
def transition_times(self, room: Room) -> typing.Set[float]:
|
2021-01-05 17:59:43 +00:00
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
2020-11-05 08:52:58 +00:00
|
|
|
|
|
2021-03-28 05:32:42 +00:00
|
|
|
|
def air_exchange(self, room: Room, time: float) -> _VectorisedFloat:
|
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)
|
2021-01-05 17:59:43 +00:00
|
|
|
|
class Ventilation(_VentilationBase):
|
|
|
|
|
|
#: The interval in which the ventilation is active.
|
|
|
|
|
|
active: Interval
|
|
|
|
|
|
|
2022-04-22 13:20:20 +00:00
|
|
|
|
def transition_times(self, room: Room) -> typing.Set[float]:
|
2021-01-05 17:59:43 +00:00
|
|
|
|
return self.active.transition_times()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class MultipleVentilation(_VentilationBase):
|
2020-11-05 19:50:58 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Represents a mechanism by which air can be exchanged (replaced/filtered)
|
|
|
|
|
|
in a time dependent manner.
|
|
|
|
|
|
|
|
|
|
|
|
Group together different sources of ventilations.
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
2021-01-05 17:59:43 +00:00
|
|
|
|
ventilations: typing.Tuple[_VentilationBase, ...]
|
2020-11-05 19:50:58 +00:00
|
|
|
|
|
2022-04-22 13:20:20 +00:00
|
|
|
|
def transition_times(self, room: Room) -> typing.Set[float]:
|
2020-11-05 19:50:58 +00:00
|
|
|
|
transitions = set()
|
|
|
|
|
|
for ventilation in self.ventilations:
|
2022-04-22 13:20:20 +00:00
|
|
|
|
transitions.update(ventilation.transition_times(room))
|
2020-11-05 21:16:03 +00:00
|
|
|
|
return transitions
|
2020-11-05 19:50:58 +00:00
|
|
|
|
|
2021-03-28 05:32:42 +00:00
|
|
|
|
def air_exchange(self, room: Room, time: float) -> _VectorisedFloat:
|
2020-11-05 19:50:58 +00:00
|
|
|
|
"""
|
2020-11-12 11:20:39 +00:00
|
|
|
|
Returns the rate at which air is being exchanged in the given room
|
2020-11-05 19:50:58 +00:00
|
|
|
|
at a given time (in hours).
|
|
|
|
|
|
"""
|
2021-03-28 05:32:42 +00:00
|
|
|
|
return np.array([
|
|
|
|
|
|
ventilation.air_exchange(room, time)
|
|
|
|
|
|
for ventilation in self.ventilations
|
2023-01-10 08:45:08 +00:00
|
|
|
|
], dtype=object).sum(axis=0)
|
2020-11-05 19:50:58 +00:00
|
|
|
|
|
|
|
|
|
|
|
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 outside of the window (Kelvin).
|
|
|
|
|
|
outside_temp: PiecewiseConstant
|
2020-10-20 14:10:52 +00:00
|
|
|
|
|
2020-12-01 07:22:51 +00:00
|
|
|
|
#: The height of the window (m).
|
2021-04-07 08:15:48 +00:00
|
|
|
|
window_height: _VectorisedFloat
|
2020-10-20 14:10:52 +00:00
|
|
|
|
|
2020-12-01 07:22:51 +00:00
|
|
|
|
#: The length of the opening-gap when the window is open (m).
|
2021-04-07 08:15:48 +00:00
|
|
|
|
opening_length: _VectorisedFloat
|
2020-11-09 08:40:11 +00:00
|
|
|
|
|
|
|
|
|
|
#: The number of windows of the given dimensions.
|
|
|
|
|
|
number_of_windows: int = 1
|
|
|
|
|
|
|
2020-12-01 07:22:51 +00:00
|
|
|
|
#: Minimum difference between inside and outside temperature (K).
|
2020-11-11 13:12:12 +00:00
|
|
|
|
min_deltaT: float = 0.1
|
|
|
|
|
|
|
2020-11-23 14:29:23 +00:00
|
|
|
|
@property
|
2021-04-15 20:07:55 +00:00
|
|
|
|
def discharge_coefficient(self) -> _VectorisedFloat:
|
2020-12-02 11:10:14 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Discharge coefficient (or cd_b): what portion effective area is
|
|
|
|
|
|
used to exchange air (0 <= discharge_coefficient <= 1).
|
|
|
|
|
|
To be implemented in subclasses.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raise NotImplementedError("Unknown discharge coefficient")
|
2020-11-23 14:29:23 +00:00
|
|
|
|
|
2022-04-22 13:20:20 +00:00
|
|
|
|
def transition_times(self, room: Room) -> typing.Set[float]:
|
|
|
|
|
|
transitions = super().transition_times(room)
|
|
|
|
|
|
transitions.update(room.inside_temp.transition_times)
|
2020-11-05 20:47:44 +00:00
|
|
|
|
transitions.update(self.outside_temp.transition_times)
|
2020-11-05 08:52:58 +00:00
|
|
|
|
return transitions
|
|
|
|
|
|
|
2021-03-28 05:32:42 +00:00
|
|
|
|
def air_exchange(self, room: Room, time: float) -> _VectorisedFloat:
|
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-12 11:06:33 +00:00
|
|
|
|
# Reminder, no dependence on time in the resulting calculation.
|
2022-04-22 13:20:20 +00:00
|
|
|
|
inside_temp: _VectorisedFloat = room.inside_temp.value(time)
|
2021-04-15 20:07:55 +00:00
|
|
|
|
outside_temp: _VectorisedFloat = self.outside_temp.value(time)
|
2020-10-27 14:06:28 +00:00
|
|
|
|
|
2020-11-12 11:06:33 +00:00
|
|
|
|
# The inside_temperature is forced to be always at least min_deltaT degree
|
|
|
|
|
|
# warmer than the outside_temperature. Further research needed to
|
|
|
|
|
|
# handle the buoyancy driven ventilation when the temperature gradient
|
|
|
|
|
|
# is inverted.
|
2021-04-15 20:07:55 +00:00
|
|
|
|
inside_temp = np.maximum(inside_temp, outside_temp + self.min_deltaT) # type: ignore
|
2020-11-12 11:06:33 +00:00
|
|
|
|
temp_gradient = (inside_temp - outside_temp) / outside_temp
|
|
|
|
|
|
root = np.sqrt(9.81 * self.window_height * temp_gradient)
|
2020-11-09 08:40:11 +00:00
|
|
|
|
window_area = self.window_height * self.opening_length * self.number_of_windows
|
2020-12-01 14:29:35 +00:00
|
|
|
|
return (3600 / (3 * room.volume)) * self.discharge_coefficient * window_area * root
|
2020-12-01 07:22:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class SlidingWindow(WindowOpening):
|
2020-12-02 11:11:09 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Sliding window, or side-hung window (with the hinge perpendicular to
|
|
|
|
|
|
the horizontal plane).
|
|
|
|
|
|
"""
|
2020-12-01 07:22:51 +00:00
|
|
|
|
@property
|
2021-04-15 20:07:55 +00:00
|
|
|
|
def discharge_coefficient(self) -> _VectorisedFloat:
|
2020-12-02 11:11:09 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Average measured value of discharge coefficient for sliding or
|
|
|
|
|
|
side-hung windows.
|
|
|
|
|
|
"""
|
2020-12-01 07:22:51 +00:00
|
|
|
|
return 0.6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class HingedWindow(WindowOpening):
|
2020-12-02 11:12:19 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Top-hung or bottom-hung hinged window (with the hinge parallel to
|
|
|
|
|
|
horizontal plane).
|
|
|
|
|
|
"""
|
2020-12-01 07:22:51 +00:00
|
|
|
|
#: Window width (m).
|
2021-04-07 08:15:48 +00:00
|
|
|
|
window_width: _VectorisedFloat = 0.0
|
2020-12-01 07:22:51 +00:00
|
|
|
|
|
2020-12-02 17:58:55 +00:00
|
|
|
|
def __post_init__(self):
|
2021-12-15 14:33:57 +00:00
|
|
|
|
if self.window_width is float(0.0):
|
2020-12-02 17:58:55 +00:00
|
|
|
|
raise ValueError('window_width must be set')
|
|
|
|
|
|
|
2020-12-01 07:22:51 +00:00
|
|
|
|
@property
|
2021-04-15 20:07:55 +00:00
|
|
|
|
def discharge_coefficient(self) -> _VectorisedFloat:
|
2020-12-02 11:12:19 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Simple model to compute discharge coefficient for top or bottom
|
|
|
|
|
|
hung hinged windows, in the absence of empirical test results
|
|
|
|
|
|
from manufacturers.
|
|
|
|
|
|
From an excel spreadsheet calculator (Richard Daniels, Crawford
|
|
|
|
|
|
Wright, Benjamin Jones - 2018) from the UK government -
|
|
|
|
|
|
see Section 8.3 of BB101 and Section 11.3 of
|
|
|
|
|
|
ESFA Output Specification Annex 2F on Ventilation opening areas.
|
|
|
|
|
|
"""
|
2021-04-07 08:15:48 +00:00
|
|
|
|
window_ratio = np.array(self.window_width / self.window_height)
|
|
|
|
|
|
coefs = np.empty(window_ratio.shape + (2, ), dtype=np.float64)
|
|
|
|
|
|
|
|
|
|
|
|
coefs[window_ratio < 0.5] = (0.06, 0.612)
|
|
|
|
|
|
coefs[np.bitwise_and(0.5 <= window_ratio, window_ratio < 1)] = (0.048, 0.589)
|
|
|
|
|
|
coefs[np.bitwise_and(1 <= window_ratio, window_ratio < 2)] = (0.04, 0.563)
|
|
|
|
|
|
coefs[window_ratio >= 2] = (0.038, 0.548)
|
2021-04-15 20:07:55 +00:00
|
|
|
|
M, cd_max = coefs.T
|
2021-04-07 08:15:48 +00:00
|
|
|
|
|
2020-12-02 17:58:55 +00:00
|
|
|
|
window_angle = 2.*np.rad2deg(np.arcsin(self.opening_length/(2.*self.window_height)))
|
2020-12-01 07:22:51 +00:00
|
|
|
|
return cd_max*(1-np.exp(-M*window_angle))
|
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
|
2021-04-29 07:13:57 +00:00
|
|
|
|
q_air_mech: _VectorisedFloat
|
2020-10-20 12:44:29 +00:00
|
|
|
|
|
2021-03-28 05:32:42 +00:00
|
|
|
|
def air_exchange(self, room: Room, time: float) -> _VectorisedFloat:
|
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
|
2021-04-29 07:13:57 +00:00
|
|
|
|
q_air_mech: _VectorisedFloat
|
2020-11-05 17:46:32 +00:00
|
|
|
|
|
2021-03-28 05:32:42 +00:00
|
|
|
|
def air_exchange(self, room: Room, time: float) -> _VectorisedFloat:
|
2020-11-05 17:46:32 +00:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2020-11-12 11:20:39 +00:00
|
|
|
|
#: The rate (in h^-1) at which the ventilation exchanges all the air
|
2020-11-05 17:46:32 +00:00
|
|
|
|
# of the room (when switched on)
|
2021-03-28 05:32:42 +00:00
|
|
|
|
air_exch: _VectorisedFloat
|
2020-11-05 17:46:32 +00:00
|
|
|
|
|
2021-03-28 05:32:42 +00:00
|
|
|
|
def air_exchange(self, room: Room, time: float) -> _VectorisedFloat:
|
2020-11-05 17:46:32 +00:00
|
|
|
|
# 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:
|
|
|
|
|
|
#: RNA copies / mL
|
2021-03-28 05:53:19 +00:00
|
|
|
|
viral_load_in_sputum: _VectorisedFloat
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2021-08-06 08:24:25 +00:00
|
|
|
|
#: Dose to initiate infection, in RNA copies
|
|
|
|
|
|
infectious_dose: _VectorisedFloat
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2021-09-17 12:46:45 +00:00
|
|
|
|
#: viable-to-RNA virus ratio as a function of the viral load
|
2021-09-17 15:27:52 +00:00
|
|
|
|
viable_to_RNA_ratio: _VectorisedFloat
|
2021-09-17 12:46:45 +00:00
|
|
|
|
|
2021-09-17 14:36:07 +00:00
|
|
|
|
#: Reported increase of transmissibility of a VOC
|
2021-09-20 13:06:35 +00:00
|
|
|
|
transmissibility_factor: float
|
2021-09-17 14:36:07 +00:00
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
#: Pre-populated examples of Viruses.
|
|
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "Virus"]]
|
|
|
|
|
|
|
2022-10-05 15:33:12 +00:00
|
|
|
|
#: Number of days the infector is contagious
|
2022-10-10 09:15:05 +00:00
|
|
|
|
infectiousness_days: int
|
2022-10-05 12:28:53 +00:00
|
|
|
|
|
2022-04-27 12:09:24 +00:00
|
|
|
|
def halflife(self, humidity: _VectorisedFloat, inside_temp: _VectorisedFloat) -> _VectorisedFloat:
|
2021-05-26 08:19:52 +00:00
|
|
|
|
# Biological decay (inactivation of the virus in air) - virus
|
|
|
|
|
|
# dependent and function of humidity
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
2022-04-27 12:09:24 +00:00
|
|
|
|
def decay_constant(self, humidity: _VectorisedFloat, inside_temp: _VectorisedFloat) -> _VectorisedFloat:
|
2022-04-27 13:23:46 +00:00
|
|
|
|
# Viral inactivation per hour (h^-1) (function of humidity and inside temperature)
|
2022-04-27 12:09:24 +00:00
|
|
|
|
return np.log(2) / self.halflife(humidity, inside_temp)
|
2021-05-26 08:19:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class SARSCoV2(Virus):
|
2022-10-10 09:15:05 +00:00
|
|
|
|
#: Number of days the infector is contagious
|
|
|
|
|
|
infectiousness_days: int = 14
|
|
|
|
|
|
|
2022-04-27 12:09:24 +00:00
|
|
|
|
def halflife(self, humidity: _VectorisedFloat, inside_temp: _VectorisedFloat) -> _VectorisedFloat:
|
2021-05-26 08:19:52 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Half-life changes with humidity level. Here is implemented a simple
|
|
|
|
|
|
piecewise constant model (for more details see A. Henriques et al,
|
|
|
|
|
|
CERN-OPEN-2021-004, DOI: 10.17181/CERN.1GDQ.5Y75)
|
|
|
|
|
|
"""
|
2022-04-25 14:20:29 +00:00
|
|
|
|
# Updated to use the formula from Dabish et al. with correction https://doi.org/10.1080/02786826.2020.1829536
|
2022-05-09 14:29:52 +00:00
|
|
|
|
# with a maximum at hl = 6.43 (compensate for the negative decay values in the paper).
|
|
|
|
|
|
# Note that humidity is in percentage and inside_temp in °C.
|
|
|
|
|
|
# factor np.log(2) -> decay rate to half-life; factor 60 -> minutes to hours
|
|
|
|
|
|
hl_calc = ((np.log(2)/((0.16030 + 0.04018*(((inside_temp-273.15)-20.615)/10.585)
|
2022-04-27 13:23:46 +00:00
|
|
|
|
+0.02176*(((humidity*100)-45.235)/28.665)
|
2022-04-25 14:20:29 +00:00
|
|
|
|
-0.14369
|
2022-05-09 12:04:27 +00:00
|
|
|
|
-0.02636*((inside_temp-273.15)-20.615)/10.585)))/60)
|
2022-05-09 14:29:52 +00:00
|
|
|
|
|
|
|
|
|
|
return np.where(hl_calc <= 0, 6.43, np.minimum(6.43, hl_calc))
|
2022-01-24 15:07:23 +00:00
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2022-09-13 15:27:06 +00:00
|
|
|
|
# Example of Viruses only used for the Expert app and tests.
|
2020-10-20 07:11:28 +00:00
|
|
|
|
Virus.types = {
|
2021-05-26 08:19:52 +00:00
|
|
|
|
'SARS_CoV_2': SARSCoV2(
|
2021-03-09 08:38:47 +00:00
|
|
|
|
viral_load_in_sputum=1e9,
|
2020-10-20 07:11:28 +00:00
|
|
|
|
# No data on coefficient for SARS-CoV-2 yet.
|
2021-05-26 12:47:30 +00:00
|
|
|
|
# It is somewhere between 1000 or 10 SARS-CoV viruses,
|
|
|
|
|
|
# as per https://www.dhs.gov/publication/st-master-question-list-covid-19
|
|
|
|
|
|
# 50 comes from Buonanno et al.
|
2021-08-06 08:24:25 +00:00
|
|
|
|
infectious_dose=50.,
|
2021-09-17 15:27:52 +00:00
|
|
|
|
viable_to_RNA_ratio = 0.5,
|
2021-09-20 13:06:35 +00:00
|
|
|
|
transmissibility_factor=1.0,
|
2020-10-20 07:11:28 +00:00
|
|
|
|
),
|
2021-10-12 07:55:44 +00:00
|
|
|
|
'SARS_CoV_2_ALPHA': SARSCoV2(
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Also called VOC-202012/01
|
2021-03-09 08:38:47 +00:00
|
|
|
|
viral_load_in_sputum=1e9,
|
2021-09-20 13:06:35 +00:00
|
|
|
|
infectious_dose=50.,
|
2021-09-17 15:27:52 +00:00
|
|
|
|
viable_to_RNA_ratio = 0.5,
|
2021-09-24 15:27:35 +00:00
|
|
|
|
transmissibility_factor=0.78,
|
2021-03-09 08:38:47 +00:00
|
|
|
|
),
|
2021-10-12 07:55:44 +00:00
|
|
|
|
'SARS_CoV_2_BETA': SARSCoV2(
|
2021-09-24 15:27:35 +00:00
|
|
|
|
viral_load_in_sputum=1e9,
|
|
|
|
|
|
infectious_dose=50.,
|
|
|
|
|
|
viable_to_RNA_ratio=0.5,
|
|
|
|
|
|
transmissibility_factor=0.8,
|
|
|
|
|
|
),
|
2021-10-12 07:55:44 +00:00
|
|
|
|
'SARS_CoV_2_GAMMA': SARSCoV2(
|
2021-03-09 08:38:47 +00:00
|
|
|
|
viral_load_in_sputum=1e9,
|
2021-09-20 13:06:35 +00:00
|
|
|
|
infectious_dose=50.,
|
2021-09-17 15:27:52 +00:00
|
|
|
|
viable_to_RNA_ratio = 0.5,
|
2021-09-24 15:27:35 +00:00
|
|
|
|
transmissibility_factor=0.72,
|
2021-03-09 08:38:47 +00:00
|
|
|
|
),
|
2021-10-12 07:55:44 +00:00
|
|
|
|
'SARS_CoV_2_DELTA': SARSCoV2(
|
2021-06-25 09:10:04 +00:00
|
|
|
|
viral_load_in_sputum=1e9,
|
2021-09-20 13:06:35 +00:00
|
|
|
|
infectious_dose=50.,
|
2021-09-17 15:27:52 +00:00
|
|
|
|
viable_to_RNA_ratio = 0.5,
|
2021-09-24 15:27:35 +00:00
|
|
|
|
transmissibility_factor=0.51,
|
2021-06-25 09:10:04 +00:00
|
|
|
|
),
|
2022-01-11 09:54:01 +00:00
|
|
|
|
'SARS_CoV_2_OMICRON': SARSCoV2(
|
2022-01-06 16:51:18 +00:00
|
|
|
|
viral_load_in_sputum=1e9,
|
2022-04-07 04:53:43 +00:00
|
|
|
|
infectious_dose=50.,
|
2022-01-11 09:54:01 +00:00
|
|
|
|
viable_to_RNA_ratio=0.5,
|
|
|
|
|
|
transmissibility_factor=0.2
|
2022-01-06 16:51:18 +00:00
|
|
|
|
),
|
2020-10-20 07:11:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
2021-05-30 17:28:51 +00:00
|
|
|
|
class Mask:
|
2020-10-20 07:11:28 +00:00
|
|
|
|
#: Filtration efficiency of masks when inhaling.
|
2021-04-28 12:22:23 +00:00
|
|
|
|
η_inhale: _VectorisedFloat
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2022-09-12 15:24:09 +00:00
|
|
|
|
#: Filtration efficiency of masks when exhaling.
|
2022-10-11 08:39:21 +00:00
|
|
|
|
η_exhale: typing.Union[None, _VectorisedFloat] = None
|
2022-09-12 15:24:09 +00:00
|
|
|
|
|
2021-05-30 17:28:51 +00:00
|
|
|
|
#: Global factor applied to filtration efficiency of masks when exhaling.
|
2021-05-31 15:12:18 +00:00
|
|
|
|
factor_exhale: float = 1.
|
2021-05-26 21:39:33 +00:00
|
|
|
|
|
2021-05-30 17:28:51 +00:00
|
|
|
|
#: Pre-populated examples of Masks.
|
|
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "Mask"]]
|
2021-05-26 21:39:33 +00:00
|
|
|
|
|
2021-09-12 09:07:35 +00:00
|
|
|
|
def exhale_efficiency(self, diameter: _VectorisedFloat) -> _VectorisedFloat:
|
2021-05-30 17:28:51 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Overall exhale efficiency, including the effect of the leaks.
|
|
|
|
|
|
See CERN-OPEN-2021-004 (doi: 10.17181/CERN.1GDQ.5Y75), and Ref.
|
|
|
|
|
|
therein (Asadi 2020).
|
|
|
|
|
|
Obtained from measurements of filtration efficiency and of
|
|
|
|
|
|
the leakage through the sides.
|
2021-05-31 04:55:07 +00:00
|
|
|
|
Diameter is in microns.
|
2021-05-30 17:28:51 +00:00
|
|
|
|
"""
|
2022-10-11 08:39:21 +00:00
|
|
|
|
if self.η_exhale is not None:
|
|
|
|
|
|
# When η_exhale is specified, return it directly
|
2022-09-12 15:24:09 +00:00
|
|
|
|
return self.η_exhale
|
|
|
|
|
|
|
2021-09-12 09:07:35 +00:00
|
|
|
|
d = np.array(diameter)
|
|
|
|
|
|
intermediate_range1 = np.bitwise_and(0.5 <= d, d < 0.94614)
|
|
|
|
|
|
intermediate_range2 = np.bitwise_and(0.94614 <= d, d < 3.)
|
|
|
|
|
|
|
|
|
|
|
|
eta_out = np.empty(d.shape, dtype=np.float64)
|
|
|
|
|
|
|
|
|
|
|
|
eta_out[d < 0.5] = 0.
|
|
|
|
|
|
eta_out[intermediate_range1] = 0.5893 * d[intermediate_range1] + 0.1546
|
|
|
|
|
|
eta_out[intermediate_range2] = 0.0509 * d[intermediate_range2] + 0.664
|
|
|
|
|
|
eta_out[d >= 3.] = 0.8167
|
|
|
|
|
|
|
2021-05-30 17:28:51 +00:00
|
|
|
|
return eta_out*self.factor_exhale
|
2021-05-26 21:39:33 +00:00
|
|
|
|
|
|
|
|
|
|
def inhale_efficiency(self) -> _VectorisedFloat:
|
2021-05-30 17:28:51 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Overall inhale efficiency, including the effect of the leaks.
|
|
|
|
|
|
"""
|
2021-05-26 21:39:33 +00:00
|
|
|
|
return self.η_inhale
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2021-05-26 21:39:33 +00:00
|
|
|
|
|
2022-09-13 15:27:06 +00:00
|
|
|
|
# Example of Masks only used for the Expert app and tests.
|
2021-05-30 17:28:51 +00:00
|
|
|
|
Mask.types = {
|
|
|
|
|
|
'No mask': Mask(0, 0),
|
2020-10-20 07:11:28 +00:00
|
|
|
|
'Type I': Mask(
|
2021-05-27 11:44:03 +00:00
|
|
|
|
η_inhale=0.5, # (CERN-OPEN-2021-004)
|
2021-05-26 21:39:33 +00:00
|
|
|
|
),
|
2021-05-30 17:28:51 +00:00
|
|
|
|
'FFP2': Mask(
|
2021-05-26 21:39:33 +00:00
|
|
|
|
η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149)
|
|
|
|
|
|
),
|
2022-09-12 15:24:09 +00:00
|
|
|
|
'Cloth': Mask( # https://doi.org/10.1080/02786826.2021.1890687
|
2022-10-11 08:39:21 +00:00
|
|
|
|
η_inhale=0.225,
|
|
|
|
|
|
η_exhale=0.35,
|
2022-09-12 15:24:09 +00:00
|
|
|
|
),
|
2020-10-20 07:11:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-01-26 12:03:37 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Particle:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Represents an aerosol particle.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
#: diameter of the aerosol in microns
|
|
|
|
|
|
diameter: typing.Union[None,_VectorisedFloat] = None
|
|
|
|
|
|
|
|
|
|
|
|
def settling_velocity(self, evaporation_factor: float=0.3) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Settling velocity (i.e. speed of deposition on the floor due
|
|
|
|
|
|
to gravity), for aerosols, in m/s. Diameter-dependent expression
|
|
|
|
|
|
from https://doi.org/10.1101/2021.10.14.21264988
|
|
|
|
|
|
When an aerosol-diameter is not given, returns
|
|
|
|
|
|
the default value of 1.88e-4 m/s (corresponds to diameter of
|
|
|
|
|
|
2.5 microns, i.e. geometric average of the breathing
|
|
|
|
|
|
expiration distribution, taking evaporation into account, see
|
|
|
|
|
|
https://doi.org/10.1101/2021.10.14.21264988)
|
|
|
|
|
|
evaporation_factor represents the factor applied to the diameter,
|
|
|
|
|
|
due to instantaneous evaporation of the particle in the air.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self.diameter is None:
|
2022-08-22 13:29:53 +00:00
|
|
|
|
return 1.88e-4
|
2022-01-26 12:03:37 +00:00
|
|
|
|
else:
|
2022-08-22 13:29:53 +00:00
|
|
|
|
return 1.88e-4 * (self.diameter*evaporation_factor / 2.5)**2
|
2022-01-26 12:03:37 +00:00
|
|
|
|
|
2022-01-27 14:38:25 +00:00
|
|
|
|
def fraction_deposited(self, evaporation_factor: float=0.3) -> _VectorisedFloat:
|
2022-01-26 12:03:37 +00:00
|
|
|
|
"""
|
2022-01-27 09:14:43 +00:00
|
|
|
|
The fraction of particles actually deposited in the respiratory
|
|
|
|
|
|
tract (over the total number of particles). It depends on the
|
|
|
|
|
|
particle diameter.
|
2022-01-26 12:03:37 +00:00
|
|
|
|
From W. C. Hinds, New York, Wiley, 1999 (pp. 233 – 259).
|
|
|
|
|
|
evaporation_factor represents the factor applied to the diameter,
|
|
|
|
|
|
due to instantaneous evaporation of the particle in the air.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self.diameter is None:
|
|
|
|
|
|
# model is not evaluated for specific values of aerosol
|
|
|
|
|
|
# diameters - we choose a single "average" deposition factor,
|
|
|
|
|
|
# as in https://doi.org/10.1101/2021.10.14.21264988.
|
|
|
|
|
|
fdep = 0.6
|
|
|
|
|
|
else:
|
|
|
|
|
|
# deposition fraction depends on aerosol particle diameter.
|
|
|
|
|
|
d = (self.diameter * evaporation_factor)
|
|
|
|
|
|
IFrac = 1 - 0.5 * (1 - (1 / (1 + (0.00076*(d**2.8)))))
|
|
|
|
|
|
fdep = IFrac * (0.0587
|
|
|
|
|
|
+ (0.911/(1 + np.exp(4.77 + 1.485 * np.log(d))))
|
|
|
|
|
|
+ (0.943/(1 + np.exp(0.508 - 2.58 * np.log(d)))))
|
|
|
|
|
|
return fdep
|
|
|
|
|
|
|
|
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
@dataclass(frozen=True)
|
2021-05-27 11:40:46 +00:00
|
|
|
|
class _ExpirationBase:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Represents the expiration of aerosols by a person.
|
|
|
|
|
|
Subclasses of _ExpirationBase represent different models.
|
|
|
|
|
|
"""
|
2021-05-30 17:28:51 +00:00
|
|
|
|
#: Pre-populated examples of Expirations.
|
2021-05-27 11:40:46 +00:00
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]]
|
|
|
|
|
|
|
2022-01-26 13:23:31 +00:00
|
|
|
|
@property
|
2022-01-26 12:03:37 +00:00
|
|
|
|
def particle(self) -> Particle:
|
|
|
|
|
|
"""
|
2022-06-09 07:46:31 +00:00
|
|
|
|
The Particle object representing the aerosol - here the default one
|
2022-01-26 12:03:37 +00:00
|
|
|
|
"""
|
|
|
|
|
|
return Particle()
|
|
|
|
|
|
|
2021-05-30 17:28:51 +00:00
|
|
|
|
def aerosols(self, mask: Mask):
|
2021-05-31 07:30:43 +00:00
|
|
|
|
"""
|
2022-06-09 14:50:23 +00:00
|
|
|
|
Total volume of aerosols expired per volume of exhaled air (mL/cm^3).
|
2021-05-31 07:30:43 +00:00
|
|
|
|
"""
|
2021-05-27 11:40:46 +00:00
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
|
|
|
|
|
|
2022-02-15 16:13:13 +00:00
|
|
|
|
def jet_origin_concentration(self):
|
2022-02-17 10:54:10 +00:00
|
|
|
|
"""
|
2022-06-09 07:46:31 +00:00
|
|
|
|
Concentration of viruses at the jet origin (mL/m3).
|
2022-02-17 10:54:10 +00:00
|
|
|
|
"""
|
2022-02-15 16:13:13 +00:00
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
|
|
|
|
|
|
2021-05-27 11:40:46 +00:00
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Expiration(_ExpirationBase):
|
2021-05-27 16:26:37 +00:00
|
|
|
|
"""
|
2021-09-13 07:54:35 +00:00
|
|
|
|
Model for the expiration. For a given diameter of aerosol, provides
|
|
|
|
|
|
the aerosol volume, weighted by the mask outward efficiency when
|
|
|
|
|
|
applicable.
|
2021-05-27 16:26:37 +00:00
|
|
|
|
"""
|
2021-09-12 06:09:56 +00:00
|
|
|
|
#: diameter of the aerosol in microns
|
|
|
|
|
|
diameter: _VectorisedFloat
|
|
|
|
|
|
|
2022-06-09 07:46:31 +00:00
|
|
|
|
#: Total concentration of aerosols per unit volume of expired air
|
2022-01-25 21:53:41 +00:00
|
|
|
|
# (in cm^-3), integrated over all aerosol diameters (corresponding
|
|
|
|
|
|
# to c_n,i in Eq. (4) of https://doi.org/10.1101/2021.10.14.21264988)
|
2021-09-15 05:01:51 +00:00
|
|
|
|
cn: float = 1.
|
|
|
|
|
|
|
2022-01-26 13:23:31 +00:00
|
|
|
|
@property
|
2022-01-26 12:03:37 +00:00
|
|
|
|
def particle(self) -> Particle:
|
|
|
|
|
|
"""
|
2022-06-09 07:46:31 +00:00
|
|
|
|
The Particle object representing the aerosol
|
2022-01-26 12:03:37 +00:00
|
|
|
|
"""
|
|
|
|
|
|
return Particle(diameter=self.diameter)
|
|
|
|
|
|
|
2021-09-12 06:09:56 +00:00
|
|
|
|
@cached()
|
2021-09-13 07:54:35 +00:00
|
|
|
|
def aerosols(self, mask: Mask):
|
2022-06-09 14:50:23 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Total volume of aerosols expired per volume of exhaled air.
|
|
|
|
|
|
Result is in mL.cm^-3
|
|
|
|
|
|
"""
|
2021-09-12 06:09:56 +00:00
|
|
|
|
def volume(d):
|
|
|
|
|
|
return (np.pi * d**3) / 6.
|
|
|
|
|
|
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Final result converted from microns^3/cm3 to mL/cm^3
|
2021-09-15 05:01:51 +00:00
|
|
|
|
return self.cn * (volume(self.diameter) *
|
2021-09-12 06:09:56 +00:00
|
|
|
|
(1 - mask.exhale_efficiency(self.diameter))) * 1e-12
|
|
|
|
|
|
|
2022-02-17 15:27:20 +00:00
|
|
|
|
@cached()
|
2022-02-08 09:57:37 +00:00
|
|
|
|
def jet_origin_concentration(self):
|
|
|
|
|
|
def volume(d):
|
|
|
|
|
|
return (np.pi * d**3) / 6.
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Final result converted from microns^3/cm3 to mL/m3
|
2022-02-15 16:13:13 +00:00
|
|
|
|
return self.cn * volume(self.diameter) * 1e-6
|
2022-02-08 09:57:37 +00:00
|
|
|
|
|
2021-05-27 16:26:37 +00:00
|
|
|
|
|
2021-05-31 09:08:42 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class MultipleExpiration(_ExpirationBase):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Represents an expiration of aerosols.
|
|
|
|
|
|
Group together different modes of expiration, that represent
|
|
|
|
|
|
each the main expiration mode for a certain fraction of time (given by
|
|
|
|
|
|
the weights).
|
2022-01-26 19:47:28 +00:00
|
|
|
|
This class can only be used with single diameters defined in each
|
|
|
|
|
|
expiration (it cannot be used with diameter distributions).
|
2021-05-31 09:08:42 +00:00
|
|
|
|
"""
|
|
|
|
|
|
expirations: typing.Tuple[_ExpirationBase, ...]
|
|
|
|
|
|
weights: typing.Tuple[float, ...]
|
|
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
|
if len(self.expirations) != len(self.weights):
|
2022-01-27 14:38:25 +00:00
|
|
|
|
raise ValueError("expirations and weigths must contain the"
|
2021-05-31 09:08:42 +00:00
|
|
|
|
"same number of elements")
|
2021-09-15 07:20:38 +00:00
|
|
|
|
if not all(np.isscalar(e.diameter) for e in self.expirations):
|
2022-01-27 14:38:25 +00:00
|
|
|
|
raise ValueError("diameters must all be scalars")
|
2021-05-31 09:08:42 +00:00
|
|
|
|
|
|
|
|
|
|
def aerosols(self, mask: Mask):
|
|
|
|
|
|
return np.array([
|
|
|
|
|
|
weight * expiration.aerosols(mask) / sum(self.weights)
|
|
|
|
|
|
for weight,expiration in zip(self.weights,self.expirations)
|
|
|
|
|
|
]).sum(axis=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2021-09-12 11:05:17 +00:00
|
|
|
|
# Typical expirations. The aerosol diameter given is an equivalent
|
|
|
|
|
|
# diameter, chosen in such a way that the aerosol volume is
|
2021-09-13 07:54:35 +00:00
|
|
|
|
# the same as the total aerosol volume given by the full BLO model
|
|
|
|
|
|
# (integrated between 0.1 and 30 microns)
|
2021-09-15 05:01:51 +00:00
|
|
|
|
# The correspondence with the BLO coefficients is given.
|
2022-09-13 15:27:06 +00:00
|
|
|
|
# Only used for the Expert app and tests.
|
2021-05-27 11:40:46 +00:00
|
|
|
|
_ExpirationBase.types = {
|
2021-09-13 07:54:35 +00:00
|
|
|
|
'Breathing': Expiration(1.3844), # corresponds to B/L/O coefficients of (1, 0, 0)
|
2021-11-08 13:58:55 +00:00
|
|
|
|
'Speaking': Expiration(5.8925), # corresponds to B/L/O coefficients of (1, 1, 1)
|
2021-09-13 07:54:35 +00:00
|
|
|
|
'Shouting': Expiration(10.0411), # corresponds to B/L/O coefficients of (1, 5, 5)
|
|
|
|
|
|
'Singing': Expiration(10.0411), # corresponds to B/L/O coefficients of (1, 5, 5)
|
2020-10-20 07:11:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Activity:
|
2021-06-01 07:22:15 +00:00
|
|
|
|
#: Inhalation rate in m^3/h
|
2021-05-11 16:18:19 +00:00
|
|
|
|
inhalation_rate: _VectorisedFloat
|
2021-06-01 07:22:15 +00:00
|
|
|
|
|
|
|
|
|
|
#: Exhalation rate in m^3/h
|
2021-05-11 16:18:19 +00:00
|
|
|
|
exhalation_rate: _VectorisedFloat
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2022-09-13 15:27:06 +00:00
|
|
|
|
#: Pre-populated examples of Activities.
|
2020-10-20 07:11:28 +00:00
|
|
|
|
types: typing.ClassVar[typing.Dict[str, "Activity"]]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-09-13 15:27:06 +00:00
|
|
|
|
# Example of Activities only used for the Expert app and tests.
|
2020-10-20 07:11:28 +00:00
|
|
|
|
Activity.types = {
|
2020-11-16 15:08:08 +00:00
|
|
|
|
'Seated': Activity(0.51, 0.51),
|
|
|
|
|
|
'Standing': Activity(0.57, 0.57),
|
2020-11-17 18:15:33 +00:00
|
|
|
|
'Light activity': Activity(1.25, 1.25),
|
|
|
|
|
|
'Moderate activity': Activity(1.78, 1.78),
|
2020-10-20 07:11:28 +00:00
|
|
|
|
'Heavy exercise': Activity(3.30, 3.30),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
2023-07-25 11:18:33 +00:00
|
|
|
|
class SimplePopulation:
|
2020-11-10 14:45:39 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Represents a group of people all with exactly the same behaviour and
|
|
|
|
|
|
situation.
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
#: How many in the population.
|
2023-04-28 10:05:30 +00:00
|
|
|
|
number: typing.Union[int, IntPiecewiseConstant]
|
2020-11-10 14:45:39 +00:00
|
|
|
|
|
|
|
|
|
|
#: The times in which the people are in the room.
|
2023-04-28 10:05:30 +00:00
|
|
|
|
presence: typing.Union[None, Interval]
|
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
|
|
|
|
|
|
|
2023-04-04 09:35:40 +00:00
|
|
|
|
def __post_init__(self):
|
|
|
|
|
|
if isinstance(self.number, int):
|
|
|
|
|
|
if not isinstance(self.presence, Interval):
|
|
|
|
|
|
raise TypeError(f'The presence argument must be an "Interval". Got {type(self.presence)}')
|
|
|
|
|
|
else:
|
|
|
|
|
|
if self.presence is not None:
|
2023-04-06 10:27:39 +00:00
|
|
|
|
raise TypeError(f'The presence argument must be None for a IntPiecewiseConstant number')
|
2023-04-28 10:05:30 +00:00
|
|
|
|
|
|
|
|
|
|
def presence_interval(self):
|
|
|
|
|
|
if isinstance(self.presence, Interval):
|
|
|
|
|
|
return self.presence
|
|
|
|
|
|
elif isinstance(self.number, IntPiecewiseConstant):
|
|
|
|
|
|
return self.number.interval()
|
2023-04-04 09:35:40 +00:00
|
|
|
|
|
|
|
|
|
|
def person_present(self, time: float):
|
|
|
|
|
|
# Allow back-compatibility
|
|
|
|
|
|
if isinstance(self.number, int) and isinstance(self.presence, Interval):
|
|
|
|
|
|
return self.presence.triggered(time)
|
2023-04-28 10:05:30 +00:00
|
|
|
|
elif isinstance(self.number, IntPiecewiseConstant):
|
2023-04-04 09:35:40 +00:00
|
|
|
|
return self.number.value(time) != 0
|
2023-04-06 08:22:53 +00:00
|
|
|
|
|
|
|
|
|
|
def people_present(self, time: float):
|
2023-04-04 09:35:40 +00:00
|
|
|
|
# Allow back-compatibility
|
2023-04-06 08:22:53 +00:00
|
|
|
|
if isinstance(self.number, int):
|
|
|
|
|
|
return self.number * self.person_present(time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return int(self.number.value(time))
|
|
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2023-07-25 11:18:33 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Population(SimplePopulation):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Represents a group of people all with exactly the same behaviour and
|
|
|
|
|
|
situation, considering the usage of mask and a certain host immunity.
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
#: The kind of mask being worn by the people.
|
|
|
|
|
|
mask: Mask
|
|
|
|
|
|
|
|
|
|
|
|
#: The ratio of virions that are inactivated by the person's immunity.
|
|
|
|
|
|
# This parameter considers the potential antibodies in the person,
|
|
|
|
|
|
# which might render inactive some RNA copies (virions).
|
|
|
|
|
|
host_immunity: float
|
|
|
|
|
|
|
|
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
|
@dataclass(frozen=True)
|
2021-09-16 11:55:04 +00:00
|
|
|
|
class _PopulationWithVirus(Population):
|
2020-11-10 14:45:39 +00:00
|
|
|
|
#: The virus with which the population is infected.
|
|
|
|
|
|
virus: Virus
|
|
|
|
|
|
|
2021-09-20 07:51:29 +00:00
|
|
|
|
@method_cache
|
|
|
|
|
|
def fraction_of_infectious_virus(self) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
|
|
|
|
|
The fraction of infectious virus.
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
return 1.
|
|
|
|
|
|
|
2022-01-26 19:47:28 +00:00
|
|
|
|
def aerosols(self):
|
|
|
|
|
|
"""
|
2022-06-09 14:50:23 +00:00
|
|
|
|
Total volume of aerosols expired per volume of exhaled air (mL/cm^3).
|
2022-01-26 19:47:28 +00:00
|
|
|
|
"""
|
|
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
|
|
|
|
|
|
2023-04-06 08:22:53 +00:00
|
|
|
|
def emission_rate_per_aerosol_per_person_when_present(self) -> _VectorisedFloat:
|
2022-01-26 19:47:28 +00:00
|
|
|
|
"""
|
2022-06-10 07:49:26 +00:00
|
|
|
|
The emission rate of virions in the expired air per mL of respiratory fluid,
|
2023-04-06 08:22:53 +00:00
|
|
|
|
per person, if the infected population is present, in (virion.cm^3)/(mL.h).
|
2022-06-10 07:49:26 +00:00
|
|
|
|
This method includes only the diameter-independent variables within the emission rate.
|
2022-01-26 19:47:28 +00:00
|
|
|
|
It should not be a function of time.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
|
|
|
|
|
|
2021-08-06 12:29:47 +00:00
|
|
|
|
@method_cache
|
2023-04-06 08:22:53 +00:00
|
|
|
|
def emission_rate_per_person_when_present(self) -> _VectorisedFloat:
|
2020-11-10 15:46:35 +00:00
|
|
|
|
"""
|
2023-04-06 08:22:53 +00:00
|
|
|
|
The emission rate if the infected population is present, per person
|
2022-01-26 19:47:28 +00:00
|
|
|
|
(in virions / h).
|
2020-11-10 15:46:35 +00:00
|
|
|
|
"""
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return (self.emission_rate_per_aerosol_per_person_when_present() *
|
2022-01-26 19:47:28 +00:00
|
|
|
|
self.aerosols())
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2021-09-14 14:11:44 +00:00
|
|
|
|
def emission_rate(self, time) -> _VectorisedFloat:
|
2020-11-10 14:45:39 +00:00
|
|
|
|
"""
|
2021-09-16 11:55:04 +00:00
|
|
|
|
The emission rate of the population vs time.
|
2020-11-10 14:45:39 +00:00
|
|
|
|
"""
|
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.
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return self.emission_rate_per_person_when_present() * self.people_present(time)
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2022-01-26 13:23:31 +00:00
|
|
|
|
@property
|
2022-01-26 12:03:37 +00:00
|
|
|
|
def particle(self) -> Particle:
|
|
|
|
|
|
"""
|
2022-06-09 07:46:31 +00:00
|
|
|
|
The Particle object representing the aerosol expired by the
|
2022-01-26 12:03:37 +00:00
|
|
|
|
population - here we take the default Particle object
|
|
|
|
|
|
"""
|
|
|
|
|
|
return Particle()
|
|
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2021-09-16 11:55:04 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class EmittingPopulation(_PopulationWithVirus):
|
|
|
|
|
|
#: The emission rate of a single individual, in virions / h.
|
|
|
|
|
|
known_individual_emission_rate: float
|
|
|
|
|
|
|
2022-01-26 19:47:28 +00:00
|
|
|
|
def aerosols(self):
|
|
|
|
|
|
"""
|
2022-06-09 14:50:23 +00:00
|
|
|
|
Total volume of aerosols expired per volume of exhaled air (mL/cm^3).
|
2022-01-26 19:47:28 +00:00
|
|
|
|
Here arbitrarily set to 1 as the full emission rate is known.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return 1.
|
|
|
|
|
|
|
2021-09-16 11:55:04 +00:00
|
|
|
|
@method_cache
|
2023-04-06 08:22:53 +00:00
|
|
|
|
def emission_rate_per_aerosol_per_person_when_present(self) -> _VectorisedFloat:
|
2021-09-16 11:55:04 +00:00
|
|
|
|
"""
|
2022-06-10 07:49:26 +00:00
|
|
|
|
The emission rate of virions in the expired air per mL of respiratory fluid,
|
2023-04-06 08:22:53 +00:00
|
|
|
|
per person, if the infected population is present, in (virion.cm^3)/(mL.h).
|
2022-06-10 07:49:26 +00:00
|
|
|
|
This method includes only the diameter-independent variables within the emission rate.
|
2022-01-26 19:47:28 +00:00
|
|
|
|
It should not be a function of time.
|
2021-09-16 11:55:04 +00:00
|
|
|
|
"""
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return self.known_individual_emission_rate
|
2021-09-16 11:55:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class InfectedPopulation(_PopulationWithVirus):
|
|
|
|
|
|
#: The type of expiration that is being emitted whilst doing the activity.
|
2022-01-24 14:01:16 +00:00
|
|
|
|
expiration: _ExpirationBase
|
2021-09-17 12:46:45 +00:00
|
|
|
|
|
2021-09-20 07:51:29 +00:00
|
|
|
|
@method_cache
|
|
|
|
|
|
def fraction_of_infectious_virus(self) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
|
|
|
|
|
The fraction of infectious virus.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return self.virus.viable_to_RNA_ratio * (1 - self.host_immunity)
|
|
|
|
|
|
|
2022-01-26 19:47:28 +00:00
|
|
|
|
def aerosols(self):
|
2021-09-16 11:55:04 +00:00
|
|
|
|
"""
|
2022-06-09 14:50:23 +00:00
|
|
|
|
Total volume of aerosols expired per volume of exhaled air (mL/cm^3).
|
2021-09-16 11:55:04 +00:00
|
|
|
|
"""
|
2022-01-26 19:47:28 +00:00
|
|
|
|
return self.expiration.aerosols(self.mask)
|
2021-09-16 11:55:04 +00:00
|
|
|
|
|
2022-01-26 19:47:28 +00:00
|
|
|
|
@method_cache
|
2023-04-06 08:22:53 +00:00
|
|
|
|
def emission_rate_per_aerosol_per_person_when_present(self) -> _VectorisedFloat:
|
2022-01-26 19:47:28 +00:00
|
|
|
|
"""
|
2022-06-10 07:49:26 +00:00
|
|
|
|
The emission rate of virions in the expired air per mL of respiratory fluid,
|
2022-06-09 14:50:23 +00:00
|
|
|
|
if the infected population is present, in (virion.cm^3)/(mL.h).
|
2022-06-10 07:49:26 +00:00
|
|
|
|
This method includes only the diameter-independent variables within the emission rate.
|
2022-01-26 19:47:28 +00:00
|
|
|
|
It should not be a function of time.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Note on units: exhalation rate is in m^3/h -> 1e6 conversion factor
|
2022-06-10 07:49:26 +00:00
|
|
|
|
# Returns the emission rate times the number of infected hosts in the room
|
2021-09-16 11:55:04 +00:00
|
|
|
|
|
|
|
|
|
|
ER = (self.virus.viral_load_in_sputum *
|
|
|
|
|
|
self.activity.exhalation_rate *
|
2022-01-26 19:47:28 +00:00
|
|
|
|
10 ** 6)
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return ER
|
2021-09-16 11:55:04 +00:00
|
|
|
|
|
2022-01-26 13:23:31 +00:00
|
|
|
|
@property
|
2022-01-26 12:03:37 +00:00
|
|
|
|
def particle(self) -> Particle:
|
|
|
|
|
|
"""
|
2022-06-09 07:46:31 +00:00
|
|
|
|
The Particle object representing the aerosol - here the default one
|
2022-01-26 12:03:37 +00:00
|
|
|
|
"""
|
2022-01-26 13:23:31 +00:00
|
|
|
|
return self.expiration.particle
|
2022-01-26 12:03:37 +00:00
|
|
|
|
|
2021-09-16 11:55:04 +00:00
|
|
|
|
|
2022-09-20 14:00:25 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Cases:
|
|
|
|
|
|
"""
|
|
|
|
|
|
The geographical data to calculate the probability of having at least 1
|
2022-10-04 09:35:51 +00:00
|
|
|
|
new infection in a probabilistic exposure.
|
2022-09-20 14:00:25 +00:00
|
|
|
|
"""
|
|
|
|
|
|
#: Geographic location population
|
|
|
|
|
|
geographic_population: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
#: Geographic location new cases
|
|
|
|
|
|
geographic_cases: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
#: Number of new cases confidence level
|
|
|
|
|
|
ascertainment_bias: int = 0
|
|
|
|
|
|
|
2022-10-05 12:28:53 +00:00
|
|
|
|
def probability_random_individual(self, virus: Virus) -> _VectorisedFloat:
|
2022-09-20 14:00:25 +00:00
|
|
|
|
"""Probability that a randomly selected individual in a focal population is infected."""
|
2022-10-05 12:28:53 +00:00
|
|
|
|
return self.geographic_cases*virus.infectiousness_days*self.ascertainment_bias/self.geographic_population
|
2022-09-20 14:00:25 +00:00
|
|
|
|
|
2022-10-07 07:03:24 +00:00
|
|
|
|
def probability_meet_infected_person(self, virus: Virus, n_infected: int, event_population: int) -> _VectorisedFloat:
|
2022-09-20 14:00:25 +00:00
|
|
|
|
"""
|
2022-10-04 09:35:51 +00:00
|
|
|
|
Probability to meet n_infected persons in an event.
|
2022-09-20 14:00:25 +00:00
|
|
|
|
From https://doi.org/10.1038/s41562-020-01000-9.
|
|
|
|
|
|
"""
|
2022-10-05 12:28:53 +00:00
|
|
|
|
return sct.binom.pmf(n_infected, event_population, self.probability_random_individual(virus))
|
2022-09-20 14:00:25 +00:00
|
|
|
|
|
|
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
@dataclass(frozen=True)
|
2022-12-13 19:59:49 +00:00
|
|
|
|
class _ConcentrationModelBase:
|
2023-01-10 12:01:34 +00:00
|
|
|
|
"""
|
|
|
|
|
|
A generic superclass that contains the methods to calculate the
|
|
|
|
|
|
concentration (e.g. viral concentration or CO2 concentration).
|
|
|
|
|
|
"""
|
2020-10-20 07:11:28 +00:00
|
|
|
|
room: Room
|
2021-01-05 17:59:43 +00:00
|
|
|
|
ventilation: _VentilationBase
|
2021-09-22 09:51:39 +00:00
|
|
|
|
|
2020-10-20 07:11:28 +00:00
|
|
|
|
@property
|
2023-07-25 11:18:33 +00:00
|
|
|
|
def population(self) -> SimplePopulation:
|
2022-12-13 19:59:49 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Population in the room (the emitters of what we compute the
|
|
|
|
|
|
concentration of)
|
|
|
|
|
|
"""
|
|
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2022-12-13 19:59:49 +00:00
|
|
|
|
def removal_rate(self, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Remove rate of the species considered, in h^-1
|
|
|
|
|
|
"""
|
|
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
2020-10-20 07:11:28 +00:00
|
|
|
|
|
2023-01-11 12:54:05 +00:00
|
|
|
|
def min_background_concentration(self) -> _VectorisedFloat:
|
2022-12-13 19:59:49 +00:00
|
|
|
|
"""
|
2023-01-11 12:54:05 +00:00
|
|
|
|
Minimum background concentration in the room for a given scenario
|
|
|
|
|
|
(in the same unit as the concentration). Its the value towards which
|
|
|
|
|
|
the concentration will decay to.
|
2022-12-13 19:59:49 +00:00
|
|
|
|
"""
|
|
|
|
|
|
return 0.
|
|
|
|
|
|
|
2023-04-06 08:22:53 +00:00
|
|
|
|
def normalization_factor(self) -> _VectorisedFloat:
|
2022-12-13 19:59:49 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Normalization factor (in the same unit as the concentration).
|
|
|
|
|
|
This factor is applied to the normalized concentration only
|
|
|
|
|
|
at the very end.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raise NotImplementedError("Subclass must implement")
|
2022-08-04 14:19:29 +00:00
|
|
|
|
|
2021-08-06 12:29:47 +00:00
|
|
|
|
@method_cache
|
2021-09-14 14:11:44 +00:00
|
|
|
|
def _normed_concentration_limit(self, time: float) -> _VectorisedFloat:
|
2021-05-04 06:57:40 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Provides a constant that represents the theoretical asymptotic
|
2021-05-05 10:45:02 +00:00
|
|
|
|
value reached by the concentration when time goes to infinity,
|
|
|
|
|
|
if all parameters were to stay time-independent.
|
2022-12-13 19:59:49 +00:00
|
|
|
|
This is normalized by the normalization factor, the latter acting as a
|
2021-09-14 14:11:44 +00:00
|
|
|
|
multiplicative constant factor for the concentration model that
|
|
|
|
|
|
can be put back in front of the concentration after the time
|
|
|
|
|
|
dependence has been solved for.
|
2021-05-04 06:57:40 +00:00
|
|
|
|
"""
|
2022-08-04 14:19:29 +00:00
|
|
|
|
V = self.room.volume
|
2022-12-13 19:59:49 +00:00
|
|
|
|
RR = self.removal_rate(time)
|
2023-03-09 15:00:00 +00:00
|
|
|
|
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return (self.population.people_present(time) / (RR * V) +
|
|
|
|
|
|
self.min_background_concentration()/self.normalization_factor())
|
2022-08-04 14:19:29 +00:00
|
|
|
|
|
2021-08-06 07:51:20 +00:00
|
|
|
|
@method_cache
|
2021-08-05 13:48:24 +00:00
|
|
|
|
def state_change_times(self) -> typing.List[float]:
|
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.
|
|
|
|
|
|
"""
|
2021-08-06 13:16:48 +00:00
|
|
|
|
state_change_times = {0.}
|
2023-04-28 10:05:30 +00:00
|
|
|
|
state_change_times.update(self.population.presence_interval().transition_times())
|
2022-04-22 13:20:20 +00:00
|
|
|
|
state_change_times.update(self.ventilation.transition_times(self.room))
|
2023-04-28 10:05:30 +00:00
|
|
|
|
|
2020-10-27 05:27:38 +00:00
|
|
|
|
return sorted(state_change_times)
|
|
|
|
|
|
|
2021-09-13 10:00:06 +00:00
|
|
|
|
@method_cache
|
|
|
|
|
|
def _first_presence_time(self) -> float:
|
|
|
|
|
|
"""
|
|
|
|
|
|
First presence time. Before that, the concentration is zero.
|
|
|
|
|
|
"""
|
2023-04-28 10:05:30 +00:00
|
|
|
|
return self.population.presence_interval().boundaries()[0][0]
|
|
|
|
|
|
|
2021-08-05 13:48:24 +00:00
|
|
|
|
def last_state_change(self, time: float) -> float:
|
2020-10-27 05:27:38 +00:00
|
|
|
|
"""
|
2021-08-06 07:29:12 +00:00
|
|
|
|
Find the most recent/previous state change.
|
2020-10-27 05:27:38 +00:00
|
|
|
|
|
2021-08-06 07:51:20 +00:00
|
|
|
|
Find the nearest time less than the given one. If there is a state
|
|
|
|
|
|
change exactly at ``time`` the previous state change is returned
|
|
|
|
|
|
(except at ``time == 0``).
|
2020-10-27 05:27:38 +00:00
|
|
|
|
"""
|
2021-08-06 07:51:20 +00:00
|
|
|
|
times = self.state_change_times()
|
2021-08-06 08:01:31 +00:00
|
|
|
|
t_index: int = np.searchsorted(times, time) # type: ignore
|
2021-08-06 07:51:20 +00:00
|
|
|
|
# Search sorted gives us the index to insert the given time. Instead we
|
|
|
|
|
|
# want to get the index of the most recent time, so reduce the index by
|
|
|
|
|
|
# one unless we are already at 0.
|
|
|
|
|
|
t_index = max([t_index - 1, 0])
|
|
|
|
|
|
return times[t_index]
|
2020-10-26 19:12:54 +00:00
|
|
|
|
|
2021-08-05 13:48:24 +00:00
|
|
|
|
def _next_state_change(self, time: float) -> float:
|
2021-05-03 10:35:10 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Find the nearest future state change.
|
|
|
|
|
|
"""
|
|
|
|
|
|
for change_time in self.state_change_times():
|
|
|
|
|
|
if change_time >= time:
|
|
|
|
|
|
return change_time
|
2021-05-05 19:03:36 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"The requested time ({time}) is greater than last available "
|
|
|
|
|
|
f"state change time ({change_time})"
|
|
|
|
|
|
)
|
2021-05-03 10:35:10 +00:00
|
|
|
|
|
2021-08-05 13:48:24 +00:00
|
|
|
|
@method_cache
|
2021-09-14 14:11:44 +00:00
|
|
|
|
def _normed_concentration_cached(self, time: float) -> _VectorisedFloat:
|
2022-12-13 19:59:49 +00:00
|
|
|
|
"""
|
|
|
|
|
|
A cached version of the _normed_concentration method. Use this
|
|
|
|
|
|
method if you expect that there may be multiple concentration
|
|
|
|
|
|
calculations for the same time (e.g. at state change times).
|
|
|
|
|
|
"""
|
2021-09-14 14:11:44 +00:00
|
|
|
|
return self._normed_concentration(time)
|
2021-06-05 16:38:57 +00:00
|
|
|
|
|
2021-09-14 14:11:44 +00:00
|
|
|
|
def _normed_concentration(self, time: float) -> _VectorisedFloat:
|
2021-05-05 10:45:02 +00:00
|
|
|
|
"""
|
2022-12-13 19:59:49 +00:00
|
|
|
|
Concentration as a function of time, and normalized by
|
|
|
|
|
|
normalization_factor.
|
2021-05-05 10:45:02 +00:00
|
|
|
|
The formulas used here assume that all parameters (ventilation,
|
|
|
|
|
|
emission rate) are constant between two state changes - only
|
|
|
|
|
|
the value of these parameters at the next state change, are used.
|
|
|
|
|
|
|
|
|
|
|
|
Note that time is not vectorised. You can only pass a single float
|
|
|
|
|
|
to this method.
|
|
|
|
|
|
"""
|
2021-09-13 10:00:06 +00:00
|
|
|
|
# The model always starts at t=0, but we avoid running concentration calculations
|
|
|
|
|
|
# before the first presence as an optimisation.
|
|
|
|
|
|
if time <= self._first_presence_time():
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return self.min_background_concentration()/self.normalization_factor()
|
2023-03-16 15:07:35 +00:00
|
|
|
|
|
2021-05-05 19:03:36 +00:00
|
|
|
|
next_state_change_time = self._next_state_change(time)
|
2022-12-13 19:59:49 +00:00
|
|
|
|
RR = self.removal_rate(next_state_change_time)
|
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)
|
2023-04-06 08:22:53 +00:00
|
|
|
|
conc_at_last_state_change = self._normed_concentration_cached(t_last_state_change)
|
2020-10-27 13:34:45 +00:00
|
|
|
|
delta_time = time - t_last_state_change
|
2023-07-25 12:56:13 +00:00
|
|
|
|
|
2022-12-13 19:59:49 +00:00
|
|
|
|
fac = np.exp(-RR * delta_time)
|
2023-03-16 15:07:35 +00:00
|
|
|
|
|
2023-07-25 12:56:13 +00:00
|
|
|
|
if isinstance(RR, float) and RR == 0:
|
|
|
|
|
|
curr_conc_state = delta_time * self.population.people_present(time) / self.room.volume
|
|
|
|
|
|
else:
|
|
|
|
|
|
curr_conc_state = self._normed_concentration_limit(next_state_change_time) * (1 - fac)
|
2021-09-14 14:11:44 +00:00
|
|
|
|
|
2023-07-25 12:56:13 +00:00
|
|
|
|
return curr_conc_state + conc_at_last_state_change * fac
|
|
|
|
|
|
|
2021-09-14 14:11:44 +00:00
|
|
|
|
def concentration(self, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
2022-12-13 19:59:49 +00:00
|
|
|
|
Total concentration as a function of time. The normalization
|
|
|
|
|
|
factor has been put back.
|
2021-09-14 14:11:44 +00:00
|
|
|
|
|
|
|
|
|
|
Note that time is not vectorised. You can only pass a single float
|
|
|
|
|
|
to this method.
|
|
|
|
|
|
"""
|
2022-03-03 14:46:14 +00:00
|
|
|
|
return (self._normed_concentration_cached(time) *
|
2023-04-06 08:22:53 +00:00
|
|
|
|
self.normalization_factor())
|
2020-10-26 18:10:53 +00:00
|
|
|
|
|
2021-08-06 09:50:06 +00:00
|
|
|
|
@method_cache
|
2021-09-14 14:11:44 +00:00
|
|
|
|
def normed_integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
|
2021-05-10 17:13:09 +00:00
|
|
|
|
"""
|
2022-12-13 19:59:49 +00:00
|
|
|
|
Get the integrated concentration between the times start and stop,
|
|
|
|
|
|
normalized by normalization_factor.
|
2021-05-10 17:13:09 +00:00
|
|
|
|
"""
|
2021-09-13 10:00:06 +00:00
|
|
|
|
if stop <= self._first_presence_time():
|
2023-01-11 12:54:05 +00:00
|
|
|
|
return (stop - start)*self.min_background_concentration()/self.normalization_factor()
|
2021-05-10 17:13:09 +00:00
|
|
|
|
state_change_times = self.state_change_times()
|
|
|
|
|
|
req_start, req_stop = start, stop
|
2021-09-14 14:11:44 +00:00
|
|
|
|
total_normed_concentration = 0.
|
2021-05-10 17:13:09 +00:00
|
|
|
|
for interval_start, interval_stop in zip(state_change_times[:-1], state_change_times[1:]):
|
2021-05-10 18:23:54 +00:00
|
|
|
|
if req_start > interval_stop or req_stop < interval_start:
|
2021-05-10 17:13:09 +00:00
|
|
|
|
continue
|
|
|
|
|
|
# Clip the current interval to the requested range.
|
|
|
|
|
|
start = max([interval_start, req_start])
|
|
|
|
|
|
stop = min([interval_stop, req_stop])
|
|
|
|
|
|
|
2021-09-14 14:11:44 +00:00
|
|
|
|
conc_start = self._normed_concentration_cached(start)
|
2021-05-10 17:13:09 +00:00
|
|
|
|
|
|
|
|
|
|
next_conc_state = self._next_state_change(stop)
|
2021-09-14 14:11:44 +00:00
|
|
|
|
conc_limit = self._normed_concentration_limit(next_conc_state)
|
2022-12-13 19:59:49 +00:00
|
|
|
|
RR = self.removal_rate(next_conc_state)
|
2021-05-10 17:13:09 +00:00
|
|
|
|
delta_time = stop - start
|
2021-09-14 14:11:44 +00:00
|
|
|
|
total_normed_concentration += (
|
2021-05-10 17:13:09 +00:00
|
|
|
|
conc_limit * delta_time +
|
2022-12-13 19:59:49 +00:00
|
|
|
|
(conc_limit - conc_start) * (np.exp(-RR*delta_time)-1) / RR
|
2021-05-10 17:13:09 +00:00
|
|
|
|
)
|
2021-09-14 14:11:44 +00:00
|
|
|
|
return total_normed_concentration
|
|
|
|
|
|
|
|
|
|
|
|
def integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
2021-12-15 14:33:57 +00:00
|
|
|
|
Get the integrated concentration of viruses in the air between the times start and stop.
|
2021-09-14 14:11:44 +00:00
|
|
|
|
"""
|
|
|
|
|
|
return (self.normed_integrated_concentration(start, stop) *
|
2023-04-06 08:22:53 +00:00
|
|
|
|
self.normalization_factor())
|
2022-12-13 19:59:49 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class ConcentrationModel(_ConcentrationModelBase):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Class used for the computation of the long-range virus concentration.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
#: Infected population in the room, emitting virions
|
|
|
|
|
|
infected: InfectedPopulation
|
|
|
|
|
|
|
|
|
|
|
|
#: evaporation factor: the particles' diameter is multiplied by this
|
|
|
|
|
|
# factor as soon as they are in the air (but AFTER going out of the,
|
|
|
|
|
|
# mask, if any).
|
|
|
|
|
|
evaporation_factor: float = 0.3
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def population(self) -> InfectedPopulation:
|
|
|
|
|
|
return self.infected
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def virus(self) -> Virus:
|
|
|
|
|
|
return self.infected.virus
|
|
|
|
|
|
|
2023-04-06 08:22:53 +00:00
|
|
|
|
def normalization_factor(self) -> _VectorisedFloat:
|
2022-12-13 19:59:49 +00:00
|
|
|
|
# we normalize by the emission rate
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return self.infected.emission_rate_per_person_when_present()
|
2022-12-13 19:59:49 +00:00
|
|
|
|
|
|
|
|
|
|
def removal_rate(self, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
# Equilibrium velocity of particle motion toward the floor
|
|
|
|
|
|
vg = self.infected.particle.settling_velocity(self.evaporation_factor)
|
|
|
|
|
|
# Height of the emission source to the floor - i.e. mouth/nose (m)
|
|
|
|
|
|
h = 1.5
|
|
|
|
|
|
# Deposition rate (h^-1)
|
|
|
|
|
|
k = (vg * 3600) / h
|
|
|
|
|
|
return (
|
|
|
|
|
|
k + self.virus.decay_constant(self.room.humidity, self.room.inside_temp.value(time))
|
|
|
|
|
|
+ self.ventilation.air_exchange(self.room, time)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def infectious_virus_removal_rate(self, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
# defined for back-compatibility purposes
|
|
|
|
|
|
return self.removal_rate(time)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class CO2ConcentrationModel(_ConcentrationModelBase):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Class used for the computation of the CO2 concentration.
|
|
|
|
|
|
"""
|
|
|
|
|
|
#: Population in the room emitting CO2
|
2023-07-25 11:18:33 +00:00
|
|
|
|
CO2_emitters: SimplePopulation
|
2022-12-13 19:59:49 +00:00
|
|
|
|
|
|
|
|
|
|
#: CO2 concentration in the atmosphere (in ppm)
|
|
|
|
|
|
CO2_atmosphere_concentration: float = 440.44
|
|
|
|
|
|
|
|
|
|
|
|
#: CO2 fraction in the exhaled air
|
|
|
|
|
|
CO2_fraction_exhaled: float = 0.042
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
2023-07-25 11:18:33 +00:00
|
|
|
|
def population(self) -> SimplePopulation:
|
2022-12-13 19:59:49 +00:00
|
|
|
|
return self.CO2_emitters
|
|
|
|
|
|
|
|
|
|
|
|
def removal_rate(self, time: float) -> _VectorisedFloat:
|
2023-07-25 12:56:13 +00:00
|
|
|
|
return self.ventilation.air_exchange(self.room, time)
|
2022-12-13 19:59:49 +00:00
|
|
|
|
|
2023-01-11 12:54:05 +00:00
|
|
|
|
def min_background_concentration(self) -> _VectorisedFloat:
|
2022-12-13 19:59:49 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Background CO2 concentration in the atmosphere (in ppm)
|
|
|
|
|
|
"""
|
|
|
|
|
|
return self.CO2_atmosphere_concentration
|
|
|
|
|
|
|
2023-04-06 08:22:53 +00:00
|
|
|
|
def normalization_factor(self) -> _VectorisedFloat:
|
|
|
|
|
|
# normalization by the CO2 exhaled per person.
|
2022-12-13 19:59:49 +00:00
|
|
|
|
# CO2 concentration given in ppm, hence the 1e6 factor.
|
2023-04-06 08:22:53 +00:00
|
|
|
|
return (1e6*self.population.activity.exhalation_rate
|
2022-12-13 19:59:49 +00:00
|
|
|
|
*self.CO2_fraction_exhaled)
|
2021-05-10 17:13:09 +00:00
|
|
|
|
|
2020-10-26 18:10:53 +00:00
|
|
|
|
|
2022-02-15 16:13:13 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class ShortRangeModel:
|
2022-08-02 08:16:09 +00:00
|
|
|
|
'''
|
|
|
|
|
|
Based on the two-stage (jet/puff) expiratory jet model by
|
|
|
|
|
|
Jia et al (2022) - https://doi.org/10.1016/j.buildenv.2022.109166
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
2022-03-29 10:19:14 +00:00
|
|
|
|
#: Expiration type
|
|
|
|
|
|
expiration: _ExpirationBase
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2022-03-29 10:19:14 +00:00
|
|
|
|
#: Activity type
|
|
|
|
|
|
activity: Activity
|
2022-03-28 13:41:55 +00:00
|
|
|
|
|
2022-03-29 10:19:14 +00:00
|
|
|
|
#: Short-range expiration and respective presence
|
|
|
|
|
|
presence: SpecificInterval
|
2022-03-28 13:41:55 +00:00
|
|
|
|
|
|
|
|
|
|
#: Interpersonal distances
|
2022-03-31 16:17:15 +00:00
|
|
|
|
distance: _VectorisedFloat
|
2022-03-28 13:41:55 +00:00
|
|
|
|
|
2022-03-29 10:19:14 +00:00
|
|
|
|
def dilution_factor(self) -> _VectorisedFloat:
|
2022-03-28 13:41:55 +00:00
|
|
|
|
'''
|
|
|
|
|
|
The dilution factor for the respective expiratory activity type.
|
|
|
|
|
|
'''
|
2022-11-07 13:50:15 +00:00
|
|
|
|
# Average mouth opening diameter (m)
|
2022-11-02 14:40:40 +00:00
|
|
|
|
mouth_diameter = 0.02
|
2022-11-03 16:32:29 +00:00
|
|
|
|
|
|
|
|
|
|
# Breathing rate, from m3/h to m3/s
|
2022-03-31 16:17:15 +00:00
|
|
|
|
BR = np.array(self.activity.exhalation_rate/3600.)
|
2022-11-03 16:32:29 +00:00
|
|
|
|
|
|
|
|
|
|
# Exhalation coefficient. Ratio between the duration of a breathing cycle and the duration of
|
2022-11-09 10:13:27 +00:00
|
|
|
|
# the exhalation.
|
|
|
|
|
|
φ = 2
|
2022-11-03 16:32:29 +00:00
|
|
|
|
|
2022-11-07 13:50:15 +00:00
|
|
|
|
# Exhalation airflow, as per Jia et al. (2022)
|
2022-12-13 19:59:49 +00:00
|
|
|
|
Q_exh: _VectorisedFloat = φ * BR
|
2022-11-03 16:32:29 +00:00
|
|
|
|
|
|
|
|
|
|
# Area of the mouth assuming a perfect circle (m2)
|
2022-11-02 14:40:40 +00:00
|
|
|
|
Am = np.pi*(mouth_diameter**2)/4
|
2022-03-31 16:17:15 +00:00
|
|
|
|
|
2022-11-03 16:32:29 +00:00
|
|
|
|
# Initial velocity of the exhalation airflow (m/s)
|
|
|
|
|
|
u0 = np.array(Q_exh/Am)
|
|
|
|
|
|
|
2022-11-07 13:50:15 +00:00
|
|
|
|
# Duration of the expiration period(s), assuming a 4s breath-cycle
|
2022-03-28 13:41:55 +00:00
|
|
|
|
tstar = 2.0
|
2022-11-03 16:32:29 +00:00
|
|
|
|
|
2022-11-07 13:50:15 +00:00
|
|
|
|
# Streamwise and radial penetration coefficients
|
2022-10-28 10:17:12 +00:00
|
|
|
|
𝛽r1 = 0.18
|
|
|
|
|
|
𝛽r2 = 0.2
|
|
|
|
|
|
𝛽x1 = 2.4
|
2022-03-31 16:17:15 +00:00
|
|
|
|
|
2022-03-28 13:41:55 +00:00
|
|
|
|
# Parameters in the jet-like stage
|
2022-11-03 16:32:29 +00:00
|
|
|
|
# Position of virtual origin
|
2022-11-02 14:40:40 +00:00
|
|
|
|
x0 = mouth_diameter/2/𝛽r1
|
2022-03-28 13:41:55 +00:00
|
|
|
|
# Time of virtual origin
|
2022-11-03 16:32:29 +00:00
|
|
|
|
t0 = (np.sqrt(np.pi)*(mouth_diameter**3))/(8*(𝛽r1**2)*(𝛽x1**2)*Q_exh)
|
2022-03-28 13:41:55 +00:00
|
|
|
|
# The transition point, m
|
2022-11-03 16:32:29 +00:00
|
|
|
|
xstar = np.array(𝛽x1*(Q_exh*u0)**0.25*(tstar + t0)**0.5 - x0)
|
2022-03-28 13:41:55 +00:00
|
|
|
|
# Dilution factor at the transition point xstar
|
2022-11-02 14:40:40 +00:00
|
|
|
|
Sxstar = np.array(2*𝛽r1*(xstar+x0)/mouth_diameter)
|
2022-03-31 16:17:15 +00:00
|
|
|
|
|
|
|
|
|
|
distances = np.array(self.distance)
|
2022-03-29 10:19:14 +00:00
|
|
|
|
factors = np.empty(distances.shape, dtype=np.float64)
|
2022-10-28 10:17:12 +00:00
|
|
|
|
factors[distances < xstar] = 2*𝛽r1*(distances[distances < xstar]
|
2022-11-02 14:40:40 +00:00
|
|
|
|
+ x0)/mouth_diameter
|
2022-03-31 16:17:15 +00:00
|
|
|
|
factors[distances >= xstar] = Sxstar[distances >= xstar]*(1 +
|
2022-10-28 10:17:12 +00:00
|
|
|
|
𝛽r2*(distances[distances >= xstar] -
|
|
|
|
|
|
xstar[distances >= xstar])/𝛽r1/(xstar[distances >= xstar]
|
|
|
|
|
|
+ x0))**3
|
2022-03-29 10:19:14 +00:00
|
|
|
|
return factors
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2022-07-28 10:13:52 +00:00
|
|
|
|
def _long_range_normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Virus long-range exposure concentration normalized by the
|
|
|
|
|
|
virus viral load, as function of time.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return (concentration_model.concentration(time) /
|
|
|
|
|
|
concentration_model.virus.viral_load_in_sputum)
|
|
|
|
|
|
|
2022-02-17 16:31:24 +00:00
|
|
|
|
def _normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
2022-03-24 10:18:08 +00:00
|
|
|
|
Virus short-range exposure concentration, as a function of time.
|
2022-02-17 16:31:24 +00:00
|
|
|
|
|
2022-03-24 10:18:08 +00:00
|
|
|
|
If the given time falls within a short-range interval it returns the
|
|
|
|
|
|
short-range concentration normalized by the virus viral load. Otherwise
|
2022-02-17 16:31:24 +00:00
|
|
|
|
it returns 0.
|
|
|
|
|
|
"""
|
2022-03-29 10:19:14 +00:00
|
|
|
|
start, stop = self.presence.boundaries()[0]
|
|
|
|
|
|
# Verifies if the given time falls within a short-range interaction
|
|
|
|
|
|
if start <= time <= stop:
|
|
|
|
|
|
dilution = self.dilution_factor()
|
|
|
|
|
|
jet_origin_concentration = self.expiration.jet_origin_concentration()
|
|
|
|
|
|
# Long-range concentration normalized by the virus viral load
|
2022-07-28 10:13:52 +00:00
|
|
|
|
long_range_normed_concentration = self._long_range_normed_concentration(concentration_model, time)
|
2022-03-29 10:19:14 +00:00
|
|
|
|
|
|
|
|
|
|
# The long-range concentration values are then approximated using interpolation:
|
|
|
|
|
|
# The set of points where we want the interpolated values are the short-range particle diameters (given the current expiration);
|
|
|
|
|
|
# The set of points with a known value are the long-range particle diameters (given the initial expiration);
|
|
|
|
|
|
# The set of known values are the long-range concentration values normalized by the viral load.
|
2022-08-22 13:29:53 +00:00
|
|
|
|
long_range_normed_concentration_interpolated=np.interp(np.array(self.expiration.particle.diameter),
|
|
|
|
|
|
np.array(concentration_model.infected.particle.diameter), long_range_normed_concentration)
|
2022-03-29 10:19:14 +00:00
|
|
|
|
|
|
|
|
|
|
# Short-range concentration formula. The long-range concentration is added in the concentration method (ExposureModel).
|
2022-05-31 09:05:28 +00:00
|
|
|
|
# based on continuum model proposed by Jia et al (2022) - https://doi.org/10.1016/j.buildenv.2022.109166
|
2022-03-29 10:19:14 +00:00
|
|
|
|
return ((1/dilution)*(jet_origin_concentration - long_range_normed_concentration_interpolated))
|
2022-02-15 16:13:13 +00:00
|
|
|
|
return 0.
|
|
|
|
|
|
|
2022-03-29 10:19:14 +00:00
|
|
|
|
def short_range_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
|
2022-02-17 16:31:24 +00:00
|
|
|
|
"""
|
2022-03-24 10:18:08 +00:00
|
|
|
|
Virus short-range exposure concentration, as a function of time.
|
2022-02-17 16:31:24 +00:00
|
|
|
|
"""
|
2022-02-15 16:13:13 +00:00
|
|
|
|
return (self._normed_concentration(concentration_model, time) *
|
|
|
|
|
|
concentration_model.virus.viral_load_in_sputum)
|
|
|
|
|
|
|
2022-03-16 16:58:30 +00:00
|
|
|
|
@method_cache
|
|
|
|
|
|
def _normed_short_range_concentration_cached(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
# A cached version of the _normed_concentration method. Use this
|
2022-03-24 10:18:08 +00:00
|
|
|
|
# method if you expect that there may be multiple short-range concentration
|
2022-03-16 16:58:30 +00:00
|
|
|
|
# calculations for the same time (e.g. at state change times).
|
|
|
|
|
|
return self._normed_concentration(concentration_model, time)
|
|
|
|
|
|
|
2022-04-22 15:13:15 +00:00
|
|
|
|
@method_cache
|
2022-09-28 09:36:38 +00:00
|
|
|
|
def extract_between_bounds(self, time1: float, time2: float) -> typing.Union[None, typing.Tuple[float,float]]:
|
2022-02-17 16:31:24 +00:00
|
|
|
|
"""
|
2022-04-22 15:13:15 +00:00
|
|
|
|
Extract the bounds of the interval resulting from the
|
|
|
|
|
|
intersection of [time1, time2] and the presence interval.
|
|
|
|
|
|
If [time1, time2] has nothing common to the presence interval,
|
|
|
|
|
|
we return (0, 0).
|
|
|
|
|
|
Raise an error if time1 and time2 are not in ascending order.
|
2022-02-17 16:31:24 +00:00
|
|
|
|
"""
|
2022-04-22 15:13:15 +00:00
|
|
|
|
if time1>time2:
|
|
|
|
|
|
raise ValueError("time1 must be less or equal to time2")
|
|
|
|
|
|
|
|
|
|
|
|
start, stop = self.presence.boundaries()[0]
|
|
|
|
|
|
if (stop < time1) or (start > time2):
|
|
|
|
|
|
return (0, 0)
|
|
|
|
|
|
elif start <= time1 and time2<= stop:
|
|
|
|
|
|
return time1, time2
|
|
|
|
|
|
elif start <= time1 and stop < time2:
|
|
|
|
|
|
return time1, stop
|
|
|
|
|
|
elif time1 < start and time2 <= stop:
|
|
|
|
|
|
return start, time2
|
|
|
|
|
|
elif time1 <= start and stop < time2:
|
|
|
|
|
|
return start, stop
|
|
|
|
|
|
|
2022-04-22 20:23:49 +00:00
|
|
|
|
def _normed_jet_exposure_between_bounds(self,
|
|
|
|
|
|
concentration_model: ConcentrationModel,
|
|
|
|
|
|
time1: float, time2: float):
|
2022-02-17 16:31:24 +00:00
|
|
|
|
"""
|
2022-04-22 20:23:49 +00:00
|
|
|
|
Get the part of the integrated short-range concentration of
|
|
|
|
|
|
viruses in the air, between the times start and stop, coming
|
|
|
|
|
|
from the jet concentration, normalized by the viral load, and
|
|
|
|
|
|
without dilution.
|
2022-02-17 16:31:24 +00:00
|
|
|
|
"""
|
2022-04-22 15:13:15 +00:00
|
|
|
|
start, stop = self.extract_between_bounds(time1, time2)
|
2022-04-06 09:48:26 +00:00
|
|
|
|
jet_origin = self.expiration.jet_origin_concentration()
|
2022-04-22 20:23:49 +00:00
|
|
|
|
return jet_origin * (stop - start)
|
|
|
|
|
|
|
|
|
|
|
|
def _normed_interpolated_longrange_exposure_between_bounds(
|
|
|
|
|
|
self, concentration_model: ConcentrationModel,
|
|
|
|
|
|
time1: float, time2: float):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get the part of the integrated short-range concentration due
|
|
|
|
|
|
to the background concentration, normalized by the viral load
|
|
|
|
|
|
and the breathing rate, and without dilution.
|
|
|
|
|
|
One needs to interpolate the integrated long-range concentration
|
|
|
|
|
|
for the particle diameters defined here.
|
|
|
|
|
|
TODO: make sure any potential extrapolation has a
|
|
|
|
|
|
negligible effect.
|
|
|
|
|
|
"""
|
|
|
|
|
|
start, stop = self.extract_between_bounds(time1, time2)
|
|
|
|
|
|
if stop<=start:
|
|
|
|
|
|
return 0.
|
2022-03-16 16:58:30 +00:00
|
|
|
|
|
2022-04-22 20:23:49 +00:00
|
|
|
|
normed_int_concentration = (
|
|
|
|
|
|
concentration_model.integrated_concentration(start, stop)
|
|
|
|
|
|
/concentration_model.virus.viral_load_in_sputum
|
|
|
|
|
|
/concentration_model.infected.activity.exhalation_rate
|
2022-04-06 09:48:26 +00:00
|
|
|
|
)
|
2022-04-22 20:23:49 +00:00
|
|
|
|
normed_int_concentration_interpolated = np.interp(
|
2022-08-22 13:29:53 +00:00
|
|
|
|
np.array(self.expiration.particle.diameter),
|
|
|
|
|
|
np.array(concentration_model.infected.particle.diameter),
|
2022-04-22 20:23:49 +00:00
|
|
|
|
normed_int_concentration
|
2022-04-06 09:48:26 +00:00
|
|
|
|
)
|
2022-04-22 20:23:49 +00:00
|
|
|
|
return normed_int_concentration_interpolated
|
2022-03-16 16:58:30 +00:00
|
|
|
|
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class ExposureModel:
|
2021-09-15 08:37:50 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Represents the exposure to a concentration of virions in the air.
|
|
|
|
|
|
"""
|
2020-11-10 14:45:39 +00:00
|
|
|
|
#: 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
|
|
|
|
|
2022-03-29 10:19:14 +00:00
|
|
|
|
#: The list of short-range models which this exposure model should consider.
|
|
|
|
|
|
short_range: typing.Tuple[ShortRangeModel, ...]
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2020-11-10 14:45:39 +00:00
|
|
|
|
#: The population of non-infected people to be used in the model.
|
|
|
|
|
|
exposed: Population
|
|
|
|
|
|
|
2022-09-20 14:00:25 +00:00
|
|
|
|
#: Geographical data
|
|
|
|
|
|
geographical_data: Cases
|
|
|
|
|
|
|
2020-11-11 08:50:54 +00:00
|
|
|
|
#: The number of times the exposure event is repeated (default 1).
|
|
|
|
|
|
repeats: int = 1
|
|
|
|
|
|
|
2022-08-01 07:42:44 +00:00
|
|
|
|
def __post_init__(self):
|
|
|
|
|
|
"""
|
2022-11-02 13:57:43 +00:00
|
|
|
|
When diameters are sampled (given as an array),
|
|
|
|
|
|
the Monte-Carlo integration over the diameters
|
|
|
|
|
|
assumes that all the parameters within the IVRR,
|
|
|
|
|
|
apart from the settling velocity, are NOT arrays.
|
|
|
|
|
|
In other words, the air exchange rate from the
|
|
|
|
|
|
ventilation, and the virus decay constant, must
|
|
|
|
|
|
not be given as arrays.
|
2022-08-01 07:42:44 +00:00
|
|
|
|
"""
|
2022-10-20 12:19:49 +00:00
|
|
|
|
c_model = self.concentration_model
|
|
|
|
|
|
# Check if the diameter is vectorised.
|
2022-12-13 19:59:49 +00:00
|
|
|
|
if (isinstance(c_model.infected, InfectedPopulation) and not np.isscalar(c_model.infected.expiration.diameter)
|
2022-10-20 12:19:49 +00:00
|
|
|
|
# Check if the diameter-independent elements of the infectious_virus_removal_rate method are vectorised.
|
|
|
|
|
|
and not (
|
|
|
|
|
|
all(np.isscalar(c_model.virus.decay_constant(c_model.room.humidity, c_model.room.inside_temp.value(time)) +
|
|
|
|
|
|
c_model.ventilation.air_exchange(c_model.room, time)) for time in c_model.state_change_times()))):
|
2023-04-06 08:22:53 +00:00
|
|
|
|
raise ValueError("If the diameter is an array, none of the ventilation parameters "
|
|
|
|
|
|
"or virus decay constant can be arrays at the same time.")
|
2023-05-15 13:53:50 +00:00
|
|
|
|
|
|
|
|
|
|
@method_cache
|
|
|
|
|
|
def population_state_change_times(self) -> typing.List[float]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
All time dependent population entities on this model must provide information
|
|
|
|
|
|
about the times at which their state changes.
|
|
|
|
|
|
"""
|
|
|
|
|
|
state_change_times = set(self.concentration_model.infected.presence_interval().transition_times())
|
|
|
|
|
|
state_change_times.update(self.exposed.presence_interval().transition_times())
|
|
|
|
|
|
|
|
|
|
|
|
return sorted(state_change_times)
|
2022-08-01 07:42:44 +00:00
|
|
|
|
|
2022-02-15 16:13:13 +00:00
|
|
|
|
def long_range_fraction_deposited(self) -> _VectorisedFloat:
|
2021-09-22 12:35:19 +00:00
|
|
|
|
"""
|
2022-01-27 09:14:43 +00:00
|
|
|
|
The fraction of particles actually deposited in the respiratory
|
|
|
|
|
|
tract (over the total number of particles). It depends on the
|
|
|
|
|
|
particle diameter.
|
2021-09-22 12:35:19 +00:00
|
|
|
|
"""
|
2022-01-26 13:23:31 +00:00
|
|
|
|
return self.concentration_model.infected.particle.fraction_deposited(
|
2022-01-26 12:03:37 +00:00
|
|
|
|
self.concentration_model.evaporation_factor)
|
2021-06-01 07:22:15 +00:00
|
|
|
|
|
2022-02-15 16:13:13 +00:00
|
|
|
|
def _long_range_normed_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
2022-06-09 07:46:31 +00:00
|
|
|
|
"""
|
|
|
|
|
|
The number of virions per meter^3 between any two times, normalized
|
|
|
|
|
|
by the emission rate of the infected population
|
|
|
|
|
|
"""
|
2021-12-15 14:33:57 +00:00
|
|
|
|
exposure = 0.
|
2023-04-28 10:05:30 +00:00
|
|
|
|
for start, stop in self.exposed.presence_interval().boundaries():
|
2023-04-06 08:22:53 +00:00
|
|
|
|
if stop < time1:
|
|
|
|
|
|
continue
|
|
|
|
|
|
elif start > time2:
|
|
|
|
|
|
break
|
|
|
|
|
|
elif start <= time1 and time2<= stop:
|
|
|
|
|
|
exposure += self.concentration_model.normed_integrated_concentration(time1, time2)
|
|
|
|
|
|
elif start <= time1 and stop < time2:
|
|
|
|
|
|
exposure += self.concentration_model.normed_integrated_concentration(time1, stop)
|
|
|
|
|
|
elif time1 < start and time2 <= stop:
|
|
|
|
|
|
exposure += self.concentration_model.normed_integrated_concentration(start, time2)
|
|
|
|
|
|
elif time1 <= start and stop < time2:
|
|
|
|
|
|
exposure += self.concentration_model.normed_integrated_concentration(start, stop)
|
2021-12-15 14:33:57 +00:00
|
|
|
|
return exposure
|
2020-10-26 18:10:53 +00:00
|
|
|
|
|
2022-02-17 16:31:24 +00:00
|
|
|
|
def concentration(self, time: float) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Virus exposure concentration, as a function of time.
|
|
|
|
|
|
|
2022-03-24 12:55:03 +00:00
|
|
|
|
It considers the long-range concentration with the
|
2022-03-24 10:18:08 +00:00
|
|
|
|
contribution of the short-range concentration.
|
2022-03-29 10:19:14 +00:00
|
|
|
|
"""
|
|
|
|
|
|
concentration = self.concentration_model.concentration(time)
|
|
|
|
|
|
for interaction in self.short_range:
|
|
|
|
|
|
concentration += interaction.short_range_concentration(self.concentration_model, time)
|
|
|
|
|
|
return concentration
|
2021-09-14 14:11:44 +00:00
|
|
|
|
|
2022-03-18 11:53:01 +00:00
|
|
|
|
def long_range_deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
|
|
|
|
|
deposited_exposure = 0.
|
|
|
|
|
|
|
2023-04-06 08:22:53 +00:00
|
|
|
|
emission_rate_per_aerosol_per_person = \
|
|
|
|
|
|
self.concentration_model.infected.emission_rate_per_aerosol_per_person_when_present()
|
2022-03-18 11:53:01 +00:00
|
|
|
|
aerosols = self.concentration_model.infected.aerosols()
|
|
|
|
|
|
f_inf = self.concentration_model.infected.fraction_of_infectious_virus()
|
|
|
|
|
|
fdep = self.long_range_fraction_deposited()
|
|
|
|
|
|
|
|
|
|
|
|
diameter = self.concentration_model.infected.particle.diameter
|
|
|
|
|
|
|
|
|
|
|
|
if not np.isscalar(diameter) and diameter is not None:
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# We compute first the mean of all diameter-dependent quantities
|
2022-03-18 11:53:01 +00:00
|
|
|
|
# to perform properly the Monte-Carlo integration over
|
|
|
|
|
|
# particle diameters (doing things in another order would
|
2022-04-22 20:23:49 +00:00
|
|
|
|
# lead to wrong results for the probability of infection).
|
2022-03-18 11:53:01 +00:00
|
|
|
|
dep_exposure_integrated = np.array(self._long_range_normed_exposure_between_bounds(time1, time2) *
|
|
|
|
|
|
aerosols *
|
|
|
|
|
|
fdep).mean()
|
|
|
|
|
|
else:
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# In the case of a single diameter or no diameter defined,
|
2022-03-18 11:53:01 +00:00
|
|
|
|
# one should not take any mean at this stage.
|
|
|
|
|
|
dep_exposure_integrated = self._long_range_normed_exposure_between_bounds(time1, time2)*aerosols*fdep
|
|
|
|
|
|
|
2023-04-06 08:22:53 +00:00
|
|
|
|
# Then we multiply by the diameter-independent quantity emission_rate_per_aerosol_per_person,
|
2022-03-18 11:53:01 +00:00
|
|
|
|
# and parameters of the vD equation (i.e. BR_k and n_in).
|
2023-04-06 08:22:53 +00:00
|
|
|
|
deposited_exposure += (dep_exposure_integrated *
|
|
|
|
|
|
emission_rate_per_aerosol_per_person *
|
|
|
|
|
|
self.exposed.activity.inhalation_rate *
|
2022-03-18 11:53:01 +00:00
|
|
|
|
(1 - self.exposed.mask.inhale_efficiency()))
|
|
|
|
|
|
|
|
|
|
|
|
# In the end we multiply the final results by the fraction of infectious virus of the vD equation.
|
|
|
|
|
|
return deposited_exposure * f_inf
|
|
|
|
|
|
|
2022-02-02 10:27:46 +00:00
|
|
|
|
def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
|
2022-02-17 16:31:24 +00:00
|
|
|
|
"""
|
|
|
|
|
|
The number of virus per m^3 deposited on the respiratory tract
|
|
|
|
|
|
between any two times.
|
|
|
|
|
|
|
2022-03-24 12:55:03 +00:00
|
|
|
|
Considers a contribution between the short-range and long-range exposures:
|
2022-03-24 10:18:08 +00:00
|
|
|
|
It calculates the deposited exposure given a short-range interaction (if any).
|
2022-03-24 12:55:03 +00:00
|
|
|
|
Then, the deposited exposure given the long-range interactions is added to the
|
2022-02-17 16:31:24 +00:00
|
|
|
|
initial deposited exposure.
|
|
|
|
|
|
"""
|
2022-08-22 13:29:53 +00:00
|
|
|
|
deposited_exposure: _VectorisedFloat = 0.
|
2022-03-29 10:19:14 +00:00
|
|
|
|
for interaction in self.short_range:
|
2022-04-22 15:13:15 +00:00
|
|
|
|
start, stop = interaction.extract_between_bounds(time1, time2)
|
2022-04-22 20:23:49 +00:00
|
|
|
|
short_range_jet_exposure = interaction._normed_jet_exposure_between_bounds(
|
2022-04-22 15:13:15 +00:00
|
|
|
|
self.concentration_model, start, stop)
|
2022-04-22 20:23:49 +00:00
|
|
|
|
short_range_lr_exposure = interaction._normed_interpolated_longrange_exposure_between_bounds(
|
|
|
|
|
|
self.concentration_model, start, stop)
|
|
|
|
|
|
dilution = interaction.dilution_factor()
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2022-03-29 10:19:14 +00:00
|
|
|
|
fdep = interaction.expiration.particle.fraction_deposited(evaporation_factor=1.0)
|
|
|
|
|
|
diameter = interaction.expiration.particle.diameter
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2022-04-22 20:23:49 +00:00
|
|
|
|
# Aerosols not considered given the formula for the initial
|
|
|
|
|
|
# concentration at mouth/nose.
|
2022-03-10 18:43:01 +00:00
|
|
|
|
if diameter is not None and not np.isscalar(diameter):
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# We compute first the mean of all diameter-dependent quantities
|
2022-02-15 16:13:13 +00:00
|
|
|
|
# to perform properly the Monte-Carlo integration over
|
|
|
|
|
|
# particle diameters (doing things in another order would
|
2022-04-22 20:23:49 +00:00
|
|
|
|
# lead to wrong results for the probability of infection).
|
2022-04-25 15:25:59 +00:00
|
|
|
|
this_deposited_exposure = (np.array(short_range_jet_exposure
|
2022-04-25 15:32:30 +00:00
|
|
|
|
* fdep).mean()
|
2022-04-22 20:23:49 +00:00
|
|
|
|
- np.array(short_range_lr_exposure * fdep).mean()
|
2022-04-25 15:32:30 +00:00
|
|
|
|
* self.concentration_model.infected.activity.exhalation_rate)
|
2022-02-15 16:13:13 +00:00
|
|
|
|
else:
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# In the case of a single diameter or no diameter defined,
|
2022-02-15 16:13:13 +00:00
|
|
|
|
# one should not take any mean at this stage.
|
2022-04-25 15:32:30 +00:00
|
|
|
|
this_deposited_exposure = (short_range_jet_exposure * fdep
|
2022-04-22 20:23:49 +00:00
|
|
|
|
- short_range_lr_exposure * fdep
|
2022-04-25 15:32:30 +00:00
|
|
|
|
* self.concentration_model.infected.activity.exhalation_rate)
|
2022-04-05 17:42:22 +00:00
|
|
|
|
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Multiply by the (diameter-independent) inhalation rate
|
2022-04-25 15:25:59 +00:00
|
|
|
|
deposited_exposure += (this_deposited_exposure *
|
2022-04-25 15:32:30 +00:00
|
|
|
|
interaction.activity.inhalation_rate
|
|
|
|
|
|
/dilution)
|
2022-04-05 17:42:22 +00:00
|
|
|
|
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Then we multiply by diameter-independent quantities: viral load
|
2022-04-05 17:42:22 +00:00
|
|
|
|
# and fraction of infected virions
|
2022-02-02 10:27:46 +00:00
|
|
|
|
f_inf = self.concentration_model.infected.fraction_of_infectious_virus()
|
2022-04-05 17:42:22 +00:00
|
|
|
|
deposited_exposure *= (f_inf
|
|
|
|
|
|
* self.concentration_model.virus.viral_load_in_sputum
|
2022-04-26 09:19:33 +00:00
|
|
|
|
* (1 - self.exposed.mask.inhale_efficiency()))
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Long-range concentration
|
2022-04-05 17:42:22 +00:00
|
|
|
|
deposited_exposure += self.long_range_deposited_exposure_between_bounds(time1, time2)
|
2021-09-22 12:35:19 +00:00
|
|
|
|
|
2022-04-05 17:42:22 +00:00
|
|
|
|
return deposited_exposure
|
2023-05-26 14:34:05 +00:00
|
|
|
|
|
2023-05-30 14:54:10 +00:00
|
|
|
|
def _deposited_exposure_list(self):
|
2023-05-26 14:34:05 +00:00
|
|
|
|
"""
|
|
|
|
|
|
The number of virus per m^3 deposited on the respiratory tract.
|
|
|
|
|
|
"""
|
|
|
|
|
|
population_change_times = self.population_state_change_times()
|
2021-09-22 12:35:19 +00:00
|
|
|
|
|
2023-05-26 14:34:05 +00:00
|
|
|
|
deposited_exposure = []
|
|
|
|
|
|
for start, stop in zip(population_change_times[:-1], population_change_times[1:]):
|
|
|
|
|
|
deposited_exposure.append(self.deposited_exposure_between_bounds(start, stop))
|
|
|
|
|
|
|
2023-05-30 14:54:10 +00:00
|
|
|
|
return deposited_exposure
|
|
|
|
|
|
|
|
|
|
|
|
def deposited_exposure(self) -> _VectorisedFloat:
|
|
|
|
|
|
"""
|
|
|
|
|
|
The number of virus per m^3 deposited on the respiratory tract.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return np.sum(self._deposited_exposure_list(), axis=0) * self.repeats
|
2023-05-26 14:34:05 +00:00
|
|
|
|
|
2023-06-05 07:46:51 +00:00
|
|
|
|
def _infection_probability_list(self):
|
2022-06-09 07:46:31 +00:00
|
|
|
|
# Viral dose (vD)
|
2023-05-30 14:54:10 +00:00
|
|
|
|
vD_list = self._deposited_exposure_list()
|
2022-02-15 16:13:13 +00:00
|
|
|
|
|
2021-09-20 13:06:35 +00:00
|
|
|
|
# oneoverln2 multiplied by ID_50 corresponds to ID_63.
|
|
|
|
|
|
infectious_dose = oneoverln2 * self.concentration_model.virus.infectious_dose
|
2021-09-17 14:36:07 +00:00
|
|
|
|
|
2023-05-26 14:34:05 +00:00
|
|
|
|
# Probability of infection.
|
|
|
|
|
|
return [(1 - np.exp(-((vD * (1 - self.exposed.host_immunity))/(infectious_dose *
|
|
|
|
|
|
self.concentration_model.virus.transmissibility_factor)))) for vD in vD_list]
|
|
|
|
|
|
|
|
|
|
|
|
@method_cache
|
|
|
|
|
|
def infection_probability(self) -> _VectorisedFloat:
|
2023-06-05 07:46:51 +00:00
|
|
|
|
return (1 - np.prod([1 - prob for prob in self._infection_probability_list()], axis = 0)) * 100
|
2023-05-26 14:34:05 +00:00
|
|
|
|
|
2022-09-20 14:00:25 +00:00
|
|
|
|
def total_probability_rule(self) -> _VectorisedFloat:
|
2023-04-28 10:05:30 +00:00
|
|
|
|
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
|
|
|
|
|
|
isinstance(self.exposed.number, IntPiecewiseConstant)):
|
2023-04-06 08:22:53 +00:00
|
|
|
|
raise NotImplementedError("Cannot compute total probability "
|
|
|
|
|
|
"(including incidence rate) with dynamic occupancy")
|
2023-04-06 10:27:39 +00:00
|
|
|
|
|
|
|
|
|
|
if (self.geographical_data.geographic_population != 0 and self.geographical_data.geographic_cases != 0):
|
|
|
|
|
|
sum_probability = 0.0
|
2023-04-06 08:22:53 +00:00
|
|
|
|
|
2022-10-04 09:35:51 +00:00
|
|
|
|
# Create an equivalent exposure model but changing the number of infected cases.
|
2023-04-06 08:22:53 +00:00
|
|
|
|
total_people = self.concentration_model.infected.number + self.exposed.number
|
|
|
|
|
|
max_num_infected = (total_people if total_people < 10 else 10)
|
2022-09-20 14:00:25 +00:00
|
|
|
|
# The influence of a higher number of simultainious infected people (> 4 - 5) yields an almost negligible contirbution to the total probability.
|
|
|
|
|
|
# To be on the safe side, a hard coded limit with a safety margin of 2x was set.
|
|
|
|
|
|
# Therefore we decided a hard limit of 10 infected people.
|
2022-10-04 12:17:32 +00:00
|
|
|
|
for num_infected in range(1, max_num_infected + 1):
|
2022-09-20 14:00:25 +00:00
|
|
|
|
exposure_model = nested_replace(
|
2022-10-04 09:35:51 +00:00
|
|
|
|
self, {'concentration_model.infected.number': num_infected}
|
2022-09-20 14:00:25 +00:00
|
|
|
|
)
|
2022-10-05 15:33:12 +00:00
|
|
|
|
prob_ind = exposure_model.infection_probability().mean() / 100
|
2022-10-07 07:03:24 +00:00
|
|
|
|
n = total_people - num_infected
|
2022-10-05 15:33:12 +00:00
|
|
|
|
# By means of the total probability rule
|
2022-10-07 07:03:24 +00:00
|
|
|
|
prob_at_least_one_infected = 1 - (1 - prob_ind)**n
|
2022-10-05 15:33:12 +00:00
|
|
|
|
sum_probability += (prob_at_least_one_infected *
|
2022-10-07 07:03:24 +00:00
|
|
|
|
self.geographical_data.probability_meet_infected_person(self.concentration_model.infected.virus, num_infected, total_people))
|
2022-09-20 14:00:25 +00:00
|
|
|
|
return sum_probability * 100
|
|
|
|
|
|
else:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
2021-04-28 12:22:23 +00:00
|
|
|
|
def expected_new_cases(self) -> _VectorisedFloat:
|
2023-06-08 14:46:00 +00:00
|
|
|
|
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
|
|
|
|
|
|
isinstance(self.exposed.number, IntPiecewiseConstant)):
|
|
|
|
|
|
raise NotImplementedError("Cannot compute expected new cases "
|
|
|
|
|
|
"with dynamic occupancy")
|
2022-06-24 13:14:30 +00:00
|
|
|
|
|
2023-06-29 09:01:59 +00:00
|
|
|
|
return self.infection_probability() * self.exposed.number / 100
|
2020-11-12 20:21:02 +00:00
|
|
|
|
|
2021-04-28 12:22:23 +00:00
|
|
|
|
def reproduction_number(self) -> _VectorisedFloat:
|
2020-11-12 20:21:02 +00:00
|
|
|
|
"""
|
|
|
|
|
|
The reproduction number can be thought of as the expected number of
|
|
|
|
|
|
cases directly generated by one infected case in a population.
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
2023-06-08 14:46:00 +00:00
|
|
|
|
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
|
|
|
|
|
|
isinstance(self.exposed.number, IntPiecewiseConstant)):
|
|
|
|
|
|
raise NotImplementedError("Cannot compute reproduction number "
|
|
|
|
|
|
"with dynamic occupancy")
|
|
|
|
|
|
|
2020-11-12 20:21:02 +00:00
|
|
|
|
if self.concentration_model.infected.number == 1:
|
|
|
|
|
|
return self.expected_new_cases()
|
|
|
|
|
|
|
|
|
|
|
|
# Create an equivalent exposure model but with precisely
|
|
|
|
|
|
# one infected case.
|
|
|
|
|
|
single_exposure_model = nested_replace(
|
2023-06-08 14:46:00 +00:00
|
|
|
|
self, {
|
|
|
|
|
|
'concentration_model.infected.number': 1}
|
2020-11-12 20:21:02 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2022-03-10 18:26:09 +00:00
|
|
|
|
return single_exposure_model.expected_new_cases()
|