From a3bd951d36e3176333eec8e87d9ac138ee682eb9 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Thu, 27 May 2021 12:33:12 +0200 Subject: [PATCH 01/32] Adding tests for MultipleExpiration --- cara/tests/test_expiration.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 cara/tests/test_expiration.py diff --git a/cara/tests/test_expiration.py b/cara/tests/test_expiration.py new file mode 100644 index 00000000..7638d613 --- /dev/null +++ b/cara/tests/test_expiration.py @@ -0,0 +1,29 @@ +import re + +import numpy as np +import numpy.testing as npt +import pytest + +from cara import models + + +def test_multiple_wrong_weight_size(): + weights = (1., 2., 3.) + e_base = models.Expiration((0.084, 0.009, 0.003, 0.002)) + with pytest.raises( + ValueError, + match=re.escape("expirations and weigths should contain the" + "same number of elements") + ): + e = models.MultipleExpiration([e_base, e_base], weights) + + +def test_multiple(): + weights = (1., 2.) + e1 = models.Expiration((0.03, 0.02, 0.01, 0.005)) + e2 = models.Expiration((0.05, 0.04, 0.03, 0.01)) + e = models.MultipleExpiration([e1, e2], weights) + assert e.aerosols(models.Mask.types['No mask']) == ( + e1.aerosols(models.Mask.types['No mask'])/3. + + 2*e2.aerosols(models.Mask.types['No mask'])/3. + ) From 720bf1a56af54d7e5f952fde445e1ff063956298 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Thu, 27 May 2021 12:34:14 +0200 Subject: [PATCH 02/32] Adapting tests for model_generator --- cara/tests/apps/calculator/test_model_generator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cara/tests/apps/calculator/test_model_generator.py b/cara/tests/apps/calculator/test_model_generator.py index 6c5050e4..eb216727 100644 --- a/cara/tests/apps/calculator/test_model_generator.py +++ b/cara/tests/apps/calculator/test_model_generator.py @@ -8,6 +8,7 @@ from cara.apps.calculator.model_generator import minutes_since_midnight from cara import models from cara import data import numpy as np +import numpy.testing as npt def test_model_from_dict(baseline_form_data): @@ -24,10 +25,11 @@ def test_model_from_dict_invalid(baseline_form_data): def test_blend_expiration(): blend = {'Breathing': 2, 'Talking': 1} r = model_generator.build_expiration(blend) + mask = models.Mask.types['Type I'] expected = models.Expiration( (0.13466666666666668, 0.02866666666666667, 0.004333333333333334, 0.005) ) - assert r == expected + npt.assert_almost_equal(r.aerosols(mask), expected.aerosols(mask)) def test_ventilation_slidingwindow(baseline_form: model_generator.FormData): From b8422aaf1bf805697e0a7abcf2e671a73f54264d Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Thu, 27 May 2021 13:40:46 +0200 Subject: [PATCH 03/32] Introducing _ExpirationBase and MultipleExpiration classes; adapting tests and model_generator accordingly (removing now obsolete expiration_blend function) --- cara/apps/calculator/model_generator.py | 32 +++------------ cara/models.py | 53 +++++++++++++++++++++---- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index fb34392d..ad65795e 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -537,38 +537,16 @@ class FormData: ) -def build_expiration(expiration_definition) -> models.Expiration: +def build_expiration(expiration_definition) -> models._ExpirationBase: if isinstance(expiration_definition, str): - return models.Expiration.types[expiration_definition] + return models._ExpirationBase.types[expiration_definition] elif isinstance(expiration_definition, dict): - return expiration_blend({ - build_expiration(exp): amount - for exp, amount in expiration_definition.items() - } + return models.MultipleExpiration( + tuple([build_expiration(exp) for exp in expiration_definition.keys()]), + tuple(expiration_definition.values()) ) -def expiration_blend(expiration_weights: typing.Dict[models.Expiration, int]) -> models.Expiration: - """ - Combine together multiple types of Expiration, using a weighted mean to - compute their ejection factor and particle sizes. - - """ - ejection_factor = np.zeros(4) - particle_sizes = np.zeros(4) - - total_weight = 0 - for expiration, weight in expiration_weights.items(): - total_weight += weight - ejection_factor += np.array(expiration.ejection_factor) * weight - particle_sizes += np.array(expiration.particle_sizes) * weight - - r_ejection_factor: typing.Tuple[float, float, float, float] = tuple(ejection_factor/total_weight) # type: ignore - r_particle_sizes: typing.Tuple[float, float, float, float] = tuple(particle_sizes/total_weight) # type: ignore - - return models.Expiration(ejection_factor=r_ejection_factor, particle_sizes=r_particle_sizes) - - def model_from_form(form: FormData) -> models.ExposureModel: # Initializes room with volume either given directly or as product of area and height if form.volume_type == 'room_volume_explicit': diff --git a/cara/models.py b/cara/models.py index 0943bd87..ecbccad3 100644 --- a/cara/models.py +++ b/cara/models.py @@ -511,14 +511,29 @@ Mask.types = { @dataclass(frozen=True) -class Expiration: +class _ExpirationBase: + """ + Represents the expiration of aerosols by a person. + Subclasses of _ExpirationBase represent different models. + """ + #: Pre-populated examples of Masks. + types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] + + def aerosols(self, mask: _MaskBase): + # total volume of aerosols expired (cm^3). + raise NotImplementedError("Subclass must implement") + + +@dataclass(frozen=True) +class Expiration(_ExpirationBase): + """ + Simple model based on four different sizes of particles emitted, + with different ejection factors. + """ ejection_factor: typing.Tuple[float, ...] particle_sizes: typing.Tuple[float, ...] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm. - #: Pre-populated examples of Expiration. - types: typing.ClassVar[typing.Dict[str, "Expiration"]] - - def aerosols(self, mask: Mask): + def aerosols(self, mask: _MaskBase): def volume(diameter): return (4 * np.pi * (diameter/2)**3) / 3 total = 0 @@ -529,7 +544,31 @@ class Expiration: return total -Expiration.types = { +@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). + + """ + 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" + "same number of elements") + + def aerosols(self, mask: _MaskBase): + return np.array([ + weight * expiration.aerosols(mask) / sum(self.weights) + for weight,expiration in zip(self.weights,self.expirations) + ]).sum(axis=0) + + +_ExpirationBase.types = { 'Breathing': Expiration((0.084, 0.009, 0.003, 0.002)), 'Whispering': Expiration((0.11, 0.014, 0.004, 0.002)), 'Talking': Expiration((0.236, 0.068, 0.007, 0.011)), @@ -585,7 +624,7 @@ class InfectedPopulation(Population): virus: Virus #: The type of expiration that is being emitted whilst doing the activity. - expiration: Expiration + expiration: _ExpirationBase def emission_rate_when_present(self) -> _VectorisedFloat: """ From f050214237a94446cc84053bb1069d2eaf500da4 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Fri, 28 May 2021 06:56:13 +0200 Subject: [PATCH 04/32] Improving docstrings in expiration classes --- cara/models.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cara/models.py b/cara/models.py index ecbccad3..3f7e629a 100644 --- a/cara/models.py +++ b/cara/models.py @@ -520,7 +520,7 @@ class _ExpirationBase: types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] def aerosols(self, mask: _MaskBase): - # total volume of aerosols expired (cm^3). + # total volume of aerosols expired per volume of air (mL/cm^3). raise NotImplementedError("Subclass must implement") @@ -528,7 +528,10 @@ class _ExpirationBase: class Expiration(_ExpirationBase): """ Simple model based on four different sizes of particles emitted, - with different ejection factors. + with different ejection factors. See Fig. 4 in L. Morawska et al, + Size distribution and sites of origin of droplets expelled from the + human respiratory tract during expiratory activities, + Aerosol Science 40 (2009) pp. 256 - 269. """ ejection_factor: typing.Tuple[float, ...] particle_sizes: typing.Tuple[float, ...] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm. From 8ac3b4cbd966bc091c2ef70acce16adc436bf4e4 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Fri, 28 May 2021 10:50:33 +0200 Subject: [PATCH 05/32] Avoid the use of the updates from #184. --- cara/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cara/models.py b/cara/models.py index 3f7e629a..c2500d68 100644 --- a/cara/models.py +++ b/cara/models.py @@ -519,7 +519,7 @@ class _ExpirationBase: #: Pre-populated examples of Masks. types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): # total volume of aerosols expired per volume of air (mL/cm^3). raise NotImplementedError("Subclass must implement") @@ -536,7 +536,7 @@ class Expiration(_ExpirationBase): ejection_factor: typing.Tuple[float, ...] particle_sizes: typing.Tuple[float, ...] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm. - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): def volume(diameter): return (4 * np.pi * (diameter/2)**3) / 3 total = 0 @@ -564,7 +564,7 @@ class MultipleExpiration(_ExpirationBase): raise ValueError("expirations and weigths should contain the" "same number of elements") - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): return np.array([ weight * expiration.aerosols(mask) / sum(self.weights) for weight,expiration in zip(self.weights,self.expirations) From 9669e5afd0f331c80188a1bb3ec5c2b1d58622b0 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Fri, 16 Apr 2021 07:47:31 +0200 Subject: [PATCH 06/32] Add a cara.monte_carlo submodule as syntactic sugar on top of the existing cara.models vectorisation. This allows us to define SampleableDistributions for key variables (and in the future, good default values for these), as well as giving us an exact mirror of the non-MC models which we can ultimately generate those models. --- cara/monte_carlo/__init__.py | 1 + cara/monte_carlo/__init__.pyi | 4 ++ cara/monte_carlo/models.py | 85 ++++++++++++++++++++++++++++++++++ cara/monte_carlo/sampleable.py | 29 ++++++++++++ cara/tests/test_monte_carlo.py | 83 +++++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 cara/monte_carlo/__init__.py create mode 100644 cara/monte_carlo/__init__.pyi create mode 100644 cara/monte_carlo/models.py create mode 100644 cara/monte_carlo/sampleable.py create mode 100644 cara/tests/test_monte_carlo.py diff --git a/cara/monte_carlo/__init__.py b/cara/monte_carlo/__init__.py new file mode 100644 index 00000000..aed4fa32 --- /dev/null +++ b/cara/monte_carlo/__init__.py @@ -0,0 +1 @@ +from .models import * diff --git a/cara/monte_carlo/__init__.pyi b/cara/monte_carlo/__init__.pyi new file mode 100644 index 00000000..5a184e17 --- /dev/null +++ b/cara/monte_carlo/__init__.pyi @@ -0,0 +1,4 @@ +from typing import Any + +# For now we disable all type-checking in the monte-carlo submodule. +def __getattr__(name) -> Any: ... diff --git a/cara/monte_carlo/models.py b/cara/monte_carlo/models.py new file mode 100644 index 00000000..acdc1dad --- /dev/null +++ b/cara/monte_carlo/models.py @@ -0,0 +1,85 @@ +import copy +import dataclasses +import sys +import typing + +import cara.models + +from .sampleable import SampleableDistribution, _VectorisedFloatOrSampleable + + +_ModelType = typing.TypeVar('_ModelType') + + +class MCModelBase(typing.Generic[_ModelType]): + """ + A model base class for monte carlo types. + + This base class is essentially a declarative description of a cara.models + model with a :meth:`.build_model` method to generate an appropriate + ``cara.models` model instance on demand. + + """ + _base_cls: typing.Type[_ModelType] + + def build_model(self, size: int) -> _ModelType: + """ + Turn this MCModelBase subclass into a cara.models Model instance + from which you can then run the model. + + """ + kwargs = {} + for field in dataclasses.fields(self._base_cls): + attr = getattr(self, field.name) + if isinstance(attr, SampleableDistribution): + attr = attr.generate_samples(size) + elif isinstance(attr, MCModelBase): + # Recurse into other MCModelBase instances by calling their + # build_model method. + attr = attr.build_model(size) + kwargs[field.name] = attr + return self._base_cls(**kwargs) # type: ignore + + +def _build_mc_model(model: _ModelType) -> typing.Type[MCModelBase[_ModelType]]: + """ + Generate a new MCModelBase subclass for the given cara.models model. + + """ + fields = [] + for field in dataclasses.fields(model): + # Note: deepcopy not needed here as we aren't mutating entities beyond + # the top level. + new_field = copy.copy(field) + if field.type is cara.models._VectorisedFloat: # noqa + new_field.type = _VectorisedFloatOrSampleable # type: ignore + # TODO: Update the type annotation to support the new model classes that exist. + fields.append((new_field.name, new_field.type, new_field)) + cls = dataclasses.make_dataclass( + model.__name__, # type: ignore + fields, # type: ignore + bases=(MCModelBase, ), + namespace={'_base_cls': model}, + # This thing can be mutable - the calculations live on + # the wrapped class, not on the MCModelBase. + frozen=False, + ) + # Update the module of the generated class to be this one. Without this the + # module will be "types". + cls.__module__ = __name__ + return cls + + +_MODEL_CLASSES = [ + cls for cls in vars(cara.models).values() + if dataclasses.is_dataclass(cls) +] + + +# Inject the runtime generated MC types into this module. +for _model in _MODEL_CLASSES: + setattr(sys.modules[__name__], _model.__name__, _build_mc_model(_model)) + + +# Make sure that each of the models is imported if you do a ``import *``. +__all__ = [_model.__name__ for _model in _MODEL_CLASSES] + ["MCModelBase"] diff --git a/cara/monte_carlo/sampleable.py b/cara/monte_carlo/sampleable.py new file mode 100644 index 00000000..6f53c43c --- /dev/null +++ b/cara/monte_carlo/sampleable.py @@ -0,0 +1,29 @@ +import typing + +import numpy as np + +import cara.models + + +# Declare a float array type of a given size. +# There is no better way to declare this currently, unfortunately. +float_array_size_n = np.ndarray + + +class SampleableDistribution: + def generate_samples(self, size: int) -> float_array_size_n: + raise NotImplementedError() + + +class Normal(SampleableDistribution): + def __init__(self, mean: float, scale: float): + self.mean = mean + self.scale = scale + + def generate_samples(self, size: int) -> float_array_size_n: + return np.random.normal(self.mean, self.scale, size=size) + + +_VectorisedFloatOrSampleable = typing.Union[ + SampleableDistribution, cara.models._VectorisedFloat, +] diff --git a/cara/tests/test_monte_carlo.py b/cara/tests/test_monte_carlo.py new file mode 100644 index 00000000..28f7dc28 --- /dev/null +++ b/cara/tests/test_monte_carlo.py @@ -0,0 +1,83 @@ +import dataclasses + +import pytest + +import cara.models +import cara.monte_carlo.models as mc_models +import cara.monte_carlo.sampleable + + +MODEL_CLASSES = [ + cls for cls in vars(cara.models).values() + if dataclasses.is_dataclass(cls) +] + + +def test_type_annotations(): + # Check that there are appropriate type annotations for all of the model + # classes in cara.models. Note that these must be statically defined in + # cara.monte_carlo, rather than being dynamically generated, in order to + # allow the type system to be able to see their definition without needing + # runtime execution. + missing = [] + for cls in MODEL_CLASSES: + if not hasattr(cara.monte_carlo, cls.__name__): + missing.append(cls.__name__) + continue + mc_cls = getattr(cara.monte_carlo, cls.__name__) + assert issubclass(mc_cls, cara.monte_carlo.MCModelBase) + + if missing: + msg = ( + 'There are missing model implementations in cara.monte_carlo. ' + 'The following definitions are needed:\n ' + + '\n '.join([f'{model} = build_mc_model(cara.models.{model})' for model in missing]) + ) + pytest.fail(msg) + + +@pytest.fixture +def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel: + mc_model = cara.monte_carlo.ConcentrationModel( + room=cara.monte_carlo.Room(volume=cara.monte_carlo.sampleable.Normal(75, 20)), + ventilation=cara.monte_carlo.SlidingWindow( + active=cara.models.PeriodicInterval(period=120, duration=120), + inside_temp=cara.models.PiecewiseConstant((0, 24), (293,)), + outside_temp=cara.models.PiecewiseConstant((0, 24), (283,)), + window_height=1.6, opening_length=0.6, + ), + infected=cara.models.InfectedPopulation( + number=1, + virus=cara.models.Virus.types['SARS_CoV_2'], + presence=cara.models.SpecificInterval(((0, 4), (5, 8))), + mask=cara.models.Mask.types['No mask'], + activity=cara.models.Activity.types['Light activity'], + expiration=cara.models.Expiration.types['Unmodulated Vocalization'], + ), + ) + return mc_model + + +@pytest.fixture +def baseline_mc_exposure_model(baseline_mc_model) -> cara.monte_carlo.ExposureModel: + return cara.monte_carlo.ExposureModel( + baseline_mc_model, + exposed=cara.models.Population( + number=10, + presence=baseline_mc_model.infected.presence, + activity=baseline_mc_model.infected.activity, + mask=baseline_mc_model.infected.mask, + ) + ) + + +def test_build_concentration_model(baseline_mc_model: cara.monte_carlo.ConcentrationModel): + model = baseline_mc_model.build_model(7) + assert isinstance(model, cara.models.ConcentrationModel) + assert isinstance(model.concentration(time=0), float) + assert model.concentration(time=1).shape == (7, ) + + +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) From 38fe6e734e56168692479167960fc58d0c87c2d2 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Fri, 28 May 2021 17:23:43 +0200 Subject: [PATCH 07/32] Review actions for monte carlo models. --- cara/monte_carlo/sampleable.py | 6 +++--- cara/tests/test_monte_carlo.py | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cara/monte_carlo/sampleable.py b/cara/monte_carlo/sampleable.py index 6f53c43c..4ed49d82 100644 --- a/cara/monte_carlo/sampleable.py +++ b/cara/monte_carlo/sampleable.py @@ -16,12 +16,12 @@ class SampleableDistribution: class Normal(SampleableDistribution): - def __init__(self, mean: float, scale: float): + def __init__(self, mean: float, standard_deviation: float): self.mean = mean - self.scale = scale + self.standard_deviation = standard_deviation def generate_samples(self, size: int) -> float_array_size_n: - return np.random.normal(self.mean, self.scale, size=size) + return np.random.normal(self.mean, self.standard_deviation, size=size) _VectorisedFloatOrSampleable = typing.Union[ diff --git a/cara/tests/test_monte_carlo.py b/cara/tests/test_monte_carlo.py index 28f7dc28..f3d9fb08 100644 --- a/cara/tests/test_monte_carlo.py +++ b/cara/tests/test_monte_carlo.py @@ -1,5 +1,6 @@ import dataclasses +import numpy as np import pytest import cara.models @@ -81,3 +82,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.quanta_exposure() + assert isinstance(prob, np.ndarray) + assert prob.shape == (7, ) From 604422fbb5fcc424a4f490f79d28d0f2733e0160 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Fri, 28 May 2021 17:34:47 +0200 Subject: [PATCH 08/32] Improve the type handling of the MC model generation. This is tested more thoroughly later when generating type stubs. --- cara/monte_carlo/models.py | 58 +++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/cara/monte_carlo/models.py b/cara/monte_carlo/models.py index acdc1dad..7348e3be 100644 --- a/cara/monte_carlo/models.py +++ b/cara/monte_carlo/models.py @@ -22,6 +22,19 @@ class MCModelBase(typing.Generic[_ModelType]): """ _base_cls: typing.Type[_ModelType] + @classmethod + def _to_vectorized_form(cls, item, size): + if isinstance(item, SampleableDistribution): + return item.generate_samples(size) + elif isinstance(item, MCModelBase): + # Recurse into other MCModelBase instances by calling their + # build_model method. + return item.build_model(size) + elif isinstance(item, tuple): + return tuple(cls._to_vectorized_form(sub, size) for sub in item) + else: + return item + def build_model(self, size: int) -> _ModelType: """ Turn this MCModelBase subclass into a cara.models Model instance @@ -31,13 +44,7 @@ class MCModelBase(typing.Generic[_ModelType]): kwargs = {} for field in dataclasses.fields(self._base_cls): attr = getattr(self, field.name) - if isinstance(attr, SampleableDistribution): - attr = attr.generate_samples(size) - elif isinstance(attr, MCModelBase): - # Recurse into other MCModelBase instances by calling their - # build_model method. - attr = attr.build_model(size) - kwargs[field.name] = attr + kwargs[field.name] = self._to_vectorized_form(attr, size) return self._base_cls(**kwargs) # type: ignore @@ -53,12 +60,43 @@ def _build_mc_model(model: _ModelType) -> typing.Type[MCModelBase[_ModelType]]: new_field = copy.copy(field) if field.type is cara.models._VectorisedFloat: # noqa new_field.type = _VectorisedFloatOrSampleable # type: ignore - # TODO: Update the type annotation to support the new model classes that exist. - fields.append((new_field.name, new_field.type, new_field)) + + field_type: typing.Any = new_field.type + + if getattr(field_type, '__origin__', None) in [typing.Union, typing.Tuple]: + # It is challenging to generalise this code, so we provide specific transformations, + # and raise for unforseen cases. + if new_field.type == typing.Tuple[cara.models._VentilationBase, ...]: + VB = getattr(sys.modules[__name__], "_VentilationBase") + field_type = typing.Tuple[typing.Union[cara.models._VentilationBase, VB], ...] + elif new_field.type == typing.Tuple[cara.models._ExpirationBase, ...]: + EB = getattr(sys.modules[__name__], "_ExpirationBase") + field_type = typing.Tuple[typing.Union[cara.models._ExpirationBase, EB], ...] + else: + # Check that we don't need to do anything with this type. + for item in new_field.type.__args__: + if getattr(item, '__module__', None) == 'cara.models': + raise ValueError( + f"unsupported type annotation transformation required for {new_field.type}") + elif field_type.__module__ == 'cara.models': + mc_model = getattr(sys.modules[__name__], new_field.type.__name__) + field_type = typing.Union[new_field.type, mc_model] + + fields.append((new_field.name, field_type, new_field)) + + bases = [] + # Update the inheritance/based to use the new MC classes, rather than the cara.models ones. + for model_base in model.__bases__: # type: ignore + if model_base is object: + bases.append(MCModelBase) + else: + mc_model = getattr(sys.modules[__name__], model_base.__name__) + bases.append(mc_model) + cls = dataclasses.make_dataclass( model.__name__, # type: ignore fields, # type: ignore - bases=(MCModelBase, ), + bases=bases, # type: ignore namespace={'_base_cls': model}, # This thing can be mutable - the calculations live on # the wrapped class, not on the MCModelBase. From 9322c27af93718df6938672d585590ae4e5d583c Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Wed, 26 May 2021 18:57:56 +0200 Subject: [PATCH 09/32] Introducing _MaskBase class, of which Mask is a subclass --- cara/models.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cara/models.py b/cara/models.py index c2500d68..f5e80d1a 100644 --- a/cara/models.py +++ b/cara/models.py @@ -472,8 +472,21 @@ Virus.types = { @dataclass(frozen=True) -class Mask: - #: Filtration efficiency. (In %/100) +class _MaskBase: + """ + Represents the filtration of aerosols by a mask, both inward and + outward. + The nature of the various air exchange schemes means that it is expected + for subclasses of _MaskBase to exist. + """ + def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: + # Overall efficiency, including the effect of the leaks. + raise NotImplementedError("Subclass must implement") + + +@dataclass(frozen=True) +class Mask(_MaskBase): + #: Filtration efficiency. η_exhale: _VectorisedFloat #: Leakage through side of masks. From 78cdb22798f7d5cad8445dbba2bac01df4693d90 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Wed, 26 May 2021 23:37:10 +0200 Subject: [PATCH 10/32] Adding tests on Mask (new classes and new methods) --- cara/tests/models/test_mask.py | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 cara/tests/models/test_mask.py diff --git a/cara/tests/models/test_mask.py b/cara/tests/models/test_mask.py new file mode 100644 index 00000000..f2069cbd --- /dev/null +++ b/cara/tests/models/test_mask.py @@ -0,0 +1,58 @@ + +import dataclasses + +import numpy as np +import numpy.testing as npt +import pytest + +from cara import models + +@pytest.mark.parametrize( + "η_inhale, expected_inhale_efficiency", + [ + [0.5, 0.5], + [np.array([0.3, 0.5]), np.array([0.3, 0.5])], + ], +) +def test_masks_inhale(η_inhale, expected_inhale_efficiency): + mask = models.Mask(η_inhale=η_inhale,η_exhale=0.95,η_leaks=0.15) + measuredmask = models.MeasuredMask(η_inhale=η_inhale) + npt.assert_equal(mask.inhale_efficiency(), + expected_inhale_efficiency) + npt.assert_equal(measuredmask.inhale_efficiency(), + expected_inhale_efficiency) + + +@pytest.mark.parametrize( + "η_exhale, η_leaks, expected_exhale_efficiency_small, expected_exhale_efficiency_large", + [ + [0.95, 0.15, 0., 0.8075], + [np.array([0.95, 1.]), 0.15, np.zeros(2), np.array([0.8075, 0.85])], + [0.95, np.array([0.15, 0.]), np.zeros(2), np.array([0.8075, 0.95])], + [np.array([0.95, 1.]), np.array([0.15, 0.]), np.zeros(2), np.array([0.8075, 1.])], + ], +) +def test_mask_exhale(η_exhale, η_leaks, expected_exhale_efficiency_small, + expected_exhale_efficiency_large): + mask = models.Mask(η_inhale=0.3,η_exhale=η_exhale,η_leaks=η_leaks) + # we test one small and one large diameter (resp. 1 and 4 microns) + npt.assert_equal(mask.exhale_efficiency(1.e-4), + expected_exhale_efficiency_small) + npt.assert_equal(mask.exhale_efficiency(4.e-4), + expected_exhale_efficiency_large) + + +@pytest.mark.parametrize( + "diameter, expected_exhale_efficiency", + [ + [0.3e-4, 0.], + [0.7e-4, 0.56711], + [1.e-4, 0.7149], + [4.e-4, 0.8167], + ], +) +def test_measuredmask_exhale(diameter, expected_exhale_efficiency): + mask = models.MeasuredMask(η_inhale=0.3) + npt.assert_almost_equal(mask.exhale_efficiency(diameter), + expected_exhale_efficiency) + From adc11cb527e2a033ad49b8496136d5c0e85b0b9f Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Wed, 26 May 2021 23:39:33 +0200 Subject: [PATCH 11/32] New method inhale_efficiency in _MaskBase; using _MaskBase everywhere needed (also for types); new MeasuredMask with different exhale_efficiency functions; new mask types --- cara/apps/calculator/model_generator.py | 4 +- cara/apps/expert.py | 14 +++--- cara/models.py | 58 +++++++++++++++++++++---- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index ad65795e..0d7506ae 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -269,10 +269,10 @@ class FormData: else: return ventilation - def mask(self) -> models.Mask: + def mask(self) -> models._MaskBase: # Initializes the mask type if mask wearing is "continuous", otherwise instantiates the mask attribute as # the "No mask"-mask - mask = models.Mask.types[self.mask_type if self.mask_wearing_option == "mask_on" else 'No mask'] + mask = models._MaskBase.types[self.mask_type if self.mask_wearing_option == "mask_on" else 'No mask'] return mask def infected_population(self) -> models.InfectedPopulation: diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 31903b79..a0f273e1 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -390,10 +390,10 @@ class ModelWidgets(View): def _build_mask(self, node): mask = node.dcs_instance() - for name, mask_ in models.Mask.types.items(): + for name, mask_ in models._MaskBase.types.items(): if mask == mask_: break - mask_choice = widgets.Select(options=list(models.Mask.types.keys()), value=name) + mask_choice = widgets.Select(options=list(models._MaskBase.types.keys()), value=name) def on_mask_change(change): node.dcs_select(change['new']) @@ -496,7 +496,7 @@ baseline_model = models.ExposureModel( number=1, virus=models.Virus.types['SARS_CoV_2'], presence=models.SpecificInterval(((8, 12), (13, 17))), - mask=models.Mask.types['No mask'], + mask=models._MaskBase.types['No mask'], activity=models.Activity.types['Seated'], expiration=models.Expiration.types['Talking'], ), @@ -505,7 +505,7 @@ baseline_model = models.ExposureModel( number=10, presence=models.SpecificInterval(((8, 12), (13, 17))), activity=models.Activity.types['Seated'], - mask=models.Mask.types['No mask'], + mask=models._MaskBase.types['No mask'], ), ) @@ -515,10 +515,10 @@ class CARAStateBuilder(state.StateBuilder): # For example, build_type__VentilationBase is called when dealing with ConcentrationModel # types as it has a ventilation: _VentilationBase field. - def build_type_Mask(self, _: dataclasses.Field): + def build_type__MaskBase(self, _: dataclasses.Field): return state.DataclassStatePredefined( - models.Mask, - choices=models.Mask.types, + models._MaskBase, + choices=models._MaskBase.types, ) def build_type_Virus(self, _: dataclasses.Field): diff --git a/cara/models.py b/cara/models.py index f5e80d1a..5dbc134e 100644 --- a/cara/models.py +++ b/cara/models.py @@ -474,13 +474,20 @@ Virus.types = { @dataclass(frozen=True) class _MaskBase: """ - Represents the filtration of aerosols by a mask, both inward and + Represents the filtration of aerosols by a mask, both inward and outward. The nature of the various air exchange schemes means that it is expected for subclasses of _MaskBase to exist. """ + #: Pre-populated examples of Masks. + types: typing.ClassVar[typing.Dict[str, "_MaskBase"]] + def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: - # Overall efficiency, including the effect of the leaks. + # Overall exhale efficiency, including the effect of the leaks. + raise NotImplementedError("Subclass must implement") + + def inhale_efficiency(self) -> _VectorisedFloat: + # Overall inhale efficiency, including the effect of the leaks. raise NotImplementedError("Subclass must implement") @@ -495,9 +502,6 @@ class Mask(_MaskBase): #: Filtration efficiency of masks when inhaling. η_inhale: _VectorisedFloat - #: Pre-populated examples of Masks. - types: typing.ClassVar[typing.Dict[str, "Mask"]] - def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: # Overall efficiency with the effect of the leaks for aerosol emission # Gammaitoni et al (1997). Diameter is in cm. @@ -507,8 +511,38 @@ class Mask(_MaskBase): eta_out = self.η_exhale * (1 - self.η_leaks) return eta_out + def inhale_efficiency(self) -> _VectorisedFloat: + # Overall inhale efficiency, including the effect of the leaks. + return self.η_inhale -Mask.types = { + +@dataclass(frozen=True) +class MeasuredMask(_MaskBase): + #: Filtration efficiency of masks when inhaling. + η_inhale: _VectorisedFloat + + def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: + # 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. + # Diameter is in cm. + if diameter < 0.5e-4: + eta_out = 0. + elif diameter < 0.94614e-4: + eta_out = 0.5893 * diameter * 1e4 + 0.1546 + elif diameter < 3e-4: + eta_out = 0.0509 * diameter * 1e4 + 0.664 + else: + eta_out = 0.8167 + return eta_out + + def inhale_efficiency(self) -> _VectorisedFloat: + # Overall inhale efficiency, including the effect of the leaks. + return self.η_inhale + + +_MaskBase.types = { 'No mask': Mask(0, 0, 0), 'Type I': Mask( η_exhale=0.95, @@ -520,6 +554,12 @@ Mask.types = { η_leaks=0.15, # (same outward effect as type 1 - Asadi 2020) η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) ), + 'Type I measured': MeasuredMask( + η_inhale=0.3, # (Browen 2010) + ), + 'FFP2 measured': MeasuredMask( + η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) + ), } @@ -549,7 +589,7 @@ class Expiration(_ExpirationBase): ejection_factor: typing.Tuple[float, ...] particle_sizes: typing.Tuple[float, ...] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm. - def aerosols(self, mask: Mask): + def aerosols(self, mask: _MaskBase): def volume(diameter): return (4 * np.pi * (diameter/2)**3) / 3 total = 0 @@ -625,7 +665,7 @@ class Population: presence: Interval #: The kind of mask being worn by the people. - mask: Mask + mask: _MaskBase #: The physical activity being carried out by the people. activity: Activity @@ -848,7 +888,7 @@ class ExposureModel: inf_aero = ( self.exposed.activity.inhalation_rate * - (1 - self.exposed.mask.η_inhale) * + (1 - self.exposed.mask.inhale_efficiency()) * exposure ) From 4aa819bea475f2547782e81ad52bf0ada7270c1b Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Thu, 27 May 2021 09:20:28 +0200 Subject: [PATCH 12/32] Correction in docstring of _MaskBase --- cara/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/models.py b/cara/models.py index 5dbc134e..087704e8 100644 --- a/cara/models.py +++ b/cara/models.py @@ -476,7 +476,7 @@ class _MaskBase: """ Represents the filtration of aerosols by a mask, both inward and outward. - The nature of the various air exchange schemes means that it is expected + The nature of the various mask models means that it is expected for subclasses of _MaskBase to exist. """ #: Pre-populated examples of Masks. From 95640cb5be37aeeb8fb0aebd1e6dcb638c3ac37f Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Thu, 27 May 2021 13:44:03 +0200 Subject: [PATCH 13/32] Modifying eta_inhale of Type I measured mask according to CERN report --- cara/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/models.py b/cara/models.py index 087704e8..6efa29db 100644 --- a/cara/models.py +++ b/cara/models.py @@ -555,7 +555,7 @@ _MaskBase.types = { η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) ), 'Type I measured': MeasuredMask( - η_inhale=0.3, # (Browen 2010) + η_inhale=0.5, # (CERN-OPEN-2021-004) ), 'FFP2 measured': MeasuredMask( η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) From de5e96fd0f86898f8cb2934be7db7e4315cc06fa Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Thu, 27 May 2021 13:40:46 +0200 Subject: [PATCH 14/32] Introducing _ExpirationBase and MultipleExpiration classes; adapting tests and model_generator accordingly (removing now obsolete expiration_blend function) --- cara/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cara/models.py b/cara/models.py index 6efa29db..50eaaec2 100644 --- a/cara/models.py +++ b/cara/models.py @@ -572,8 +572,8 @@ class _ExpirationBase: #: Pre-populated examples of Masks. types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] - def aerosols(self, mask: Mask): - # total volume of aerosols expired per volume of air (mL/cm^3). + def aerosols(self, mask: _MaskBase): + # total volume of aerosols expired (cm^3). raise NotImplementedError("Subclass must implement") @@ -617,7 +617,7 @@ class MultipleExpiration(_ExpirationBase): raise ValueError("expirations and weigths should contain the" "same number of elements") - def aerosols(self, mask: Mask): + def aerosols(self, mask: _MaskBase): return np.array([ weight * expiration.aerosols(mask) / sum(self.weights) for weight,expiration in zip(self.weights,self.expirations) From b04dfbad01806cf18dc742510bf68e6d965afb2b Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 07:56:06 +0200 Subject: [PATCH 15/32] Fixing previous merge --- cara/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cara/models.py b/cara/models.py index 6efa29db..a72f1b48 100644 --- a/cara/models.py +++ b/cara/models.py @@ -572,7 +572,7 @@ class _ExpirationBase: #: Pre-populated examples of Masks. types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] - def aerosols(self, mask: Mask): + def aerosols(self, mask: _MaskBase): # total volume of aerosols expired per volume of air (mL/cm^3). raise NotImplementedError("Subclass must implement") @@ -617,7 +617,7 @@ class MultipleExpiration(_ExpirationBase): raise ValueError("expirations and weigths should contain the" "same number of elements") - def aerosols(self, mask: Mask): + def aerosols(self, mask: _MaskBase): return np.array([ weight * expiration.aerosols(mask) / sum(self.weights) for weight,expiration in zip(self.weights,self.expirations) From 947bd013e0c07131eb8ddc33c4c876f2bbead2e8 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:25:39 +0200 Subject: [PATCH 16/32] Modifying tests on mask to comply with a single Mask class --- cara/tests/models/test_mask.py | 43 +++++++--------------------------- 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/cara/tests/models/test_mask.py b/cara/tests/models/test_mask.py index f2069cbd..7f7cb242 100644 --- a/cara/tests/models/test_mask.py +++ b/cara/tests/models/test_mask.py @@ -1,6 +1,3 @@ - -import dataclasses - import numpy as np import numpy.testing as npt import pytest @@ -15,44 +12,22 @@ from cara import models ], ) def test_masks_inhale(η_inhale, expected_inhale_efficiency): - mask = models.Mask(η_inhale=η_inhale,η_exhale=0.95,η_leaks=0.15) - measuredmask = models.MeasuredMask(η_inhale=η_inhale) + mask = models.Mask(η_inhale=η_inhale) npt.assert_equal(mask.inhale_efficiency(), expected_inhale_efficiency) - npt.assert_equal(measuredmask.inhale_efficiency(), - expected_inhale_efficiency) @pytest.mark.parametrize( - "η_exhale, η_leaks, expected_exhale_efficiency_small, expected_exhale_efficiency_large", + "diameter, factor_exhale, expected_exhale_efficiency", [ - [0.95, 0.15, 0., 0.8075], - [np.array([0.95, 1.]), 0.15, np.zeros(2), np.array([0.8075, 0.85])], - [0.95, np.array([0.15, 0.]), np.zeros(2), np.array([0.8075, 0.95])], - [np.array([0.95, 1.]), np.array([0.15, 0.]), np.zeros(2), np.array([0.8075, 1.])], + [0.3e-4, 1., 0.], + [0.7e-4, 0.3, 0.56711*0.3], + [1.e-4, 1., 0.7149], + [4.e-4, 0.5, 0.8167*0.5], + [5.e-4, 0., 0.], ], ) -def test_mask_exhale(η_exhale, η_leaks, expected_exhale_efficiency_small, - expected_exhale_efficiency_large): - mask = models.Mask(η_inhale=0.3,η_exhale=η_exhale,η_leaks=η_leaks) - # we test one small and one large diameter (resp. 1 and 4 microns) - npt.assert_equal(mask.exhale_efficiency(1.e-4), - expected_exhale_efficiency_small) - npt.assert_equal(mask.exhale_efficiency(4.e-4), - expected_exhale_efficiency_large) - - -@pytest.mark.parametrize( - "diameter, expected_exhale_efficiency", - [ - [0.3e-4, 0.], - [0.7e-4, 0.56711], - [1.e-4, 0.7149], - [4.e-4, 0.8167], - ], -) -def test_measuredmask_exhale(diameter, expected_exhale_efficiency): - mask = models.MeasuredMask(η_inhale=0.3) +def test_mask_exhale(diameter, factor_exhale, expected_exhale_efficiency): + mask = models.Mask(η_inhale=0.3, factor_exhale=factor_exhale) npt.assert_almost_equal(mask.exhale_efficiency(diameter), expected_exhale_efficiency) - From fa0550233c8369f81158ad6abf1cb4bc8bf12817 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:28:51 +0200 Subject: [PATCH 17/32] Removing _MaskBase and old Mask class, which is replaced by MeasuredMask --- cara/models.py | 100 ++++++++++++++----------------------------------- 1 file changed, 28 insertions(+), 72 deletions(-) diff --git a/cara/models.py b/cara/models.py index a72f1b48..736e0992 100644 --- a/cara/models.py +++ b/cara/models.py @@ -472,61 +472,25 @@ Virus.types = { @dataclass(frozen=True) -class _MaskBase: - """ - Represents the filtration of aerosols by a mask, both inward and - outward. - The nature of the various mask models means that it is expected - for subclasses of _MaskBase to exist. - """ +class Mask: + #: Filtration efficiency of masks when inhaling. + η_inhale: _VectorisedFloat + + #: Global factor applied to filtration efficiency of masks when exhaling. + factor_exhale: _VectorisedFloat = 1. + #: Pre-populated examples of Masks. - types: typing.ClassVar[typing.Dict[str, "_MaskBase"]] + types: typing.ClassVar[typing.Dict[str, "Mask"]] def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: - # Overall exhale efficiency, including the effect of the leaks. - raise NotImplementedError("Subclass must implement") - - def inhale_efficiency(self) -> _VectorisedFloat: - # Overall inhale efficiency, including the effect of the leaks. - raise NotImplementedError("Subclass must implement") - - -@dataclass(frozen=True) -class Mask(_MaskBase): - #: Filtration efficiency. - η_exhale: _VectorisedFloat - - #: Leakage through side of masks. - η_leaks: _VectorisedFloat - - #: Filtration efficiency of masks when inhaling. - η_inhale: _VectorisedFloat - - def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: - # Overall efficiency with the effect of the leaks for aerosol emission - # Gammaitoni et al (1997). Diameter is in cm. - if diameter < 3e-4: - eta_out = 0. - else: - eta_out = self.η_exhale * (1 - self.η_leaks) - return eta_out - - def inhale_efficiency(self) -> _VectorisedFloat: - # Overall inhale efficiency, including the effect of the leaks. - return self.η_inhale - - -@dataclass(frozen=True) -class MeasuredMask(_MaskBase): - #: Filtration efficiency of masks when inhaling. - η_inhale: _VectorisedFloat - - def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: - # 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. - # Diameter is in cm. + """ + 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. + Diameter is in cm. + """ if diameter < 0.5e-4: eta_out = 0. elif diameter < 0.94614e-4: @@ -535,29 +499,21 @@ class MeasuredMask(_MaskBase): eta_out = 0.0509 * diameter * 1e4 + 0.664 else: eta_out = 0.8167 - return eta_out + return eta_out*self.factor_exhale def inhale_efficiency(self) -> _VectorisedFloat: - # Overall inhale efficiency, including the effect of the leaks. + """ + Overall inhale efficiency, including the effect of the leaks. + """ return self.η_inhale -_MaskBase.types = { - 'No mask': Mask(0, 0, 0), +Mask.types = { + 'No mask': Mask(0, 0), 'Type I': Mask( - η_exhale=0.95, - η_leaks=0.15, # (Huang 2007) - η_inhale=0.3, # (Browen 2010) - ), - 'FFP2': Mask( - η_exhale=0.95, # (same outward effect as type 1 - Asadi 2020) - η_leaks=0.15, # (same outward effect as type 1 - Asadi 2020) - η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) - ), - 'Type I measured': MeasuredMask( η_inhale=0.5, # (CERN-OPEN-2021-004) ), - 'FFP2 measured': MeasuredMask( + 'FFP2': Mask( η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) ), } @@ -569,10 +525,10 @@ class _ExpirationBase: Represents the expiration of aerosols by a person. Subclasses of _ExpirationBase represent different models. """ - #: Pre-populated examples of Masks. + #: Pre-populated examples of Expirations. types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): # total volume of aerosols expired per volume of air (mL/cm^3). raise NotImplementedError("Subclass must implement") @@ -589,7 +545,7 @@ class Expiration(_ExpirationBase): ejection_factor: typing.Tuple[float, ...] particle_sizes: typing.Tuple[float, ...] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm. - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): def volume(diameter): return (4 * np.pi * (diameter/2)**3) / 3 total = 0 @@ -617,7 +573,7 @@ class MultipleExpiration(_ExpirationBase): raise ValueError("expirations and weigths should contain the" "same number of elements") - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): return np.array([ weight * expiration.aerosols(mask) / sum(self.weights) for weight,expiration in zip(self.weights,self.expirations) @@ -665,7 +621,7 @@ class Population: presence: Interval #: The kind of mask being worn by the people. - mask: _MaskBase + mask: Mask #: The physical activity being carried out by the people. activity: Activity From 26075cd53cc22a0aba35185d28c8b49e496e4108 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:30:25 +0200 Subject: [PATCH 18/32] Modifying infected populations tests and concentration tests, according to the new Mask class --- cara/tests/models/test_concentration_model.py | 12 +++++------- cara/tests/test_infected_population.py | 9 +++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/cara/tests/models/test_concentration_model.py b/cara/tests/models/test_concentration_model.py index 48ef0b66..f7230430 100644 --- a/cara/tests/models/test_concentration_model.py +++ b/cara/tests/models/test_concentration_model.py @@ -1,6 +1,7 @@ import re import numpy as np +import numpy.testing as npt import pytest from cara import models @@ -13,8 +14,7 @@ from cara import models {'air_change': np.array([100, 120])}, {'viral_load_in_sputum': np.array([5e8, 1e9])}, {'quantum_infectious_dose': np.array([50, 20])}, - {'η_exhale': np.array([0.92, 0.95])}, - {'η_leaks': np.array([0.15, 0.20])}, + {'factor_exhale': np.array([0.92, 0.95])}, ] ) def test_concentration_model_vectorisation(override_params): @@ -24,8 +24,7 @@ def test_concentration_model_vectorisation(override_params): 'air_change': 100, 'viral_load_in_sputum': 1e9, 'quantum_infectious_dose': 50, - 'η_exhale': 0.95, - 'η_leaks': 0.15, + 'factor_exhale': 0.95, } defaults.update(override_params) @@ -37,8 +36,7 @@ def test_concentration_model_vectorisation(override_params): number=1, presence=always, mask=models.Mask( - η_exhale=defaults['η_exhale'], - η_leaks=defaults['η_leaks'], + factor_exhale=defaults['factor_exhale'], η_inhale=0.3, ), activity=models.Activity( @@ -128,4 +126,4 @@ def test_integrated_concentration(simple_conc_model): c2 = simple_conc_model.integrated_concentration(0, 1) c3 = simple_conc_model.integrated_concentration(1, 2) assert c1 != 0 - assert c1 == c2 + c3 + npt.assert_almost_equal(c1, c2 + c3, decimal=15) diff --git a/cara/tests/test_infected_population.py b/cara/tests/test_infected_population.py index e87ddb20..8ef94729 100644 --- a/cara/tests/test_infected_population.py +++ b/cara/tests/test_infected_population.py @@ -8,8 +8,7 @@ import cara.models "override_params", [ {'viral_load_in_sputum': np.array([5e8, 1e9])}, {'quantum_infectious_dose': np.array([50, 20])}, - {'η_exhale': np.array([0.92, 0.95])}, - {'η_leaks': np.array([0.15, 0.20])}, + {'factor_exhale': np.array([0.92, 0.95])}, {'exhalation_rate': np.array([0.75, 0.81])}, ] ) @@ -17,8 +16,7 @@ def test_infected_population_vectorisation(override_params): defaults = { 'viral_load_in_sputum': 1e9, 'quantum_infectious_dose': 50, - 'η_exhale': 0.95, - 'η_leaks': 0.15, + 'factor_exhale': 0.95, 'exhalation_rate': 0.75, } defaults.update(override_params) @@ -28,8 +26,7 @@ def test_infected_population_vectorisation(override_params): number=1, presence=office_hours, mask=cara.models.Mask( - η_exhale=defaults['η_exhale'], - η_leaks=defaults['η_leaks'], + factor_exhale=defaults['factor_exhale'], η_inhale=0.3, ), activity=cara.models.Activity( From 0087bff41ed69b9dcb561189978b6e1d623a6e1e Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:31:13 +0200 Subject: [PATCH 19/32] Propagating the change in Mask class to the calculator and expoert apps --- cara/apps/calculator/model_generator.py | 4 ++-- cara/apps/expert.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 0d7506ae..ad65795e 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -269,10 +269,10 @@ class FormData: else: return ventilation - def mask(self) -> models._MaskBase: + def mask(self) -> models.Mask: # Initializes the mask type if mask wearing is "continuous", otherwise instantiates the mask attribute as # the "No mask"-mask - mask = models._MaskBase.types[self.mask_type if self.mask_wearing_option == "mask_on" else 'No mask'] + mask = models.Mask.types[self.mask_type if self.mask_wearing_option == "mask_on" else 'No mask'] return mask def infected_population(self) -> models.InfectedPopulation: diff --git a/cara/apps/expert.py b/cara/apps/expert.py index a0f273e1..31903b79 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -390,10 +390,10 @@ class ModelWidgets(View): def _build_mask(self, node): mask = node.dcs_instance() - for name, mask_ in models._MaskBase.types.items(): + for name, mask_ in models.Mask.types.items(): if mask == mask_: break - mask_choice = widgets.Select(options=list(models._MaskBase.types.keys()), value=name) + mask_choice = widgets.Select(options=list(models.Mask.types.keys()), value=name) def on_mask_change(change): node.dcs_select(change['new']) @@ -496,7 +496,7 @@ baseline_model = models.ExposureModel( number=1, virus=models.Virus.types['SARS_CoV_2'], presence=models.SpecificInterval(((8, 12), (13, 17))), - mask=models._MaskBase.types['No mask'], + mask=models.Mask.types['No mask'], activity=models.Activity.types['Seated'], expiration=models.Expiration.types['Talking'], ), @@ -505,7 +505,7 @@ baseline_model = models.ExposureModel( number=10, presence=models.SpecificInterval(((8, 12), (13, 17))), activity=models.Activity.types['Seated'], - mask=models._MaskBase.types['No mask'], + mask=models.Mask.types['No mask'], ), ) @@ -515,10 +515,10 @@ class CARAStateBuilder(state.StateBuilder): # For example, build_type__VentilationBase is called when dealing with ConcentrationModel # types as it has a ventilation: _VentilationBase field. - def build_type__MaskBase(self, _: dataclasses.Field): + def build_type_Mask(self, _: dataclasses.Field): return state.DataclassStatePredefined( - models._MaskBase, - choices=models._MaskBase.types, + models.Mask, + choices=models.Mask.types, ) def build_type_Virus(self, _: dataclasses.Field): From d7bd53c288416a41c229ed8ddefd87523733a7d0 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:31:56 +0200 Subject: [PATCH 20/32] Starting to modify exposure tests according to new Mask class --- cara/tests/models/test_exposure_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index 464f25a9..c1e9090d 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -43,7 +43,7 @@ populations = [ ), # A population with some array component for η_inhale. models.Population( - 10, halftime, models.Mask(0.95, 0.15, np.array([0.3, 0.35])), + 10, halftime, models.Mask(np.array([0.3, 0.35])), models.Activity.types['Standing'], ), # A population with some array component for inhalation_rate. From ffb85c6b72b39b7fe62e1cdfa420282452960650 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Mon, 31 May 2021 06:32:17 +0200 Subject: [PATCH 21/32] Fixing the remaining tests, with the new masks --- cara/tests/models/test_exposure_model.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index c1e9090d..009cf092 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -60,10 +60,10 @@ populations = [ np.array([14.4, 14.4]), np.array([99.6803184113, 99.5181053773])], [populations[2], KnownConcentrations(lambda t: 1.2), - np.array([14.4, 14.4]), np.array([99.4146994564, 99.6803184113])], + np.array([14.4, 14.4]), np.array([97.4574432074, 98.3493482895])], [populations[0], KnownConcentrations(lambda t: np.array([1.2, 2.4])), - np.array([14.4, 28.8]), np.array([99.6803184113, 99.9989780368])], + np.array([14.4, 28.8]), np.array([98.3493482895, 99.9727534893])], [populations[1], KnownConcentrations(lambda t: np.array([1.2, 2.4])), np.array([14.4, 28.8]), np.array([99.6803184113, 99.9976777757])], @@ -123,22 +123,22 @@ def conc_model(): models.InfectedPopulation( number=1, presence=interesting_times, - mask=models.Mask.types['Type I'], + mask=models.Mask.types['No mask'], activity=models.Activity.types['Seated'], virus=models.Virus.types['SARS_CoV_2'], - expiration=models.Expiration.types['Breathing'], + expiration=models.Expiration.types['Superspreading event'], ) ) # expected quanta were computed with a trapezoidal integration, using # a mesh of 10'000 pts per exposed presence interval. @pytest.mark.parametrize("exposed_time_interval, expected_quanta", [ - [(0, 1), 0.0055680845], - [(1, 1.01), 6.4960491e-05], - [(1.01, 1.02), 6.3187723e-05], - [(12, 12.01), 1.9307359e-06], - [(12, 24), 0.079347465], - [(0, 24), 0.086122050], + [(0, 1), 5.4869151], + [(1, 1.01), 0.064013521], + [(1.01, 1.02), 0.062266596], + [(12, 12.01), 0.0019025904], + [(12, 24), 78.190763], + [(0, 24), 84.866592], ] ) def test_exposure_model_integral_accuracy(exposed_time_interval, From bfdb3e322e1b6f1de291fb64adeb4f1591974a10 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Mon, 31 May 2021 06:55:07 +0200 Subject: [PATCH 22/32] Mask exhale_efficiency use now a diameter in microns --- cara/models.py | 14 +++++++------- cara/tests/models/test_mask.py | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cara/models.py b/cara/models.py index 736e0992..56f663f4 100644 --- a/cara/models.py +++ b/cara/models.py @@ -489,14 +489,14 @@ class Mask: therein (Asadi 2020). Obtained from measurements of filtration efficiency and of the leakage through the sides. - Diameter is in cm. + Diameter is in microns. """ - if diameter < 0.5e-4: + if diameter < 0.5: eta_out = 0. - elif diameter < 0.94614e-4: - eta_out = 0.5893 * diameter * 1e4 + 0.1546 - elif diameter < 3e-4: - eta_out = 0.0509 * diameter * 1e4 + 0.664 + elif diameter < 0.94614: + eta_out = 0.5893 * diameter + 0.1546 + elif diameter < 3.: + eta_out = 0.0509 * diameter + 0.664 else: eta_out = 0.8167 return eta_out*self.factor_exhale @@ -551,7 +551,7 @@ class Expiration(_ExpirationBase): total = 0 for diameter, factor in zip(self.particle_sizes, self.ejection_factor): contribution = (volume(diameter) * factor * - (1 - mask.exhale_efficiency(diameter))) + (1 - mask.exhale_efficiency(diameter*1e4))) total += contribution return total diff --git a/cara/tests/models/test_mask.py b/cara/tests/models/test_mask.py index 7f7cb242..4a71d13c 100644 --- a/cara/tests/models/test_mask.py +++ b/cara/tests/models/test_mask.py @@ -11,7 +11,7 @@ from cara import models [np.array([0.3, 0.5]), np.array([0.3, 0.5])], ], ) -def test_masks_inhale(η_inhale, expected_inhale_efficiency): +def test_mask_inhale(η_inhale, expected_inhale_efficiency): mask = models.Mask(η_inhale=η_inhale) npt.assert_equal(mask.inhale_efficiency(), expected_inhale_efficiency) @@ -20,11 +20,11 @@ def test_masks_inhale(η_inhale, expected_inhale_efficiency): @pytest.mark.parametrize( "diameter, factor_exhale, expected_exhale_efficiency", [ - [0.3e-4, 1., 0.], - [0.7e-4, 0.3, 0.56711*0.3], - [1.e-4, 1., 0.7149], - [4.e-4, 0.5, 0.8167*0.5], - [5.e-4, 0., 0.], + [0.3, 1., 0.], + [0.7, 0.3, 0.56711*0.3], + [1., 1., 0.7149], + [4., 0.5, 0.8167*0.5], + [5., 0., 0.], ], ) def test_mask_exhale(diameter, factor_exhale, expected_exhale_efficiency): From 9325eea09e5d339c103df48dfda2ba773cecda14 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Mon, 31 May 2021 08:55:55 +0200 Subject: [PATCH 23/32] Fixing docstring --- cara/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/models.py b/cara/models.py index 56f663f4..88e5493a 100644 --- a/cara/models.py +++ b/cara/models.py @@ -529,7 +529,7 @@ class _ExpirationBase: types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] def aerosols(self, mask: Mask): - # total volume of aerosols expired per volume of air (mL/cm^3). + # total volume of aerosols expired (cm^3). raise NotImplementedError("Subclass must implement") From 6fd8d6531b768631c68b118bd3fbdf6ad37100a1 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 07:56:06 +0200 Subject: [PATCH 24/32] Fixing previous merge --- cara/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/models.py b/cara/models.py index 50eaaec2..a72f1b48 100644 --- a/cara/models.py +++ b/cara/models.py @@ -573,7 +573,7 @@ class _ExpirationBase: types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] def aerosols(self, mask: _MaskBase): - # total volume of aerosols expired (cm^3). + # total volume of aerosols expired per volume of air (mL/cm^3). raise NotImplementedError("Subclass must implement") From 3226bb04e8d71847757fee8bd0f35f89f2eb4bfd Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:25:39 +0200 Subject: [PATCH 25/32] Modifying tests on mask to comply with a single Mask class --- cara/tests/models/test_mask.py | 43 +++++++--------------------------- 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/cara/tests/models/test_mask.py b/cara/tests/models/test_mask.py index f2069cbd..7f7cb242 100644 --- a/cara/tests/models/test_mask.py +++ b/cara/tests/models/test_mask.py @@ -1,6 +1,3 @@ - -import dataclasses - import numpy as np import numpy.testing as npt import pytest @@ -15,44 +12,22 @@ from cara import models ], ) def test_masks_inhale(η_inhale, expected_inhale_efficiency): - mask = models.Mask(η_inhale=η_inhale,η_exhale=0.95,η_leaks=0.15) - measuredmask = models.MeasuredMask(η_inhale=η_inhale) + mask = models.Mask(η_inhale=η_inhale) npt.assert_equal(mask.inhale_efficiency(), expected_inhale_efficiency) - npt.assert_equal(measuredmask.inhale_efficiency(), - expected_inhale_efficiency) @pytest.mark.parametrize( - "η_exhale, η_leaks, expected_exhale_efficiency_small, expected_exhale_efficiency_large", + "diameter, factor_exhale, expected_exhale_efficiency", [ - [0.95, 0.15, 0., 0.8075], - [np.array([0.95, 1.]), 0.15, np.zeros(2), np.array([0.8075, 0.85])], - [0.95, np.array([0.15, 0.]), np.zeros(2), np.array([0.8075, 0.95])], - [np.array([0.95, 1.]), np.array([0.15, 0.]), np.zeros(2), np.array([0.8075, 1.])], + [0.3e-4, 1., 0.], + [0.7e-4, 0.3, 0.56711*0.3], + [1.e-4, 1., 0.7149], + [4.e-4, 0.5, 0.8167*0.5], + [5.e-4, 0., 0.], ], ) -def test_mask_exhale(η_exhale, η_leaks, expected_exhale_efficiency_small, - expected_exhale_efficiency_large): - mask = models.Mask(η_inhale=0.3,η_exhale=η_exhale,η_leaks=η_leaks) - # we test one small and one large diameter (resp. 1 and 4 microns) - npt.assert_equal(mask.exhale_efficiency(1.e-4), - expected_exhale_efficiency_small) - npt.assert_equal(mask.exhale_efficiency(4.e-4), - expected_exhale_efficiency_large) - - -@pytest.mark.parametrize( - "diameter, expected_exhale_efficiency", - [ - [0.3e-4, 0.], - [0.7e-4, 0.56711], - [1.e-4, 0.7149], - [4.e-4, 0.8167], - ], -) -def test_measuredmask_exhale(diameter, expected_exhale_efficiency): - mask = models.MeasuredMask(η_inhale=0.3) +def test_mask_exhale(diameter, factor_exhale, expected_exhale_efficiency): + mask = models.Mask(η_inhale=0.3, factor_exhale=factor_exhale) npt.assert_almost_equal(mask.exhale_efficiency(diameter), expected_exhale_efficiency) - From d5fb86d694a9b7cb33fd6e305ce89b3e765feafc Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:28:51 +0200 Subject: [PATCH 26/32] Removing _MaskBase and old Mask class, which is replaced by MeasuredMask --- cara/models.py | 100 ++++++++++++++----------------------------------- 1 file changed, 28 insertions(+), 72 deletions(-) diff --git a/cara/models.py b/cara/models.py index a72f1b48..736e0992 100644 --- a/cara/models.py +++ b/cara/models.py @@ -472,61 +472,25 @@ Virus.types = { @dataclass(frozen=True) -class _MaskBase: - """ - Represents the filtration of aerosols by a mask, both inward and - outward. - The nature of the various mask models means that it is expected - for subclasses of _MaskBase to exist. - """ +class Mask: + #: Filtration efficiency of masks when inhaling. + η_inhale: _VectorisedFloat + + #: Global factor applied to filtration efficiency of masks when exhaling. + factor_exhale: _VectorisedFloat = 1. + #: Pre-populated examples of Masks. - types: typing.ClassVar[typing.Dict[str, "_MaskBase"]] + types: typing.ClassVar[typing.Dict[str, "Mask"]] def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: - # Overall exhale efficiency, including the effect of the leaks. - raise NotImplementedError("Subclass must implement") - - def inhale_efficiency(self) -> _VectorisedFloat: - # Overall inhale efficiency, including the effect of the leaks. - raise NotImplementedError("Subclass must implement") - - -@dataclass(frozen=True) -class Mask(_MaskBase): - #: Filtration efficiency. - η_exhale: _VectorisedFloat - - #: Leakage through side of masks. - η_leaks: _VectorisedFloat - - #: Filtration efficiency of masks when inhaling. - η_inhale: _VectorisedFloat - - def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: - # Overall efficiency with the effect of the leaks for aerosol emission - # Gammaitoni et al (1997). Diameter is in cm. - if diameter < 3e-4: - eta_out = 0. - else: - eta_out = self.η_exhale * (1 - self.η_leaks) - return eta_out - - def inhale_efficiency(self) -> _VectorisedFloat: - # Overall inhale efficiency, including the effect of the leaks. - return self.η_inhale - - -@dataclass(frozen=True) -class MeasuredMask(_MaskBase): - #: Filtration efficiency of masks when inhaling. - η_inhale: _VectorisedFloat - - def exhale_efficiency(self, diameter: float) -> _VectorisedFloat: - # 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. - # Diameter is in cm. + """ + 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. + Diameter is in cm. + """ if diameter < 0.5e-4: eta_out = 0. elif diameter < 0.94614e-4: @@ -535,29 +499,21 @@ class MeasuredMask(_MaskBase): eta_out = 0.0509 * diameter * 1e4 + 0.664 else: eta_out = 0.8167 - return eta_out + return eta_out*self.factor_exhale def inhale_efficiency(self) -> _VectorisedFloat: - # Overall inhale efficiency, including the effect of the leaks. + """ + Overall inhale efficiency, including the effect of the leaks. + """ return self.η_inhale -_MaskBase.types = { - 'No mask': Mask(0, 0, 0), +Mask.types = { + 'No mask': Mask(0, 0), 'Type I': Mask( - η_exhale=0.95, - η_leaks=0.15, # (Huang 2007) - η_inhale=0.3, # (Browen 2010) - ), - 'FFP2': Mask( - η_exhale=0.95, # (same outward effect as type 1 - Asadi 2020) - η_leaks=0.15, # (same outward effect as type 1 - Asadi 2020) - η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) - ), - 'Type I measured': MeasuredMask( η_inhale=0.5, # (CERN-OPEN-2021-004) ), - 'FFP2 measured': MeasuredMask( + 'FFP2': Mask( η_inhale=0.865, # (94% penetration efficiency + 8% max inward leakage -> EN 149) ), } @@ -569,10 +525,10 @@ class _ExpirationBase: Represents the expiration of aerosols by a person. Subclasses of _ExpirationBase represent different models. """ - #: Pre-populated examples of Masks. + #: Pre-populated examples of Expirations. types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): # total volume of aerosols expired per volume of air (mL/cm^3). raise NotImplementedError("Subclass must implement") @@ -589,7 +545,7 @@ class Expiration(_ExpirationBase): ejection_factor: typing.Tuple[float, ...] particle_sizes: typing.Tuple[float, ...] = (0.8e-4, 1.8e-4, 3.5e-4, 5.5e-4) # In cm. - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): def volume(diameter): return (4 * np.pi * (diameter/2)**3) / 3 total = 0 @@ -617,7 +573,7 @@ class MultipleExpiration(_ExpirationBase): raise ValueError("expirations and weigths should contain the" "same number of elements") - def aerosols(self, mask: _MaskBase): + def aerosols(self, mask: Mask): return np.array([ weight * expiration.aerosols(mask) / sum(self.weights) for weight,expiration in zip(self.weights,self.expirations) @@ -665,7 +621,7 @@ class Population: presence: Interval #: The kind of mask being worn by the people. - mask: _MaskBase + mask: Mask #: The physical activity being carried out by the people. activity: Activity From 8305144b1d31dbcd1f2b369f5a6277cdc0544ef3 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:30:25 +0200 Subject: [PATCH 27/32] Modifying infected populations tests and concentration tests, according to the new Mask class --- cara/tests/models/test_concentration_model.py | 12 +++++------- cara/tests/test_infected_population.py | 9 +++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/cara/tests/models/test_concentration_model.py b/cara/tests/models/test_concentration_model.py index 48ef0b66..f7230430 100644 --- a/cara/tests/models/test_concentration_model.py +++ b/cara/tests/models/test_concentration_model.py @@ -1,6 +1,7 @@ import re import numpy as np +import numpy.testing as npt import pytest from cara import models @@ -13,8 +14,7 @@ from cara import models {'air_change': np.array([100, 120])}, {'viral_load_in_sputum': np.array([5e8, 1e9])}, {'quantum_infectious_dose': np.array([50, 20])}, - {'η_exhale': np.array([0.92, 0.95])}, - {'η_leaks': np.array([0.15, 0.20])}, + {'factor_exhale': np.array([0.92, 0.95])}, ] ) def test_concentration_model_vectorisation(override_params): @@ -24,8 +24,7 @@ def test_concentration_model_vectorisation(override_params): 'air_change': 100, 'viral_load_in_sputum': 1e9, 'quantum_infectious_dose': 50, - 'η_exhale': 0.95, - 'η_leaks': 0.15, + 'factor_exhale': 0.95, } defaults.update(override_params) @@ -37,8 +36,7 @@ def test_concentration_model_vectorisation(override_params): number=1, presence=always, mask=models.Mask( - η_exhale=defaults['η_exhale'], - η_leaks=defaults['η_leaks'], + factor_exhale=defaults['factor_exhale'], η_inhale=0.3, ), activity=models.Activity( @@ -128,4 +126,4 @@ def test_integrated_concentration(simple_conc_model): c2 = simple_conc_model.integrated_concentration(0, 1) c3 = simple_conc_model.integrated_concentration(1, 2) assert c1 != 0 - assert c1 == c2 + c3 + npt.assert_almost_equal(c1, c2 + c3, decimal=15) diff --git a/cara/tests/test_infected_population.py b/cara/tests/test_infected_population.py index e87ddb20..8ef94729 100644 --- a/cara/tests/test_infected_population.py +++ b/cara/tests/test_infected_population.py @@ -8,8 +8,7 @@ import cara.models "override_params", [ {'viral_load_in_sputum': np.array([5e8, 1e9])}, {'quantum_infectious_dose': np.array([50, 20])}, - {'η_exhale': np.array([0.92, 0.95])}, - {'η_leaks': np.array([0.15, 0.20])}, + {'factor_exhale': np.array([0.92, 0.95])}, {'exhalation_rate': np.array([0.75, 0.81])}, ] ) @@ -17,8 +16,7 @@ def test_infected_population_vectorisation(override_params): defaults = { 'viral_load_in_sputum': 1e9, 'quantum_infectious_dose': 50, - 'η_exhale': 0.95, - 'η_leaks': 0.15, + 'factor_exhale': 0.95, 'exhalation_rate': 0.75, } defaults.update(override_params) @@ -28,8 +26,7 @@ def test_infected_population_vectorisation(override_params): number=1, presence=office_hours, mask=cara.models.Mask( - η_exhale=defaults['η_exhale'], - η_leaks=defaults['η_leaks'], + factor_exhale=defaults['factor_exhale'], η_inhale=0.3, ), activity=cara.models.Activity( From 58e40eff273a637f844b2d291217d5310c65c4bc Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:31:13 +0200 Subject: [PATCH 28/32] Propagating the change in Mask class to the calculator and expoert apps --- cara/apps/calculator/model_generator.py | 4 ++-- cara/apps/expert.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 0d7506ae..ad65795e 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -269,10 +269,10 @@ class FormData: else: return ventilation - def mask(self) -> models._MaskBase: + def mask(self) -> models.Mask: # Initializes the mask type if mask wearing is "continuous", otherwise instantiates the mask attribute as # the "No mask"-mask - mask = models._MaskBase.types[self.mask_type if self.mask_wearing_option == "mask_on" else 'No mask'] + mask = models.Mask.types[self.mask_type if self.mask_wearing_option == "mask_on" else 'No mask'] return mask def infected_population(self) -> models.InfectedPopulation: diff --git a/cara/apps/expert.py b/cara/apps/expert.py index a0f273e1..31903b79 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -390,10 +390,10 @@ class ModelWidgets(View): def _build_mask(self, node): mask = node.dcs_instance() - for name, mask_ in models._MaskBase.types.items(): + for name, mask_ in models.Mask.types.items(): if mask == mask_: break - mask_choice = widgets.Select(options=list(models._MaskBase.types.keys()), value=name) + mask_choice = widgets.Select(options=list(models.Mask.types.keys()), value=name) def on_mask_change(change): node.dcs_select(change['new']) @@ -496,7 +496,7 @@ baseline_model = models.ExposureModel( number=1, virus=models.Virus.types['SARS_CoV_2'], presence=models.SpecificInterval(((8, 12), (13, 17))), - mask=models._MaskBase.types['No mask'], + mask=models.Mask.types['No mask'], activity=models.Activity.types['Seated'], expiration=models.Expiration.types['Talking'], ), @@ -505,7 +505,7 @@ baseline_model = models.ExposureModel( number=10, presence=models.SpecificInterval(((8, 12), (13, 17))), activity=models.Activity.types['Seated'], - mask=models._MaskBase.types['No mask'], + mask=models.Mask.types['No mask'], ), ) @@ -515,10 +515,10 @@ class CARAStateBuilder(state.StateBuilder): # For example, build_type__VentilationBase is called when dealing with ConcentrationModel # types as it has a ventilation: _VentilationBase field. - def build_type__MaskBase(self, _: dataclasses.Field): + def build_type_Mask(self, _: dataclasses.Field): return state.DataclassStatePredefined( - models._MaskBase, - choices=models._MaskBase.types, + models.Mask, + choices=models.Mask.types, ) def build_type_Virus(self, _: dataclasses.Field): From cb1b31c820d2ba1a676394fd6f01f8d6766ab07b Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Sun, 30 May 2021 19:31:56 +0200 Subject: [PATCH 29/32] Starting to modify exposure tests according to new Mask class --- cara/tests/models/test_exposure_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index 464f25a9..c1e9090d 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -43,7 +43,7 @@ populations = [ ), # A population with some array component for η_inhale. models.Population( - 10, halftime, models.Mask(0.95, 0.15, np.array([0.3, 0.35])), + 10, halftime, models.Mask(np.array([0.3, 0.35])), models.Activity.types['Standing'], ), # A population with some array component for inhalation_rate. From 421495241e47e704614a0180f4f97ec61c0a69bd Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Mon, 31 May 2021 06:32:17 +0200 Subject: [PATCH 30/32] Fixing the remaining tests, with the new masks --- cara/tests/models/test_exposure_model.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index c1e9090d..009cf092 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -60,10 +60,10 @@ populations = [ np.array([14.4, 14.4]), np.array([99.6803184113, 99.5181053773])], [populations[2], KnownConcentrations(lambda t: 1.2), - np.array([14.4, 14.4]), np.array([99.4146994564, 99.6803184113])], + np.array([14.4, 14.4]), np.array([97.4574432074, 98.3493482895])], [populations[0], KnownConcentrations(lambda t: np.array([1.2, 2.4])), - np.array([14.4, 28.8]), np.array([99.6803184113, 99.9989780368])], + np.array([14.4, 28.8]), np.array([98.3493482895, 99.9727534893])], [populations[1], KnownConcentrations(lambda t: np.array([1.2, 2.4])), np.array([14.4, 28.8]), np.array([99.6803184113, 99.9976777757])], @@ -123,22 +123,22 @@ def conc_model(): models.InfectedPopulation( number=1, presence=interesting_times, - mask=models.Mask.types['Type I'], + mask=models.Mask.types['No mask'], activity=models.Activity.types['Seated'], virus=models.Virus.types['SARS_CoV_2'], - expiration=models.Expiration.types['Breathing'], + expiration=models.Expiration.types['Superspreading event'], ) ) # expected quanta were computed with a trapezoidal integration, using # a mesh of 10'000 pts per exposed presence interval. @pytest.mark.parametrize("exposed_time_interval, expected_quanta", [ - [(0, 1), 0.0055680845], - [(1, 1.01), 6.4960491e-05], - [(1.01, 1.02), 6.3187723e-05], - [(12, 12.01), 1.9307359e-06], - [(12, 24), 0.079347465], - [(0, 24), 0.086122050], + [(0, 1), 5.4869151], + [(1, 1.01), 0.064013521], + [(1.01, 1.02), 0.062266596], + [(12, 12.01), 0.0019025904], + [(12, 24), 78.190763], + [(0, 24), 84.866592], ] ) def test_exposure_model_integral_accuracy(exposed_time_interval, From be5aea72f0d288d663847e5a48002687fee39ac7 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Mon, 31 May 2021 06:55:07 +0200 Subject: [PATCH 31/32] Mask exhale_efficiency use now a diameter in microns --- cara/models.py | 14 +++++++------- cara/tests/models/test_mask.py | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cara/models.py b/cara/models.py index 736e0992..56f663f4 100644 --- a/cara/models.py +++ b/cara/models.py @@ -489,14 +489,14 @@ class Mask: therein (Asadi 2020). Obtained from measurements of filtration efficiency and of the leakage through the sides. - Diameter is in cm. + Diameter is in microns. """ - if diameter < 0.5e-4: + if diameter < 0.5: eta_out = 0. - elif diameter < 0.94614e-4: - eta_out = 0.5893 * diameter * 1e4 + 0.1546 - elif diameter < 3e-4: - eta_out = 0.0509 * diameter * 1e4 + 0.664 + elif diameter < 0.94614: + eta_out = 0.5893 * diameter + 0.1546 + elif diameter < 3.: + eta_out = 0.0509 * diameter + 0.664 else: eta_out = 0.8167 return eta_out*self.factor_exhale @@ -551,7 +551,7 @@ class Expiration(_ExpirationBase): total = 0 for diameter, factor in zip(self.particle_sizes, self.ejection_factor): contribution = (volume(diameter) * factor * - (1 - mask.exhale_efficiency(diameter))) + (1 - mask.exhale_efficiency(diameter*1e4))) total += contribution return total diff --git a/cara/tests/models/test_mask.py b/cara/tests/models/test_mask.py index 7f7cb242..4a71d13c 100644 --- a/cara/tests/models/test_mask.py +++ b/cara/tests/models/test_mask.py @@ -11,7 +11,7 @@ from cara import models [np.array([0.3, 0.5]), np.array([0.3, 0.5])], ], ) -def test_masks_inhale(η_inhale, expected_inhale_efficiency): +def test_mask_inhale(η_inhale, expected_inhale_efficiency): mask = models.Mask(η_inhale=η_inhale) npt.assert_equal(mask.inhale_efficiency(), expected_inhale_efficiency) @@ -20,11 +20,11 @@ def test_masks_inhale(η_inhale, expected_inhale_efficiency): @pytest.mark.parametrize( "diameter, factor_exhale, expected_exhale_efficiency", [ - [0.3e-4, 1., 0.], - [0.7e-4, 0.3, 0.56711*0.3], - [1.e-4, 1., 0.7149], - [4.e-4, 0.5, 0.8167*0.5], - [5.e-4, 0., 0.], + [0.3, 1., 0.], + [0.7, 0.3, 0.56711*0.3], + [1., 1., 0.7149], + [4., 0.5, 0.8167*0.5], + [5., 0., 0.], ], ) def test_mask_exhale(diameter, factor_exhale, expected_exhale_efficiency): From a61e900178faa46b331490dd1546c3b05ff01e77 Mon Sep 17 00:00:00 2001 From: Nicolas Mounet Date: Mon, 31 May 2021 08:55:55 +0200 Subject: [PATCH 32/32] Fixing docstring --- cara/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/models.py b/cara/models.py index 56f663f4..88e5493a 100644 --- a/cara/models.py +++ b/cara/models.py @@ -529,7 +529,7 @@ class _ExpirationBase: types: typing.ClassVar[typing.Dict[str, "_ExpirationBase"]] def aerosols(self, mask: Mask): - # total volume of aerosols expired per volume of air (mL/cm^3). + # total volume of aerosols expired (cm^3). raise NotImplementedError("Subclass must implement")