Skip to content

Commit

Permalink
Added dynamic groups in backend and propagated required changes to re…
Browse files Browse the repository at this point in the history
…quests

- Correction of typos

- Changes do model to accommodate group of exposure models

- Handled virus validator to accommodate group of exposure models

- Modifications to accomodate generation of results for exposure model group

- Added data registry attribute to exposuremodelgroups class

- Fixed test for dynamic models

- Defined ExposureModelGroup with required methods

- Adapted virus report data to generate results for groups of exposed population

- Build same distributions for different models

- Fixed bug with defaults

- Handled definition of a single ExposureModel root obj when only one group is defined

- Added short-range expirations per group

- Added full validation on short_range interactions with dynamic exposure model

- Added full set of tests

- Updated docstrings

- Added support for number of people in exposed population, which should be identical within each group

- Added type checks

- Added UI adjustments for expected new cases
  • Loading branch information
lrdossan committed Nov 26, 2024
1 parent 50edc1a commit 3b85bde
Show file tree
Hide file tree
Showing 15 changed files with 991 additions and 455 deletions.
2 changes: 0 additions & 2 deletions caimira/setup.cfg

This file was deleted.

113 changes: 83 additions & 30 deletions caimira/src/caimira/calculator/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ class Activity:
@dataclass(frozen=True)
class SimplePopulation:
"""
Represents a group of people all with exactly the same behaviour and
Represents a group of people all with exactly the same behavior and
situation.
"""
Expand Down Expand Up @@ -844,7 +844,7 @@ def people_present(self, time: float):
@dataclass(frozen=True)
class Population(SimplePopulation):
"""
Represents a group of people all with exactly the same behaviour and
Represents a group of people all with exactly the same behavior and
situation, considering the usage of mask and a certain host immunity.
"""
Expand Down Expand Up @@ -1324,6 +1324,9 @@ class ShortRangeModel:
#: Interpersonal distances
distance: _VectorisedFloat

#: Expiration definition
expiration_def: typing.Optional[str] = None

def dilution_factor(self) -> _VectorisedFloat:
'''
The dilution factor for the respective expiratory activity type.
Expand Down Expand Up @@ -1653,6 +1656,9 @@ def __post_init__(self):
In other words, the air exchange rate from the
ventilation, and the virus decay constant, must
not be given as arrays.
It also checks that the number of exposed is
static during the simulation time.
"""
c_model = self.concentration_model
# Check if the diameter is vectorised.
Expand All @@ -1663,6 +1669,11 @@ def __post_init__(self):
c_model.ventilation.air_exchange(c_model.room, time)) for time in c_model.state_change_times()))):
raise ValueError("If the diameter is an array, none of the ventilation parameters "
"or virus decay constant can be arrays at the same time.")

# Check if exposed population is static
if not isinstance(self.exposed.number, int) or not isinstance(self.exposed.presence, Interval):
raise TypeError("The exposed number must be an int and presence an Interval. "
f"Got {type(self.exposed.number)} and {type(self.exposed.presence)}.")

@method_cache
def population_state_change_times(self) -> typing.List[float]:
Expand Down Expand Up @@ -1809,11 +1820,9 @@ def _deposited_exposure_list(self):
The number of virus per m^3 deposited on the respiratory tract.
"""
population_change_times = self.population_state_change_times()

deposited_exposure = []
for start, stop in zip(population_change_times[:-1], population_change_times[1:]):
deposited_exposure.append(self.deposited_exposure_between_bounds(start, stop))

return deposited_exposure

def deposited_exposure(self) -> _VectorisedFloat:
Expand All @@ -1838,18 +1847,17 @@ def infection_probability(self) -> _VectorisedFloat:
return (1 - np.prod([1 - prob for prob in self._infection_probability_list()], axis = 0)) * 100

def total_probability_rule(self) -> _VectorisedFloat:
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
isinstance(self.exposed.number, IntPiecewiseConstant)):
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant)):
raise NotImplementedError("Cannot compute total probability "
"(including incidence rate) with dynamic occupancy")

if (self.geographical_data.geographic_population != 0 and self.geographical_data.geographic_cases != 0):
sum_probability = 0.0

# Create an equivalent exposure model but changing the number of infected cases.
total_people = self.concentration_model.infected.number + self.exposed.number
total_people = self.concentration_model.infected.number + self.exposed.number # type: ignore
max_num_infected = (total_people if total_people < 10 else 10)
# The influence of a higher number of simultainious infected people (> 4 - 5) yields an almost negligible contirbution to the total probability.
# The influence of a higher number of simultaneous infected people (> 4 - 5) yields an almost negligible contribution to the total probability.
# To be on the safe side, a hard coded limit with a safety margin of 2x was set.
# Therefore we decided a hard limit of 10 infected people.
for num_infected in range(1, max_num_infected + 1):
Expand All @@ -1872,43 +1880,88 @@ def expected_new_cases(self) -> _VectorisedFloat:
1) Long-range exposure: take the infection_probability and multiply by the occupants exposed to long-range.
2) Short- and long-range exposure: take the infection_probability of long-range multiplied by the occupants exposed to long-range only,
plus the infection_probability of short- and long-range multiplied by the occupants exposed to short-range only.
Currently disabled when dynamic occupancy is defined for the exposed population.
"""

if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
isinstance(self.exposed.number, IntPiecewiseConstant)):
raise NotImplementedError("Cannot compute expected new cases "
"with dynamic occupancy")

number = self.exposed.number
if self.short_range != ():
new_cases_long_range = nested_replace(self, {'short_range': [],}).infection_probability() * (self.exposed.number - self.exposed_to_short_range)
new_cases_long_range = nested_replace(self, {'short_range': [],}).infection_probability() * (number - self.exposed_to_short_range) # type: ignore
return (new_cases_long_range + (self.infection_probability() * self.exposed_to_short_range)) / 100

return self.infection_probability() * self.exposed.number / 100
return self.infection_probability() * number / 100

def reproduction_number(self) -> _VectorisedFloat:
"""
The reproduction number can be thought of as the expected number of
cases directly generated by one infected case in a population.
Currently disabled when dynamic occupancy is defined for both the infected and exposed population.
"""

if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
isinstance(self.exposed.number, IntPiecewiseConstant)):
raise NotImplementedError("Cannot compute reproduction number "
"with dynamic occupancy")

if self.concentration_model.infected.number == 1:
infected_population: InfectedPopulation = self.concentration_model.infected
if isinstance(infected_population.number, int) and infected_population.number == 1:
return self.expected_new_cases()

# Create an equivalent exposure model but with precisely
# one infected case.
# one infected case, respecting the presence interval.
single_exposure_model = nested_replace(
self, {
'concentration_model.infected.number': 1}
'concentration_model.infected.number': 1,
'concentration_model.infected.presence': infected_population.presence_interval(),
}
)

return single_exposure_model.expected_new_cases()


@dataclass(frozen=True)
class ExposureModelGroup:
"""
Represents a group of exposure models. This is to handle the case
when different groups of people come and go in the room at different
times. These groups are then handled fully independently, with
exposure dose and probability of infection defined for each of them.
"""
data_registry: DataRegistry

#: The set of exposure models for each exposed population
exposure_models: typing.Tuple[ExposureModel, ...]

@method_cache
def _deposited_exposure_list(self) -> typing.List[_VectorisedFloat]:
"""
List of doses absorbed by each member of the groups.
"""
return [model.deposited_exposure() for model in self.exposure_models]

@method_cache
def _infection_probability_list(self):
"""
List of the probability of infection for each group.
"""
return [model.infection_probability() for model in self.exposure_models] # type: ignore

def expected_new_cases(self) -> _VectorisedFloat:
"""
Final expected number of new cases considering the
contribution of each individual probability of infection.
"""
return np.sum([model.expected_new_cases() for model in self.exposure_models], axis=0) # type: ignore

def reproduction_number(self) -> _VectorisedFloat:
"""
Reproduction number considering the contribution
of each individual probability of infection and
a single infected occupant.
"""
single_exposure_models = []
for model in self.exposure_models:
if model.concentration_model.infected.number != 1:
model = nested_replace(
self, {
'model.concentration_model.infected.number': 1
}
)
single_exposure_models.append(model)

single_exposure_model_group = nested_replace(
self, {
'exposure_models': single_exposure_models,
}
)
return single_exposure_model_group.expected_new_cases()

Loading

0 comments on commit 3b85bde

Please sign in to comment.