Skip to content

Commit 2192073

Browse files
Merge branch 'feature/dynamic_groups' into 'master'
Dynamic groups in backend See merge request caimira/caimira!510
2 parents c50f9b4 + f15fa1c commit 2192073

File tree

26 files changed

+1743
-979
lines changed

26 files changed

+1743
-979
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
# 4.18.0 (July 24, 2025)
2+
3+
## Feature Added
4+
- Dynamic occupancy: groups of exposed + infected may now be defined.
5+
Model and calculator updated, and tests added. Documentation still to
6+
be updated. User interface mostly unchanged (the new feature is not
7+
visible there).
8+
9+
## Bug Fixes
10+
- Fix in profiler to adapt to a non back-compatible change in pyinstrument
11+
package.
12+
- Type ignored in the expert app.
13+
14+
## Other
15+
- Update of mypy, pytest-mypy and pyinstrument dependencies (version).
16+
117
# 4.17.8 (March 13, 2025)
218

319
## Bug Fixes

caimira/pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "caimira"
7-
version = "4.17.8"
7+
version = "4.18.0"
88
description = "CAiMIRA - CERN Airborne Model for Indoor Risk Assessment"
99
readme = "README.md"
1010
license = { text = "Apache-2.0" }
@@ -23,7 +23,7 @@ dependencies = [
2323
"mistune",
2424
"numpy",
2525
"pandas",
26-
"pyinstrument",
26+
"pyinstrument >= 5.0.3",
2727
"python-dateutil",
2828
"requests",
2929
"retry",
@@ -39,8 +39,8 @@ dependencies = [
3939
dev = []
4040
test = [
4141
"pytest",
42-
"pytest-mypy >= 0.10.3",
43-
"mypy >= 1.0.0",
42+
"pytest-mypy >= 1.0.1",
43+
"mypy >= 1.17.0",
4444
"pytest-tornasync",
4545
"types-dataclasses",
4646
"types-python-dateutil",

caimira/src/caimira/api/controller/virus_report_controller.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ def submit_virus_form(form_data: typing.Dict, report_generation_parallelism: typ
3131
report_data: typing.Dict = generate_report(form_obj=form_obj, report_generation_parallelism=report_generation_parallelism)
3232

3333
# Handle model representation
34-
if report_data['model']: report_data['model'] = repr(report_data['model'])
34+
if report_data['model']:
35+
report_data['model'] = repr(report_data['model'])
36+
for single_group_output in report_data['groups'].values():
37+
del single_group_output['model'] # Model representation per group not needed
3538

3639
return report_data

caimira/src/caimira/calculator/models/models.py

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
800803
class 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)
845848
class 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

caimira/src/caimira/calculator/models/monte_carlo/data.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ def expiration_distribution(
365365
BLO_factors,
366366
d_min=0.1,
367367
d_max=30.,
368+
exp_type=None,
368369
):
369370
"""
370371
Returns an Expiration with an aerosol diameter distribution, defined
@@ -382,6 +383,7 @@ def expiration_distribution(
382383
kernel_bandwidth=0.1,
383384
),
384385
cn=BLOmodel(data_registry, BLO_factors).integrate(d_min, d_max),
386+
name=exp_type,
385387
)
386388

387389

@@ -432,7 +434,8 @@ def short_range_expiration_distributions(data_registry):
432434
data_registry=data_registry,
433435
BLO_factors=BLO_factors,
434436
d_min=param_evaluation(data_registry.expiration_particle['particle_size_range']['short_range'], 'minimum_diameter'),
435-
d_max=param_evaluation(data_registry.expiration_particle['particle_size_range']['short_range'], 'maximum_diameter')
437+
d_max=param_evaluation(data_registry.expiration_particle['particle_size_range']['short_range'], 'maximum_diameter'),
438+
exp_type=exp_type,
436439
)
437440
for exp_type, BLO_factors in expiration_BLO_factors(data_registry).items()
438441
}

caimira/src/caimira/calculator/models/monte_carlo/models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ def _build_mc_model(model: dataclass_instance) -> typing.Type[MCModelBase[_Model
7474
elif new_field.type == typing.Tuple[models.SpecificInterval, ...]:
7575
SI = getattr(sys.modules[__name__], "SpecificInterval")
7676
field_type = typing.Tuple[typing.Union[models.SpecificInterval, SI], ...]
77-
7877
elif new_field.type == typing.Union[int, models.IntPiecewiseConstant]:
7978
IPC = getattr(sys.modules[__name__], "IntPiecewiseConstant")
8079
field_type = typing.Union[int, models.IntPiecewiseConstant, IPC]

caimira/src/caimira/calculator/models/profiler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def from_str(value):
3131

3232

3333
class PyInstrumentWrapper:
34-
profiler = PyInstrumentProfiler(async_mode=True)
34+
profiler = PyInstrumentProfiler(async_mode='enabled')
3535

3636
@property
3737
def is_running(self):

0 commit comments

Comments
 (0)