Merge with master

This commit is contained in:
Luis Aleixo 2022-02-04 15:08:36 +01:00
commit 4704a6e022
12 changed files with 311 additions and 236 deletions

View file

@ -33,7 +33,7 @@ from .user import AuthenticatedUser, AnonymousUser
# calculator version. If the calculator needs to make breaking changes (e.g. change
# form attributes) then it can also increase its MAJOR version without needing to
# increase the overall CARA version (found at ``cara.__version__``).
__version__ = "3.3.0"
__version__ = "4.0.0"
class BaseRequestHandler(RequestHandler):

View file

@ -138,7 +138,7 @@ def calculate_report_data(model: models.ExposureModel):
exposed_occupants = model.exposed.number
expected_new_cases = np.array(model.expected_new_cases()).mean()
cumulative_doses = np.cumsum([
np.array(model.exposure_between_bounds(float(time1), float(time2))).mean()
np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean()
for time1, time2 in zip(times[:-1], times[1:])
])
@ -337,11 +337,13 @@ class ReportGenerator:
)
context['permalink'] = generate_permalink(base_url, self.calculator_prefix, form)
context['calculator_prefix'] = self.calculator_prefix
# For further information about these values visit https://gitlab.cern.ch/cara/cara/-/merge_requests/321.
context['scale_warning'] = {
'level': 'red-4',
'incidence_rate': 'higher or equal to 100 new cases per 100 000 inhabitants',
'onsite_access': 'lower than 4000',
'threshold': '5%'
'level': 'red-4', # 'red-4' - 'orange-3' - 'yellow-2' - 'green-1'
'risk': 'strong', # 'strong' - 'medium' - 'reduced' - ''
'onsite_access': '4000', # '4000' - '5000' - '6500' - '8000'
'threshold': '2%' # '2%' - '' - '' - ''
}
return context

View file

@ -7,9 +7,9 @@
{% endblock report_preamble_navtab %}
{% block warning_animation %}
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %} {% set warning_color= 'bg-danger' %}
{% elif (5 <= prob_inf <= 15) %} {% set warning_color = 'bg-warning' %}
{% elif (prob_inf < 5) %} {% set warning_color = 'bg-success' %}
{% if ((prob_inf > 10) or (expected_new_cases >= 1)) %} {% set warning_color= 'bg-danger' %}
{% elif (2 <= prob_inf <= 10) %} {% set warning_color = 'bg-warning' %}
{% elif (prob_inf < 2) %} {% set warning_color = 'bg-success' %}
{% endif %}
<div class="intro-banner-vdo-play-btn {{warning_color}} m-auto d-flex align-items-center justify-content-center">
@ -23,41 +23,41 @@
{% block report_summary %}
<div class="flex-row align-self-center">
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
{% if ((prob_inf > 10) or (expected_new_cases >= 1)) %}
<div class="alert alert-danger mb-0" role="alert">
<strong>Not Acceptable:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
</div>
{% elif 5 <= prob_inf <= 15 %}
{% elif 2 <= prob_inf <= 10 %}
<div class="alert alert-warning mb-0" role="alert">
<strong>Attention:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
</div>
{% elif prob_inf < 5 %}
{% elif prob_inf < 2 %}
<div class="alert alert-success mb-0" role="alert">
<strong>Acceptable:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
</div>
{% endif %}
{% if (prob_inf > 5) %}
{% if (prob_inf > 2) %}
<br>
{% if scale_warning.level == "green-1" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Note: the current CERN COVID Scale is <b>Green - 1</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. Align your risk assessment with the guidance and instructions provided by the HSE Unit.
Note: the current CERN COVID Scale is <b>Green 1</b>. The risk of circulation of asymptomatic or pre-symptomatic infected individuals within the CERN site is considered negligible. There may be more than <b>{{scale_warning.onsite_access}} daily on-site accesses</b>. Align your risk assessment with the guidance and instructions provided by the HSE Unit.
</div>
{% elif scale_warning.level == "yellow-2" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Note: the current CERN COVID Scale is <b>Yellow - 2</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a reduced chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. See with your supervisor if this scenario is acceptable.
Note: the current CERN COVID Scale is <b>Yellow - 2</b>. There is a {{scale_warning.risk}} risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>{{scale_warning.onsite_access}} daily on-site accesses</b> during this stage. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures can be applied (ALARA).
</div>
{% elif scale_warning.level == "orange-3" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Warning: the current CERN COVID Scale is <b>Orange - 3</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a slight chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. See with your supervisor if any additional measures can be applied (ALARA).
Warning: the current CERN COVID Scale is <b>Orange - 3</b>. There is a {{scale_warning.risk}} risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>{{scale_warning.onsite_access}} daily on-site accesses</b> during this stage. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures can be applied (ALARA).
</div>
{% elif scale_warning.level == "red-4" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Warning: the current CERN COVID Scale is <b>Red - 4</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a strong chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. Please reduce the value below the threshold of <b>{{scale_warning.threshold}}</b>.
Warning: the current CERN COVID Scale is <b>Red - 4</b>. There is a {{scale_warning.risk}} risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>{{scale_warning.onsite_access}} daily on-site accesses</b> during this stage. Please reduce the value below the threshold of {{scale_warning.threshold}}. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures are required.
</div>
{% else %}
<p><b>Note:</b> The CERN COVID Level is not specified.</p>
@ -67,13 +67,13 @@
{% endblock report_summary %}
{% block report_summary_footnote %}
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
{% if ((prob_inf > 10) or (expected_new_cases >= 1)) %}
This exceeds the authorised risk threshold or number of expected new cases.
The risk level must be reduced before this activity can be undertaken.
{% elif (5 <= prob_inf <= 15) %}
{% elif (2 <= prob_inf <= 10) %}
This activity has an elevated level of risk, ALARA principles must be applied to minimise the level of risk before undertaking the activity.
See the footnotes for more details on the ALARA principles.
{% elif (prob_inf < 5) %}
{% elif (prob_inf < 2) %}
This level of risk is within acceptable parameters, no further actions are required.
{% endif %}
{% endblock report_summary_footnote %}
@ -124,9 +124,9 @@
<div class="d-flex">
<div class="split">
<div class="d-flex flex-column col-xl-8 p-0">
<div class="alert alert-success" role="alert">Events with a <strong>P(i) less than 5%</strong> may go ahead without further mitigation measures.</div>
<div class="alert alert-warning" role="alert">Events with a <strong>P(i) between 5% and 15%</strong> shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.</div>
<div class="alert alert-danger mb-0" role="alert">Events with a <strong>P(i) exceeding 15% or a number of expected new cases that exceeds 1</strong> may not take place until additional measures are in place and a risk reduction has been performed.</div>
<div class="alert alert-success" role="alert">Events with a <strong>P(i) less than 2%</strong> may go ahead without further mitigation measures.</div>
<div class="alert alert-warning" role="alert">Events with a <strong>P(i) between 2% and 10%</strong> shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.</div>
<div class="alert alert-danger mb-0" role="alert">Events with a <strong>P(i) exceeding 10% or a number of expected new cases that exceeds 1</strong> may not take place until additional measures are in place and a risk reduction has been performed.</div>
</div>
<div class="col-xl-3 align-self-center text-center"><img id="cern_level" class="rounded" src="{{ calculator_prefix }}/static/images/warning_scale/{{scale_warning.level}}.png"></div>
</div>

View file

@ -22,10 +22,6 @@ geneva_coordinates = (46.204391, 6.143158)
local_hourly_temperatures_celsius_per_hour = get_hourly_temperatures_celsius_per_hour(
geneva_coordinates)
# Load the weather data (temperature in kelvin) for Toronto.
toronto_coordinates = (43.667, 79.400)
toronto_hourly_temperatures_celsius_per_hour = get_hourly_temperatures_celsius_per_hour(
toronto_coordinates)
# Geneva hourly temperatures as piecewise constant function (in Kelvin).
GenevaTemperatures_hourly = {
@ -38,25 +34,9 @@ GenevaTemperatures_hourly = {
for month, temperatures in local_hourly_temperatures_celsius_per_hour.items()
}
# Toronto hourly temperatures as piecewise constant function (in Kelvin).
TorontoTemperatures_hourly = {
month: models.PiecewiseConstant(
# 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 range(25)),
tuple(273.15 + np.array(temperatures)),
)
for month, temperatures in toronto_hourly_temperatures_celsius_per_hour.items()
}
# Same Geneva temperatures on a finer temperature mesh (every 6 minutes).
GenevaTemperatures = {
month: GenevaTemperatures_hourly[month].refine(refine_factor=10)
for month, temperatures in local_hourly_temperatures_celsius_per_hour.items()
}
# Same Toronto temperatures on a finer temperature mesh (every 6 minutes).
TorontoTemperatures = {
month: TorontoTemperatures_hourly[month].refine(refine_factor=10)
for month, temperatures in toronto_hourly_temperatures_celsius_per_hour.items()
}

View file

@ -51,7 +51,6 @@ from .utils import method_cache
from .dataclass_utils import nested_replace
oneoverln2 = 1 / np.log(2)
# 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.
@ -152,9 +151,9 @@ class PiecewiseConstant:
def __post_init__(self):
if len(self.transition_times) != len(self.values)+1:
raise ValueError("transition_times should contain one more element than values")
raise ValueError("transition_times must contain one more element than values")
if tuple(sorted(set(self.transition_times))) != self.transition_times:
raise ValueError("transition_times should not contain duplicated elements and should be sorted")
raise ValueError("transition_times must not contain duplicated elements and must be sorted")
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")
@ -460,8 +459,8 @@ class SARSCoV2(Virus):
piecewise constant model (for more details see A. Henriques et al,
CERN-OPEN-2021-004, DOI: 10.17181/CERN.1GDQ.5Y75)
"""
# Taken from Morris et al (https://doi.org/10.7554/eLife.65902) data at T = 22°C and RH = 40 % and
# from Doremalen et al (https://www.nejm.org/doi/10.1056/NEJMc2004973).
# Taken from Morris et al (https://doi.org/10.7554/eLife.65902) data at T = 22°C and RH = 40 %,
# and from Doremalen et al (https://www.nejm.org/doi/10.1056/NEJMc2004973).
return np.piecewise(humidity, [humidity <= 0.4, humidity > 0.4], [6.43, 1.1])
@ -561,6 +560,58 @@ Mask.types = {
}
@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:
vg = 1.88e-4
else:
vg = 1.88e-4 * (self.diameter*evaporation_factor / 2.5)**2
return vg
def fraction_deposited(self, evaporation_factor: float=0.3) -> _VectorisedFloat:
"""
The fraction of particles actually deposited in the respiratory
tract (over the total number of particles). It depends on the
particle diameter.
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
@dataclass(frozen=True)
class _ExpirationBase:
"""
@ -570,6 +621,13 @@ class _ExpirationBase:
#: Pre-populated examples of Expirations.
types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]]
@property
def particle(self) -> Particle:
"""
the Particle object representing the aerosol - here the default one
"""
return Particle()
def aerosols(self, mask: Mask):
"""
total volume of aerosols expired per volume of air (mL/cm^3).
@ -587,9 +645,18 @@ class Expiration(_ExpirationBase):
#: diameter of the aerosol in microns
diameter: _VectorisedFloat
#: total concentration of aerosols (cm^-3)
#: total concentration of aerosols per unit volume of expired air
# (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)
cn: float = 1.
@property
def particle(self) -> Particle:
"""
the Particle object representing the aerosol
"""
return Particle(diameter=self.diameter)
@cached()
def aerosols(self, mask: Mask):
""" Result is in mL.cm^-3 """
@ -608,18 +675,18 @@ class MultipleExpiration(_ExpirationBase):
Group together different modes of expiration, that represent
each the main expiration mode for a certain fraction of time (given by
the weights).
This class can only be used with single diameters (it cannot
be used with diameter distributions).
This class can only be used with single diameters defined in each
expiration (it cannot be used with diameter distributions).
"""
expirations: typing.Tuple[_ExpirationBase, ...]
weights: typing.Tuple[float, ...]
def __post_init__(self):
if len(self.expirations) != len(self.weights):
raise ValueError("expirations and weigths should contain the"
raise ValueError("expirations and weigths must contain the"
"same number of elements")
if not all(np.isscalar(e.diameter) for e in self.expirations):
raise ValueError("diameters should all be scalars")
raise ValueError("diameters must all be scalars")
def aerosols(self, mask: Mask):
return np.array([
@ -709,13 +776,28 @@ class _PopulationWithVirus(Population):
"""
return 1.
def aerosols(self):
"""
total volume of aerosols expired per volume of air (mL/cm^3).
"""
raise NotImplementedError("Subclass must implement")
def emission_rate_per_aerosol_when_present(self) -> _VectorisedFloat:
"""
The emission rate of virions per fraction of aerosol volume in
the expired air, if the infected population is present, in cm^3/(mL.h).
It should not be a function of time.
"""
raise NotImplementedError("Subclass must implement")
@method_cache
def emission_rate_when_present(self) -> _VectorisedFloat:
"""
The emission rate if the infected population is present
(in virions / h). It should not be a function of time.
(in virions / h).
"""
raise NotImplementedError("Subclass must implement")
return (self.emission_rate_per_aerosol_when_present() *
self.aerosols())
def emission_rate(self, time) -> _VectorisedFloat:
"""
@ -734,16 +816,33 @@ class _PopulationWithVirus(Population):
return self.emission_rate_when_present()
@property
def particle(self) -> Particle:
"""
the Particle object representing the aerosol expired by the
population - here we take the default Particle object
"""
return Particle()
@dataclass(frozen=True)
class EmittingPopulation(_PopulationWithVirus):
#: The emission rate of a single individual, in virions / h.
known_individual_emission_rate: float
@method_cache
def emission_rate_when_present(self) -> _VectorisedFloat:
def aerosols(self):
"""
The emission rate if the infected population is present.
total volume of aerosols expired per volume of air (mL/cm^3).
Here arbitrarily set to 1 as the full emission rate is known.
"""
return 1.
@method_cache
def emission_rate_per_aerosol_when_present(self) -> _VectorisedFloat:
"""
The emission rate of virions per fraction of aerosol volume in
the expired air, if the infected population is present, in cm^3/(mL.h).
It should not be a function of time.
"""
return self.known_individual_emission_rate * self.number
@ -757,29 +856,37 @@ class InfectedPopulation(_PopulationWithVirus):
def fraction_of_infectious_virus(self) -> _VectorisedFloat:
"""
The fraction of infectious virus.
"""
return self.virus.viable_to_RNA_ratio * (1 - self.host_immunity)
@method_cache
def emission_rate_when_present(self) -> _VectorisedFloat:
def aerosols(self):
"""
The emission rate if the infected population is present.
Note that the rate is not currently time-dependent.
total volume of aerosols expired per volume of air (mL/cm^3).
"""
# Emission Rate (virions / h)
# Note on units: exhalation rate is in m^3/h, aerosols in mL/cm^3
# and viral load in virus/mL -> 1e6 conversion factor
return self.expiration.aerosols(self.mask)
aerosols = self.expiration.aerosols(self.mask)
@method_cache
def emission_rate_per_aerosol_when_present(self) -> _VectorisedFloat:
"""
The emission rate of virions per fraction of aerosol volume in
the expired air, if the infected population is present, in cm^3/(mL.h).
It should not be a function of time.
"""
# Note on units: exhalation rate is in m^3/h -> 1e6 conversion factor
ER = (self.virus.viral_load_in_sputum *
self.activity.exhalation_rate *
10 ** 6 *
aerosols)
10 ** 6)
return ER * self.number
@property
def particle(self) -> Particle:
"""
the Particle object representing the aerosol - here the default one
"""
return self.expiration.particle
@dataclass(frozen=True)
class ConcentrationModel:
@ -798,20 +905,7 @@ class ConcentrationModel:
def infectious_virus_removal_rate(self, time: float) -> _VectorisedFloat:
# Equilibrium velocity of particle motion toward the floor
if (isinstance(self.infected, InfectedPopulation)
and isinstance(self.infected.expiration, Expiration)):
d = self.infected.expiration.diameter * self.evaporation_factor
vg = 1.88e-4 * (d / 2.5)**2
# see https://doi.org/10.1101/2021.10.14.21264988
# (velocity of 1.88e-4 corresponds to diameter of 2.5 microns)
else:
# model is not evaluated for specific values of aerosol
# diameters - we choose a single velocity value
# corresponding to that obtained with a diameter of 2.5 microns
# (geometric average of the breathing expiration distribution,
# taking evaporation into account, see
# https://doi.org/10.1101/2021.10.14.21264988)
vg = 1.88e-4
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)
@ -989,26 +1083,14 @@ class ExposureModel:
#: The number of times the exposure event is repeated (default 1).
repeats: int = 1
def fraction_deposited(self):
def fraction_deposited(self) -> _VectorisedFloat:
"""
The fraction of viruses actually deposited in the respiratory tract.
From W. C. Hinds, New York, Wiley, 1999 (pp. 233 259).
The fraction of particles actually deposited in the respiratory
tract (over the total number of particles). It depends on the
particle diameter.
"""
if not (isinstance(self.concentration_model.infected,InfectedPopulation)
and isinstance(self.concentration_model.infected.expiration,Expiration)):
# 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 factor depends on aerosol particle diameter.
d = (self.concentration_model.infected.expiration.diameter
* self.concentration_model.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
return self.concentration_model.infected.particle.fraction_deposited(
self.concentration_model.evaporation_factor)
def _normed_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
"""The number of virions per meter^3 between any two times, normalized
@ -1028,11 +1110,6 @@ class ExposureModel:
elif time1 <= start and stop < time2:
exposure += self.concentration_model.normed_integrated_concentration(start, stop)
return exposure
def exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
"""The number of virions per meter^3 between any two times."""
return (self._normed_exposure_between_bounds(time1, time2) *
self.concentration_model.infected.emission_rate_when_present())
def _normed_exposure(self) -> _VectorisedFloat:
"""
@ -1046,76 +1123,57 @@ class ExposureModel:
return normed_exposure * self.repeats
def exposure(self) -> _VectorisedFloat:
def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
"""
The number of virus per meter^3. With sampled diameters, the
aerosol volume has to be put back in the exposure before taking
the mean, to obtain the proper result for the exposure (which
corresponds to an integration on diameters).
The number of virus per m^3 deposited on the respiratory tract
between any two times.
"""
emission_rate = self.concentration_model.infected.emission_rate_when_present()
if (not isinstance(self.concentration_model.infected,InfectedPopulation)
or not isinstance(self.concentration_model.infected.expiration,Expiration)
or np.isscalar(self.concentration_model.infected.expiration.diameter)
):
# in all these cases, there is no distribution of
# diameters that need to be integrated over
return self._normed_exposure() * emission_rate
else:
# the mean of the diameter-dependent exposure (including
# aerosols volume, but NOT the other factors) has to be
# taken first (this is equivalent to integrating over the
# diameters)
mask = self.concentration_model.infected.mask
aerosols = self.concentration_model.infected.expiration.aerosols(mask)
return (np.array(self._normed_exposure()*aerosols).mean() *
emission_rate/aerosols)
def _deposited_exposure(self) -> _VectorisedFloat:
"""
The number of virus per meter^3 deposited on the respiratory
tract. As in the exposure method, with sampled diameters the
aerosol volume and the deposited fraction, have to be put
in the deposited exposure before taking the mean, to obtain the
proper result (which corresponds to an integration on diameters).
"""
emission_rate = self.concentration_model.infected.emission_rate_when_present()
if (not isinstance(self.concentration_model.infected,InfectedPopulation)
or not isinstance(self.concentration_model.infected.expiration,Expiration)
or np.isscalar(self.concentration_model.infected.expiration.diameter)
):
# in all these cases, there is no distribution of
# diameters that need to be integrated over
return (self._normed_exposure() *
self.fraction_deposited() *
emission_rate)
else:
# the mean of the diameter-dependent exposure (including
# aerosols volume, but NOT the other factors) has to be
# taken first (this is equivalent to integrating over the
# diameters)
mask = self.concentration_model.infected.mask
aerosols = self.concentration_model.infected.expiration.aerosols(mask)
return (np.array(self._normed_exposure() * aerosols *
self.fraction_deposited()).mean() *
emission_rate/aerosols)
def infection_probability(self) -> _VectorisedFloat:
deposited_exposure = self._deposited_exposure()
emission_rate_per_aerosol = self.concentration_model.infected.emission_rate_per_aerosol_when_present()
aerosols = self.concentration_model.infected.aerosols()
fdep = self.fraction_deposited()
f_inf = self.concentration_model.infected.fraction_of_infectious_virus()
inf_aero = (
self.exposed.activity.inhalation_rate *
(1 - self.exposed.mask.inhale_efficiency()) *
deposited_exposure * f_inf
)
diameter = self.concentration_model.infected.particle.diameter
if not np.isscalar(diameter) and diameter is not None:
# we compute first the mean of all diameter-dependent quantities
# to perform properly the Monte-Carlo integration over
# particle diameters (doing things in another order would
# lead to wrong results).
dep_exposure_integrated = np.array(self._normed_exposure_between_bounds(time1, time2) *
aerosols *
fdep).mean()
else:
# in the case of a single diameter or no diameter defined,
# one should not take any mean at this stage.
dep_exposure_integrated = self._normed_exposure_between_bounds(time1, time2)*aerosols*fdep
# then we multiply by the diameter-independent quantity emission_rate_per_aerosol,
# and parameters of the vD equation (i.e. f_inf, BR_k and n_in).
return (dep_exposure_integrated * emission_rate_per_aerosol *
f_inf * self.exposed.activity.inhalation_rate *
(1 - self.exposed.mask.inhale_efficiency()))
def deposited_exposure(self) -> _VectorisedFloat:
"""
The number of virus per m^3 deposited on the respiratory tract.
"""
deposited_exposure = 0.0
for start, stop in self.exposed.presence.boundaries():
deposited_exposure += self.deposited_exposure_between_bounds(start, stop)
return deposited_exposure * self.repeats
def infection_probability(self) -> _VectorisedFloat:
# viral dose (vD)
vD = self.deposited_exposure()
# oneoverln2 multiplied by ID_50 corresponds to ID_63.
infectious_dose = oneoverln2 * self.concentration_model.virus.infectious_dose
# Probability of infection.
return (1 - np.exp(-((inf_aero * (1 - self.exposed.host_immunity))/(infectious_dose *
return (1 - np.exp(-((vD * (1 - self.exposed.host_immunity))/(infectious_dose *
self.concentration_model.virus.transmissibility_factor)))) * 100
def expected_new_cases(self) -> _VectorisedFloat:

View file

@ -165,15 +165,15 @@ def expiration_distribution(BLO_factors):
"""
Returns an Expiration with an aerosol diameter distribution, defined
by the BLO factors (a length-3 tuple).
The total concentration of aerosols is computed by integrating
the distribution between 0.1 and D microns - these boundaries are
The total concentration of aerosols, cn, is computed by integrating
the distribution between 0.1 and 30 microns - these boundaries are
an historical choice based on previous implementations of the model
(it limits the influence of the O-mode).
"""
dscan = np.linspace(0.1, 30. ,3000)
return mc.Expiration(CustomKernel(dscan,
BLOmodel(BLO_factors).distribution(dscan),kernel_bandwidth=0.1),
BLOmodel(BLO_factors).integrate(0.1, 30.))
cn=BLOmodel(BLO_factors).integrate(0.1, 30.))
def dilution_factor(distance, D=0.02):

View file

@ -13,8 +13,6 @@ from cara.monte_carlo.data import expiration_distributions
# TODO: seed better the random number generators
np.random.seed(2000)
SAMPLE_SIZE = 250000
TOLERANCE = 0.02
def test_model_from_dict(baseline_form_data):
form = model_generator.FormData.from_dict(baseline_form_data)
@ -35,6 +33,8 @@ def test_model_from_dict_invalid(baseline_form_data):
]
)
def test_blend_expiration(mask_type):
SAMPLE_SIZE = 250000
TOLERANCE = 0.02
blend = {'Breathing': 2, 'Speaking': 1}
r = model_generator.build_expiration(blend).build_model(SAMPLE_SIZE)
mask = models.Mask.types[mask_type]

View file

@ -77,25 +77,25 @@ def known_concentrations(func):
@pytest.mark.parametrize(
"population, cm, expected_exposure, expected_probability", [
[populations[1], known_concentrations(lambda t: 36.),
np.array([432, 432]), np.array([67.9503762594, 65.2366759251])],
np.array([64.02320633, 59.45012016]), np.array([67.9503762594, 65.2366759251])],
[populations[2], known_concentrations(lambda t: 36.),
np.array([432, 432]), np.array([51.6749232285, 55.6374622042])],
np.array([40.91708675, 45.73086166]), np.array([51.6749232285, 55.6374622042])],
[populations[0], known_concentrations(lambda t: np.array([36., 72.])),
np.array([432, 864]), np.array([55.6374622042, 80.3196524031])],
np.array([45.73086166, 91.46172332]), np.array([55.6374622042, 80.3196524031])],
[populations[1], known_concentrations(lambda t: np.array([36., 72.])),
np.array([432, 864]), np.array([67.9503762594, 87.9151129926])],
np.array([64.02320633, 118.90024032]), np.array([67.9503762594, 87.9151129926])],
[populations[2], known_concentrations(lambda t: np.array([36., 72.])),
np.array([432, 864]), np.array([51.6749232285, 80.3196524031])],
np.array([40.91708675, 91.46172332]), np.array([51.6749232285, 80.3196524031])],
])
def test_exposure_model_ndarray(population, cm,
expected_exposure, expected_probability):
model = ExposureModel(cm, population)
np.testing.assert_almost_equal(
model.exposure(), expected_exposure
model.deposited_exposure(), expected_exposure
)
np.testing.assert_almost_equal(
model.infection_probability(), expected_probability, decimal=10
@ -107,33 +107,43 @@ def test_exposure_model_ndarray(population, cm,
assert model.expected_new_cases().shape == (2,)
@pytest.mark.parametrize("population", populations)
def test_exposure_model_ndarray_and_float_mix(population):
@pytest.mark.parametrize("population, expected_deposited_exposure", [
[populations[0], np.array([1.52436206, 1.52436206])],
[populations[1], np.array([2.13410688, 1.98167067])],
[populations[2], np.array([1.36390289, 1.52436206])],
])
def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exposure):
cm = known_concentrations(
lambda t: 0. if np.floor(t) % 2 else np.array([1.2, 1.2]))
model = ExposureModel(cm, population)
expected_exposure = np.array([14.4, 14.4])
np.testing.assert_almost_equal(
model.exposure(), expected_exposure
model.deposited_exposure(), expected_deposited_exposure
)
assert isinstance(model.infection_probability(), np.ndarray)
assert isinstance(model.expected_new_cases(), np.ndarray)
@pytest.mark.parametrize("population", populations)
def test_exposure_model_compare_scalar_vector(population):
cm_scalar = known_concentrations(lambda t: 1.2)
@pytest.mark.parametrize("population, expected_deposited_exposure", [
[populations[0], np.array([1.52436206, 1.52436206])],
[populations[1], np.array([2.13410688, 1.98167067])],
[populations[2], np.array([1.36390289, 1.52436206])],
])
def test_exposure_model_vector(population, expected_deposited_exposure):
cm_array = known_concentrations(lambda t: np.array([1.2, 1.2]))
model_scalar = ExposureModel(cm_scalar, population)
model_array = ExposureModel(cm_array, population)
expected_exposure = 14.4
np.testing.assert_almost_equal(
model_scalar.exposure(), expected_exposure
model_array.deposited_exposure(), np.array(expected_deposited_exposure)
)
def test_exposure_model_scalar():
cm_scalar = known_concentrations(lambda t: 1.2)
model_scalar = ExposureModel(cm_scalar, populations[0])
expected_deposited_exposure = 1.52436206
np.testing.assert_almost_equal(
model_array.exposure(), np.array([expected_exposure]*2)
model_scalar.deposited_exposure(), expected_deposited_exposure
)
@ -163,28 +173,28 @@ def conc_model():
)
# Expected exposure were computed with a trapezoidal integration, using
# Expected deposited exposure were computed with a trapezoidal integration, using
# a mesh of 10'000 pts per exposed presence interval.
@pytest.mark.parametrize(
["exposed_time_interval", "expected_exposure"],
["exposed_time_interval", "expected_deposited_exposure"],
[
[(0., 1.), 266.67176],
[(1., 1.01), 3.0879539],
[(1.01, 1.02), 3.00082435],
[(12., 12.01), 0.095063235],
[(12., 24.), 3775.65025],
[(0., 24.), 4097.8494],
[(0., 1.), 45.6008710],
[(1., 1.01), 0.5280401],
[(1.01, 1.02), 0.51314096385],
[(12., 12.01), 0.016255813185],
[(12., 24.), 645.63619275],
[(0., 24.), 700.7322474],
]
)
def test_exposure_model_integral_accuracy(exposed_time_interval,
expected_exposure, conc_model):
expected_deposited_exposure, conc_model):
presence_interval = models.SpecificInterval((exposed_time_interval,))
population = models.Population(
10, presence_interval, models.Mask.types['Type I'],
models.Activity.types['Standing'], 0.,
)
model = ExposureModel(conc_model, population)
np.testing.assert_allclose(model.exposure(), expected_exposure)
np.testing.assert_allclose(model.deposited_exposure(), expected_deposited_exposure)
def test_infectious_dose_vectorisation():
@ -212,7 +222,7 @@ def test_infectious_dose_vectorisation():
10, presence_interval, models.Mask.types['Type I'],
models.Activity.types['Standing'], 0.,
)
model = ExposureModel(cm, population) #, fraction_deposited=1.0
model = ExposureModel(cm, population)
inf_probability = model.infection_probability()
assert isinstance(inf_probability, np.ndarray)
assert inf_probability.shape == (3, )

View file

@ -13,7 +13,7 @@ def test_multiple_wrong_weight_size():
e_base = models.Expiration(2.5)
with pytest.raises(
ValueError,
match=re.escape("expirations and weigths should contain the"
match=re.escape("expirations and weigths must contain the"
"same number of elements")
):
e = models.MultipleExpiration([e_base, e_base], weights)
@ -26,7 +26,7 @@ def test_multiple_wrong_diameters():
e3 = models.Expiration(2.)
with pytest.raises(
ValueError,
match=re.escape("diameters should all be scalars")
match=re.escape("diameters must all be scalars")
):
e = models.MultipleExpiration([e1, e2, e3], weights)

View file

@ -97,7 +97,7 @@ def test_r0(baseline_exposure_model):
# expected r0 was computed with a trapezoidal integration, using
# a mesh of 100'000 pts per exposed presence interval.
r0 = baseline_exposure_model.reproduction_number()
npt.assert_allclose(r0, 776.9419902161412)
npt.assert_allclose(r0, 776.941990)
def test_periodic_window(baseline_periodic_window, baseline_room):
@ -389,16 +389,16 @@ def build_exposure_model(concentration_model):
)
# expected exposure were computed with a trapezoidal integration, using
# expected deposited exposure were computed with a trapezoidal integration, using
# a mesh of 100'000 pts per exposed presence interval.
@pytest.mark.parametrize(
"month, expected_exposure",
"month, expected_deposited_exposure",
[
['Jan', 503.254087759],
['Jun', 2294.71115639],
['Jan', 377.440565819],
['Jun', 1721.03336729],
],
)
def test_exposure_hourly_dep(month,expected_exposure):
def test_exposure_hourly_dep(month,expected_deposited_exposure):
m = build_exposure_model(
build_hourly_dependent_model(
month,
@ -406,20 +406,20 @@ def test_exposure_hourly_dep(month,expected_exposure):
intervals_presence_infected=((8., 12.), (13., 17.))
)
)
exposure = m.exposure()
npt.assert_allclose(exposure, expected_exposure)
deposited_exposure = m.deposited_exposure()
npt.assert_allclose(deposited_exposure, expected_deposited_exposure)
# expected exposure were computed with a trapezoidal integration, using
# expected deposited exposure were computed with a trapezoidal integration, using
# a mesh of 100'000 pts per exposed presence interval and 25 pts per hour
# for the temperature discretization.
@pytest.mark.parametrize(
"month, expected_exposure",
"month, expected_deposited_exposure",
[
['Jan', 511.118941481],
['Jun', 2398.90129579],
['Jan', 383.339206111],
['Jun', 1799.17597184],
],
)
def test_exposure_hourly_dep_refined(month,expected_exposure):
def test_exposure_hourly_dep_refined(month,expected_deposited_exposure):
m = build_exposure_model(
build_hourly_dependent_model(
month,
@ -428,5 +428,5 @@ def test_exposure_hourly_dep_refined(month,expected_exposure):
temperatures=data.GenevaTemperatures,
)
)
exposure = m.exposure()
npt.assert_allclose(exposure, expected_exposure, rtol=0.02)
deposited_exposure = m.deposited_exposure()
npt.assert_allclose(deposited_exposure, expected_deposited_exposure, rtol=0.02)

View file

@ -89,6 +89,6 @@ def test_build_concentration_model(baseline_mc_model: cara.monte_carlo.Concentra
def test_build_exposure_model(baseline_mc_exposure_model: cara.monte_carlo.ExposureModel):
model = baseline_mc_exposure_model.build_model(7)
assert isinstance(model, cara.models.ExposureModel)
prob = model.exposure()
prob = model.deposited_exposure()
assert isinstance(prob, np.ndarray)
assert prob.shape == (7, )

View file

@ -12,6 +12,31 @@ np.random.seed(2000)
SAMPLE_SIZE = 250000
TOLERANCE = 0.05
# Load the weather data (temperature in kelvin) for Toronto.
toronto_coordinates = (43.667, 79.400)
toronto_hourly_temperatures_celsius_per_hour = data.get_hourly_temperatures_celsius_per_hour(
toronto_coordinates)
# Toronto hourly temperatures as piecewise constant function (in Kelvin).
TorontoTemperatures_hourly = {
month: models.PiecewiseConstant(
# 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 range(25)),
tuple(273.15 + np.array(temperatures)),
)
for month, temperatures in toronto_hourly_temperatures_celsius_per_hour.items()
}
# Same Toronto temperatures on a finer temperature mesh (every 6 minutes).
TorontoTemperatures = {
month: TorontoTemperatures_hourly[month].refine(refine_factor=10)
for month, temperatures in toronto_hourly_temperatures_celsius_per_hour.items()
}
# references values for infection_probability and expected new cases
# in the following tests, were obtained from the feature/mc branch
@ -22,7 +47,7 @@ def shared_office_mc():
"""
concentration_mc = mc.ConcentrationModel(
room=models.Room(volume=50, humidity=0.5),
ventilation = models.MultipleVentilation(
ventilation=models.MultipleVentilation(
ventilations=(
models.SlidingWindow(
active=models.PeriodicInterval(period=120, duration=120),
@ -36,7 +61,7 @@ def shared_office_mc():
),
infected=mc.InfectedPopulation(
number=1,
presence=mc.SpecificInterval(present_times = ((0, 3.5), (4.5, 9))),
presence=mc.SpecificInterval(present_times=((0, 3.5), (4.5, 9))),
virus=virus_distributions['SARS_CoV_2_DELTA'],
mask=models.Mask.types['No mask'],
activity=activity_distributions['Seated'],
@ -51,7 +76,7 @@ def shared_office_mc():
concentration_model=concentration_mc,
exposed=mc.Population(
number=3,
presence=mc.SpecificInterval(present_times = ((0, 3.5), (4.5, 9))),
presence=mc.SpecificInterval(present_times=((0, 3.5), (4.5, 9))),
activity=activity_distributions['Seated'],
mask=models.Mask.types['No mask'],
host_immunity=0.,
@ -66,12 +91,12 @@ def classroom_mc():
"""
concentration_mc = mc.ConcentrationModel(
room=models.Room(volume=160, humidity=0.3),
ventilation = models.MultipleVentilation(
ventilation=models.MultipleVentilation(
ventilations=(
models.SlidingWindow(
active=models.PeriodicInterval(period=120, duration=120),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=data.TorontoTemperatures['Dec'],
outside_temp=TorontoTemperatures['Dec'],
window_height=1.6,
opening_length=0.2,
),
@ -295,13 +320,13 @@ def waiting_room_mc():
@pytest.mark.parametrize(
"mc_model, expected_pi, expected_new_cases, expected_dose, expected_ER",
[
["shared_office_mc", 6.03, 0.18, 27.22, 809],
["classroom_mc", 9.5, 1.85, 79.98, 5624],
["ski_cabin_mc", 16.0, 0.5, 40.25, 7966],
["skagit_chorale_mc",65.7, 40.0, 241.28, 190422],
["bus_ride_mc", 12.0, 8.0, 63.79, 5419],
["gym_mc", 0.45, 0.13, 0.4852, 1145],
["waiting_room_mc", 1.59, 0.22, 7.23, 737],
["shared_office_mc", 6.03, 0.18, 3.198, 809],
["classroom_mc", 9.5, 1.85, 9.478, 5624],
["ski_cabin_mc", 16.0, 0.5, 17.315, 7966],
["skagit_chorale_mc",65.7, 40.0, 102.213, 190422],
["bus_ride_mc", 12.0, 8.0, 7.65, 5419],
["gym_mc", 0.45, 0.13, 0.208, 1145],
["waiting_room_mc", 1.59, 0.22, 0.821, 737],
]
)
def test_report_models(mc_model, expected_pi, expected_new_cases,
@ -312,7 +337,7 @@ def test_report_models(mc_model, expected_pi, expected_new_cases,
expected_pi, rtol=TOLERANCE)
npt.assert_allclose(exposure_model.expected_new_cases().mean(),
expected_new_cases, rtol=TOLERANCE)
npt.assert_allclose(exposure_model.exposure().mean(),
npt.assert_allclose(exposure_model.deposited_exposure().mean(),
expected_dose, rtol=TOLERANCE)
npt.assert_allclose(
exposure_model.concentration_model.infected.emission_rate_when_present().mean(),
@ -322,10 +347,10 @@ def test_report_models(mc_model, expected_pi, expected_new_cases,
@pytest.mark.parametrize(
"mask_type, month, expected_pi, expected_dose, expected_ER",
[
["No mask", "Jul", 9.52, 84.54, 809],
["Type I", "Jul", 1.7, 15.64, 149],
["FFP2", "Jul", 0.51, 15.64, 149],
["Type I", "Feb", 0.57, 4.59, 162],
["No mask", "Jul", 9.52, 9.920, 809],
["Type I", "Jul", 1.7, 0.913, 149],
["FFP2", "Jul", 0.51, 0.239, 149],
["Type I", "Feb", 0.57, 0.272, 162],
],
)
def test_small_shared_office_Geneva(mask_type, month, expected_pi,
@ -372,7 +397,7 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi,
exposure_model = exposure_mc.build_model(size=SAMPLE_SIZE)
npt.assert_allclose(exposure_model.infection_probability().mean(),
expected_pi, rtol=TOLERANCE)
npt.assert_allclose(exposure_model.exposure().mean(),
npt.assert_allclose(exposure_model.deposited_exposure().mean(),
expected_dose, rtol=TOLERANCE)
npt.assert_allclose(
exposure_model.concentration_model.infected.emission_rate_when_present().mean(),