@@ -666,7 +666,7 @@ def fraction_deposited(self, evaporation_factor: float=0.3) -> _VectorisedFloat:
666666 # deposition fraction depends on aerosol particle diameter.
667667 d = (self .diameter * evaporation_factor )
668668 IFrac = 1 - 0.5 * (1 - (1 / (1 + (0.00076 * (d ** 2.8 )))))
669- fdep = IFrac * (0.0587
669+ fdep = IFrac * (0.0587 # type: ignore
670670 + (0.911 / (1 + np .exp (4.77 + 1.485 * np .log (d ))))
671671 + (0.943 / (1 + np .exp (0.508 - 2.58 * np .log (d ))))) # type: ignore
672672 return fdep
@@ -711,6 +711,9 @@ class Expiration(_ExpirationBase):
711711 # to c_n,i in Eq. (4) of https://doi.org/10.1101/2021.10.14.21264988)
712712 cn : float = 1.
713713
714+ #: Expiration name
715+ name : typing .Optional [str ] = None
716+
714717 @property
715718 def particle (self ) -> Particle :
716719 """
@@ -799,7 +802,7 @@ class Activity:
799802@dataclass (frozen = True )
800803class SimplePopulation :
801804 """
802- Represents a group of people all with exactly the same behaviour and
805+ Represents a group of people all with exactly the same behavior and
803806 situation.
804807
805808 """
@@ -844,7 +847,7 @@ def people_present(self, time: float):
844847@dataclass (frozen = True )
845848class Population (SimplePopulation ):
846849 """
847- Represents a group of people all with exactly the same behaviour and
850+ Represents a group of people all with exactly the same behavior and
848851 situation, considering the usage of mask and a certain host immunity.
849852
850853 """
@@ -1313,7 +1316,7 @@ class ShortRangeModel:
13131316 data_registry : DataRegistry
13141317
13151318 #: Expiration type
1316- expiration : _ExpirationBase
1319+ expiration : Expiration
13171320
13181321 #: Activity type
13191322 activity : Activity
@@ -1639,6 +1642,9 @@ class ExposureModel:
16391642 #: Total people with short-range interactions
16401643 exposed_to_short_range : int = 0
16411644
1645+ #: Unique group identifier
1646+ identifier : str = 'group_1'
1647+
16421648 #: The number of times the exposure event is repeated (default 1).
16431649 @property
16441650 def repeats (self ) -> int :
@@ -1653,6 +1659,9 @@ def __post_init__(self):
16531659 In other words, the air exchange rate from the
16541660 ventilation, and the virus decay constant, must
16551661 not be given as arrays.
1662+
1663+ It also checks that the number of exposed is
1664+ static during the simulation time.
16561665 """
16571666 c_model = self .concentration_model
16581667 # Check if the diameter is vectorised.
@@ -1663,6 +1672,11 @@ def __post_init__(self):
16631672 c_model .ventilation .air_exchange (c_model .room , time )) for time in c_model .state_change_times ()))):
16641673 raise ValueError ("If the diameter is an array, none of the ventilation parameters "
16651674 "or virus decay constant can be arrays at the same time." )
1675+
1676+ # Check if exposed population is static
1677+ if not isinstance (self .exposed .number , int ) or not isinstance (self .exposed .presence , Interval ):
1678+ raise TypeError ("The exposed number must be an int and presence an Interval. "
1679+ f"Got { type (self .exposed .number )} and { type (self .exposed .presence )} ." )
16661680
16671681 @method_cache
16681682 def population_state_change_times (self ) -> typing .List [float ]:
@@ -1809,11 +1823,9 @@ def _deposited_exposure_list(self):
18091823 The number of virus per m^3 deposited on the respiratory tract.
18101824 """
18111825 population_change_times = self .population_state_change_times ()
1812-
18131826 deposited_exposure = []
18141827 for start , stop in zip (population_change_times [:- 1 ], population_change_times [1 :]):
18151828 deposited_exposure .append (self .deposited_exposure_between_bounds (start , stop ))
1816-
18171829 return deposited_exposure
18181830
18191831 def deposited_exposure (self ) -> _VectorisedFloat :
@@ -1838,18 +1850,17 @@ def infection_probability(self) -> _VectorisedFloat:
18381850 return (1 - np .prod ([1 - prob for prob in self ._infection_probability_list ()], axis = 0 )) * 100
18391851
18401852 def total_probability_rule (self ) -> _VectorisedFloat :
1841- if (isinstance (self .concentration_model .infected .number , IntPiecewiseConstant ) or
1842- isinstance (self .exposed .number , IntPiecewiseConstant )):
1853+ if (isinstance (self .concentration_model .infected .number , IntPiecewiseConstant )):
18431854 raise NotImplementedError ("Cannot compute total probability "
18441855 "(including incidence rate) with dynamic occupancy" )
18451856
18461857 if (self .geographical_data .geographic_population != 0 and self .geographical_data .geographic_cases != 0 ):
18471858 sum_probability = 0.0
18481859
18491860 # Create an equivalent exposure model but changing the number of infected cases.
1850- total_people = self .concentration_model .infected .number + self .exposed .number
1861+ total_people = self .concentration_model .infected .number + self .exposed .number # type: ignore
18511862 max_num_infected = (total_people if total_people < 10 else 10 )
1852- # The influence of a higher number of simultainious infected people (> 4 - 5) yields an almost negligible contirbution to the total probability.
1863+ # The influence of a higher number of simultaneous infected people (> 4 - 5) yields an almost negligible contribution to the total probability.
18531864 # To be on the safe side, a hard coded limit with a safety margin of 2x was set.
18541865 # Therefore we decided a hard limit of 10 infected people.
18551866 for num_infected in range (1 , max_num_infected + 1 ):
@@ -1872,43 +1883,81 @@ def expected_new_cases(self) -> _VectorisedFloat:
18721883 1) Long-range exposure: take the infection_probability and multiply by the occupants exposed to long-range.
18731884 2) Short- and long-range exposure: take the infection_probability of long-range multiplied by the occupants exposed to long-range only,
18741885 plus the infection_probability of short- and long-range multiplied by the occupants exposed to short-range only.
1875-
1876- Currently disabled when dynamic occupancy is defined for the exposed population.
18771886 """
1878-
1879- if (isinstance (self .concentration_model .infected .number , IntPiecewiseConstant ) or
1880- isinstance (self .exposed .number , IntPiecewiseConstant )):
1881- raise NotImplementedError ("Cannot compute expected new cases "
1882- "with dynamic occupancy" )
1883-
1887+ number = self .exposed .number
18841888 if self .short_range != ():
1885- new_cases_long_range = nested_replace (self , {'short_range' : [],}).infection_probability () * (self . exposed . number - self .exposed_to_short_range )
1889+ new_cases_long_range = nested_replace (self , {'short_range' : [],}).infection_probability () * (number - self .exposed_to_short_range ) # type: ignore
18861890 return (new_cases_long_range + (self .infection_probability () * self .exposed_to_short_range )) / 100
18871891
1888- return self .infection_probability () * self . exposed . number / 100
1892+ return self .infection_probability () * number / 100
18891893
18901894 def reproduction_number (self ) -> _VectorisedFloat :
18911895 """
18921896 The reproduction number can be thought of as the expected number of
18931897 cases directly generated by one infected case in a population.
1894-
1895- Currently disabled when dynamic occupancy is defined for both the infected and exposed population.
18961898 """
1897-
1898- if (isinstance (self .concentration_model .infected .number , IntPiecewiseConstant ) or
1899- isinstance (self .exposed .number , IntPiecewiseConstant )):
1900- raise NotImplementedError ("Cannot compute reproduction number "
1901- "with dynamic occupancy" )
1902-
1903- if self .concentration_model .infected .number == 1 :
1899+ infected_population : InfectedPopulation = self .concentration_model .infected
1900+ if isinstance (infected_population .number , int ) and infected_population .number == 1 :
19041901 return self .expected_new_cases ()
19051902
19061903 # Create an equivalent exposure model but with precisely
1907- # one infected case.
1904+ # one infected case, respecting the presence interval .
19081905 single_exposure_model = nested_replace (
19091906 self , {
1910- 'concentration_model.infected.number' : 1 }
1907+ 'concentration_model.infected.number' : 1 ,
1908+ 'concentration_model.infected.presence' : infected_population .presence_interval (),
1909+ }
19111910 )
1912-
19131911 return single_exposure_model .expected_new_cases ()
1914-
1912+
1913+
1914+ @dataclass (frozen = True )
1915+ class ExposureModelGroup :
1916+ """
1917+ Represents a group of exposure models. This is to handle the case
1918+ when different groups of people come and go in the room at different
1919+ times. These groups are then handled fully independently, with
1920+ exposure dose and probability of infection defined for each of them.
1921+ """
1922+ data_registry : DataRegistry
1923+
1924+ #: The set of exposure models for each exposed population
1925+ exposure_models : typing .Tuple [ExposureModel , ...]
1926+
1927+ def __post_init__ (self ):
1928+ """
1929+ Validate that all ExposureModels have the same ConcentrationModel.
1930+ """
1931+ first_concentration_model = self .exposure_models [0 ].concentration_model
1932+ for model in self .exposure_models [1 :]:
1933+ # Check that the number of infected people and their presence is the same
1934+ if (model .concentration_model .infected .number != first_concentration_model .infected .number or
1935+ model .concentration_model .infected .presence != first_concentration_model .infected .presence ):
1936+ raise ValueError ("All ExposureModels must have the same infected number and presence in the ConcentrationModel." )
1937+
1938+ @method_cache
1939+ def _deposited_exposure_list (self ) -> typing .List [_VectorisedFloat ]:
1940+ """
1941+ List of doses absorbed by each member of the groups.
1942+ """
1943+ return [model .deposited_exposure () for model in self .exposure_models ]
1944+
1945+ @method_cache
1946+ def _infection_probability_list (self ):
1947+ """
1948+ List of the probability of infection for each group.
1949+ """
1950+ return [model .infection_probability () for model in self .exposure_models ] # type: ignore
1951+
1952+ def expected_new_cases (self ) -> _VectorisedFloat :
1953+ """
1954+ Final expected number of new cases considering the
1955+ contribution of each individual probability of infection.
1956+ """
1957+ return np .sum ([model .expected_new_cases () for model in self .exposure_models ], axis = 0 ) # type: ignore
1958+
1959+ def reproduction_number (self ) -> _VectorisedFloat :
1960+ """
1961+ Expected number of cases when there is only one infected case.
1962+ """
1963+ return np .sum ([model .reproduction_number () for model in self .exposure_models ], axis = 0 ) # type: ignore
0 commit comments