Skip to content

Commit

Permalink
Merge branch 'feature/expected_reproduction_sr' into 'master'
Browse files Browse the repository at this point in the history
Expected number of new cases fix

See merge request caimira/caimira!493
  • Loading branch information
lrdossan committed May 24, 2024
2 parents 4ca9816 + 7b17243 commit 3f1e373
Show file tree
Hide file tree
Showing 11 changed files with 110 additions and 22 deletions.
2 changes: 1 addition & 1 deletion caimira/apps/calculator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
1 change: 1 addition & 0 deletions caimira/apps/calculator/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
'sensor_in_use': '',
'short_range_option': 'short_range_no',
'short_range_interactions': '[]',
'short_range_occupants': 0,
}

# ------------------ Activities ----------------------
Expand Down
10 changes: 9 additions & 1 deletion caimira/apps/calculator/model_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion caimira/apps/calculator/report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions caimira/apps/calculator/static/js/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions caimira/apps/templates/base/calculator.form.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,16 @@
<div class="col-md-12 p-0 form-group" id="dialog_sr"></div>
<div class="text-center"><button type="button" class="add_node_btn_frm_field btn btn-primary btn-sm">Add row</button></div>
<input type="text" class="form-control d-none" name="short_range_interactions">
<br />
<div class="form-group row">
<div class="col-sm-4">
<label class="col-form-label col-form-label-sm">Total people with short-range interactions:</label>
</div>
<div class="col-sm-2">
<input type="number" id="short_range_occupants" name="short_range_occupants" class="form-control form-control-sm" min="0" value="1" tabindex="-1" onchange="validate_sr_people(this)" required>
</div>
<div class="col-sm-6"></div>
</div>
</div>

<div class="modal-footer">
Expand Down
33 changes: 22 additions & 11 deletions caimira/apps/templates/base/calculator.report.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,20 @@
<div class="tab-content" style="border-top: #dee2e6 1px solid; margin-top: -1px" >

<div class="tab-pane show active" id="results" role="tabpanel" aria-labelledby="results-tab" style="padding: 1%">
{% set long_range_prob_inf = prob_inf %}
{% set long_range_expected_cases = expected_new_cases %}

{# Update values if short range option is "short_range_yes" #}
{% if form.short_range_option == "short_range_yes" %}
{% set scenario = alternative_scenarios.stats.values() | first %}
{# Probability of infection values #}
{% set long_range_prob_inf = scenario.probability_of_infection %}
{% set long_range_prob_probabilistic_exposure = scenario.prob_probabilistic_exposure if form.exposure_option == 'p_probabilistic_exposure' %}
{% else %}
{% set long_range_prob_inf = prob_inf %}
{# Expected new case values #}
{% set long_range_expected_cases = scenario.expected_new_cases %}

{% if form.exposure_option == 'p_probabilistic_exposure' %}
{% set long_range_prob_probabilistic_exposure = scenario.prob_probabilistic_exposure %}
{% endif %}
{% endif %}

{% block report_results %}
Expand Down Expand Up @@ -97,6 +104,7 @@
</div>
{% endblock long_range_warning_animation %}
</div>
<h6><b>Expected new cases:</b> {{ long_range_expected_cases | float_format }}</h6>
</div>
<br>
{% if form.short_range_option == "short_range_yes" %}
Expand All @@ -118,18 +126,19 @@
</div>
{% endblock warning_animation %}
</div>
<h6><b>Expected new cases:</b> {{ expected_new_cases | float_format }}</h6>
</div>
{% endif %}
<div class="d-flex">
{% block report_summary %}
<div class="flex-row align-self-center">
<div class="align-self-center alert alert-dark mb-0" role="alert">
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{ long_range_prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{ long_range_prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ long_range_expected_cases | float_format }}</b>*.
</div>
{% if form.short_range_option == "short_range_yes" %}
<br>
<div class="align-self-center alert alert-dark mb-0" role="alert">
In this scenario, assuming <b>short-range interactions</b> occur, the <b>probability of one exposed occupant getting infected can go as high as {{ prob_inf | non_zero_percentage }}</b>.
In this scenario, the <b>probability the occupant(s) exposed to short-range interactions get infected can go as high as {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases increases to {{ expected_new_cases | float_format }}</b>.
</div>
{% endif %}
{% block probabilistic_exposure_probability %}
Expand Down Expand Up @@ -601,14 +610,16 @@
{% endif %}
</p></li>
{% if form.short_range_option == "short_range_yes" %}
<li><p class="data_text">
Short-range interactions: {{ form.short_range_interactions|length }}
</p></li>
<li><p class="data_text">Total number of occupants having short-range interactions: {{ form.short_range_occupants }}</p></li>
<ul>
{% for interaction in form.short_range_interactions %}
<li>Expiratory activity {{ loop.index if form.short_range_interactions|length > 1 }}: {{ "Shouting/Singing" if interaction.expiration == "Shouting" else interaction.expiration }} </li>
<li>Start time {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.start_time }} </li>
<li>Duration {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}</li>
<li>Interaction no. {{ loop.index }}:
<ul>
<li>Expiratory activity: {{ "Shouting/Singing" if interaction.expiration == "Shouting" else interaction.expiration }} </li>
<li>Start time: {{ interaction.start_time }} </li>
<li>Duration: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}</li>
</ul>
</li>
{% endfor %}
</ul>
{% endif %}
Expand Down
4 changes: 2 additions & 2 deletions caimira/apps/templates/cern/calculator.report.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
<div class="alert alert-success mb-0" role="alert">
<strong>Acceptable:</strong>
{% endif %}
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{long_range_prob_inf | non_zero_percentage}}</b> and the <b>expected number of new cases is {{expected_new_cases | float_format}}</b>*.
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{long_range_prob_inf | non_zero_percentage}}</b> and the <b>expected number of new cases is {{ long_range_expected_cases | float_format }}</b>*.
</div>
{% if form.short_range_option == "short_range_yes" %}
<br>
Expand All @@ -84,7 +84,7 @@
<div class="alert alert-success mb-0" role="alert">
<strong>Acceptable:</strong>
{% endif %}
In this scenario, assuming <b>short-range interactions</b> occur, the <b>probability of one exposed occupant getting infected can go as high as {{prob_inf | non_zero_percentage}}</b>.
In this scenario, the <b>probability the occupant(s) exposed to short-range interactions get infected can go as high as {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases increases to {{ expected_new_cases | float_format }}</b>.
</div>
{% endif %}

Expand Down
12 changes: 10 additions & 2 deletions caimira/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions caimira/tests/apps/calculator/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
27 changes: 26 additions & 1 deletion caimira/tests/apps/calculator/test_report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)

0 comments on commit 3f1e373

Please sign in to comment.