From 5b578c15eff7061983c8da851b2dd105c36365e2 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 20 Sep 2022 16:00:25 +0200 Subject: [PATCH 01/15] backend model changes --- caimira/apps/calculator/__init__.py | 2 +- caimira/apps/calculator/model_generator.py | 23 ++++++++-- caimira/apps/expert.py | 1 + caimira/models.py | 51 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) 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): From 8936b0db48aa753d91ba80be889e9d523e684294 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Wed, 21 Sep 2022 09:14:33 +0200 Subject: [PATCH 02/15] added UI input fields and updated report for the new P(I) --- caimira/apps/calculator/report_generator.py | 16 ++++++- caimira/apps/calculator/static/js/form.js | 47 +++++++++++++++++++ .../templates/base/calculator.form.html.j2 | 30 ++++++++++++ .../templates/base/calculator.report.html.j2 | 47 ++++++++++++++++--- caimira/apps/templates/base/userguide.html.j2 | 17 ++++++- .../templates/cern/calculator.report.html.j2 | 5 ++ 6 files changed, 154 insertions(+), 8 deletions(-) diff --git a/caimira/apps/calculator/report_generator.py b/caimira/apps/calculator/report_generator.py index 1a7cdf02..7317f2b0 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/caimira/apps/calculator/report_generator.py @@ -131,6 +131,7 @@ def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing ]) prob = np.array(model.infection_probability()).mean() + prob_specific_event = np.array(model.total_probability_rule()).mean() er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean() exposed_occupants = model.exposed.number expected_new_cases = np.array(model.expected_new_cases()).mean() @@ -147,6 +148,7 @@ def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing "cumulative_doses": list(cumulative_doses), "long_range_cumulative_doses": list(long_range_cumulative_doses), "prob_inf": prob, + "prob_specific_event": prob_specific_event, "emission_rate": er, "exposed_occupants": exposed_occupants, "expected_new_cases": expected_new_cases, @@ -272,8 +274,13 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.Exp return scenarios -def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[float]): +def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[float], specific_event: bool): model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE) + if (specific_event): + # It means we have data to calculate the total_probability_rule + prob_specific_event = np.array(model.total_probability_rule()).mean() + else: + prob_specific_event = 0. return { 'probability_of_infection': np.mean(model.infection_probability()), @@ -282,6 +289,7 @@ def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[fl np.mean(model.concentration(time)) for time in sample_times ], + 'prob_specific_event': prob_specific_event, } @@ -303,11 +311,17 @@ def comparison_report( else: statistics = {} + if (form.short_range_option == "short_range_yes" and form.p_recurrent_option == "p_specific_event"): + specific_event = True + else: + specific_event = False + with executor_factory() as executor: results = executor.map( scenario_statistics, scenarios.values(), [sample_times] * len(scenarios), + [specific_event] * len(scenarios), timeout=60, ) diff --git a/caimira/apps/calculator/static/js/form.js b/caimira/apps/calculator/static/js/form.js index 45656c78..5e551b7d 100644 --- a/caimira/apps/calculator/static/js/form.js +++ b/caimira/apps/calculator/static/js/form.js @@ -61,6 +61,12 @@ function require_fields(obj) { case "hepa_no": require_hepa(false); break; + case "p_specific_event": + require_population(true); + break; + case "p_recurrent_event": + require_population(false); + break; case "mask_on": require_mask(true); break; @@ -172,6 +178,12 @@ function require_lunch(id, option) { } } +function require_population(option) { + require_input_field("#geographic_population", option); + require_input_field("#geographic_cases", option); + require_input_field("#ascertainment_bias", option); +} + function require_mask(option) { $("#mask_type_1").prop('required', option); $("#mask_type_ffp2").prop('required', option); @@ -269,6 +281,23 @@ function on_hepa_option_change() { }) } +function on_p_recurrent_change() { + p_recurrent = $('input[type=radio][name=p_recurrent_option]') + p_recurrent.each(function (index) { + if (this.checked) { + getChildElement($(this)).show(); + require_fields(this); + } + else { + getChildElement($(this)).hide(); + unrequire_fields(this); + + //Clear invalid inputs for this newly hidden child element + removeInvalid("#"+getChildElement($(this)).find('input').not('input[type=radio]').attr('id')); + } + }) +} + function on_wearing_mask_change() { wearing_mask = $('input[type=radio][name=mask_wearing_option]') wearing_mask.each(function (index) { @@ -538,6 +567,18 @@ function validate_form(form) { } } + // Validate cases < population + if ($("#p_specific_event").prop('checked')) { + var geographicPopulationObj = document.getElementById("geographic_population"); + var geographicCasesObj = document.getElementById("geographic_cases"); + removeErrorFor(geographicCasesObj); + + if (parseInt(geographicPopulationObj.value) < parseInt(geographicCasesObj.value)) { + insertErrorFor(geographicCasesObj, "Cases > Population"); + submit = false; + } + } + // Generate the short-range interactions list var short_range_interactions = []; $(".form_field_outer_row").each(function (index, element){ @@ -870,6 +911,12 @@ $(document).ready(function () { // Call the function now to handle forward/back button presses in the browser. on_hepa_option_change(); + // When the p_recurrent_option changes we want to make its respective + // children show/hide. + $("input[type=radio][name=p_recurrent_option]").change(on_p_recurrent_change); + // Call the function now to handle forward/back button presses in the browser. + on_p_recurrent_change(); + // When the mask_wearing_option changes we want to make its respective // children show/hide. $("input[type=radio][name=mask_wearing_option]").change(on_wearing_mask_change); diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2 index 37d8d932..5278a040 100644 --- a/caimira/apps/templates/base/calculator.form.html.j2 +++ b/caimira/apps/templates/base/calculator.form.html.j2 @@ -317,6 +317,36 @@
+ + + + +
+ ? +
+ + + +
diff --git a/caimira/apps/templates/base/calculator.report.html.j2 b/caimira/apps/templates/base/calculator.report.html.j2 index 34d12776..1b247015 100644 --- a/caimira/apps/templates/base/calculator.report.html.j2 +++ b/caimira/apps/templates/base/calculator.report.html.j2 @@ -57,6 +57,7 @@ {% if form.short_range_option == "short_range_yes" %} {% set scenario = alternative_scenarios.stats.values() | first %} {% set long_range_prob_inf = scenario.probability_of_infection %} + {% set long_range_prob_specific_event = scenario.prob_specific_event if form.p_recurrent_option == 'p_specific_event' %} {% else %} {% set long_range_prob_inf = prob_inf %} {% endif %} @@ -120,19 +121,41 @@ {% endif %}
{% block report_summary %} -
-
+
+ + {% if form.short_range_option == "short_range_yes" %}
- {% if form.short_range_option == "short_range_yes" %} + {% endif %} + {% block specific_event_probability %} + {% if form.p_recurrent_option == "p_specific_event" %}
{% endif %} -
+ {% endblock %} +
{% endblock report_summary %}
@@ -419,6 +442,18 @@
  • Number of attendees and infected people: {{ form.total_people }} in attendance, of whom {{ form.infected_people }} {{ "is" if form.infected_people == 1 else "are" }} infected.

  • + {% if form.p_recurrent_option == "p_specific_event" %} + {% if form.ascertainment_bias == "confidence_high" %} + {% set conf_level = "High - Mandatory population surveillance." %} + {% elif form.ascertainment_bias == "confidence_medium" %} + {% set conf_level = "Medium - Recommended population wide surveillance." %} + {% else %} + {% set conf_level = "Low - Surveillance only for sympotmatic patients." %} + {% endif %} +
  • Population in {{ form.location_name }}: {{ form.geographic_population }}

  • +
  • New reported cases in {{ form.location_name }} (7-day average): {{ form.geographic_cases }}

  • +
  • Confidence level: {{ conf_level }}

  • + {% endif %}
  • Activity type: {% if form.activity_type == "office" %} diff --git a/caimira/apps/templates/base/userguide.html.j2 b/caimira/apps/templates/base/userguide.html.j2 index 7775a7ec..976ef328 100644 --- a/caimira/apps/templates/base/userguide.html.j2 +++ b/caimira/apps/templates/base/userguide.html.j2 @@ -113,7 +113,22 @@ The recommended airflow rate for the HEPA filter should correspond to a total ai

    Here we capture the information about the event being simulated. First enter the number of occupants in the space, if you have a (small) variation in the number of people, please input the average or consider using the expert tool. Within the number of people occupying the space, you should specify how many are infected.

    -

    As an example, for a shared office with 4 people, where one person is infected, we enter 4 occupants and 1 infected person.

    +

    As an example, for a shared office with 4 people, where one person is infected, we enter 4 occupants and 1 infected person.


    +

    In case one would like to simulate an event happening at a given time and location, where the epidemiological situation is known, the tool allows for an estimation of the probability of on-site transmission, considering the chances that a given person in the event is infected. +The user will need to select Specific event, input the number of inhabitants and the cumulative weekly (7-day average) value of new reported positive cases at the event location, as well as the confidence level of the inputs. The first two inputs need to the related, i.e. the values of reported new cases and the number of inhabitants shall correspond to the a same geographical location. For example:

    +
      +
    • Population of Geneva, CH: 508 000 inhabitants
    • +
    • New reported cases in the canton of Geneva: 1000 (in one week - avg)
    • +
    +

    The confidence level has the following options:

    +
      +
    • High - mandatory population wide surveillance
    • +
    • Medium - recommended population wide surveillance
    • +
    • Low - surveillance only for sympotmatic patients
    • +
    +

    Depending on the epidemiological situation in the choosen location, the public health surveillance can be more or less active. The confidence level will provide an ascertainment bias to the data collected by the user.

    +

    The higher the incidence rate (i.e. new cases / population) the higher are the chances of having at least one infected occupant participating to the event. +For general and recurrent layout simply select the Recurrent exposure option.


    Activity type


    diff --git a/caimira/apps/templates/cern/calculator.report.html.j2 b/caimira/apps/templates/cern/calculator.report.html.j2 index f98c5a23..48ec1188 100644 --- a/caimira/apps/templates/cern/calculator.report.html.j2 +++ b/caimira/apps/templates/cern/calculator.report.html.j2 @@ -87,6 +87,11 @@ In this scenario, assuming short-range interactions occur, the probability of one exposed occupant getting infected can go as high as {{prob_inf | non_zero_percentage}}. {% endif %} + + {% block specific_event_probability %} + {{ super() }} + {% endblock specific_event_probability %} + {% if (prob_inf > 2) %}
    {% if cern_level == "green-1" %} From 6b8f438a4834492064b04328e140a432ca2521fa Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Wed, 21 Sep 2022 09:14:44 +0200 Subject: [PATCH 03/15] added Cases() for each test scenario and added tests for the test_exposure_model --- caimira/tests/conftest.py | 1 + caimira/tests/models/test_exposure_model.py | 66 +++++++++++++++++-- .../tests/models/test_short_range_model.py | 1 + caimira/tests/test_full_algorithm.py | 4 ++ caimira/tests/test_known_quantities.py | 1 + caimira/tests/test_monte_carlo.py | 3 +- caimira/tests/test_monte_carlo_full_models.py | 14 +++- 7 files changed, 80 insertions(+), 10 deletions(-) diff --git a/caimira/tests/conftest.py b/caimira/tests/conftest.py index b64c0b20..c1bdb8aa 100644 --- a/caimira/tests/conftest.py +++ b/caimira/tests/conftest.py @@ -46,6 +46,7 @@ def baseline_exposure_model(baseline_concentration_model, baseline_sr_model): mask=baseline_concentration_model.infected.mask, host_immunity=0., ), + geographical_data=models.Cases(), ) diff --git a/caimira/tests/models/test_exposure_model.py b/caimira/tests/models/test_exposure_model.py index 49662bf0..16fded63 100644 --- a/caimira/tests/models/test_exposure_model.py +++ b/caimira/tests/models/test_exposure_model.py @@ -91,7 +91,8 @@ def known_concentrations(func): ]) def test_exposure_model_ndarray(population, cm, expected_exposure, expected_probability, sr_model): - model = ExposureModel(cm, sr_model, population) + geographical_data = models.Cases() + model = ExposureModel(cm, sr_model, population, geographical_data) np.testing.assert_almost_equal( model.deposited_exposure(), expected_exposure ) @@ -113,7 +114,8 @@ def test_exposure_model_ndarray(population, cm, def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exposure, sr_model): cm = known_concentrations( lambda t: 0. if np.floor(t) % 2 else np.array([1.2, 1.2])) - model = ExposureModel(cm, sr_model, population) + geographical_data = models.Cases() + model = ExposureModel(cm, sr_model, population, geographical_data) np.testing.assert_almost_equal( model.deposited_exposure(), expected_deposited_exposure @@ -130,7 +132,8 @@ def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exp ]) def test_exposure_model_vector(population, expected_deposited_exposure, sr_model): cm_array = known_concentrations(lambda t: np.array([1.2, 1.2])) - model_array = ExposureModel(cm_array, sr_model, population) + geographical_data = models.Cases() + model_array = ExposureModel(cm_array, sr_model, population, geographical_data) np.testing.assert_almost_equal( model_array.deposited_exposure(), np.array(expected_deposited_exposure) ) @@ -138,7 +141,8 @@ def test_exposure_model_vector(population, expected_deposited_exposure, sr_model def test_exposure_model_scalar(sr_model): cm_scalar = known_concentrations(lambda t: 1.2) - model_scalar = ExposureModel(cm_scalar, sr_model, populations[0]) + geographical_data = models.Cases() + model_scalar = ExposureModel(cm_scalar, sr_model, populations[0], geographical_data) expected_deposited_exposure = 1.52436206 np.testing.assert_almost_equal( model_scalar.deposited_exposure(), expected_deposited_exposure @@ -194,7 +198,8 @@ def test_exposure_model_integral_accuracy(exposed_time_interval, 10, presence_interval, models.Mask.types['Type I'], models.Activity.types['Standing'], 0., ) - model = ExposureModel(conc_model, sr_model, population) + geographical_data = models.Cases() + model = ExposureModel(conc_model, sr_model, population, geographical_data) np.testing.assert_allclose(model.deposited_exposure(), expected_deposited_exposure) @@ -221,7 +226,56 @@ def test_infectious_dose_vectorisation(sr_model): 10, presence_interval, models.Mask.types['Type I'], models.Activity.types['Standing'], 0., ) - model = ExposureModel(cm, sr_model, population) + geographical_data = models.Cases() + model = ExposureModel(cm, sr_model, population, geographical_data) inf_probability = model.infection_probability() assert isinstance(inf_probability, np.ndarray) assert inf_probability.shape == (3, ) + + +@pytest.mark.parametrize( + "pop, cases, AB, prob_random_individual", [ + [100_000, 67, 5, 0.00335], + [200_000, 121, 5, 0.003025], + [np.array([100_000, 200_000]), 67, 10, np.array([0.0067, 0.00335])], + [150_000, np.array([67, 121]), 2, np.array([0.00089333, 0.00161333])], + [np.array([100_000, 200_000]), np.array([67, 121]), 5, np.array([0.00335, 0.003025])] + ] +) +def test_probability_random_individual(pop, cases, AB, prob_random_individual): + model = models.Cases(geographic_population=pop, geographic_cases=cases, + ascertainment_bias=AB) + np.testing.assert_allclose( + model.probability_random_individual(), prob_random_individual, rtol=0.05 + ) + + +@pytest.mark.parametrize( + "pop, cases, AB, exposed, infected, prob_meet_infected_person", [ + [100_000, 67, 5, 10, 2, 0.00049], + [200_000, 121, 5, 10, 1, 0.02944], + [np.array([100_000, 200_000]), 67, 10, 15, 2, np.array([0.00432, 0.00113])], + [150_000, np.array([67, 121]), 2, np.array([10, 15]), np.array([1, 2]), np.array([0.00886, 0.00027])], + ] +) +def test_prob_meet_infected_person(pop, cases, AB, exposed, infected, prob_meet_infected_person): + model = models.Cases(geographic_population=pop, geographic_cases=cases, + ascertainment_bias=AB) + np.testing.assert_allclose(model.probability_meet_infected_person(exposed, infected), + prob_meet_infected_person, rtol=0.05) + + +@pytest.mark.parametrize( + "population, cm, pop, cases, AB, specific_event_probability",[ + [populations[1], known_concentrations(lambda t: 36.), + 100000, 68, 5, 2.24124], + [populations[0], known_concentrations(lambda t: 36.), + 100000, 68, 5, 1.875652], + ]) +def test_specific_event_probability(population, cm, + pop, AB, cases, specific_event_probability): + model = ExposureModel(cm, (), population, models.Cases(geographic_population=pop, + geographic_cases=cases, ascertainment_bias=AB)) + np.testing.assert_allclose( + model.total_probability_rule(), specific_event_probability, rtol=0.05 + ) diff --git a/caimira/tests/models/test_short_range_model.py b/caimira/tests/models/test_short_range_model.py index 28bb8934..50d005db 100644 --- a/caimira/tests/models/test_short_range_model.py +++ b/caimira/tests/models/test_short_range_model.py @@ -146,6 +146,7 @@ def test_short_range_exposure_with_ndarray_mask(): activity=models.Activity.types['Light activity'], host_immunity=0., ), + geographical_data = mc_models.Cases(), ).build_model(SAMPLE_SIZE) assert isinstance(e_model.deposited_exposure(), np.ndarray) assert len(e_model.deposited_exposure()) == 3 diff --git a/caimira/tests/test_full_algorithm.py b/caimira/tests/test_full_algorithm.py index 89af5545..0ef12fda 100644 --- a/caimira/tests/test_full_algorithm.py +++ b/caimira/tests/test_full_algorithm.py @@ -560,6 +560,7 @@ def expo_sr_model(c_model,sr_models) -> mc.ExposureModel: activity=models.Activity.types['Seated'], host_immunity=0., ), + geographical_data=models.Cases(), ) @@ -605,6 +606,7 @@ def expo_sr_model_distr(c_model_distr) -> mc.ExposureModel: activity=models.Activity.types['Seated'], host_immunity=0., ), + geographical_data=models.Cases(), ) @@ -696,6 +698,7 @@ def test_longrange_exposure(c_model): activity=models.Activity.types['Seated'], host_immunity=0., ), + geographical_data=models.Cases(), ).build_model(SAMPLE_SIZE) npt.assert_allclose( expo_model.deposited_exposure().mean(), @@ -756,6 +759,7 @@ def test_longrange_exposure_with_distributions(c_model_distr): activity=activity_distributions['Seated'], host_immunity=0., ), + geographical_data=models.Cases(), ).build_model(SAMPLE_SIZE) npt.assert_allclose( expo_model.deposited_exposure().mean(), diff --git a/caimira/tests/test_known_quantities.py b/caimira/tests/test_known_quantities.py index 8f061486..86fb49a0 100644 --- a/caimira/tests/test_known_quantities.py +++ b/caimira/tests/test_known_quantities.py @@ -373,6 +373,7 @@ def build_exposure_model(concentration_model, short_range_model): mask=infected.mask, host_immunity=0., ), + geographical_data=models.Cases(), ) diff --git a/caimira/tests/test_monte_carlo.py b/caimira/tests/test_monte_carlo.py index c549e501..76a0610f 100644 --- a/caimira/tests/test_monte_carlo.py +++ b/caimira/tests/test_monte_carlo.py @@ -76,7 +76,8 @@ def baseline_mc_exposure_model(baseline_mc_concentration_model, baseline_mc_sr_m activity=baseline_mc_concentration_model.infected.activity, mask=baseline_mc_concentration_model.infected.mask, host_immunity=0., - ) + ), + geographical_data=caimira.models.Cases(), ) diff --git a/caimira/tests/test_monte_carlo_full_models.py b/caimira/tests/test_monte_carlo_full_models.py index dfcff434..0e333526 100644 --- a/caimira/tests/test_monte_carlo_full_models.py +++ b/caimira/tests/test_monte_carlo_full_models.py @@ -74,7 +74,8 @@ def shared_office_mc(): activity=activity_distributions['Seated'], mask=models.Mask.types['No mask'], host_immunity=0., - ) + ), + geographical_data=models.Cases(), ) @@ -117,6 +118,7 @@ def classroom_mc(): mask=models.Mask.types["No mask"], host_immunity=0., ), + geographical_data=models.Cases(), ) @@ -151,6 +153,7 @@ def ski_cabin_mc(): mask=models.Mask.types['No mask'], host_immunity=0., ), + geographical_data=models.Cases(), ) @@ -190,7 +193,8 @@ def skagit_chorale_mc(): activity=activity_distributions['Moderate activity'], mask=models.Mask.types['No mask'], host_immunity=0., - ), + ), + geographical_data=models.Cases(), ) @@ -230,7 +234,8 @@ def bus_ride_mc(): activity=activity_distributions['Seated'], mask=models.Mask.types['No mask'], host_immunity=0., - ), + ), + geographical_data=models.Cases(), ) @@ -266,6 +271,7 @@ def gym_mc(): mask=concentration_mc.infected.mask, host_immunity=0., ), + geographical_data=models.Cases(), ) @@ -301,6 +307,7 @@ def waiting_room_mc(): mask=concentration_mc.infected.mask, host_immunity=0., ), + geographical_data=models.Cases(), ) @@ -380,6 +387,7 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi, mask=concentration_mc.infected.mask, host_immunity=0., ), + geographical_data=models.Cases(), ) exposure_model = exposure_mc.build_model(size=SAMPLE_SIZE) npt.assert_allclose(exposure_model.infection_probability().mean(), From 9f1155540c862e9394ab6ebecb1a8d719cf117f4 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Wed, 21 Sep 2022 09:16:37 +0200 Subject: [PATCH 04/15] added new activity type conference/training --- caimira/apps/calculator/model_generator.py | 4 +++- caimira/apps/templates/base/calculator.form.html.j2 | 6 ++++-- caimira/apps/templates/base/calculator.report.html.j2 | 4 +++- caimira/apps/templates/base/userguide.html.j2 | 6 ++++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index dfac768d..4919b6c2 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -492,6 +492,7 @@ class FormData: 'callcentre': ('Seated', 'Speaking'), 'library': ('Seated', 'Breathing'), 'training': ('Standing', 'Speaking'), + 'training_attendee': ('Seated', 'Breathing'), 'lab': ( 'Light activity', #Model 1/2 of time spent speaking in a lab. @@ -530,6 +531,7 @@ class FormData: 'callcentre': 'Seated', 'library': 'Seated', 'training': 'Seated', + 'training_attendee': 'Seated', 'workshop': 'Moderate activity', 'lab':'Light activity', 'gym':'Heavy exercise', @@ -803,7 +805,7 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]: } -ACTIVITY_TYPES = {'office', 'smallmeeting', 'largemeeting', 'training', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym'} +ACTIVITY_TYPES = {'office', 'smallmeeting', 'largemeeting', 'training', 'training_attendee', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym'} MECHANICAL_VENTILATION_TYPES = {'mech_type_air_changes', 'mech_type_air_supply', 'not-applicable'} MASK_TYPES = {'Type I', 'FFP2'} MASK_WEARING_OPTIONS = {'mask_on', 'mask_off'} diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2 index 5278a040..35bbd69f 100644 --- a/caimira/apps/templates/base/calculator.form.html.j2 +++ b/caimira/apps/templates/base/calculator.form.html.j2 @@ -362,7 +362,8 @@ - + + @@ -645,8 +646,9 @@
  • Library = all seated, no talking, just breathing,
  • Laboratory = light physical activity, talking 50% of the time,
  • Workshop = moderate physical activity, talking 50% of the time,
  • -
  • Conference/Training = speaker/trainer standing and talking, rest seated and talking quietly. +
  • Conference/Training (speaker infected) = speaker/trainer standing and talking, rest seated and talking quietly. Speaker/Trainer assumed infected (worst case scenario),
  • +
  • Conference/Training (attendee infected) = someone in the audience is infected, all are seated and breathing.
  • Gym = heavy exercise, no talking, just breathing.
  • Activity breaks:
    diff --git a/caimira/apps/templates/base/calculator.report.html.j2 b/caimira/apps/templates/base/calculator.report.html.j2 index 1b247015..22a80dfc 100644 --- a/caimira/apps/templates/base/calculator.report.html.j2 +++ b/caimira/apps/templates/base/calculator.report.html.j2 @@ -473,7 +473,9 @@ {% elif form.activity_type == "workshop" %} Workshop = assembly workshop environment, all persons doing moderate physical activity, speaking 50% of the time. {% elif form.activity_type == "training" %} - Conference/Training – one person (the speaker/trainer) standing, talking, all others seated, talking quietly (whispering). It is assumed the speaker/trainer is the infected person, for the worst case scenario. + Conference/Training (speaker infected) – one person (the speaker/trainer) standing, talking, all others seated, talking quietly (whispering). It is assumed the speaker/trainer is the infected person, for the worst case scenario. + {% elif form.activity_type == "training_attendee" %} + Conference/Training (attendee infected) – the infected person(s) are in the audience. All persons seated and breathing. {% elif form.activity_type == "lab" %} Laboratory = Lab or technical environment, all persons doing light physical activity, speaking 50% of the time. {% elif form.activity_type == "gym" %} diff --git a/caimira/apps/templates/base/userguide.html.j2 b/caimira/apps/templates/base/userguide.html.j2 index 976ef328..cf9ac568 100644 --- a/caimira/apps/templates/base/userguide.html.j2 +++ b/caimira/apps/templates/base/userguide.html.j2 @@ -143,9 +143,11 @@ For general and recurrent layout simply select the Recurrent exposure opt
  • Control Room (night shift) = All persons seated, all talking 10% of the time. Everyone (exposed and infected occupants) is treated the same in this model.
  • Lab = Based on a typical lab or technical working area, all persons are doing light activity and talking 50% of the time. Everyone (exposed and infected occupants) is treated the same in this model.
  • Workshop = Based on a mechanical assembly workshop or equipment installation scenario, all persons are doing moderate activity and talking 50% of the time. This activity is equally applicable to bicycling, or walking on a gradient, in the LHC tunnels. Everyone (exposed and infected occupants) is treated the same in this model.
  • -
  • Conference/Training = Based on a typical conference/training course scenario. +
  • Conference/Training (speaker infected) = Based on a typical conference/training course scenario. One individual (the speaker/trainer) is standing and talking, with all other individuals seated and talking quietly (whispering). In this case it is assumed that the infected person is the speaker/trainer, because this is the worst case in terms of viral shedding.
  • +
  • Conference/Training (attendee infected) = All individuals seated and breathing. +In this case it is assumed that the infected person is not the speaker/trainer.
  • Gym = All persons are doing heavy exercise and breathing (not talking). Everyone (exposed and infected occupants) is treated the same in this model.

  • Timings

    @@ -196,7 +198,7 @@ If not, then you can input separate breaks. This is particularly different when

    The model allows for a simulation with either a continuous wearing of face masks throughout the duration of the event, or have the removed at all times - i.e. all occupants (infected and exposed alike) wear or not masks for the duration of the simulation. Please bear in mind the user inputs shall be aligned with the current applicable public health & safety instructions. Please check what are the applicable rules, before deciding which assumptions are used for the simulation.

    -

    If you have selected the Conference/Training activity type, this equates to the speakr/trainer and all participants either wearing masks throughout the conference/training (Yes), or removing them when seated/standing at their socially distanced positions within the conference/training room (No). +

    If you have selected the Conference/Training activity type, this equates to the speaker/trainer and all participants either wearing masks throughout the conference/training (Yes), or removing them when seated/standing at their socially distanced positions within the conference/training room (No). Please confirm what are the applicable rules, before deciding which assumptions are used for the simulation

    For the time being only the Type 1 surgical and FFP2 masks can be selected.


    From 1b3ef45b006752460fa7e9a4789e013962946184 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 3 Oct 2022 14:40:12 +0100 Subject: [PATCH 05/15] changes to user guide --- caimira/apps/templates/base/userguide.html.j2 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/caimira/apps/templates/base/userguide.html.j2 b/caimira/apps/templates/base/userguide.html.j2 index cf9ac568..1ca1de8d 100644 --- a/caimira/apps/templates/base/userguide.html.j2 +++ b/caimira/apps/templates/base/userguide.html.j2 @@ -115,12 +115,14 @@ First enter the number of occupants in the space, if you have a (small) variatio Within the number of people occupying the space, you should specify how many are infected.

    As an example, for a shared office with 4 people, where one person is infected, we enter 4 occupants and 1 infected person.


    In case one would like to simulate an event happening at a given time and location, where the epidemiological situation is known, the tool allows for an estimation of the probability of on-site transmission, considering the chances that a given person in the event is infected. -The user will need to select Specific event, input the number of inhabitants and the cumulative weekly (7-day average) value of new reported positive cases at the event location, as well as the confidence level of the inputs. The first two inputs need to the related, i.e. the values of reported new cases and the number of inhabitants shall correspond to the a same geographical location. For example:

    +The user will need to select Specific event, input the number of inhabitants and the the weekly (7-day rolling average) value of new reported laboratory - ⁠confirmed cases at the event location, as well as the confidence level of these inputs. +The 7-day rolling average consists in the average of the previous 3 days to subsequent 3 days, generally reported by the different public health authorities (e.g. in Switzerland here). +These two inputs need to the related, i.e. the values of reported new cases and the number of inhabitants shall correspond to the a same geographical location. For example:

    • Population of Geneva, CH: 508 000 inhabitants
    • -
    • New reported cases in the canton of Geneva: 1000 (in one week - avg)
    • +
    • New reported cases in the canton of Geneva: 1000 (7-day rolling avg)
    -

    The confidence level has the following options:

    +

    The confidence level allows for an ascertainment bias to the data. The user can add the following options:

    • High - mandatory population wide surveillance
    • Medium - recommended population wide surveillance
    • From 10d743c1497d375c7109c99fa900db71ca991f58 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 3 Oct 2022 14:42:37 +0100 Subject: [PATCH 06/15] UI changes for probabilistic approach --- caimira/apps/calculator/model_generator.py | 4 +-- caimira/apps/calculator/report_generator.py | 2 +- caimira/apps/calculator/static/js/form.js | 12 ++++----- .../templates/base/calculator.form.html.j2 | 25 ++++++++++--------- .../templates/base/calculator.report.html.j2 | 6 ++--- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index 4919b6c2..475ca028 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -60,7 +60,7 @@ class FormData: geographic_population: int geographic_cases: int ascertainment_bias: str - p_recurrent_option: str + exposure_option: str mask_type: str mask_wearing_option: str mechanical_ventilation_type: str @@ -123,7 +123,7 @@ class FormData: 'geographic_population': 0, 'geographic_cases': 0, 'ascertainment_bias': 'confidence_low', - 'p_recurrent_option': 'p_recurrent_event', + 'exposure_option': 'p_deterministic_exposure', 'mask_type': 'Type I', 'mask_wearing_option': 'mask_off', 'mechanical_ventilation_type': 'not-applicable', diff --git a/caimira/apps/calculator/report_generator.py b/caimira/apps/calculator/report_generator.py index 7317f2b0..a4cadd35 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/caimira/apps/calculator/report_generator.py @@ -311,7 +311,7 @@ def comparison_report( else: statistics = {} - if (form.short_range_option == "short_range_yes" and form.p_recurrent_option == "p_specific_event"): + if (form.short_range_option == "short_range_yes" and form.exposure_option == "p_probabilistic_exposure"): specific_event = True else: specific_event = False diff --git a/caimira/apps/calculator/static/js/form.js b/caimira/apps/calculator/static/js/form.js index 5e551b7d..79a0b277 100644 --- a/caimira/apps/calculator/static/js/form.js +++ b/caimira/apps/calculator/static/js/form.js @@ -61,10 +61,10 @@ function require_fields(obj) { case "hepa_no": require_hepa(false); break; - case "p_specific_event": + case "p_probabilistic_exposure": require_population(true); break; - case "p_recurrent_event": + case "p_deterministic_exposure": require_population(false); break; case "mask_on": @@ -282,7 +282,7 @@ function on_hepa_option_change() { } function on_p_recurrent_change() { - p_recurrent = $('input[type=radio][name=p_recurrent_option]') + p_recurrent = $('input[type=radio][name=exposure_option]') p_recurrent.each(function (index) { if (this.checked) { getChildElement($(this)).show(); @@ -568,7 +568,7 @@ function validate_form(form) { } // Validate cases < population - if ($("#p_specific_event").prop('checked')) { + if ($("#p_probabilistic_exposure").prop('checked')) { var geographicPopulationObj = document.getElementById("geographic_population"); var geographicCasesObj = document.getElementById("geographic_cases"); removeErrorFor(geographicCasesObj); @@ -911,9 +911,9 @@ $(document).ready(function () { // Call the function now to handle forward/back button presses in the browser. on_hepa_option_change(); - // When the p_recurrent_option changes we want to make its respective + // When the exposure_option changes we want to make its respective // children show/hide. - $("input[type=radio][name=p_recurrent_option]").change(on_p_recurrent_change); + $("input[type=radio][name=exposure_option]").change(on_p_recurrent_change); // Call the function now to handle forward/back button presses in the browser. on_p_recurrent_change(); diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2 index 35bbd69f..c658a7d3 100644 --- a/caimira/apps/templates/base/calculator.form.html.j2 +++ b/caimira/apps/templates/base/calculator.form.html.j2 @@ -303,7 +303,8 @@ Event data: -
      +
      ?

      @@ -312,20 +313,20 @@
      -
      -
      -
      +
      + +
      - - - - - -
      - ? +
      + +
      -