diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py index be8de7ab..74435939 100644 --- a/caimira/apps/calculator/__init__.py +++ b/caimira/apps/calculator/__init__.py @@ -42,7 +42,7 @@ # 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.15.1" +__version__ = "4.15.2" LOG = logging.getLogger("Calculator") diff --git a/caimira/apps/calculator/defaults.py b/caimira/apps/calculator/defaults.py index 4976d266..e12664eb 100644 --- a/caimira/apps/calculator/defaults.py +++ b/caimira/apps/calculator/defaults.py @@ -73,6 +73,7 @@ 'sensor_in_use': '', 'short_range_option': 'short_range_no', 'short_range_interactions': '[]', + 'short_range_occupants': 0, } # ------------------ Activities ---------------------- diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index 4a305418..5d6e1a34 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -72,6 +72,7 @@ class VirusFormData(FormData): sensor_in_use: str short_range_option: str short_range_interactions: list + short_range_occupants: int _DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = DEFAULTS @@ -182,6 +183,13 @@ def validate(self): if total_percentage != 100: raise ValueError(f'The sum of all respiratory activities should be 100. Got {total_percentage}.') + + # Validate number of people with short-range interactions + max_occupants_for_sr = self.total_people - self.infected_people + if self.short_range_occupants > max_occupants_for_sr: + raise ValueError( + f'The total number of occupants having short-range interactions ({self.short_range_occupants}) should be lower than the exposed population ({max_occupants_for_sr}).' + ) def initialize_room(self) -> models.Room: # Initializes room with volume either given directly or as product of area and height @@ -206,7 +214,6 @@ def build_mc_model(self) -> mc.ExposureModel: room = self.initialize_room() ventilation: models._VentilationBase = self.ventilation() infected_population = self.infected_population() - short_range = [] if self.short_range_option == "short_range_yes": for interaction in self.short_range_interactions: @@ -234,6 +241,7 @@ def build_mc_model(self) -> mc.ExposureModel: geographic_cases=self.geographic_cases, ascertainment_bias=CONFIDENCE_LEVEL_OPTIONS[self.ascertainment_bias], ), + exposed_to_short_range=self.short_range_occupants, ) def build_model(self, sample_size=None) -> models.ExposureModel: diff --git a/caimira/apps/calculator/report_generator.py b/caimira/apps/calculator/report_generator.py index fae89ef5..8ae92987 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/caimira/apps/calculator/report_generator.py @@ -431,7 +431,7 @@ def manufacture_alternative_scenarios(form: VirusFormData) -> typing.Dict[str, m scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model() else: - no_short_range_alternative = dataclass_utils.replace(form, short_range_interactions=[]) + no_short_range_alternative = dataclass_utils.replace(form, short_range_interactions=[], total_people=form.total_people - form.short_range_occupants) scenarios['Base scenario without short-range interactions'] = no_short_range_alternative.build_mc_model() return scenarios diff --git a/caimira/apps/calculator/static/js/form.js b/caimira/apps/calculator/static/js/form.js index ed28293e..71023c2a 100644 --- a/caimira/apps/calculator/static/js/form.js +++ b/caimira/apps/calculator/static/js/form.js @@ -869,7 +869,7 @@ function validate_sr_parameter(obj, error_message) { if ($(obj).val() == "" || $(obj).val() == null) { if (!$(obj).hasClass("red_border") && !$(obj).prop("disabled")) { var parameter = document.getElementById($(obj).attr('id')); - insertErrorFor(parameter, error_message) + insertErrorFor(parameter, error_message); $(parameter).addClass("red_border"); } return false; @@ -880,6 +880,22 @@ function validate_sr_parameter(obj, error_message) { } } +function validate_sr_people(obj) { + let sr_total_people = document.getElementById($(obj).attr('id')); + let max = document.getElementById("total_people").valueAsNumber - document.getElementById("infected_people").valueAsNumber; + if ($(obj).val() == "" || $(obj).val() == null || sr_total_people.valueAsNumber > max) { + if (!$(obj).hasClass("red_border")) { + insertErrorFor(sr_total_people, "Value must be less or equal than the number of exposed people."); + $(sr_total_people).addClass("red_border"); + } + return false; + } else { + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + return true; + } +} + function parseValToNumber(val) { return parseInt(val.replace(':',''), 10); } @@ -1084,7 +1100,6 @@ $(document).ready(function () { validateMaxInfectedPeople(); $("#total_people").change(validateMaxInfectedPeople); $("#activity_type").change(validateMaxInfectedPeople); - $("#total_people").change(validateMaxInfectedPeople); $("#infected_people").change(validateMaxInfectedPeople); //Validate all non zero values @@ -1253,7 +1268,8 @@ $(document).ready(function () { let activity = validate_sr_parameter('#sr_expiration_no_' + String(index)[0], "Required input."); let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input."); let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input."); - if (activity && start && duration) { + let total_people = validate_sr_people('#short_range_occupants'); + if (activity && start && duration && total_people) { if (validate_sr_time('#sr_start_no_' + String(index)) && validate_sr_time('#sr_duration_no_' + String(index))) { document.getElementById('sr_expiration_no_' + String(index)).disabled = true; document.getElementById('sr_start_no_' + String(index)).disabled = true; diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/caimira/apps/templates/base/calculator.form.html.j2 index 6842ea63..745612e6 100644 --- a/caimira/apps/templates/base/calculator.form.html.j2 +++ b/caimira/apps/templates/base/calculator.form.html.j2 @@ -584,6 +584,16 @@
+
+
+
+ +
+
+ +
+
+

{% if form.short_range_option == "short_range_yes" %} @@ -118,18 +126,19 @@ {% endblock warning_animation %} +
Expected new cases: {{ expected_new_cases | float_format }}
{% endif %}
{% block report_summary %}
{% if form.short_range_option == "short_range_yes" %}
{% endif %} {% block probabilistic_exposure_probability %} @@ -601,14 +610,16 @@ {% endif %}

{% if form.short_range_option == "short_range_yes" %} -
  • - Short-range interactions: {{ form.short_range_interactions|length }} -

  • +
  • Total number of occupants having short-range interactions: {{ form.short_range_occupants }}

  • {% endif %} diff --git a/caimira/apps/templates/cern/calculator.report.html.j2 b/caimira/apps/templates/cern/calculator.report.html.j2 index d482b1dc..701c2ab0 100644 --- a/caimira/apps/templates/cern/calculator.report.html.j2 +++ b/caimira/apps/templates/cern/calculator.report.html.j2 @@ -70,7 +70,7 @@ {% if form.short_range_option == "short_range_yes" %}
    @@ -84,7 +84,7 @@ {% endif %} diff --git a/caimira/models.py b/caimira/models.py index 192a6afc..441b70ff 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -1579,6 +1579,9 @@ class ExposureModel: #: Geographical data geographical_data: Cases + #: Total people with short-range interactions + exposed_to_short_range: int = 0 + #: The number of times the exposure event is repeated (default 1). @property def repeats(self) -> int: @@ -1814,10 +1817,15 @@ def expected_new_cases(self) -> _VectorisedFloat: "with dynamic occupancy") """ - The expect_new_cases should always take the long-range infection_probability and multiply by the occupants exposed to long-range. + The expected_new_cases may provide one or two different outputs: + 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. """ + if self.short_range != (): - return nested_replace(self, {'short_range': ()}).infection_probability() * self.exposed.number / 100 + new_cases_long_range = nested_replace(self, {'short_range': (),}).infection_probability() * (self.exposed.number - self.exposed_to_short_range) + return (new_cases_long_range + (self.infection_probability() * self.exposed_to_short_range)) / 100 return self.infection_probability() * self.exposed.number / 100 diff --git a/caimira/tests/apps/calculator/conftest.py b/caimira/tests/apps/calculator/conftest.py index 38c19e97..d774e333 100644 --- a/caimira/tests/apps/calculator/conftest.py +++ b/caimira/tests/apps/calculator/conftest.py @@ -11,3 +11,12 @@ def baseline_form_data(): @pytest.fixture def baseline_form(baseline_form_data, data_registry): return model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + + +@pytest.fixture +def baseline_form_with_sr(baseline_form_data, data_registry): + form_data_sr = baseline_form_data + form_data_sr['short_range_option'] = 'short_range_yes' + form_data_sr['short_range_interactions'] = '[{"expiration": "Shouting", "start_time": "10:30", "duration": "30"}]' + form_data_sr['short_range_occupants'] = 5 + return model_generator.VirusFormData.from_dict(form_data_sr, data_registry) \ No newline at end of file diff --git a/caimira/tests/apps/calculator/test_report_generator.py b/caimira/tests/apps/calculator/test_report_generator.py index 22355c5d..ba0295eb 100644 --- a/caimira/tests/apps/calculator/test_report_generator.py +++ b/caimira/tests/apps/calculator/test_report_generator.py @@ -7,7 +7,9 @@ import pytest from caimira.apps.calculator import make_app -from caimira.apps.calculator.report_generator import ReportGenerator, readable_minutes +from caimira.apps.calculator.model_generator import VirusFormData +from caimira.apps.calculator.report_generator import (ReportGenerator, readable_minutes, calculate_report_data, + manufacture_alternative_scenarios, interesting_times, comparison_report) import caimira.apps.calculator.report_generator as rep_gen @@ -90,3 +92,26 @@ def test_interesting_times_w_temp(exposure_model_w_outside_temp_changes): 5., 5.4, 5.8, 6.2, 6.6, 7., 7.4, 7.8, 8. ] np.testing.assert_allclose(result, expected) + + +def test_expected_new_cases(baseline_form_with_sr: VirusFormData): + model = baseline_form_with_sr.build_model() + + executor_factory = partial( + concurrent.futures.ThreadPoolExecutor, 1, + ) + + # Short- and Long-range contributions + report_data = calculate_report_data(baseline_form_with_sr, model, executor_factory) + sr_lr_expected_new_cases = report_data['expected_new_cases'] + sr_lr_prob_inf = report_data['prob_inf']/100 + + # Long-range contributions alone + scenario_sample_times = interesting_times(model) + alternative_scenarios = manufacture_alternative_scenarios(baseline_form_with_sr) + alternative_statistics = comparison_report( + baseline_form_with_sr, report_data, alternative_scenarios, scenario_sample_times, executor_factory=executor_factory, + ) + + lr_expected_new_cases = alternative_statistics['stats']['Base scenario without short-range interactions']['expected_new_cases'] + np.testing.assert_almost_equal(sr_lr_expected_new_cases, lr_expected_new_cases + sr_lr_prob_inf * baseline_form_with_sr.short_range_occupants, 2)