diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py index fe1898eb..4072648c 100644 --- a/caimira/apps/calculator/__init__.py +++ b/caimira/apps/calculator/__init__.py @@ -33,7 +33,7 @@ from .user import AuthenticatedUser, AnonymousUser # calculator version. If the calculator needs to make breaking changes (e.g. change # form attributes) then it can also increase its MAJOR version without needing to # increase the overall CAiMIRA version (found at ``caimira.__version__``). -__version__ = "4.3" +__version__ = "4.4" class BaseRequestHandler(RequestHandler): diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index 290d22d3..dfac768d 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -57,6 +57,10 @@ class FormData: location_name: str location_latitude: float location_longitude: float + geographic_population: int + geographic_cases: int + ascertainment_bias: str + p_recurrent_option: str mask_type: str mask_wearing_option: str mechanical_ventilation_type: str @@ -116,6 +120,10 @@ class FormData: 'location_latitude': _NO_DEFAULT, 'location_longitude': _NO_DEFAULT, 'location_name': _NO_DEFAULT, + 'geographic_population': 0, + 'geographic_cases': 0, + 'ascertainment_bias': 'confidence_low', + 'p_recurrent_option': 'p_recurrent_event', 'mask_type': 'Type I', 'mask_wearing_option': 'mask_off', 'mechanical_ventilation_type': 'not-applicable', @@ -261,7 +269,9 @@ class FormData: ('volume_type', VOLUME_TYPES), ('window_opening_regime', WINDOWS_OPENING_REGIMES), ('window_type', WINDOWS_TYPES), - ('event_month', MONTH_NAMES)] + ('event_month', MONTH_NAMES), + ('ascertainment_bias', CONFIDENCE_LEVEL_OPTIONS),] + for attr_name, valid_set in validation_tuples: if getattr(self, attr_name) not in valid_set: raise ValueError(f"{getattr(self, attr_name)} is not a valid value for {attr_name}") @@ -329,6 +339,11 @@ class FormData: ), short_range = tuple(short_range), exposed=self.exposed_population(), + geographical_data=mc.Cases( + geographic_population=self.geographic_population, + geographic_cases=self.geographic_cases, + ascertainment_bias=CONFIDENCE_LEVEL_OPTIONS[self.ascertainment_bias], + ), ) def build_model(self, sample_size=_DEFAULT_MC_SAMPLE_SIZE) -> models.ExposureModel: @@ -759,6 +774,9 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]: 'location_latitude': 46.20833, 'location_longitude': 6.14275, 'location_name': 'Geneva', + 'geographic_population': 0, + 'geographic_cases': 0, + 'ascertainment_bias': 'confidence_low', 'mask_type': 'Type I', 'mask_wearing_option': 'mask_off', 'mechanical_ventilation_type': '', @@ -794,9 +812,8 @@ VIRUS_TYPES = {'SARS_CoV_2', 'SARS_CoV_2_ALPHA', 'SARS_CoV_2_BETA','SARS_CoV_2_G VOLUME_TYPES = {'room_volume_explicit', 'room_volume_from_dimensions'} WINDOWS_OPENING_REGIMES = {'windows_open_permanently', 'windows_open_periodically', 'not-applicable'} WINDOWS_TYPES = {'window_sliding', 'window_hinged', 'not-applicable'} - COFFEE_OPTIONS_INT = {'coffee_break_0': 0, 'coffee_break_1': 1, 'coffee_break_2': 2, 'coffee_break_4': 4} - +CONFIDENCE_LEVEL_OPTIONS = {'confidence_low': 10, 'confidence_medium': 5, 'confidence_high': 2} MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', diff --git a/caimira/apps/expert.py b/caimira/apps/expert.py index d3740bbf..361dc1c4 100644 --- a/caimira/apps/expert.py +++ b/caimira/apps/expert.py @@ -847,6 +847,7 @@ baseline_model = models.ExposureModel( mask=models.Mask.types['No mask'], host_immunity=0., ), + geographical_data=models.Cases(), ) diff --git a/caimira/models.py b/caimira/models.py index 0e6fec4c..f07b715c 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -37,6 +37,7 @@ import typing import numpy as np from scipy.interpolate import interp1d +import scipy.stats as sct if not typing.TYPE_CHECKING: from memoization import cached @@ -911,6 +912,33 @@ class InfectedPopulation(_PopulationWithVirus): return self.expiration.particle +@dataclass(frozen=True) +class Cases: + """ + The geographical data to calculate the probability of having at least 1 + new infection in a specific event. + """ + #: Geographic location population + geographic_population: int = 0 + + #: Geographic location new cases + geographic_cases: int = 0 + + #: Number of new cases confidence level + ascertainment_bias: int = 0 + + def probability_random_individual(self) -> _VectorisedFloat: + """Probability that a randomly selected individual in a focal population is infected.""" + return self.geographic_cases*self.ascertainment_bias/self.geographic_population + + def probability_meet_infected_person(self, event, x) -> _VectorisedFloat: + """ + Probability to meet x infected persons in an event. + From https://doi.org/10.1038/s41562-020-01000-9. + """ + return sct.binom.pmf(x, event, self.probability_random_individual()) + + @dataclass(frozen=True) class ConcentrationModel: room: Room @@ -1280,6 +1308,9 @@ class ExposureModel: #: The population of non-infected people to be used in the model. exposed: Population + #: Geographical data + geographical_data: Cases + #: The number of times the exposure event is repeated (default 1). repeats: int = 1 @@ -1435,6 +1466,26 @@ class ExposureModel: return (1 - np.exp(-((vD * (1 - self.exposed.host_immunity))/(infectious_dose * self.concentration_model.virus.transmissibility_factor)))) * 100 + def total_probability_rule(self) -> _VectorisedFloat: + if (self.geographical_data.geographic_population != 0 and self.geographical_data.geographic_cases != 0): + sum_probability = 0.0 + # Create an equivalent exposure model but with i infected cases + total_people = self.concentration_model.infected.number + self.exposed.number + X = (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. + # 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 x in range(1, X): + exposure_model = nested_replace( + self, {'concentration_model.infected.number': x} + ) + prob_exposed_occupant = exposure_model.infection_probability().mean() / 100 + # By means of a Binomial Distribution + sum_probability += (prob_exposed_occupant)*self.geographical_data.probability_meet_infected_person(self.exposed.number, x) + return sum_probability * 100 + else: + return 0 + def expected_new_cases(self) -> _VectorisedFloat: # Create an equivalent exposure model without short-range interactions, if any. if (len(self.short_range) == 0):