From 202ab88ff9aeb831cf758f7c9e751af92b0e0782 Mon Sep 17 00:00:00 2001 From: markus Date: Thu, 12 Nov 2020 13:53:14 +0100 Subject: [PATCH 01/14] add placeholder for comparison tab --- cara/apps/expert.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 410e9127..486e017e 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -419,15 +419,16 @@ class ExpertApplication: self.views = (WidgetView(default_scenario),) self.selected_tab = 0 self.tabs = (widgets.VBox(children=(self.build_settings_menu(0), self.views[0].present())),) - self.tab_widget = widgets.Tab(children=self.tabs) - self.display_titles() + self.tab_widget = widgets.Tab() + self.update_tab_widget() def display_titles(self): for i, name in enumerate(self.scenario_names): self.tab_widget.set_title(i, name) + self.tab_widget.set_title(len(self.scenario_names), 'Comparison') def update_tab_widget(self): - self.tab_widget.children = self.tabs + self.tab_widget.children = self.tabs + (widgets.Label('comparison widget'),) self.display_titles() def build_settings_menu(self, tab_index): From c6a374d42f6b4a3b8a8e3c0edc8878949f3853da Mon Sep 17 00:00:00 2001 From: markus Date: Fri, 13 Nov 2020 11:02:14 +0100 Subject: [PATCH 02/14] add working comparison tab --- cara/apps/expert.py | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 486e017e..4c5d9cd6 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -419,16 +419,22 @@ class ExpertApplication: self.views = (WidgetView(default_scenario),) self.selected_tab = 0 self.tabs = (widgets.VBox(children=(self.build_settings_menu(0), self.views[0].present())),) - self.tab_widget = widgets.Tab() + self.tab_widget = widgets.Tab(children=(widgets.VBox(children=(self.plot_all_concentrations(),)),)) self.update_tab_widget() + def handle_tab_change(change): + if change['new'] == len(self.scenarios): + self.tab_widget.children = self.tabs + (widgets.VBox(children=(self.plot_all_concentrations(),)),) + + self.tab_widget.observe(handle_tab_change, names='selected_index') + def display_titles(self): for i, name in enumerate(self.scenario_names): self.tab_widget.set_title(i, name) - self.tab_widget.set_title(len(self.scenario_names), 'Comparison') + self.tab_widget.set_title(len(self.scenario_names), '- Comparison -') def update_tab_widget(self): - self.tab_widget.children = self.tabs + (widgets.Label('comparison widget'),) + self.tab_widget.children = self.tabs + (self.tab_widget.children[-1],) self.display_titles() def build_settings_menu(self, tab_index): @@ -470,6 +476,35 @@ class ExpertApplication: buttons = duplicate_button if tab_index == 0 else widgets.HBox(children=(duplicate_button, delete_button)) return widgets.VBox(children=(buttons, rename_text_field)) + def plot_all_concentrations(self): + figure = matplotlib.figure.Figure(figsize=(9, 6)) + ax = figure.add_subplot(1, 1, 1) + resolution = 600 + # Uses default time interval currently + ts = np.linspace(8, 17, resolution) + concentrations = [[s.dcs_instance().concentration_model.concentration(t) for t in ts] for s in self.scenarios] + for concentration in concentrations: + ax.plot(ts, concentration) + + ax.spines['right'].set_visible(False) + ax.spines['top'].set_visible(False) + + ax.set_xlabel('Time (hours)') + ax.set_ylabel('Concentration ($q/m^3$)') + ax.set_title('Concentration of infectious quanta aerosols') + top = max(3, max([max(conc) for conc in concentrations])) + ax.set_ylim(bottom=0., top=top) + figure.canvas.draw() + matplotlib.interactive(False) + ipympl.backend_nbagg.new_figure_manager_given_figure(uuid.uuid1(), figure) + figure.canvas.toolbar_visible = True + figure.canvas.toolbar.collapsed = True + figure.canvas.footer_visible = False + figure.canvas.header_visible = False + figure.canvas.draw() + figure.legend(self.scenario_names) + return figure.canvas + @property def widget(self): return self.tab_widget From 23cc6d2f8ce9be1f7ad336ff0733c09aa3158a00 Mon Sep 17 00:00:00 2001 From: markus Date: Mon, 16 Nov 2020 17:34:30 +0100 Subject: [PATCH 03/14] change "views" to "tab_views" --- cara/apps/expert.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 4c5d9cd6..19f246f6 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -416,9 +416,9 @@ class ExpertApplication: default_scenario.concentration_model.infected.mask.dcs_select('No mask') self.scenarios = (default_scenario,) self.scenario_names = ('Scenario 1',) - self.views = (WidgetView(default_scenario),) + self.tab_views = (WidgetView(default_scenario),) self.selected_tab = 0 - self.tabs = (widgets.VBox(children=(self.build_settings_menu(0), self.views[0].present())),) + self.tabs = (widgets.VBox(children=(self.build_settings_menu(0), self.tab_views[0].present())),) self.tab_widget = widgets.Tab(children=(widgets.VBox(children=(self.plot_all_concentrations(),)),)) self.update_tab_widget() @@ -446,10 +446,10 @@ class ExpertApplication: def on_delete_click(b): self.scenario_names = tuple_without_index(self.scenario_names, tab_index) self.scenarios = tuple_without_index(self.scenarios, tab_index) - self.views = tuple_without_index(self.views, tab_index) + self.tab_views = tuple_without_index(self.tab_views, tab_index) self.selected_tab = min(0, self.selected_tab - 1) self.tabs = tuple(widgets.VBox(children=(self.build_settings_menu(i), view.present())) - for i, view in enumerate(self.views)) + for i, view in enumerate(self.tab_views)) self.update_tab_widget() def on_rename_text_field(change): @@ -466,8 +466,9 @@ class ExpertApplication: new_scenario.dcs_update_from(self.scenarios[tab_index].dcs_instance()) self.scenarios += (new_scenario,) - self.views += (WidgetView(new_scenario),) - self.tabs += (widgets.VBox(children=(self.build_settings_menu(len(self.scenario_names) - 1), self.views[-1].present())),) + self.tab_views += (WidgetView(new_scenario),) + self.tabs += (widgets.VBox(children=(self.build_settings_menu(len(self.scenario_names) - 1), + self.tab_views[-1].present())),) self.update_tab_widget() delete_button.on_click(on_delete_click) From 32e1548e2f336678b2a6887b825e99ece491c3c7 Mon Sep 17 00:00:00 2001 From: markus Date: Mon, 16 Nov 2020 18:06:49 +0100 Subject: [PATCH 04/14] move comparison plot to separate class --- cara/apps/expert.py | 73 +++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 19f246f6..01f8ef95 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -419,22 +419,16 @@ class ExpertApplication: self.tab_views = (WidgetView(default_scenario),) self.selected_tab = 0 self.tabs = (widgets.VBox(children=(self.build_settings_menu(0), self.tab_views[0].present())),) - self.tab_widget = widgets.Tab(children=(widgets.VBox(children=(self.plot_all_concentrations(),)),)) + self.tab_widget = widgets.Tab() self.update_tab_widget() - - def handle_tab_change(change): - if change['new'] == len(self.scenarios): - self.tab_widget.children = self.tabs + (widgets.VBox(children=(self.plot_all_concentrations(),)),) - - self.tab_widget.observe(handle_tab_change, names='selected_index') + self.comparison_view = ComparisonView() def display_titles(self): for i, name in enumerate(self.scenario_names): self.tab_widget.set_title(i, name) - self.tab_widget.set_title(len(self.scenario_names), '- Comparison -') def update_tab_widget(self): - self.tab_widget.children = self.tabs + (self.tab_widget.children[-1],) + self.tab_widget.children = self.tabs self.display_titles() def build_settings_menu(self, tab_index): @@ -477,38 +471,51 @@ class ExpertApplication: buttons = duplicate_button if tab_index == 0 else widgets.HBox(children=(duplicate_button, delete_button)) return widgets.VBox(children=(buttons, rename_text_field)) - def plot_all_concentrations(self): + @property + def widget(self): + self.comparison_view.update_plot() + return widgets.VBox(children=(self.tab_widget, self.comparison_view.figure.canvas)) + + +class ComparisonView: + def __init__(self): + self.figure = self.initialize_figure() + self.ax = self.initialize_axis() + + @staticmethod + def initialize_figure() -> matplotlib.figure.Figure: figure = matplotlib.figure.Figure(figsize=(9, 6)) - ax = figure.add_subplot(1, 1, 1) - resolution = 600 - # Uses default time interval currently - ts = np.linspace(8, 17, resolution) - concentrations = [[s.dcs_instance().concentration_model.concentration(t) for t in ts] for s in self.scenarios] - for concentration in concentrations: - ax.plot(ts, concentration) - - ax.spines['right'].set_visible(False) - ax.spines['top'].set_visible(False) - - ax.set_xlabel('Time (hours)') - ax.set_ylabel('Concentration ($q/m^3$)') - ax.set_title('Concentration of infectious quanta aerosols') - top = max(3, max([max(conc) for conc in concentrations])) - ax.set_ylim(bottom=0., top=top) - figure.canvas.draw() matplotlib.interactive(False) ipympl.backend_nbagg.new_figure_manager_given_figure(uuid.uuid1(), figure) figure.canvas.toolbar_visible = True figure.canvas.toolbar.collapsed = True figure.canvas.footer_visible = False figure.canvas.header_visible = False - figure.canvas.draw() - figure.legend(self.scenario_names) - return figure.canvas + return figure - @property - def widget(self): - return self.tab_widget + def initialize_axis(self) -> matplotlib.figure.Axes: + ax = self.figure.add_subplot(1, 1, 1) + ax.spines['right'].set_visible(False) + ax.spines['top'].set_visible(False) + ax.set_xlabel('Time (hours)') + ax.set_ylabel('Concentration ($q/m^3$)') + ax.set_title('Concentration of infectious quanta aerosols') + return ax + + def update_plot(self, conc_models: typing.Tuple[models.ConcentrationModel], labels: typing.Tuple[str]): + self.figure.clf() + # Hard-coded 8:00-17:00 interval + ts = np.linspace(8, 17) + concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in conc_models] + for concentration in concentrations: + self.ax.plot(ts, concentration) + + top = max(3., max([max(conc) for conc in concentrations])) + self.ax.set_ylim(bottom=0., top=top) + + self.figure.legend(labels) + + self.figure.canvas.draw() def tuple_without_index(t: typing.Tuple, index: int) -> typing.Tuple: From e0923d2e8325b5f22f842007503cf9176e219b8c Mon Sep 17 00:00:00 2001 From: markus Date: Tue, 17 Nov 2020 10:51:49 +0100 Subject: [PATCH 05/14] expose comparison plot canvas as property --- cara/apps/expert.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 01f8ef95..779caac2 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -474,7 +474,7 @@ class ExpertApplication: @property def widget(self): self.comparison_view.update_plot() - return widgets.VBox(children=(self.tab_widget, self.comparison_view.figure.canvas)) + return widgets.VBox(children=(self.tab_widget, self.comparison_view.widget)) class ComparisonView: @@ -517,6 +517,10 @@ class ComparisonView: self.figure.canvas.draw() + @property + def widget(self): + return self.figure.canvas + def tuple_without_index(t: typing.Tuple, index: int) -> typing.Tuple: return t[:index] + t[index + 1:] From 455955c391635c9a58ef36bb0f2e233298775a47 Mon Sep 17 00:00:00 2001 From: markus Date: Tue, 17 Nov 2020 11:01:34 +0100 Subject: [PATCH 06/14] pre-package tabs and comparison into multi_model_view --- cara/apps/expert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 779caac2..36ce93f0 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -422,6 +422,7 @@ class ExpertApplication: self.tab_widget = widgets.Tab() self.update_tab_widget() self.comparison_view = ComparisonView() + self.multi_model_view = (widgets.VBox(children=(self.tab_widget, self.comparison_view))) def display_titles(self): for i, name in enumerate(self.scenario_names): @@ -473,8 +474,7 @@ class ExpertApplication: @property def widget(self): - self.comparison_view.update_plot() - return widgets.VBox(children=(self.tab_widget, self.comparison_view.widget)) + return self.multi_model_view class ComparisonView: From 0d68a6efd43f570c1eb2604ff99534f7eb482882 Mon Sep 17 00:00:00 2001 From: markus Date: Tue, 17 Nov 2020 11:03:19 +0100 Subject: [PATCH 07/14] use widget of comparison_view --- cara/apps/expert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 36ce93f0..850220ed 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -422,7 +422,7 @@ class ExpertApplication: self.tab_widget = widgets.Tab() self.update_tab_widget() self.comparison_view = ComparisonView() - self.multi_model_view = (widgets.VBox(children=(self.tab_widget, self.comparison_view))) + self.multi_model_view = (widgets.VBox(children=(self.tab_widget, self.comparison_view.widget))) def display_titles(self): for i, name in enumerate(self.scenario_names): From fa9b87ca35d3d1c94f0f4ad52ae65238e197b4a5 Mon Sep 17 00:00:00 2001 From: markus Date: Tue, 17 Nov 2020 13:33:21 +0100 Subject: [PATCH 08/14] move tab-logic to MultiModelView --- cara/apps/expert.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 850220ed..d4a941e8 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -405,11 +405,22 @@ class CARAStateBuilder(state.StateBuilder): class ExpertApplication: + def __init__(self): + self.multi_model_view = MultiModelView() + self.comparison_view = ComparisonView() + self.app = widgets.VBox(children=(self.multi_model_view.widget, self.comparison_view.widget)) + + @property + def widget(self): + return self.app + + +class MultiModelView: def __init__(self): default_scenario = state.DataclassInstanceState( - models.ExposureModel, - state_builder=CARAStateBuilder(), - ) + models.ExposureModel, + state_builder=CARAStateBuilder(), + ) default_scenario.dcs_update_from(baseline_model) # For the time-being, we have to initialise the select states. Careful # as values might not correspond to what the baseline model says. @@ -421,8 +432,6 @@ class ExpertApplication: self.tabs = (widgets.VBox(children=(self.build_settings_menu(0), self.tab_views[0].present())),) self.tab_widget = widgets.Tab() self.update_tab_widget() - self.comparison_view = ComparisonView() - self.multi_model_view = (widgets.VBox(children=(self.tab_widget, self.comparison_view.widget))) def display_titles(self): for i, name in enumerate(self.scenario_names): @@ -474,7 +483,7 @@ class ExpertApplication: @property def widget(self): - return self.multi_model_view + return self.tab_widget class ComparisonView: From 936b339edfdadf86532398d3bd5df3bdbf99b218 Mon Sep 17 00:00:00 2001 From: markus Date: Tue, 17 Nov 2020 13:36:28 +0100 Subject: [PATCH 09/14] fix pipeline --- cara/tests/apps/test_expert_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cara/tests/apps/test_expert_app.py b/cara/tests/apps/test_expert_app.py index 1cb55d3b..2edb5d28 100644 --- a/cara/tests/apps/test_expert_app.py +++ b/cara/tests/apps/test_expert_app.py @@ -6,4 +6,4 @@ def test_app(): # do anything fancy to verify how it looks etc., we leave that for manual # testing. expert_app = cara.apps.ExpertApplication() - assert expert_app.scenario_names[0] == "Scenario 1" + assert expert_app.multi_model_view.scenario_names[0] == "Scenario 1" From 26f621d5af6cfda12ac96c7e52d44b05ec2ef595 Mon Sep 17 00:00:00 2001 From: markus Date: Tue, 17 Nov 2020 14:08:23 +0100 Subject: [PATCH 10/14] use model presence-boundaries to determine time-interval of plot --- cara/apps/expert.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index d4a941e8..e019ad6b 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -513,8 +513,8 @@ class ComparisonView: def update_plot(self, conc_models: typing.Tuple[models.ConcentrationModel], labels: typing.Tuple[str]): self.figure.clf() - # Hard-coded 8:00-17:00 interval - ts = np.linspace(8, 17) + start, finish = models_start_end(conc_models) + ts = np.linspace(start, finish) concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in conc_models] for concentration in concentrations: self.ax.plot(ts, concentration) @@ -533,3 +533,14 @@ class ComparisonView: def tuple_without_index(t: typing.Tuple, index: int) -> typing.Tuple: return t[:index] + t[index + 1:] + + +def models_start_end(models: typing.Iterable[models.ConcentrationModel]) -> typing.Tuple[float, float]: + """ + Returns the union of the presence intervals of a collection of ConcentrationModel objects + :param models: An iterable (e.g. list or tuple) of ConcentrationModel objects + :return: A tuple (start, finish) corresponding to the union of the presence intervals + """ + start = min(model.infected.presence.boundaries()[0][0] for model in models) + finish = min(model.infected.presence.boundaries()[-1][1] for model in models) + return start, finish From 12cb3d1427264477b549551c8b7c53e3e062ebdf Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 19 Nov 2020 11:30:43 +0100 Subject: [PATCH 11/14] Split the MultiModelView so that its data is being handled by the Controller (ExpertApp). --- cara/apps/expert.py | 219 +++++++++++++++++++++++++++++--------------- 1 file changed, 144 insertions(+), 75 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index e019ad6b..e01c1099 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -404,92 +404,164 @@ class CARAStateBuilder(state.StateBuilder): return s +#: A scenario is a name and a (mutable) model. +ScenarioType = typing.Tuple[str, state.DataclassState] + + class ExpertApplication: def __init__(self): - self.multi_model_view = MultiModelView() + self._debug_output = widgets.Output() + + #: A list of scenario name and ModelState instances. This is intended to be + #: mutated. Any mutation should notify the appropriate Views for handling. + self._model_scenarios: typing.List[ScenarioType] = [] + self.multi_model_view = MultiModelView(self) self.comparison_view = ComparisonView() - self.app = widgets.VBox(children=(self.multi_model_view.widget, self.comparison_view.widget)) + self.widget = widgets.VBox( + children=( + self.multi_model_view.widget, + self.comparison_view.widget, + self._debug_output, + ), + ) + self.add_scenario('Scenario 1') - @property - def widget(self): - return self.app - - -class MultiModelView: - def __init__(self): - default_scenario = state.DataclassInstanceState( + def build_new_model(self): + default_model = state.DataclassInstanceState( models.ExposureModel, state_builder=CARAStateBuilder(), ) - default_scenario.dcs_update_from(baseline_model) + default_model.dcs_update_from(baseline_model) # For the time-being, we have to initialise the select states. Careful # as values might not correspond to what the baseline model says. - default_scenario.concentration_model.infected.mask.dcs_select('No mask') - self.scenarios = (default_scenario,) - self.scenario_names = ('Scenario 1',) - self.tab_views = (WidgetView(default_scenario),) - self.selected_tab = 0 - self.tabs = (widgets.VBox(children=(self.build_settings_menu(0), self.tab_views[0].present())),) - self.tab_widget = widgets.Tab() + default_model.concentration_model.infected.mask.dcs_select('No mask') + return default_model + + def add_scenario(self, name, copy_from_model: typing.Optional[state.DataclassInstanceState] = None): + model = self.build_new_model() + if copy_from_model is not None: + model.dcs_update_from(copy_from_model.dcs_instance()) + self._model_scenarios.append((name, model)) + model.dcs_observe(self.notify_model_values_changed) + self.notify_model_scenario_changed() + + def _find_model_id(self, model_id): + for index, (name, model) in enumerate(list(self._model_scenarios)): + if id(model) == model_id: + return index, name, model + else: + raise ValueError("Model not found") + + def rename_scenario(self, model_id, new_name): + index, _, model = self._find_model_id(model_id) + self._model_scenarios[index] = (new_name, model) + self.notify_model_scenario_changed() + + def remove_scenario(self, model_id): + index, _, model = self._find_model_id(model_id) + self._model_scenarios.pop(index) + self.notify_model_scenario_changed() + + def notify_model_scenario_changed(self): + self.multi_model_view.scenarios_updated(self._model_scenarios) + self.comparison_view.scenarios_updated(self._model_scenarios) + + def notify_model_values_changed(self): + self.comparison_view.scenarios_updated(self._model_scenarios) + + +class MultiModelView: + def __init__(self, controller: ExpertApplication): + self._controller = controller + self.widget = widgets.Tab() + self._tab_model_ids: typing.List[int] = [] + self._tab_widgets: typing.List[widgets.Widget] = [] + self._tab_model_views: typing.List[WidgetView] = [] + self._active_tab_index = 0 + + def scenarios_updated(self, model_scenarios: typing.Sequence[ScenarioType]): + """ + Called when a scenario is added/removed/renamed etc. + + Note: Not called when the model state is modified. + + """ + model_scenario_ids = [] + for i, (scenario_name, model) in enumerate(model_scenarios): + if id(model) not in self._tab_model_ids: + self.add_tab(scenario_name, model) + model_scenario_ids.append(id(model)) + tab_index = self._tab_model_ids.index(id(model)) + self.widget.set_title(tab_index, scenario_name) + + # Any remaining model_scenario_ids are no longer needed, so remove + # their tabs. + for tab_index, tab_scenario_id in enumerate(self._tab_model_ids[:]): + if tab_scenario_id not in model_scenario_ids: + self.remove_tab(tab_index) + + def add_tab(self, name, model): + self._tab_model_views.append(WidgetView(model)) + self._tab_model_ids.append(id(model)) + tab_idx = len(self._tab_model_ids) - 1 + tab_widget = widgets.VBox( + children=( + self._build_settings_menu(name, model), + self._tab_model_views[tab_idx].widget, + ) + ) + self._tab_widgets.append(tab_widget) self.update_tab_widget() - def display_titles(self): - for i, name in enumerate(self.scenario_names): - self.tab_widget.set_title(i, name) + def remove_tab(self, tab_index): + assert 0 <= tab_index < len(self._tab_model_ids) + assert len(self._tab_model_ids) > 1 + self._tab_model_ids.pop(tab_index) + self._tab_widgets.pop(tab_index) + self._tab_model_views.pop(tab_index) + if self._active_tab_index >= tab_index: + self._active_tab_index = max(0, self._active_tab_index - 1) + self.update_tab_widget() def update_tab_widget(self): - self.tab_widget.children = self.tabs - self.display_titles() + self.widget.children = tuple(self._tab_widgets) - def build_settings_menu(self, tab_index): + def _build_settings_menu(self, name, model): delete_button = widgets.Button(description='Delete Scenario', button_style='danger') - rename_text_field = widgets.Text(description='Rename Scenario:', value=self.scenario_names[tab_index], + rename_text_field = widgets.Text(description='Rename Scenario:', value=name, style={'description_width': 'auto'}) duplicate_button = widgets.Button(description='Duplicate Scenario', button_style='success') + model_id = id(model) def on_delete_click(b): - self.scenario_names = tuple_without_index(self.scenario_names, tab_index) - self.scenarios = tuple_without_index(self.scenarios, tab_index) - self.tab_views = tuple_without_index(self.tab_views, tab_index) - self.selected_tab = min(0, self.selected_tab - 1) - self.tabs = tuple(widgets.VBox(children=(self.build_settings_menu(i), view.present())) - for i, view in enumerate(self.tab_views)) - self.update_tab_widget() + self._controller.remove_scenario(model_id) def on_rename_text_field(change): - self.scenario_names = tuple(change['new'] if i == tab_index else value - for i, value in enumerate(self.scenario_names)) - self.update_tab_widget() + self._controller.rename_scenario(model_id, new_name=change['new']) def on_duplicate_click(b): - self.scenario_names += (self.scenario_names[tab_index] + " (copy)",) - new_scenario = state.DataclassInstanceState( - models.ExposureModel, - state_builder=CARAStateBuilder(), - ) - new_scenario.dcs_update_from(self.scenarios[tab_index].dcs_instance()) - self.scenarios += (new_scenario,) - - self.tab_views += (WidgetView(new_scenario),) - self.tabs += (widgets.VBox(children=(self.build_settings_menu(len(self.scenario_names) - 1), - self.tab_views[-1].present())),) - self.update_tab_widget() + tab_index = self._tab_model_ids.index(model_id) + name = self.widget.get_title(tab_index) + self._controller.add_scenario(f'{name} (copy)', model) delete_button.on_click(on_delete_click) duplicate_button.on_click(on_duplicate_click) rename_text_field.observe(on_rename_text_field, 'value') - buttons = duplicate_button if tab_index == 0 else widgets.HBox(children=(duplicate_button, delete_button)) + # TODO: This should be dynamic - we don't want to be able to delete the + # last scenario, so this should be controlled in the remove_tab method. + buttons_w_delete = widgets.HBox(children=(duplicate_button, delete_button)) + buttons = duplicate_button if len(self._tab_model_ids) < 2 else buttons_w_delete return widgets.VBox(children=(buttons, rename_text_field)) - @property - def widget(self): - return self.tab_widget - class ComparisonView: def __init__(self): self.figure = self.initialize_figure() - self.ax = self.initialize_axis() + self.ax = self.initialize_axes() + + @property + def widget(self): + return self.figure.canvas @staticmethod def initialize_figure() -> matplotlib.figure.Figure: @@ -502,7 +574,7 @@ class ComparisonView: figure.canvas.header_visible = False return figure - def initialize_axis(self) -> matplotlib.figure.Axes: + def initialize_axes(self) -> matplotlib.figure.Axes: ax = self.figure.add_subplot(1, 1, 1) ax.spines['right'].set_visible(False) ax.spines['top'].set_visible(False) @@ -511,36 +583,33 @@ class ComparisonView: ax.set_title('Concentration of infectious quanta aerosols') return ax + def scenarios_updated(self, scenarios: typing.Sequence[ScenarioType]): + labels, models = zip(*scenarios) + conc_models: typing.Tuple[models.ConcentrationModel] = tuple( + model.concentration_model.dcs_instance() for model in models + ) + self.update_plot(conc_models, labels) + def update_plot(self, conc_models: typing.Tuple[models.ConcentrationModel], labels: typing.Tuple[str]): - self.figure.clf() + self.ax.cla() start, finish = models_start_end(conc_models) - ts = np.linspace(start, finish) + ts = np.linspace(start, finish, num=250) concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in conc_models] - for concentration in concentrations: - self.ax.plot(ts, concentration) + for label, concentration in zip(labels, concentrations): + self.ax.plot(ts, concentration, label=label) top = max(3., max([max(conc) for conc in concentrations])) self.ax.set_ylim(bottom=0., top=top) - self.figure.legend(labels) - + self.ax.legend() self.figure.canvas.draw() - @property - def widget(self): - return self.figure.canvas - -def tuple_without_index(t: typing.Tuple, index: int) -> typing.Tuple: - return t[:index] + t[index + 1:] - - -def models_start_end(models: typing.Iterable[models.ConcentrationModel]) -> typing.Tuple[float, float]: +def models_start_end(models: typing.Sequence[models.ConcentrationModel]) -> typing.Tuple[float, float]: """ - Returns the union of the presence intervals of a collection of ConcentrationModel objects - :param models: An iterable (e.g. list or tuple) of ConcentrationModel objects - :return: A tuple (start, finish) corresponding to the union of the presence intervals + Returns the earliest start and latest end time of a collection of ConcentrationModel objects + """ - start = min(model.infected.presence.boundaries()[0][0] for model in models) - finish = min(model.infected.presence.boundaries()[-1][1] for model in models) - return start, finish + infected_start = min(model.infected.presence.boundaries()[0][0] for model in models) + infected_finish = min(model.infected.presence.boundaries()[-1][1] for model in models) + return infected_start, infected_finish From 1bb8bbb6e092e7c7801abbe43261eac1439388b2 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 19 Nov 2020 12:19:15 +0100 Subject: [PATCH 12/14] Move the results accordion to its own tab. --- cara/apps/expert.py | 211 ++++++++++++++++++++++++++------------------ 1 file changed, 127 insertions(+), 84 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index e01c1099..fcb58070 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -28,12 +28,36 @@ def widget_group(label_widget_pairs): return widgets.HBox([labels_w, widgets_w]) -class ConcentrationFigure: +#: A scenario is a name and a (mutable) model. +ScenarioType = typing.Tuple[str, state.DataclassState] + + +class View: + pass + +def ipympl_canvas(figure): + matplotlib.interactive(False) + ipympl.backend_nbagg.new_figure_manager_given_figure(uuid.uuid1(), figure) + figure.canvas.toolbar_visible = True + figure.canvas.toolbar.collapsed = True + figure.canvas.footer_visible = False + figure.canvas.header_visible = False + return figure.canvas + + +class ConcentrationFigure(View): def __init__(self): self.figure = matplotlib.figure.Figure(figsize=(9, 6)) + ipympl_canvas(self.figure) self.ax = self.figure.add_subplot(1, 1, 1) self.line = None + @property + def widget(self): + # Workaround to a bug with ipymlp, which doesn't work well with tabs + # unless the widget is wrapped in a container (it is seen on all tabs otherwise!). + return widgets.HBox([self.figure.canvas]) + def update(self, model: models.ConcentrationModel): resolution = 600 ts = np.linspace(sorted(model.infected.presence.transition_times())[0], @@ -61,17 +85,54 @@ class ConcentrationFigure: self.figure.canvas.draw() -def ipympl_canvas(figure: matplotlib.figure.Figure): - # Make a plain matplotlib figure render as a Jupyter widget. - matplotlib.interactive(False) - ipympl.backend_nbagg.new_figure_manager_given_figure(uuid.uuid1(), figure) - figure.canvas.toolbar_visible = True - figure.canvas.toolbar.collapsed = True - figure.canvas.footer_visible = False - figure.canvas.header_visible = False +class ComparisonFigure(View): + def __init__(self): + self.figure = matplotlib.figure.Figure(figsize=(9, 6)) + ipympl_canvas(self.figure) + self.ax = self.initialize_axes() + + @property + def widget(self): + # Workaround to a bug with ipymlp, which doesn't work well with tabs + # unless the widget is wrapped in a container (it is seen on all tabs otherwise!). + return widgets.HBox([self.figure.canvas]) + + def initialize_axes(self) -> matplotlib.figure.Axes: + ax = self.figure.add_subplot(1, 1, 1) + ax.spines['right'].set_visible(False) + ax.spines['top'].set_visible(False) + ax.set_xlabel('Time (hours)') + ax.set_ylabel('Concentration ($q/m^3$)') + ax.set_title('Concentration of infectious quanta aerosols') + return ax + + def scenarios_updated( + self, + scenarios: typing.Sequence[ScenarioType], + active_scenario_index: int + ): + labels, models = zip(*scenarios) + conc_models: typing.Tuple[models.ConcentrationModel] = tuple( + model.concentration_model.dcs_instance() for model in models + ) + self.update_plot(conc_models, labels) + + def update_plot(self, conc_models: typing.Tuple[models.ConcentrationModel], labels: typing.Tuple[str]): + self.ax.lines.clear() + start, finish = models_start_end(conc_models) + ts = np.linspace(start, finish, num=250) + concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in conc_models] + for label, concentration in zip(labels, concentrations): + self.ax.plot(ts, concentration, label=label) + + top = max(3., max([max(conc) for conc in concentrations])) + self.ax.set_ylim(bottom=0., top=top) + + self.ax.legend() + self.figure.canvas.draw() -class WidgetView: +class ModelWidgets(View): def __init__(self, model_state: state.DataclassState): self.model_state = model_state self.model_state.dcs_observe(self.update) @@ -91,17 +152,16 @@ class WidgetView: # And the output widget figure. concentration = ConcentrationFigure() self.plots.append(concentration) - ipympl_canvas(concentration.figure) - self.widgets['results'] = collapsible([ - widgets.HBox([ - concentration.figure.canvas, - self.out, - ]) - ], 'Results', start_collapsed=False) + # self.widgets['results'] = collapsible([ + # widgets.HBox([ + # concentration.widget, + # self.out, + # ]) + # ], 'Results', start_collapsed=False) # Join inputs and outputs together in a single widget for convenience. - self.widget.children += (self.widgets['results'], ) + # self.widget.children += (self.widgets['results'], ) def prepare_output(self): pass @@ -404,10 +464,6 @@ class CARAStateBuilder(state.StateBuilder): return s -#: A scenario is a name and a (mutable) model. -ScenarioType = typing.Tuple[str, state.DataclassState] - - class ExpertApplication: def __init__(self): self._debug_output = widgets.Output() @@ -415,13 +471,21 @@ class ExpertApplication: #: A list of scenario name and ModelState instances. This is intended to be #: mutated. Any mutation should notify the appropriate Views for handling. self._model_scenarios: typing.List[ScenarioType] = [] + self._active_scenario = 0 self.multi_model_view = MultiModelView(self) - self.comparison_view = ComparisonView() - self.widget = widgets.VBox( + self.comparison_view = ComparisonFigure() + self.current_scenario_figure = ConcentrationFigure() + self._results_tab = widgets.Tab(children=( + self.current_scenario_figure.widget, + self.comparison_view.widget, + # self._debug_output, + )) + for i, title in enumerate(['Current scenario', 'Scenario comparison', "Debug"]): + self._results_tab.set_title(i, title) + self.widget = widgets.HBox( children=( self.multi_model_view.widget, - self.comparison_view.widget, - self._debug_output, + self._results_tab, ), ) self.add_scenario('Scenario 1') @@ -442,6 +506,7 @@ class ExpertApplication: if copy_from_model is not None: model.dcs_update_from(copy_from_model.dcs_instance()) self._model_scenarios.append((name, model)) + self._active_scenario = len(self._model_scenarios) - 1 model.dcs_observe(self.notify_model_values_changed) self.notify_model_scenario_changed() @@ -460,26 +525,46 @@ class ExpertApplication: def remove_scenario(self, model_id): index, _, model = self._find_model_id(model_id) self._model_scenarios.pop(index) + if self._active_scenario >= index: + self._active_scenario = max(self._active_scenario - 1, 0) self.notify_model_scenario_changed() + def set_active_scenario(self, model_id): + index, _, model = self._find_model_id(model_id) + self._active_scenario = index + self.notify_model_scenario_changed() + self.notify_model_values_changed() + def notify_model_scenario_changed(self): - self.multi_model_view.scenarios_updated(self._model_scenarios) - self.comparison_view.scenarios_updated(self._model_scenarios) + """ + Occurs when the set of scenarios has been modified, but not if the values of the scenario has changed. + + """ + self.multi_model_view.scenarios_updated(self._model_scenarios, self._active_scenario) + self.comparison_view.scenarios_updated(self._model_scenarios, self._active_scenario) def notify_model_values_changed(self): - self.comparison_view.scenarios_updated(self._model_scenarios) + """ + Occurs when *any* value in *any* of the scenarios has been modified. + """ + self.comparison_view.scenarios_updated(self._model_scenarios, self._active_scenario) + self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].concentration_model.dcs_instance()) class MultiModelView: def __init__(self, controller: ExpertApplication): self._controller = controller self.widget = widgets.Tab() + self.widget.observe(self._on_tab_change, 'selected_index') self._tab_model_ids: typing.List[int] = [] self._tab_widgets: typing.List[widgets.Widget] = [] - self._tab_model_views: typing.List[WidgetView] = [] - self._active_tab_index = 0 + self._tab_model_views: typing.List[ModelWidgets] = [] - def scenarios_updated(self, model_scenarios: typing.Sequence[ScenarioType]): + def scenarios_updated( + self, + model_scenarios: typing.Sequence[ScenarioType], + active_scenario_index: int + ): """ Called when a scenario is added/removed/renamed etc. @@ -500,8 +585,12 @@ class MultiModelView: if tab_scenario_id not in model_scenario_ids: self.remove_tab(tab_index) + assert self._tab_model_ids == model_scenario_ids + + self.widget.selected_index = active_scenario_index + def add_tab(self, name, model): - self._tab_model_views.append(WidgetView(model)) + self._tab_model_views.append(ModelWidgets(model)) self._tab_model_ids.append(id(model)) tab_idx = len(self._tab_model_ids) - 1 tab_widget = widgets.VBox( @@ -526,6 +615,11 @@ class MultiModelView: def update_tab_widget(self): self.widget.children = tuple(self._tab_widgets) + def _on_tab_change(self, change): + self._controller.set_active_scenario( + self._tab_model_ids[change['new']] + ) + def _build_settings_menu(self, name, model): delete_button = widgets.Button(description='Delete Scenario', button_style='danger') rename_text_field = widgets.Text(description='Rename Scenario:', value=name, @@ -554,57 +648,6 @@ class MultiModelView: return widgets.VBox(children=(buttons, rename_text_field)) -class ComparisonView: - def __init__(self): - self.figure = self.initialize_figure() - self.ax = self.initialize_axes() - - @property - def widget(self): - return self.figure.canvas - - @staticmethod - def initialize_figure() -> matplotlib.figure.Figure: - figure = matplotlib.figure.Figure(figsize=(9, 6)) - matplotlib.interactive(False) - ipympl.backend_nbagg.new_figure_manager_given_figure(uuid.uuid1(), figure) - figure.canvas.toolbar_visible = True - figure.canvas.toolbar.collapsed = True - figure.canvas.footer_visible = False - figure.canvas.header_visible = False - return figure - - def initialize_axes(self) -> matplotlib.figure.Axes: - ax = self.figure.add_subplot(1, 1, 1) - ax.spines['right'].set_visible(False) - ax.spines['top'].set_visible(False) - ax.set_xlabel('Time (hours)') - ax.set_ylabel('Concentration ($q/m^3$)') - ax.set_title('Concentration of infectious quanta aerosols') - return ax - - def scenarios_updated(self, scenarios: typing.Sequence[ScenarioType]): - labels, models = zip(*scenarios) - conc_models: typing.Tuple[models.ConcentrationModel] = tuple( - model.concentration_model.dcs_instance() for model in models - ) - self.update_plot(conc_models, labels) - - def update_plot(self, conc_models: typing.Tuple[models.ConcentrationModel], labels: typing.Tuple[str]): - self.ax.cla() - start, finish = models_start_end(conc_models) - ts = np.linspace(start, finish, num=250) - concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in conc_models] - for label, concentration in zip(labels, concentrations): - self.ax.plot(ts, concentration, label=label) - - top = max(3., max([max(conc) for conc in concentrations])) - self.ax.set_ylim(bottom=0., top=top) - - self.ax.legend() - self.figure.canvas.draw() - - def models_start_end(models: typing.Sequence[models.ConcentrationModel]) -> typing.Tuple[float, float]: """ Returns the earliest start and latest end time of a collection of ConcentrationModel objects From ce4e886e54a0ca57a6f6c0df03394220423694e8 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 19 Nov 2020 12:33:37 +0100 Subject: [PATCH 13/14] Use a HTML output, rather than a stdout one. It produces better lookign visuals, and is less jumpy with an update to the html. --- cara/apps/expert.py | 99 ++++++++++++++++----------------------------- 1 file changed, 35 insertions(+), 64 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index fcb58070..06047f1d 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -13,7 +13,7 @@ from cara import state from cara import data -def collapsible(widgets_to_collapse: typing.List, title: str, start_collapsed=True): +def collapsible(widgets_to_collapse: typing.List, title: str, start_collapsed=False): collapsed = widgets.Accordion([widgets.VBox(widgets_to_collapse)]) collapsed.set_title(0, title) if start_collapsed: @@ -35,6 +35,7 @@ ScenarioType = typing.Tuple[str, state.DataclassState] class View: pass + def ipympl_canvas(figure): matplotlib.interactive(False) ipympl.backend_nbagg.new_figure_manager_given_figure(uuid.uuid1(), figure) @@ -45,20 +46,26 @@ def ipympl_canvas(figure): return figure.canvas -class ConcentrationFigure(View): +class ExposureModelResult(View): def __init__(self): self.figure = matplotlib.figure.Figure(figsize=(9, 6)) ipympl_canvas(self.figure) + self.html_output = widgets.HTML() self.ax = self.figure.add_subplot(1, 1, 1) self.line = None @property def widget(self): - # Workaround to a bug with ipymlp, which doesn't work well with tabs - # unless the widget is wrapped in a container (it is seen on all tabs otherwise!). - return widgets.HBox([self.figure.canvas]) + return widgets.VBox([ + self.html_output, + self.figure.canvas, + ]) - def update(self, model: models.ConcentrationModel): + def update(self, model: models.ExposureModel): + self.update_plot(model.concentration_model) + self.update_textual_result(model) + + def update_plot(self, model: models.ConcentrationModel): resolution = 600 ts = np.linspace(sorted(model.infected.presence.transition_times())[0], sorted(model.infected.presence.transition_times())[-1], resolution) @@ -84,8 +91,19 @@ class ConcentrationFigure(View): self.ax.set_ylim(bottom=0., top=top) self.figure.canvas.draw() + def update_textual_result(self, model: models.ExposureModel): + lines = [] + P = model.infection_probability() + lines.append(f'Emission rate (quanta/hr): {model.concentration_model.infected.emission_rate_when_present()}') + lines.append(f'Probability of infection: {np.round(P, 0)}%') -class ComparisonFigure(View): + lines.append(f'Number of exposed: {model.exposed.number}') + R0 = np.round(model.reproduction_rate(), 1) + lines.append(f'Number of expected new cases (R0): {R0}') + self.html_output.value = '
\n'.join(lines) + + +class ExposureComparissonResult(View): def __init__(self): self.figure = matplotlib.figure.Figure(figsize=(9, 6)) ipympl_canvas(self.figure) @@ -106,11 +124,7 @@ class ComparisonFigure(View): ax.set_title('Concentration of infectious quanta aerosols') return ax - def scenarios_updated( - self, - scenarios: typing.Sequence[ScenarioType], - active_scenario_index: int - ): + def scenarios_updated(self, scenarios: typing.Sequence[ScenarioType], _): labels, models = zip(*scenarios) conc_models: typing.Tuple[models.ConcentrationModel] = tuple( model.concentration_model.dcs_instance() for model in models @@ -134,56 +148,13 @@ class ComparisonFigure(View): class ModelWidgets(View): def __init__(self, model_state: state.DataclassState): - self.model_state = model_state - self.model_state.dcs_observe(self.update) #: The widgets that this view produces (inputs and outputs together) self.widget = widgets.VBox([]) - self.widgets = {} - self.out = widgets.Output() - self.plots = [] - self.construct_widgets() - # Trigger the first result. - self.update() + self.construct_widgets(model_state) - def construct_widgets(self): + def construct_widgets(self, model_state: state.DataclassState): # Build the input widgets. - self._build_widget(self.model_state) - - # And the output widget figure. - concentration = ConcentrationFigure() - self.plots.append(concentration) - - # self.widgets['results'] = collapsible([ - # widgets.HBox([ - # concentration.widget, - # self.out, - # ]) - # ], 'Results', start_collapsed=False) - - # Join inputs and outputs together in a single widget for convenience. - # self.widget.children += (self.widgets['results'], ) - - def prepare_output(self): - pass - - def update(self): - model: models.ExposureModel = self.model_state.dcs_instance() - for plot in self.plots: - plot.update(model.concentration_model) - - self.out.clear_output() - with self.out: - P = model.infection_probability() - print(f'Emission rate (quanta/hr): {model.concentration_model.infected.emission_rate_when_present()}') - print(f'Probability of infection: {np.round(P, 0)}%') - - print(f'Number of exposed: {model.exposed.number}') - - new_cases = np.round(model.expected_new_cases(), 1) - print(f'Number of expected new cases: {new_cases}') - - R0 = np.round(model.reproduction_number(), 1) - print(f'Reproduction number (R0): {R0}') + self._build_widget(model_state) def _build_widget(self, node): self.widget.children += (self._build_room(node.concentration_model.room),) @@ -220,7 +191,7 @@ class ModelWidgets(View): [widget_group( [[widgets.Label('Room volume'), room_volume]] )], - title='Specification of workplace', start_collapsed=False, + title='Specification of workplace', ) return widget @@ -407,7 +378,7 @@ class ModelWidgets(View): w = collapsible( [widget_group([[widgets.Label('Ventilation type'), ventilation_w]])] + list(ventilation_widgets.values()), - title='Ventilation scheme' + title='Ventilation scheme', ) return w @@ -473,8 +444,8 @@ class ExpertApplication: self._model_scenarios: typing.List[ScenarioType] = [] self._active_scenario = 0 self.multi_model_view = MultiModelView(self) - self.comparison_view = ComparisonFigure() - self.current_scenario_figure = ConcentrationFigure() + self.comparison_view = ExposureComparissonResult() + self.current_scenario_figure = ExposureModelResult() self._results_tab = widgets.Tab(children=( self.current_scenario_figure.widget, self.comparison_view.widget, @@ -548,10 +519,10 @@ class ExpertApplication: Occurs when *any* value in *any* of the scenarios has been modified. """ self.comparison_view.scenarios_updated(self._model_scenarios, self._active_scenario) - self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].concentration_model.dcs_instance()) + self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].dcs_instance()) -class MultiModelView: +class MultiModelView(View): def __init__(self, controller: ExpertApplication): self._controller = controller self.widget = widgets.Tab() From ffbe9baba6ba6833faf63368162b21b69838f585 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 19 Nov 2020 12:44:13 +0100 Subject: [PATCH 14/14] Improve the class hierachy, and add an extra ExpertApp test. --- cara/apps/expert.py | 43 ++++++++++++++++++++++++------ cara/tests/apps/test_expert_app.py | 19 ++++++++++--- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/cara/apps/expert.py b/cara/apps/expert.py index 06047f1d..9dc9289d 100644 --- a/cara/apps/expert.py +++ b/cara/apps/expert.py @@ -33,6 +33,28 @@ ScenarioType = typing.Tuple[str, state.DataclassState] class View: + """ + A thing which exposes a ``.widget`` attribute which is a view on some + data. This view is essentially a complex combination of widgets, along with + some event handling capabilities, which may or may not be sent back up to + the underlying controller. + + We strive hard to keep "Model" data out of the View (and try to avoid + storing it at all on the View itself), instead relying on being able + to notify, and receive notifications, of important events from the Controller. + + """ + pass + + +class Controller: + """ + The singleton thing which is the top-level Application. + + It is responsible for owning the Model data and the Views, and + orchestrating event messages to each if the Model/View change. + + """ pass @@ -98,8 +120,13 @@ class ExposureModelResult(View): lines.append(f'Probability of infection: {np.round(P, 0)}%') lines.append(f'Number of exposed: {model.exposed.number}') - R0 = np.round(model.reproduction_rate(), 1) - lines.append(f'Number of expected new cases (R0): {R0}') + + new_cases = np.round(model.expected_new_cases(), 1) + lines.append(f'Number of expected new cases: {new_cases}') + + R0 = np.round(model.reproduction_number(), 1) + lines.append(f'Reproduction number (R0): {R0}') + self.html_output.value = '
\n'.join(lines) @@ -435,7 +462,7 @@ class CARAStateBuilder(state.StateBuilder): return s -class ExpertApplication: +class ExpertApplication(Controller): def __init__(self): self._debug_output = widgets.Output() @@ -479,7 +506,7 @@ class ExpertApplication: self._model_scenarios.append((name, model)) self._active_scenario = len(self._model_scenarios) - 1 model.dcs_observe(self.notify_model_values_changed) - self.notify_model_scenario_changed() + self.notify_scenarios_changed() def _find_model_id(self, model_id): for index, (name, model) in enumerate(list(self._model_scenarios)): @@ -491,22 +518,22 @@ class ExpertApplication: def rename_scenario(self, model_id, new_name): index, _, model = self._find_model_id(model_id) self._model_scenarios[index] = (new_name, model) - self.notify_model_scenario_changed() + self.notify_scenarios_changed() def remove_scenario(self, model_id): index, _, model = self._find_model_id(model_id) self._model_scenarios.pop(index) if self._active_scenario >= index: self._active_scenario = max(self._active_scenario - 1, 0) - self.notify_model_scenario_changed() + self.notify_scenarios_changed() def set_active_scenario(self, model_id): index, _, model = self._find_model_id(model_id) self._active_scenario = index - self.notify_model_scenario_changed() + self.notify_scenarios_changed() self.notify_model_values_changed() - def notify_model_scenario_changed(self): + def notify_scenarios_changed(self): """ Occurs when the set of scenarios has been modified, but not if the values of the scenario has changed. diff --git a/cara/tests/apps/test_expert_app.py b/cara/tests/apps/test_expert_app.py index 2edb5d28..3e4d2362 100644 --- a/cara/tests/apps/test_expert_app.py +++ b/cara/tests/apps/test_expert_app.py @@ -1,9 +1,22 @@ +import pytest + import cara.apps -def test_app(): +@pytest.fixture +def expert_app(): + return cara.apps.ExpertApplication() + + +def test_app(expert_app): # To start with, let's just test that the application runs. We don't try to # do anything fancy to verify how it looks etc., we leave that for manual # testing. - expert_app = cara.apps.ExpertApplication() - assert expert_app.multi_model_view.scenario_names[0] == "Scenario 1" + assert expert_app._model_scenarios[0][0] == "Scenario 1" + + +def test_new_scenario_changes_tab(expert_app): + # Adding a new scenario should change the tab index of the multi-model view. + assert expert_app.multi_model_view.widget.selected_index == 0 + expert_app.add_scenario("Another scenario") + assert expert_app.multi_model_view.widget.selected_index == 1