diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 006ec25c..fb34392d 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -53,6 +53,7 @@ class FormData: calculator_version: str opening_distance: float event_month: str + room_heating_option: bool room_number: str room_volume: float simulation_name: str @@ -100,6 +101,7 @@ class FormData: 'mask_wearing_option': 'mask_off', 'mechanical_ventilation_type': 'not-applicable', 'opening_distance': 0., + 'room_heating_option': False, 'room_number': _NO_DEFAULT, 'room_volume': 0., 'simulation_name': _NO_DEFAULT, @@ -573,7 +575,11 @@ def model_from_form(form: FormData) -> models.ExposureModel: volume = form.room_volume else: volume = form.floor_area * form.ceiling_height - room = models.Room(volume=volume) + if form.room_heating_option: + humidity = 0.3 + else: + humidity = 0.5 + room = models.Room(volume=volume, humidity=humidity) # Initializes and returns a model with the attributes defined above return models.ExposureModel( @@ -618,6 +624,7 @@ def baseline_raw_form_data(): 'calculator_version': calculator.__version__, 'opening_distance': '0.2', 'event_month': 'January', + 'room_heating_option': '0', 'room_number': '123', 'room_volume': '75', 'simulation_name': 'Test', diff --git a/cara/apps/calculator/templates/base/calculator.report.html.j2 b/cara/apps/calculator/templates/base/calculator.report.html.j2 index 27bdacf9..9b618988 100644 --- a/cara/apps/calculator/templates/base/calculator.report.html.j2 +++ b/cara/apps/calculator/templates/base/calculator.report.html.j2 @@ -46,6 +46,7 @@ {% endif %}

  • Room Volume: {{ model.concentration_model.room.volume }} m³

  • +
  • Room Central Heating: {{ "On" if form.room_heating_option else "Off" }}

  • Ventilation data:

    diff --git a/cara/apps/calculator/templates/calculator.form.html.j2 b/cara/apps/calculator/templates/calculator.form.html.j2 index 254ea53c..b03dc47d 100644 --- a/cara/apps/calculator/templates/calculator.form.html.j2 +++ b/cara/apps/calculator/templates/calculator.form.html.j2 @@ -69,9 +69,10 @@ v{{ calculator_version }} Please sen
    Room data: -
    +
    ?

    +
    @@ -93,7 +94,13 @@ v{{ calculator_version }} Please sen         
    -
    +

    + + Central heating system in use: + +    + +   
    diff --git a/cara/apps/calculator/templates/userguide.html.j2 b/cara/apps/calculator/templates/userguide.html.j2 index 6407e503..013a1763 100644 --- a/cara/apps/calculator/templates/userguide.html.j2 +++ b/cara/apps/calculator/templates/userguide.html.j2 @@ -72,6 +72,8 @@ However, this value may be revised in the future as more studies of P.1 transmis

    Room Data

    Please enter either the room volume (in m³) or both the floor area (m²) and the room height (m). This information is available via GIS Portal (https://gis.cern.ch/gisportal/).

    +

    Room heating system

    +

    The use of central heating (e.g. radiators) reduces relative humidity of the indoor air, which can decrease the decay rate of viral infectivity. If your space is heated with such water radiators, select 'Yes'. If your space does not have such heating, or they are not in use in the period of the simulation (e.g. summer), select 'No'.

    Ventilation type

    There are three main options:

    Mechanical ventilation

    diff --git a/cara/models.py b/cara/models.py index 28841f40..d17b962b 100644 --- a/cara/models.py +++ b/cara/models.py @@ -60,6 +60,9 @@ class Room: #: The total volume of the room volume: _VectorisedFloat + #: The humidity in the room (from 0 to 1 - e.g. 0.5 is 50% humidity) + humidity: _VectorisedFloat=0.5 + Time_t = typing.TypeVar('Time_t', float, int) BoundaryPair_t = typing.Tuple[Time_t, Time_t] @@ -413,9 +416,6 @@ class AirChange(Ventilation): @dataclass(frozen=True) class Virus: - #: Biological decay (inactivation of the virus in air) - halflife: _VectorisedFloat - #: RNA copies / mL viral_load_in_sputum: _VectorisedFloat @@ -425,29 +425,45 @@ class Virus: #: Pre-populated examples of Viruses. types: typing.ClassVar[typing.Dict[str, "Virus"]] - @property - def decay_constant(self) -> _VectorisedFloat: - # Viral inactivation per hour (h^-1) - return np.log(2) / self.halflife + def halflife(self, humidity: _VectorisedFloat) -> _VectorisedFloat: + # Biological decay (inactivation of the virus in air) - virus + # dependent and function of humidity + raise NotImplementedError + + def decay_constant(self, humidity: _VectorisedFloat) -> _VectorisedFloat: + # Viral inactivation per hour (h^-1) (function of humidity) + return np.log(2) / self.halflife(humidity) + + +@dataclass(frozen=True) +class SARSCoV2(Virus): + + def halflife(self, humidity: _VectorisedFloat) -> _VectorisedFloat: + """ + Half-life changes with humidity level. Here is implemented a simple + piecewise constant model (for more details see A. Henriques et al, + CERN-OPEN-2021-004, DOI: 10.17181/CERN.1GDQ.5Y75) + """ + halflife = np.empty_like(humidity) + halflife[humidity <= 0.4] = 3.8 + halflife[humidity > 0.4] = 1.1 + return halflife Virus.types = { - 'SARS_CoV_2': Virus( - halflife=1.1, + 'SARS_CoV_2': SARSCoV2( viral_load_in_sputum=1e9, # No data on coefficient for SARS-CoV-2 yet. # It is somewhere between 1000 or 100 SARS-CoV viruses to have # a 50% chance to cause infection. quantum_infectious_dose=50., ), - 'SARS_CoV_2_B117': Virus( + 'SARS_CoV_2_B117': SARSCoV2( # also called VOC-202012/01 - halflife=1.1, viral_load_in_sputum=1e9, quantum_infectious_dose=30., ), - 'SARS_CoV_2_P1': Virus( - halflife=1.1, + 'SARS_CoV_2_P1': SARSCoV2( viral_load_in_sputum=1e9, quantum_infectious_dose=1/0.045, ), @@ -640,9 +656,8 @@ class ConcentrationModel: # Deposition rate (h^-1) k = (vg * 3600) / h - return k + self.virus.decay_constant + self.ventilation.air_exchange( - self.room, time - ) + return k + self.virus.decay_constant(self.room.humidity + ) + self.ventilation.air_exchange(self.room, time) @cached() def _concentration_limit(self, time: float) -> _VectorisedFloat: diff --git a/cara/tests/models/test_concentration_model.py b/cara/tests/models/test_concentration_model.py index 03a042d6..48ef0b66 100644 --- a/cara/tests/models/test_concentration_model.py +++ b/cara/tests/models/test_concentration_model.py @@ -9,8 +9,8 @@ from cara import models @pytest.mark.parametrize( "override_params", [ {'volume': np.array([100, 120])}, + {'humidity': np.array([0.5, 0.4])}, {'air_change': np.array([100, 120])}, - {'virus_halflife': np.array([1.1, 1.5])}, {'viral_load_in_sputum': np.array([5e8, 1e9])}, {'quantum_infectious_dose': np.array([50, 20])}, {'η_exhale': np.array([0.92, 0.95])}, @@ -20,8 +20,8 @@ from cara import models def test_concentration_model_vectorisation(override_params): defaults = { 'volume': 75, + 'humidity': 0.5, 'air_change': 100, - 'virus_halflife': 1.1, 'viral_load_in_sputum': 1e9, 'quantum_infectious_dose': 50, 'η_exhale': 0.95, @@ -31,7 +31,7 @@ def test_concentration_model_vectorisation(override_params): always = models.PeriodicInterval(240, 240) # TODO: This should be a thing on an interval. c_model = models.ConcentrationModel( - models.Room(defaults['volume']), + models.Room(defaults['volume'], defaults['humidity']), models.AirChange(always, defaults['air_change']), models.InfectedPopulation( number=1, @@ -45,8 +45,7 @@ def test_concentration_model_vectorisation(override_params): 0.51, 0.75, ), - virus=models.Virus( - halflife=defaults['virus_halflife'], + virus=models.SARSCoV2( viral_load_in_sputum=defaults['viral_load_in_sputum'], quantum_infectious_dose=defaults['quantum_infectious_dose'], ), diff --git a/cara/tests/test_infected_population.py b/cara/tests/test_infected_population.py index c237e251..e87ddb20 100644 --- a/cara/tests/test_infected_population.py +++ b/cara/tests/test_infected_population.py @@ -15,7 +15,6 @@ import cara.models ) def test_infected_population_vectorisation(override_params): defaults = { - 'virus_halflife': 1.1, 'viral_load_in_sputum': 1e9, 'quantum_infectious_dose': 50, 'η_exhale': 0.95, @@ -38,7 +37,6 @@ def test_infected_population_vectorisation(override_params): defaults['exhalation_rate'], ), virus=cara.models.Virus( - halflife=defaults['virus_halflife'], viral_load_in_sputum=defaults['viral_load_in_sputum'], quantum_infectious_dose=defaults['quantum_infectious_dose'], ),