From 9faf7c6134a7e7929ee28d531aa613fafb3c37f9 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Mon, 14 Dec 2020 15:10:50 +0100 Subject: [PATCH] Beef up the interval testing for model generator. Improve the validation of the FormData. --- cara/apps/calculator/model_generator.py | 124 +++++++++++------- .../apps/calculator/test_model_generator.py | 75 +++++++++-- 2 files changed, 147 insertions(+), 52 deletions(-) diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index b1d97e4b..28f4084c 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -53,6 +53,8 @@ class FormData: @classmethod def from_dict(cls, form_data: typing.Dict) -> "FormData": + # Take a copy of the form data so that we can mutate it. + form_data = form_data.copy() valid_na_values = ['windows_open', 'window_type', 'mechanical_ventilation_type'] for name in valid_na_values: @@ -63,26 +65,6 @@ class FormData: if not form_data.get(name, ''): form_data[name] = '00:00' - validation_tuples = [('activity_type', ACTIVITY_TYPES), - ('event_type', EVENT_TYPES), - ('mechanical_ventilation_type', MECHANICAL_VENTILATION_TYPES), - ('mask_type', MASK_TYPES), - ('mask_wearing', MASK_WEARING), - ('ventilation_type', VENTILATION_TYPES), - ('volume_type', VOLUME_TYPES), - ('windows_open', WINDOWS_OPEN), - ('window_type', WINDOWS_TYPES)] - for key, valid_set in validation_tuples: - if key not in form_data: - raise ValueError(f"Missing key {key}") - if form_data[key] not in valid_set: - raise ValueError(f"{form_data[key]} is not a valid value for {key}") - - if (form_data['ventilation_type'] == 'natural' and - form_data['window_type'] == 'not-applicable'): - raise ValueError("window_type cannot be ''not-applicable'' if " - "ventilation_type is ''natural''") - # Don't let arbitrary unescaped HTML through the net. for key, value in form_data.items(): if isinstance(value, str): @@ -93,9 +75,22 @@ class FormData: if value == "": form_data[key] = "0" - return cls( - activity_finish=time_string_to_minutes(form_data['activity_finish']), - activity_start=time_string_to_minutes(form_data['activity_start']), + time_attributes = [ + 'activity_start', 'activity_finish', 'lunch_start', + 'lunch_finish', 'infected_start', 'infected_finish', + ] + for attr_name in time_attributes: + form_data[attr_name] = time_string_to_minutes(form_data[attr_name]) + + boolean_attributes = [ + 'hepa_option', 'lunch_option', + ] + for attr_name in boolean_attributes: + form_data[attr_name] = form_data[attr_name] == '1' + + instance = cls( + activity_finish=form_data['activity_finish'], + activity_start=form_data['activity_start'], activity_type=form_data['activity_type'], air_changes=float(form_data['air_changes']), air_supply=float(form_data['air_supply']), @@ -105,11 +100,11 @@ class FormData: event_type=form_data['event_type'], floor_area=float(form_data['floor_area']), hepa_amount=float(form_data['hepa_amount']), - hepa_option=(form_data['hepa_option'] == '1'), + hepa_option=form_data['hepa_option'], infected_people=int(form_data['infected_people']), - lunch_finish=time_string_to_minutes(form_data['lunch_finish']), - lunch_option=(form_data['lunch_option'] == '1'), - lunch_start=time_string_to_minutes(form_data['lunch_start']), + lunch_finish=form_data['lunch_finish'], + lunch_option=form_data['lunch_option'], + lunch_start=form_data['lunch_start'], mask_type=form_data['mask_type'], mask_wearing=form_data['mask_wearing'], mechanical_ventilation_type=form_data['mechanical_ventilation_type'], @@ -130,9 +125,44 @@ class FormData: window_width=float(form_data['window_width']), windows_number=int(form_data['windows_number']), windows_open=form_data['windows_open'], - infected_start=time_string_to_minutes(form_data['infected_start']), - infected_finish=time_string_to_minutes(form_data['infected_finish']), + infected_start=form_data['infected_start'], + infected_finish=form_data['infected_finish'], ) + instance.validate() + return instance + + def validate(self): + time_intervals = [ + ['activity_start', 'activity_finish'], + ['lunch_start', 'lunch_finish'], + ['infected_start', 'infected_finish'], + ] + for start_name, end_name in time_intervals: + start = getattr(self, start_name) + end = getattr(self, end_name) + if start > end: + raise ValueError( + f"{start_name} must be less than {end_name}. Got {start} and {end}.") + + validation_tuples = [('activity_type', ACTIVITY_TYPES), + ('event_type', EVENT_TYPES), + ('mechanical_ventilation_type', MECHANICAL_VENTILATION_TYPES), + ('mask_type', MASK_TYPES), + ('mask_wearing', MASK_WEARING), + ('ventilation_type', VENTILATION_TYPES), + ('volume_type', VOLUME_TYPES), + ('windows_open', WINDOWS_OPEN), + ('window_type', WINDOWS_TYPES)] + 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}") + + if ( + self.ventilation_type == 'natural' + and self.window_type == 'not-applicable' + ): + raise ValueError("window_type cannot be 'not-applicable' if " + "ventilation_type is 'natural'") def build_model(self) -> models.ExposureModel: return model_from_form(self) @@ -320,30 +350,36 @@ class FormData: present_intervals = [] time = start is_present = True + while time < finish: if is_present: if not leave_times: + # There are no further leave times, so the final interval + # is from the current time to the end. present_intervals.append((time / 60, finish / 60)) break - if leave_times[-1] < time: - leave_times.pop() + next_leave_time = leave_times.pop() + if next_leave_time > finish: + next_leave_time = finish + + if next_leave_time < time: + # If there is an interval which has been interrupted by the + # end time, we cut it off (e.g. finish happens in the middle + # of a coffee/lunch break). + pass + elif time == next_leave_time: + # If the start time and the end time are the same, then + # ignore this interval. + pass else: - new_time = leave_times.pop() - if time / 60 != min(new_time, finish) / 60 : - present_intervals.append((time / 60, min(new_time, finish) / 60)) - is_present = False - time = new_time + present_intervals.append((time / 60, next_leave_time / 60)) + time = next_leave_time + is_present = False else: - if not enter_times: - break - - if enter_times[-1] < time: - enter_times.pop() - else: - is_present = True - time = enter_times.pop() + is_present = True + time = enter_times.pop() return models.SpecificInterval(tuple(present_intervals)) diff --git a/cara/tests/apps/calculator/test_model_generator.py b/cara/tests/apps/calculator/test_model_generator.py index 7fe601ce..f02c1bfb 100644 --- a/cara/tests/apps/calculator/test_model_generator.py +++ b/cara/tests/apps/calculator/test_model_generator.py @@ -16,9 +16,8 @@ def baseline_form(baseline_form_data): def test_model_from_dict(baseline_form_data): - model = model_generator.FormData.from_dict(baseline_form_data) - # TODO: - # assert model.ventilation == cara.models.Ventilation() + form = model_generator.FormData.from_dict(baseline_form_data) + assert isinstance(form.build_model(), models.ExposureModel) def test_blend_expiration(): @@ -156,21 +155,81 @@ def test_exposed_present_intervals(baseline_form): baseline_form.activity_finish = 17 * 60 baseline_form.lunch_start = 12 * 60 + 30 baseline_form.lunch_finish = 13 * 60 + 30 - baseline_form.infected_start = 10 * 60 - baseline_form.infected_finish = 15 * 60 correct = ((9, 10+37/60), (10+52/60, 12.5), (13.5, 15+7/60), (15+22/60, 17.0)) assert baseline_form.exposed_present_interval().present_times == correct + def test_exposed_present_intervals_starting_with_lunch(baseline_form): baseline_form.coffee_breaks = 0 baseline_form.activity_start = baseline_form.lunch_start = 13 * 60 baseline_form.activity_finish = 18 * 60 baseline_form.lunch_finish = 14 * 60 - baseline_form.infected_start = 9 * 60 - baseline_form.infected_finish = 13 * 60 correct = ((14.0, 18.0),) assert baseline_form.exposed_present_interval().present_times == correct + +def test_exposed_present_intervals_ending_with_lunch(baseline_form): + baseline_form.coffee_breaks = 0 + baseline_form.activity_start = 11 * 60 + baseline_form.activity_finish = baseline_form.lunch_start = 13 * 60 + baseline_form.lunch_finish = 14 * 60 + correct = ((11.0, 13.0),) + assert baseline_form.exposed_present_interval().present_times == correct + + +def test_exposed_present_lunch_end_before_beginning(baseline_form): + baseline_form.coffee_breaks = 0 + baseline_form.lunch_start = 14 * 60 + baseline_form.lunch_finish = 13 * 60 + with pytest.raises(ValueError): + baseline_form.validate() + + +@pytest.fixture +def coffee_break_between_1045_and_1115(baseline_form): + baseline_form.coffee_breaks = 1 + baseline_form.coffee_duration = 30 + baseline_form.activity_start = 10 * 60 + baseline_form.activity_finish = 12 * 60 + baseline_form.lunch_option = False + + coffee_breaks = baseline_form.coffee_break_times() + assert coffee_breaks == ((10.75 * 60, 11.25 * 60),) + return baseline_form + + +def test_present_before_coffee(coffee_break_between_1045_and_1115): + interval = coffee_break_between_1045_and_1115.present_interval(10.5 * 60, 11 * 60) + assert interval.boundaries() == ((10.5, 10.75),) + + +def test_present_after_coffee(coffee_break_between_1045_and_1115): + interval = coffee_break_between_1045_and_1115.present_interval(11 * 60, 11.5 * 60) + assert interval.boundaries() == ((11.25, 11.5),) + + +def test_present_when_coffee_starts(coffee_break_between_1045_and_1115): + interval = coffee_break_between_1045_and_1115.present_interval(10.75 * 60, 11.5 * 60) + assert interval.boundaries() == ((11.25, 11.5),) + + +def test_present_when_coffee_ends(coffee_break_between_1045_and_1115): + interval = coffee_break_between_1045_and_1115.present_interval(10.5 * 60, 11.25 * 60) + assert interval.boundaries() == ((10.5, 10.75), ) + + +def test_present_only_for_coffee_ends(coffee_break_between_1045_and_1115): + interval = coffee_break_between_1045_and_1115.present_interval(10.75 * 60, 11.25 * 60) + assert interval.boundaries() == () + + +def test_no_lunch(baseline_form): + baseline_form.lunch_option = False + baseline_form.lunch_start = 0 + baseline_form.lunch_finish = 0 + baseline_form.validate() + + def test_coffee_lunch_breaks(baseline_form): baseline_form.coffee_duration = 30 baseline_form.coffee_breaks = 4 @@ -190,7 +249,7 @@ def test_coffee_lunch_breaks_unbalance(baseline_form): baseline_form.activity_finish = 13 * 60 + 30 baseline_form.lunch_start = 12 * 60 + 30 baseline_form.lunch_finish = 13 * 60 + 30 - correct = ((9, 9+50/60), (10+20/60, 11+10/60), (11+40/60, 12+30/60) ) + correct = ((9, 9+50/60), (10+20/60, 11+10/60), (11+40/60, 12+30/60)) np.testing.assert_allclose(baseline_form.exposed_present_interval().present_times, correct, rtol=1e-14)