From 11be1abf8ec2aa03de06d994ffa17b1ce17f9b77 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 23 Jan 2023 16:47:30 +0100 Subject: [PATCH 01/24] initial tests for CO2_expert --- caimira/apps/__init__.py | 4 +- caimira/apps/simulator.py | 664 +++++++++++++++++++++++++++ caimira/apps/simulator/caimira.ipynb | 59 +++ caimira/models.py | 3 +- 4 files changed, 727 insertions(+), 3 deletions(-) create mode 100644 caimira/apps/simulator.py create mode 100644 caimira/apps/simulator/caimira.ipynb diff --git a/caimira/apps/__init__.py b/caimira/apps/__init__.py index da903ca1..57441379 100644 --- a/caimira/apps/__init__.py +++ b/caimira/apps/__init__.py @@ -1,4 +1,4 @@ from .expert import ExpertApplication +from .simulator import CO2Application - -__all__ = ['ExpertApplication'] +__all__ = ['ExpertApplication', 'CO2Application'] diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py new file mode 100644 index 00000000..f8de9148 --- /dev/null +++ b/caimira/apps/simulator.py @@ -0,0 +1,664 @@ +import dataclasses +import ipywidgets as widgets +import typing +import numpy as np + +from caimira import data, models, state +import matplotlib +import matplotlib.figure +import matplotlib.lines as mlines +import matplotlib.patches as patches +from .expert import collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder + +# ventilation=models.MultipleVentilation( +# ventilations=[ +# models.AirChange(active=models.PeriodicInterval(period=120, duration=120), air_exch=100), +# ], +# ), +baseline_model = models.CO2ConcentrationModel( + room=models.Room(volume=75, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))), + ventilation=models.SlidingWindow( + active=models.PeriodicInterval(period=120, duration=15), + outside_temp=models.PiecewiseConstant((0., 24.), (283.15,)), + window_height=1.6, opening_length=0.6, + ), + CO2_emitters=models.Population( + number=2, + presence=models.SpecificInterval(((8., 12.), (13., 17.))), + mask=models.Mask.types['No mask'], + activity=models.Activity.types['Seated'], + host_immunity=0., + ), + CO2_atmosphere_concentration=440.44, + CO2_fraction_exhaled=0.042, +) + + +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 + + +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 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.initialize_axes() + self.concentration_line = None + self.concentration_area = None + + @property + def widget(self): + return widgets.VBox([ + self.html_output, + 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('CO₂ concentration (ppm)') + ax.set_title('CO₂ Concentration') + + return ax + + def update(self, model: models.CO2ConcentrationModel): + self.update_plot(model) + + def update_plot(self, model: models.CO2ConcentrationModel): + resolution = 600 + ts = np.linspace(sorted(model.CO2_emitters.presence.transition_times())[0], + sorted(model.CO2_emitters.presence.transition_times())[-1], resolution) + concentration = [model.concentration(t) for t in ts] + + if self.concentration_line is None: + [self.concentration_line] = self.ax.plot(ts, concentration, color='#3530fe') + + else: + self.ax.ignore_existing_data_limits = False + self.concentration_line.set_data(ts, concentration) + + if self.concentration_area is None: + self.concentration_area = self.ax.fill_between(x = ts, y1=0, y2=concentration, color="#96cbff", + where = ((model.CO2_emitters.presence.boundaries()[0][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[0][1]) | + (model.CO2_emitters.presence.boundaries()[1][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[1][1]))) + + else: + self.concentration_area.remove() + self.concentration_area = self.ax.fill_between(x = ts, y1=0, y2=concentration, color="#96cbff", + where = ((model.CO2_emitters.presence.boundaries()[0][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[0][1]) | + (model.CO2_emitters.presence.boundaries()[1][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[1][1]))) + + concentration_top = max(np.array(concentration)) + self.ax.set_ylim(bottom=400., top=concentration_top*1.1) + self.ax.set_xlim(left = min(model.CO2_emitters.presence.boundaries()[0])*0.95, + right = max(model.CO2_emitters.presence.boundaries()[1])*1.05) + + figure_legends = [mlines.Line2D([], [], color='#3530fe', markersize=15, label='CO₂ concentration'), + patches.Patch(edgecolor="#96cbff", facecolor='#96cbff', label='Presence of person(s)')] + self.ax.legend(handles=figure_legends) + if 1500 < max(concentration): + self.ax.set_ylim(top=max(concentration)*1.1) + else: + self.ax.set_ylim(top=1550) + self.ax.hlines([800, 1500], xmin=min(model.CO2_emitters.presence.boundaries()[0])*0.95, xmax=max(model.CO2_emitters.presence.boundaries()[1])*1.05, colors=['limegreen', 'salmon'], linestyles='dashed') + self.figure.canvas.draw() + + + +class CO2Application(Controller): + def __init__(self) -> None: + # 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 = [] + self._active_scenario = 0 + self.multi_model_view = MultiModelView(self) + # self.comparison_view = ExposureComparissonResult() + self.current_scenario_figure = ExposureModelResult() + self._results_tab = widgets.Tab(children=( + self.current_scenario_figure.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._results_tab, + ), + ) + self.add_scenario('Scenario 1') + + def build_new_model(self) -> state.DataclassInstanceState[models.CO2ConcentrationModel]: + default_model = state.DataclassInstanceState( + models.CO2ConcentrationModel, + state_builder=CAIMIRAStateBuilder(), + ) + default_model.dcs_update_from(baseline_model) + 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)) + self._active_scenario = len(self._model_scenarios) - 1 + model.dcs_observe(self.notify_model_values_changed) + self.notify_scenarios_changed() + self.notify_model_values_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 set_active_scenario(self, model_id): + index, _, model = self._find_model_id(model_id) + self._active_scenario = index + self.notify_scenarios_changed() + self.notify_model_values_changed() + + def notify_scenarios_changed(self): + """ + 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) + + def notify_model_values_changed(self): + """ + Occurs when *any* value in *any* of the scenarios has been modified. + """ + self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].dcs_instance()) + + +class ModelWidgets(View): + def __init__(self, model_state: state.DataclassState): + #: The widgets that this view produces (inputs and outputs together) + self.widget = widgets.VBox([]) + self.construct_widgets(model_state) + + def construct_widgets(self, model_state: state.DataclassState): + # Build the input widgets. + self._build_widget(model_state) + + def _build_widget(self, node): + self.widget.children += (self._build_room(node.room),) + self.widget.children += (self._build_population(node.CO2_emitters),) + self.widget.children += (self._build_ventilation(node.ventilation),) + + def _build_population(self, node): + return collapsible([widgets.VBox([ + self._build_population_number(node), + self._build_activity(node.activity), + self._build_population_presence(node.presence) + ])], title="Population") + + def _build_room(self,node): + room_volume = widgets.IntSlider(value=node.volume, min=5, max=200, step=5) + humidity = widgets.FloatSlider(value = node.humidity*100, min=20, max=80, step=5) + inside_temp = widgets.IntSlider(value=node.inside_temp.values[0]-273.15, min=15., max=25.) + + def on_volume_change(change): + node.volume = change['new'] + + def on_humidity_change(change): + node.humidity = change['new']/100 + + def on_insidetemp_change(change): + node.inside_temp.values = (change['new']+273.15,) + + room_volume.observe(on_volume_change, names=['value']) + humidity.observe(on_humidity_change, names=['value']) + inside_temp.observe(on_insidetemp_change, names=['value']) + + widget = collapsible( + [ widgets.VBox([ + widgets.HBox([widgets.Label('Room volume (m³)'), room_volume], + layout=widgets.Layout(width='100%', justify_content='space-between')), + widgets.HBox([widgets.Label('Inside temperature (℃)'), inside_temp], + layout=widgets.Layout(width='100%', justify_content='space-between')), + widgets.HBox([widgets.Label('Indoor relative humidity (%)'), humidity], + layout=widgets.Layout(width='100%', justify_content='space-between')),]) + ], title="Specification of workspace" + ) + + return widget + + def _build_activity(self, node): + activity = node.dcs_instance() + for name, activity_ in models.Activity.types.items(): + if activity == activity_: + break + activity = widgets.Dropdown(options=list(models.Activity.types.keys()), value=name) + + def on_activity_change(change): + act = models.Activity.types[change['new']] + node.dcs_update_from(act) + activity.observe(on_activity_change, names=['value']) + + return widgets.HBox([widgets.Label("Activity"), activity], layout=widgets.Layout(justify_content='space-between')) + + def _build_population_number(self, node): + number = widgets.IntSlider(value=node.number, min=1, max=200, step=1) + + def on_population_number_change(change): + node.number = change['new'] + # TODO: Link the state back to the widget, not just the other way around. + number.observe(on_population_number_change, names=['value']) + + return widgets.HBox([widgets.Label('Number of people in the room '), number], layout=widgets.Layout(justify_content='space-between')) + + def _build_population_presence(self, node): + presence_start = widgets.FloatRangeSlider(value = node.present_times[0], min = 8., max=13., step=0.1) + presence_finish = widgets.FloatRangeSlider(value = node.present_times[1], min = 13., max=18., step=0.1) + + def on_presence_start_change(change): + node.present_times = (change['new'], presence_finish.value) + + def on_presence_finish_change(change): + node.present_times = (presence_start.value, change['new']) + + presence_start.observe(on_presence_start_change, names=['value']) + presence_finish.observe(on_presence_finish_change, names=['value']) + + return widgets.HBox([widgets.Label('Population presence'), presence_start, presence_finish], layout = widgets.Layout(justify_content='space-between')) + + def present(self): + return self.widget + + def _build_ventilation( + self, + node: typing.Union[ + state.DataclassStateNamed[models.Ventilation], + state.DataclassStateNamed[models.MultipleVentilation], + ], + ) -> widgets.Widget: + ventilation_widgets = { + 'Natural': self._build_window(node), + 'HVACMechanical': self._build_mechanical(node), + 'HEPAFilter': self._build_HEPA(node), + } + + keys=[("Natural", "Natural"), ("Mechanical", "HVACMechanical"), ("No ventilation", "No ventilation"), ("HEPA Filter", "HEPAFilter")] + + for name, widget in ventilation_widgets.items(): + widget.layout.visible = False + + ventilation_w = widgets.Dropdown( + options=keys, + ) + + def toggle_ventilation(value): + for name, widget in ventilation_widgets.items(): + widget.layout.visible = False + widget.layout.display = 'none' + + if value == 'No ventilation': + node.dcs_select(value) + node.air_exch = 0.25 + + return + + node.dcs_select(value) + + widget = ventilation_widgets[value] + widget.layout.visible = True + widget.layout.display = 'flex' + + ventilation_w.observe(lambda event: toggle_ventilation(event['new']), 'value') + toggle_ventilation(ventilation_w.value) + + w = collapsible( + ([widgets.HBox([widgets.Label('Ventilation type'), ventilation_w], layout=widgets.Layout(justify_content='space-between'))]) + + list(ventilation_widgets.values()), + title='Ventilation scheme', + ) + return w + + def _build_month(self, node) -> WidgetGroup: + + month_choice = widgets.Select(options=list(data.GenevaTemperatures.keys()), value='Jan') + + def on_month_change(change): + node.outside_temp = data.GenevaTemperatures[change['new']] + month_choice.observe(on_month_change, names=['value']) + + return WidgetGroup( + ( + (widgets.Label("Month"), month_choice), + ), + ) + + def _build_outsidetemp(self, node) -> WidgetGroup: + outside_temp = widgets.IntSlider(value=10, min=-10, max=30) + + def on_outsidetemp_change(change): + node.values = (change['new'] + 273.15, ) + + outside_temp.observe(on_outsidetemp_change, names=['value']) + auto_width = widgets.Layout(width='auto') + return WidgetGroup( + ( + ( + widgets.Label('Outside temperature (℃)', layout=auto_width,), + outside_temp, + ), + ), + ) + + def _build_hinged_window(self, node): + hinged_window = widgets.FloatSlider(value=node.window_width, min=0.1, max=2, step=0.1) + + def on_hinged_window_change(change): + node.window_width = change['new'] + + # TODO: Link the state back to the widget, not just the other way around. + hinged_window.observe(on_hinged_window_change, names=['value']) + + return widgets.HBox([widgets.Label('Window width (meters) '), hinged_window], layout=widgets.Layout(justify_content='space-between', width='100%')) + + def _build_sliding_window(self, node): + return widgets.HBox([]) + + def _build_window(self, node) -> WidgetGroup: + window_widgets = { + 'Natural': self._build_sliding_window(node._states['Natural']), + 'Hinged window': self._build_hinged_window(node._states['Hinged window']), + } + + for name, widget in window_widgets.items(): + widget.layout.visible = False + widget.layout.display = 'none' + + window_w = widgets.RadioButtons( + options= list(zip(['Sliding window', 'Hinged window'], window_widgets.keys())), + button_style='info', + layout=widgets.Layout(height='auto', width='auto'), + ) + + def toggle_window(value): + for name, widget in window_widgets.items(): + widget.layout.visible = False + widget.layout.display = 'none' + + node.dcs_select(value) + + widget = window_widgets[value] + widget.layout.visible = True + widget.layout.display = 'flex' + + window_w.observe(lambda event: toggle_window(event['new']), 'value') + toggle_window(window_w.value) + + number_of_windows= widgets.IntText(value= 1, min= 0, max= 5, step=1) + period = widgets.IntSlider(value=node.active.period, min=0, max=240) + interval = widgets.IntSlider(value=node.active.duration, min=0, max=240) + opening_length = widgets.FloatSlider(value=node.opening_length, min=0, max=3, step=0.1) + window_height = widgets.FloatSlider(value=node.window_height, min=0, max=3, step=0.1) + + def on_value_change(change): + node.number_of_windows = change['new'] + + def on_period_change(change): + node.active.period = change['new'] + + def on_interval_change(change): + node.active.duration = change['new'] + + def on_opening_length_change(change): + node.opening_length = change['new'] + + def on_window_height_change(change): + node.window_height = change['new'] + + # TODO: Link the state back to the widget, not just the other way around. + number_of_windows.observe(on_value_change, names=['value']) + period.observe(on_period_change, names=['value']) + interval.observe(on_interval_change, names=['value']) + opening_length.observe(on_opening_length_change, names=['value']) + window_height.observe(on_window_height_change, names=['value']) + + outsidetemp_widgets = { + 'Fixed': self._build_outsidetemp(node.outside_temp), + 'Meteorological data': self._build_month(node), + } + + outsidetemp_w = widgets.Dropdown( + options=outsidetemp_widgets.keys(), + ) + + def toggle_outsidetemp(value): + for name, widget_group in outsidetemp_widgets.items(): + widget_group.set_visible(False) + + widget_group = outsidetemp_widgets[value] + widget_group.set_visible(True) + + outsidetemp_w.observe(lambda event: toggle_outsidetemp(event['new']), 'value') + toggle_outsidetemp(outsidetemp_w.value) + + auto_width = widgets.Layout(width='auto', justify_content='space-between') + result = WidgetGroup( + ( + ( + widgets.Label('Number of windows ', layout=auto_width), + number_of_windows, + ), + ( + widgets.Label('Opening distance (meters)', layout=auto_width), + opening_length, + ), + ( + widgets.Label('Window height (meters)', layout=auto_width), + window_height, + ), + ( + widgets.Label('Interval between openings (minutes)', layout=auto_width), + period, + ), + ( + widgets.Label('Duration of opening (minutes)', layout=auto_width), + interval, + ), + ( + widgets.Label('Outside temperature scheme', layout=auto_width), + outsidetemp_w, + ), + ), + ) + for sub_group in outsidetemp_widgets.values(): + result.add_pairs(sub_group.pairs()) + return widgets.VBox([window_w, widgets.HBox(list(window_widgets.values())), result.build()]) + + def _build_q_air_mech(self, node): + q_air_mech = widgets.FloatSlider(value=node.q_air_mech, min=0, max=5000, step=25) + + def on_q_air_mech_change(change): + node.q_air_mech = change['new'] + + # TODO: Link the state back to the widget, not just the other way around. + q_air_mech.observe(on_q_air_mech_change, names=['value']) + + return widgets.HBox([q_air_mech, widgets.Label('m³/h')]) + + def _build_ach(self, node): + air_exch = widgets.IntSlider(value=node.air_exch, min=0, max=20, step=1) + + def on_air_exch_change(change): + node.air_exch = change['new'] + + # TODO: Link the state back to the widget, not just the other way around. + air_exch.observe(on_air_exch_change, names=['value']) + + return widgets.HBox([air_exch, widgets.Label('h⁻¹')]) + + def _build_mechanical(self, node): + mechanical_widgets = { + 'HVACMechanical': self._build_q_air_mech(node._states['HVACMechanical']), + 'AirChange': self._build_ach(node._states['AirChange']), + } + + for name, widget in mechanical_widgets.items(): + widget.layout.visible = False + + mechanival_w = widgets.RadioButtons( + options=list(zip(['Air supply flow rate (m³/h)', 'Air changes per hour (h⁻¹)'], mechanical_widgets.keys())), + button_style='info', + ) + + def toggle_mechanical(value): + for name, widget in mechanical_widgets.items(): + widget.layout.visible = False + widget.layout.display = 'none' + + node.dcs_select(value) + widget = mechanical_widgets[value] + widget.layout.visible = True + widget.layout.display = 'flex' + + mechanival_w.observe(lambda event: toggle_mechanical(event['new']), 'value') + toggle_mechanical(mechanival_w.value) + + return widgets.VBox([mechanival_w, widgets.HBox(list(mechanical_widgets.values()))]) + + def _build_HEPA( + self, + node, + ) -> widgets.Widget: + + HEPA_w = widgets.FloatSlider(value=node.q_air_mech, min=10, max=500, step=5) + + def on_value_change(change): + node.q_air_mech=change['new'] + + HEPA_w.observe(on_value_change,names= ['value']) + + return widgets.HBox([widgets.Label('HEPA Filtration (m³/h) '),HEPA_w], layout=widgets.Layout(justify_content='space-between')) + +class MultiModelView(View): + def __init__(self, controller: CO2Application): + 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[ModelWidgets] = [] + + def scenarios_updated( + self, + model_scenarios: typing.Sequence[ScenarioType], + active_scenario_index: int + ): + """ + 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) + + 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(ModelWidgets(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 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.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, + style={'description_width': 'auto'}) + duplicate_button = widgets.Button(description='Duplicate Scenario', button_style='success') + model_id = id(model) + + def on_delete_click(b): + self._controller.remove_scenario(model_id) + + def on_rename_text_field(change): + self._controller.rename_scenario(model_id, new_name=change['new']) + + def on_duplicate_click(b): + 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') + # 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)) \ No newline at end of file diff --git a/caimira/apps/simulator/caimira.ipynb b/caimira/apps/simulator/caimira.ipynb new file mode 100644 index 00000000..13501bfd --- /dev/null +++ b/caimira/apps/simulator/caimira.ipynb @@ -0,0 +1,59 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "pycharm": { + "name": "#%%\n" + }, + "scrolled": false + }, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "module 'caimira.apps' has no attribute 'CO2Application'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[1], line 3\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[39mimport\u001b[39;00m \u001b[39mcaimira\u001b[39;00m\u001b[39m.\u001b[39;00m\u001b[39mapps\u001b[39;00m\n\u001b[0;32m----> 3\u001b[0m app \u001b[39m=\u001b[39m caimira\u001b[39m.\u001b[39;49mapps\u001b[39m.\u001b[39;49mCO2Application()\n\u001b[1;32m 4\u001b[0m app\u001b[39m.\u001b[39mwidget\n", + "\u001b[0;31mAttributeError\u001b[0m: module 'caimira.apps' has no attribute 'CO2Application'" + ] + } + ], + "source": [ + "import caimira.apps\n", + "\n", + "app = caimira.apps.CO2Application()\n", + "app.widget" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.9.6 64-bit ('caimira')", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + }, + "vscode": { + "interpreter": { + "hash": "c77495895472738765eb97c8f848f37a4e60c741d594ab92dd40b6b8f4cac818" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/caimira/models.py b/caimira/models.py index a9e5ae01..81d3183a 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -1008,6 +1008,7 @@ class _ConcentrationModelBase: if not self.population.person_present(time): return self.min_background_concentration()/self.normalization_factor() V = self.room.volume + # RR = self.removal_rate(time) if self.removal_rate(time) != 0 else 0.25 RR = self.removal_rate(time) return (1. / (RR * V) + self.min_background_concentration()/ @@ -1210,7 +1211,7 @@ class CO2ConcentrationModel(_ConcentrationModelBase): return self.CO2_emitters def removal_rate(self, time: float) -> _VectorisedFloat: - return self.ventilation.air_exchange(self.room, time) + return self.ventilation.air_exchange(self.room, time) + 0.25 def min_background_concentration(self) -> _VectorisedFloat: """ From 4a965edde6af8a81ab7c3bec531846c8f1d4dab7 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 24 Jan 2023 16:42:08 +0100 Subject: [PATCH 02/24] fixed mypy errors --- caimira/apps/simulator.py | 6 +++--- caimira/models.py | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index f8de9148..4b1a1ace 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -124,8 +124,8 @@ class ExposureModelResult(View): figure_legends = [mlines.Line2D([], [], color='#3530fe', markersize=15, label='CO₂ concentration'), patches.Patch(edgecolor="#96cbff", facecolor='#96cbff', label='Presence of person(s)')] self.ax.legend(handles=figure_legends) - if 1500 < max(concentration): - self.ax.set_ylim(top=max(concentration)*1.1) + if 1500 < concentration_top: + self.ax.set_ylim(top=concentration_top*1.1) else: self.ax.set_ylim(top=1550) self.ax.hlines([800, 1500], xmin=min(model.CO2_emitters.presence.boundaries()[0])*0.95, xmax=max(model.CO2_emitters.presence.boundaries()[1])*1.05, colors=['limegreen', 'salmon'], linestyles='dashed') @@ -139,7 +139,7 @@ class CO2Application(Controller): #: 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 = [] + self._model_scenarios: typing.List[ScenarioType] = [] self._active_scenario = 0 self.multi_model_view = MultiModelView(self) # self.comparison_view = ExposureComparissonResult() diff --git a/caimira/models.py b/caimira/models.py index 81d3183a..22dc8e6d 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -1008,11 +1008,12 @@ class _ConcentrationModelBase: if not self.population.person_present(time): return self.min_background_concentration()/self.normalization_factor() V = self.room.volume - # RR = self.removal_rate(time) if self.removal_rate(time) != 0 else 0.25 RR = self.removal_rate(time) - - return (1. / (RR * V) + self.min_background_concentration()/ + try: + return (1. / (RR * V) + self.min_background_concentration()/ self.normalization_factor()) + except ZeroDivisionError: + return 0 @method_cache def state_change_times(self) -> typing.List[float]: @@ -1211,7 +1212,7 @@ class CO2ConcentrationModel(_ConcentrationModelBase): return self.CO2_emitters def removal_rate(self, time: float) -> _VectorisedFloat: - return self.ventilation.air_exchange(self.room, time) + 0.25 + return self.ventilation.air_exchange(self.room, time) def min_background_concentration(self) -> _VectorisedFloat: """ From b791581fe85c6e1b51c3e6ef1487917bf4a10974 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Feb 2023 10:46:03 +0100 Subject: [PATCH 03/24] updated docker files for expert app --- app-config/caimira-public-docker-image/Dockerfile | 1 + app-config/caimira-public-docker-image/nginx.conf | 6 ++++++ app-config/caimira-webservice/app.sh | 3 +++ app-config/docker-compose.yml | 8 ++++++++ 4 files changed, 18 insertions(+) diff --git a/app-config/caimira-public-docker-image/Dockerfile b/app-config/caimira-public-docker-image/Dockerfile index ee69552b..fe4aa1b8 100644 --- a/app-config/caimira-public-docker-image/Dockerfile +++ b/app-config/caimira-public-docker-image/Dockerfile @@ -15,6 +15,7 @@ COPY ./app-config/caimira-public-docker-image/run_caimira.sh /opt/caimira/start. # In the best case this will be a no-op. RUN cd /opt/caimira/src/ && /opt/caimira/app/bin/pip install -r /opt/caimira/src/requirements.txt RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/expert/*.ipynb +RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/simulator/*.ipynb COPY ./app-config/caimira-public-docker-image/nginx.conf /opt/caimira/nginx.conf EXPOSE 8080 diff --git a/app-config/caimira-public-docker-image/nginx.conf b/app-config/caimira-public-docker-image/nginx.conf index 88cd8e2b..caef1720 100644 --- a/app-config/caimira-public-docker-image/nginx.conf +++ b/app-config/caimira-public-docker-image/nginx.conf @@ -50,6 +50,12 @@ http { rewrite ^/expert-app$ /voila-server/ last; rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; + location /CO2-voila-server/ { + proxy_pass http://localhost:8082/CO2-voila-server/; + } + rewrite ^/CO2-app$ /voila-server/ last; + rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; + location / { proxy_pass http://localhost:8081; } diff --git a/app-config/caimira-webservice/app.sh b/app-config/caimira-webservice/app.sh index 40380a7e..dfa1a237 100755 --- a/app-config/caimira-webservice/app.sh +++ b/app-config/caimira-webservice/app.sh @@ -26,6 +26,9 @@ if [[ "$APP_NAME" == "caimira-webservice" ]]; then elif [[ "$APP_NAME" == "caimira-voila" ]]; then echo "Starting the voila service" voila caimira/apps/expert/ --port=8080 --no-browser --base_url=/voila-server/ --tornado_settings 'allow_origin=*' +elif [[ "$APP_NAME" == "caimira-CO2-voila" ]]; then + echo "Starting the voila service" + voila caimira/apps/simulator/ --port=8080 --no-browser --base_url=/CO2-voila-server/ --tornado_settings 'allow_origin=*' else echo "No APP_NAME specified" exit 1 diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index b2046c21..c2aef12e 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -6,6 +6,12 @@ services: - APP_NAME=caimira-voila user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} + caimira-CO2-app: + image: caimira-webservice + environment: + - APP_NAME=caimira-CO2-voila + user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} + caimira-webservice: image: caimira-webservice environment: @@ -46,6 +52,8 @@ services: condition: service_started caimira-app: condition: service_started + caimira-CO2-app: + condition: service_started auth-service: condition: service_started user: ${CURRENT_UID} From b9418083170cb28ff91ed25c4e6d1e5fb93adab4 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Feb 2023 10:46:14 +0100 Subject: [PATCH 04/24] updated nginx conf file --- app-config/nginx/nginx.conf | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app-config/nginx/nginx.conf b/app-config/nginx/nginx.conf index 2fef5b56..e59de300 100644 --- a/app-config/nginx/nginx.conf +++ b/app-config/nginx/nginx.conf @@ -93,6 +93,25 @@ http { absolute_redirect off; rewrite ^/voila/(.*)$ /voila-server/voila/$1 redirect; + location /CO2-voila-server/ { + proxy_intercept_errors on; + + # Anything under voila-server or CO2-app is authenticated. + auth_request /auth/probe; + error_page 401 = @error401; + error_page 404 = @proxy_404_error_handler; + + # caimira-co2-app is the name of the voila server in each of docker-compose, + # caimira-test.web.cern.ch and caimira.web.cern.ch. + proxy_pass http://caimira-co2-app:8080/CO2-voila-server/; + } + rewrite ^/CO2-app$ /CO2-voila-server/voila/render/caimira.ipynb last; + rewrite ^/(files/static)/(.*)$ /CO2-voila-server/voila/$1/$2 last; + + # Before implementing the nginx router we could access /voila/render/caimira.ipynb. + # Redirect this (and all other) URLs to the new scheme. + rewrite ^/voila/(.*)$ /CO2-voila-server/voila/$1 redirect; + location / { # By default we have no authentication. proxy_pass http://caimira-webservice:8080; From 22f12f67b07da0cffb9491c0a035659beeae1b11 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Feb 2023 10:46:34 +0100 Subject: [PATCH 05/24] updated expert app layout --- caimira/apps/expert/caimira.ipynb | 29 ++++++- caimira/apps/simulator.py | 91 +++++++++++++++++++++- caimira/apps/templates/base/layout.html.j2 | 1 + 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/caimira/apps/expert/caimira.ipynb b/caimira/apps/expert/caimira.ipynb index 747ab1e9..9200ce2a 100644 --- a/caimira/apps/expert/caimira.ipynb +++ b/caimira/apps/expert/caimira.ipynb @@ -12,14 +12,30 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": { "pycharm": { "name": "#%%\n" }, "scrolled": false }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e8c4e2146d4847d5a1443781f2018483", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "HBox(children=(Tab(children=(VBox(children=(VBox(children=(Button(button_style='success', description='Duplica…" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "import caimira.apps\n", "\n", @@ -30,7 +46,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "caimira", "language": "python", "name": "python3" }, @@ -44,7 +60,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.12" + "version": "3.9.6" + }, + "vscode": { + "interpreter": { + "hash": "c77495895472738765eb97c8f848f37a4e60c741d594ab92dd40b6b8f4cac818" + } } }, "nbformat": 4, diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 4b1a1ace..6f1fd789 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -122,7 +122,9 @@ class ExposureModelResult(View): right = max(model.CO2_emitters.presence.boundaries()[1])*1.05) figure_legends = [mlines.Line2D([], [], color='#3530fe', markersize=15, label='CO₂ concentration'), - patches.Patch(edgecolor="#96cbff", facecolor='#96cbff', label='Presence of person(s)')] + mlines.Line2D([], [], color='salmon', markersize=15, label='Insufficient level', linestyle='--'), + mlines.Line2D([], [], color='limegreen', markersize=15, label='Acceptable level', linestyle='--'), + patches.Patch(edgecolor="#96cbff", facecolor='#96cbff', label='Presence of person(s)')] self.ax.legend(handles=figure_legends) if 1500 < concentration_top: self.ax.set_ylim(top=concentration_top*1.1) @@ -132,6 +134,61 @@ class ExposureModelResult(View): self.figure.canvas.draw() +class ExposureComparissonResult(View): + def __init__(self): + self.figure = matplotlib.figure.Figure(figsize=(9, 6)) + ipympl_canvas(self.figure) + self.html_output = widgets.HTML() + 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('CO₂ concentration (ppm)') + ax.set_title('CO₂ Concentration') + + return ax + + def scenarios_updated(self, scenarios: typing.Sequence[ScenarioType], _): + updated_labels, updated_models = zip(*scenarios) + CO2_models = tuple( + model.dcs_instance() for model in updated_models + ) + self.update_plot(CO2_models, updated_labels) + + def update_plot(self, CO2_models: typing.Tuple[models.CO2ConcentrationModel, ...], labels: typing.Tuple[str, ...]): + [line.remove() for line in self.ax.lines] + + start, finish = models_start_end(CO2_models) + colors=['blue', 'red', 'orange', 'yellow', 'pink', 'purple', 'green', 'brown', 'black' ] + ts = np.linspace(start, finish, num=250) + concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in CO2_models] + for label, concentration, color in zip(labels, concentrations, colors): + self.ax.plot(ts, concentration, label=label, color=color) + + concentration_top = max([max(np.array(concentration)) for concentration in concentrations]) + + self.ax.set_ylim(bottom=400., top=concentration_top*1.1) + self.ax.set_xlim(left = start*0.95, + right = finish*1.05) + if 1500 < concentration_top: + self.ax.set_ylim(top=concentration_top*1.1) + else: + self.ax.set_ylim(top=1550) + self.ax.hlines([800, 1500], xmin=start*0.95, xmax=finish*1.05, colors=['limegreen', 'salmon'], linestyles='dashed') + + self.ax.legend() + self.figure.canvas.draw() + class CO2Application(Controller): def __init__(self) -> None: @@ -142,14 +199,16 @@ class CO2Application(Controller): self._model_scenarios: typing.List[ScenarioType] = [] self._active_scenario = 0 self.multi_model_view = MultiModelView(self) - # self.comparison_view = ExposureComparissonResult() + self.comparison_view = ExposureComparissonResult() self.current_scenario_figure = ExposureModelResult() 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._results_tab.titles = ['Current scenario', 'Scenario comparison', "Debug"] self.widget = widgets.HBox( children=( self.multi_model_view.widget, @@ -182,6 +241,18 @@ class CO2Application(Controller): 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_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_scenarios_changed() def set_active_scenario(self, model_id): index, _, model = self._find_model_id(model_id) @@ -195,12 +266,14 @@ class CO2Application(Controller): """ 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): """ Occurs when *any* value in *any* of the scenarios has been modified. """ self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].dcs_instance()) + self.comparison_view.scenarios_updated(self._model_scenarios, self._active_scenario) class ModelWidgets(View): @@ -593,7 +666,7 @@ class MultiModelView(View): 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) + self.widget.titles = [scenario_name for (scenario_name, _) in model_scenarios] # Any remaining model_scenario_ids are no longer needed, so remove # their tabs. @@ -661,4 +734,14 @@ class MultiModelView(View): # 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)) \ No newline at end of file + return widgets.VBox(children=(buttons, rename_text_field)) + + +def models_start_end(models: typing.Sequence[models.CO2ConcentrationModel]) -> typing.Tuple[float, float]: + """ + Returns the earliest start and latest end time of a collection of v objects + + """ + emitters_start = min(model.CO2_emitters.presence.boundaries()[0][0] for model in models) + emitters_finish = min(model.CO2_emitters.presence.boundaries()[-1][1] for model in models) + return emitters_start, emitters_finish diff --git a/caimira/apps/templates/base/layout.html.j2 b/caimira/apps/templates/base/layout.html.j2 index 60983242..f2c49bc7 100644 --- a/caimira/apps/templates/base/layout.html.j2 +++ b/caimira/apps/templates/base/layout.html.j2 @@ -46,6 +46,7 @@
  • Calculator
  • Expert app (beta)
  • +
  • CO₂ Simulator
  • From 1770c319773a85520b4c2cba52c17cc8bef808a2 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Feb 2023 11:11:06 +0100 Subject: [PATCH 06/24] used lower case for CO2 app --- .../caimira-public-docker-image/nginx.conf | 6 +- app-config/caimira-webservice/app.sh | 4 +- app-config/docker-compose.yml | 6 +- app-config/nginx/nginx.conf | 12 ++-- app-config/openshift/deploymentconfig.yaml | 57 +++++++++++++++++++ caimira/apps/templates/base/layout.html.j2 | 2 +- 6 files changed, 72 insertions(+), 15 deletions(-) diff --git a/app-config/caimira-public-docker-image/nginx.conf b/app-config/caimira-public-docker-image/nginx.conf index caef1720..08edcc69 100644 --- a/app-config/caimira-public-docker-image/nginx.conf +++ b/app-config/caimira-public-docker-image/nginx.conf @@ -50,10 +50,10 @@ http { rewrite ^/expert-app$ /voila-server/ last; rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; - location /CO2-voila-server/ { - proxy_pass http://localhost:8082/CO2-voila-server/; + location /co2-voila-server/ { + proxy_pass http://localhost:8082/co2-voila-server/; } - rewrite ^/CO2-app$ /voila-server/ last; + rewrite ^/co2-app$ /voila-server/ last; rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; location / { diff --git a/app-config/caimira-webservice/app.sh b/app-config/caimira-webservice/app.sh index dfa1a237..024c8a28 100755 --- a/app-config/caimira-webservice/app.sh +++ b/app-config/caimira-webservice/app.sh @@ -26,9 +26,9 @@ if [[ "$APP_NAME" == "caimira-webservice" ]]; then elif [[ "$APP_NAME" == "caimira-voila" ]]; then echo "Starting the voila service" voila caimira/apps/expert/ --port=8080 --no-browser --base_url=/voila-server/ --tornado_settings 'allow_origin=*' -elif [[ "$APP_NAME" == "caimira-CO2-voila" ]]; then +elif [[ "$APP_NAME" == "caimira-co2-voila" ]]; then echo "Starting the voila service" - voila caimira/apps/simulator/ --port=8080 --no-browser --base_url=/CO2-voila-server/ --tornado_settings 'allow_origin=*' + voila caimira/apps/simulator/ --port=8080 --no-browser --base_url=/co2-voila-server/ --tornado_settings 'allow_origin=*' else echo "No APP_NAME specified" exit 1 diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index c2aef12e..a6b62067 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -6,10 +6,10 @@ services: - APP_NAME=caimira-voila user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} - caimira-CO2-app: + caimira-co2-app: image: caimira-webservice environment: - - APP_NAME=caimira-CO2-voila + - APP_NAME=caimira-co2-voila user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} caimira-webservice: @@ -52,7 +52,7 @@ services: condition: service_started caimira-app: condition: service_started - caimira-CO2-app: + caimira-co2-app: condition: service_started auth-service: condition: service_started diff --git a/app-config/nginx/nginx.conf b/app-config/nginx/nginx.conf index e59de300..73be04cc 100644 --- a/app-config/nginx/nginx.conf +++ b/app-config/nginx/nginx.conf @@ -93,24 +93,24 @@ http { absolute_redirect off; rewrite ^/voila/(.*)$ /voila-server/voila/$1 redirect; - location /CO2-voila-server/ { + location /co2-voila-server/ { proxy_intercept_errors on; - # Anything under voila-server or CO2-app is authenticated. + # Anything under voila-server or co2-app is authenticated. auth_request /auth/probe; error_page 401 = @error401; error_page 404 = @proxy_404_error_handler; # caimira-co2-app is the name of the voila server in each of docker-compose, # caimira-test.web.cern.ch and caimira.web.cern.ch. - proxy_pass http://caimira-co2-app:8080/CO2-voila-server/; + proxy_pass http://caimira-co2-app:8080/co2-voila-server/; } - rewrite ^/CO2-app$ /CO2-voila-server/voila/render/caimira.ipynb last; - rewrite ^/(files/static)/(.*)$ /CO2-voila-server/voila/$1/$2 last; + rewrite ^/co2-app$ /co2-voila-server/voila/render/caimira.ipynb last; + rewrite ^/(files/static)/(.*)$ /co2-voila-server/voila/$1/$2 last; # Before implementing the nginx router we could access /voila/render/caimira.ipynb. # Redirect this (and all other) URLs to the new scheme. - rewrite ^/voila/(.*)$ /CO2-voila-server/voila/$1 redirect; + rewrite ^/voila/(.*)$ /co2-voila-server/voila/$1 redirect; location / { # By default we have no authentication. diff --git a/app-config/openshift/deploymentconfig.yaml b/app-config/openshift/deploymentconfig.yaml index 456a2d4b..887429ac 100644 --- a/app-config/openshift/deploymentconfig.yaml +++ b/app-config/openshift/deploymentconfig.yaml @@ -125,6 +125,63 @@ kind: ImageStreamTag name: 'caimira-webservice:latest' namespace: ${PROJECT_NAME} + - + apiVersion: apps.openshift.io/v1 + kind: DeploymentConfig + metadata: + name: caimira-co2-app + labels: {app: caimira-co2-app} + spec: + replicas: 1 + template: + metadata: + labels: + app: caimira-co2-app + spec: + containers: + - name: caimira-webservice + env: + - name: APP_NAME + value: caimira-co2-voila + image: '${PROJECT_NAME}/caimira-webservice' + ports: + - containerPort: 8080 + protocol: TCP + imagePullPolicy: Always + resources: + limits: { cpu: '1', memory: 1Gi } + requests: { cpu: 1m, memory: 512Mi } + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: { } + terminationGracePeriodSeconds: 30 + strategy: + activeDeadlineSeconds: 21600 + resources: { } + rollingParams: + intervalSeconds: 1 + maxSurge: 25% + maxUnavailable: 25% + timeoutSeconds: 600 + updatePeriodSeconds: 1 + type: Rolling + test: false + selector: + app: caimira-co2-app + triggers: + - type: ConfigChange + - type: ImageChange + imageChangeParams: + automatic: true + containerNames: + - caimira-webservice + from: + kind: ImageStreamTag + name: 'caimira-webservice:latest' + namespace: ${PROJECT_NAME} - apiVersion: apps.openshift.io/v1 kind: DeploymentConfig diff --git a/caimira/apps/templates/base/layout.html.j2 b/caimira/apps/templates/base/layout.html.j2 index f2c49bc7..48bca715 100644 --- a/caimira/apps/templates/base/layout.html.j2 +++ b/caimira/apps/templates/base/layout.html.j2 @@ -46,7 +46,7 @@
  • Calculator
  • Expert app (beta)
  • -
  • CO₂ Simulator
  • +
  • CO₂ Simulator
  • From 3ea34b6979ea84be7a8db350a9d88d55f65f1305 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Feb 2023 11:32:04 +0100 Subject: [PATCH 07/24] changed docker public image --- app-config/caimira-public-docker-image/nginx.conf | 2 +- app-config/caimira-public-docker-image/run_caimira.sh | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app-config/caimira-public-docker-image/nginx.conf b/app-config/caimira-public-docker-image/nginx.conf index 08edcc69..dd9595d9 100644 --- a/app-config/caimira-public-docker-image/nginx.conf +++ b/app-config/caimira-public-docker-image/nginx.conf @@ -51,7 +51,7 @@ http { rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; location /co2-voila-server/ { - proxy_pass http://localhost:8082/co2-voila-server/; + proxy_pass http://localhost:8083/co2-voila-server/; } rewrite ^/co2-app$ /voila-server/ last; rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; diff --git a/app-config/caimira-public-docker-image/run_caimira.sh b/app-config/caimira-public-docker-image/run_caimira.sh index 22d80533..260b2864 100755 --- a/app-config/caimira-public-docker-image/run_caimira.sh +++ b/app-config/caimira-public-docker-image/run_caimira.sh @@ -12,5 +12,10 @@ cd /opt/caimira/src/caimira --Voila.tornado_settings 'allow_origin=*' \ >> /var/log/expert-app.log 2>&1 & +/opt/caimira/app/bin/python -m voila /opt/caimira/src/caimira/apps/simulator/caimira.ipynb \ + --port=8083 --no-browser --base_url=/co2-voila-server/ \ + --Voila.tornado_settings 'allow_origin=*' \ + >> /var/log/co2-app.log 2>&1 & + # Run the calculator in the foreground. /opt/caimira/app/bin/python -m caimira.apps.calculator --port 8081 --no-debug From 0f8dd421c64d610cc7494a016042458e1a8d2be8 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Feb 2023 14:23:18 +0100 Subject: [PATCH 08/24] updated simulator image --- caimira/apps/simulator.py | 26 +++++------- caimira/apps/simulator/caimira.ipynb | 40 +++++++++++------- .../simulator/static/images/header_image.png | Bin 0 -> 41609 bytes 3 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 caimira/apps/simulator/static/images/header_image.png diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 6f1fd789..1a0b55d0 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -10,15 +10,10 @@ import matplotlib.lines as mlines import matplotlib.patches as patches from .expert import collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder -# ventilation=models.MultipleVentilation( -# ventilations=[ -# models.AirChange(active=models.PeriodicInterval(period=120, duration=120), air_exch=100), -# ], -# ), baseline_model = models.CO2ConcentrationModel( room=models.Room(volume=75, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))), ventilation=models.SlidingWindow( - active=models.PeriodicInterval(period=120, duration=15), + active=models.PeriodicInterval(period=120, duration=15, start=8.), outside_temp=models.PiecewiseConstant((0., 24.), (283.15,)), window_height=1.6, opening_length=0.6, ), @@ -496,8 +491,8 @@ class ModelWidgets(View): toggle_window(window_w.value) number_of_windows= widgets.IntText(value= 1, min= 0, max= 5, step=1) - period = widgets.IntSlider(value=node.active.period, min=0, max=240) - interval = widgets.IntSlider(value=node.active.duration, min=0, max=240) + frequency = widgets.IntSlider(value=node.active.period, min=0, max=120) + duration = widgets.IntSlider(value=node.active.duration, min=0, max=frequency.value-1) opening_length = widgets.FloatSlider(value=node.opening_length, min=0, max=3, step=0.1) window_height = widgets.FloatSlider(value=node.window_height, min=0, max=3, step=0.1) @@ -506,8 +501,9 @@ class ModelWidgets(View): def on_period_change(change): node.active.period = change['new'] + duration.max = change['new'] - 1 - def on_interval_change(change): + def on_duration_change(change): node.active.duration = change['new'] def on_opening_length_change(change): @@ -518,8 +514,8 @@ class ModelWidgets(View): # TODO: Link the state back to the widget, not just the other way around. number_of_windows.observe(on_value_change, names=['value']) - period.observe(on_period_change, names=['value']) - interval.observe(on_interval_change, names=['value']) + frequency.observe(on_period_change, names=['value']) + duration.observe(on_duration_change, names=['value']) opening_length.observe(on_opening_length_change, names=['value']) window_height.observe(on_window_height_change, names=['value']) @@ -558,12 +554,12 @@ class ModelWidgets(View): window_height, ), ( - widgets.Label('Interval between openings (minutes)', layout=auto_width), - period, + widgets.Label('Frequency (minutes)', layout=auto_width), + frequency, ), ( - widgets.Label('Duration of opening (minutes)', layout=auto_width), - interval, + widgets.Label('Duration (minutes)', layout=auto_width), + duration, ), ( widgets.Label('Outside temperature scheme', layout=auto_width), diff --git a/caimira/apps/simulator/caimira.ipynb b/caimira/apps/simulator/caimira.ipynb index 13501bfd..259328ac 100644 --- a/caimira/apps/simulator/caimira.ipynb +++ b/caimira/apps/simulator/caimira.ipynb @@ -1,25 +1,35 @@ { "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
    \n", + "
    \n", + "

    \n", + "Please see the CAiMIRA homepage for details on the methodology, assumptions and limitations of CAiMIRA.

    " + ] + }, { "cell_type": "code", "execution_count": 1, - "metadata": { - "pycharm": { - "name": "#%%\n" - }, - "scrolled": false - }, + "metadata": {}, "outputs": [ { - "ename": "AttributeError", - "evalue": "module 'caimira.apps' has no attribute 'CO2Application'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[1], line 3\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[39mimport\u001b[39;00m \u001b[39mcaimira\u001b[39;00m\u001b[39m.\u001b[39;00m\u001b[39mapps\u001b[39;00m\n\u001b[0;32m----> 3\u001b[0m app \u001b[39m=\u001b[39m caimira\u001b[39m.\u001b[39;49mapps\u001b[39m.\u001b[39;49mCO2Application()\n\u001b[1;32m 4\u001b[0m app\u001b[39m.\u001b[39mwidget\n", - "\u001b[0;31mAttributeError\u001b[0m: module 'caimira.apps' has no attribute 'CO2Application'" - ] + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "888611458e044cd3b7cda7dfcf1fdbe4", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "HBox(children=(Tab(children=(VBox(children=(VBox(children=(Button(button_style='success', description='Duplica…" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ diff --git a/caimira/apps/simulator/static/images/header_image.png b/caimira/apps/simulator/static/images/header_image.png new file mode 100644 index 0000000000000000000000000000000000000000..5f2a27023c8900a6ea87773add3efaa72746fd6e GIT binary patch literal 41609 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 Date: Thu, 23 Feb 2023 15:06:42 +0100 Subject: [PATCH 09/24] bounded min value for start ventilation --- caimira/apps/simulator.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 1a0b55d0..17879927 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -283,14 +283,14 @@ class ModelWidgets(View): def _build_widget(self, node): self.widget.children += (self._build_room(node.room),) - self.widget.children += (self._build_population(node.CO2_emitters),) + self.widget.children += (self._build_population(node.CO2_emitters, node.ventilation),) self.widget.children += (self._build_ventilation(node.ventilation),) - def _build_population(self, node): + def _build_population(self, node, ventilation_node): return collapsible([widgets.VBox([ self._build_population_number(node), self._build_activity(node.activity), - self._build_population_presence(node.presence) + self._build_population_presence(node.presence, ventilation_node) ])], title="Population") def _build_room(self,node): @@ -348,12 +348,13 @@ class ModelWidgets(View): return widgets.HBox([widgets.Label('Number of people in the room '), number], layout=widgets.Layout(justify_content='space-between')) - def _build_population_presence(self, node): + def _build_population_presence(self, node, ventilation_node): presence_start = widgets.FloatRangeSlider(value = node.present_times[0], min = 8., max=13., step=0.1) presence_finish = widgets.FloatRangeSlider(value = node.present_times[1], min = 13., max=18., step=0.1) def on_presence_start_change(change): node.present_times = (change['new'], presence_finish.value) + ventilation_node.active.start = node.present_times[0][0] def on_presence_finish_change(change): node.present_times = (presence_start.value, change['new']) From 8782069a9c3271587083b32ac7db300ddfcf15ea Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Feb 2023 16:15:35 +0100 Subject: [PATCH 10/24] added services and fixed ventilation --- app-config/openshift/services.yaml | 17 +++++++++++++++++ caimira/models.py | 4 +++- .../models/test_co2_concentration_model.py | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app-config/openshift/services.yaml b/app-config/openshift/services.yaml index d6aaf814..2d01387e 100644 --- a/app-config/openshift/services.yaml +++ b/app-config/openshift/services.yaml @@ -44,6 +44,23 @@ deploymentconfig: caimira-app sessionAffinity: 'None' type: 'ClusterIP' + - + apiVersion: v1 + kind: Service + metadata: + labels: + app: caimira-co2-app + name: caimira-co2-app + spec: + ports: + - name: 8080-tcp + port: 8080 + protocol: TCP + targetPort: 8080 + selector: + deploymentconfig: caimira-co2-app + sessionAffinity: 'None' + type: 'ClusterIP' - apiVersion: v1 kind: Service diff --git a/caimira/models.py b/caimira/models.py index 22dc8e6d..87f98776 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -1212,7 +1212,9 @@ class CO2ConcentrationModel(_ConcentrationModelBase): return self.CO2_emitters def removal_rate(self, time: float) -> _VectorisedFloat: - return self.ventilation.air_exchange(self.room, time) + # 0.25 is a minimal, always present source of ventilation, due + # to the air infiltration from the outside. + return self.ventilation.air_exchange(self.room, time) + 0.25 def min_background_concentration(self) -> _VectorisedFloat: """ diff --git a/caimira/tests/models/test_co2_concentration_model.py b/caimira/tests/models/test_co2_concentration_model.py index ad4a348c..8c20a994 100644 --- a/caimira/tests/models/test_co2_concentration_model.py +++ b/caimira/tests/models/test_co2_concentration_model.py @@ -8,7 +8,7 @@ from caimira import models def simple_co2_conc_model(): return models.CO2ConcentrationModel( room=models.Room(200, models.PiecewiseConstant((0., 24.), (293,))), - ventilation=models.AirChange(models.PeriodicInterval(period=120, duration=120), 0.25), + ventilation=models.AirChange(models.PeriodicInterval(period=120, duration=120), 0.), CO2_emitters=models.Population( number=5, presence=models.SpecificInterval((([0., 4.], ))), From 830c0d0edbec92dfe64bb2012025c9d9c340801f Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 24 Feb 2023 10:54:47 +0100 Subject: [PATCH 11/24] modified test values to consider min ventilation --- caimira/models.py | 2 +- caimira/tests/models/test_co2_concentration_model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/caimira/models.py b/caimira/models.py index 87f98776..140f3935 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -1214,7 +1214,7 @@ class CO2ConcentrationModel(_ConcentrationModelBase): def removal_rate(self, time: float) -> _VectorisedFloat: # 0.25 is a minimal, always present source of ventilation, due # to the air infiltration from the outside. - return self.ventilation.air_exchange(self.room, time) + 0.25 + return self.ventilation.air_exchange(self.room, time) + 1e-6 def min_background_concentration(self) -> _VectorisedFloat: """ diff --git a/caimira/tests/models/test_co2_concentration_model.py b/caimira/tests/models/test_co2_concentration_model.py index 8c20a994..d1a8a6db 100644 --- a/caimira/tests/models/test_co2_concentration_model.py +++ b/caimira/tests/models/test_co2_concentration_model.py @@ -8,7 +8,7 @@ from caimira import models def simple_co2_conc_model(): return models.CO2ConcentrationModel( room=models.Room(200, models.PiecewiseConstant((0., 24.), (293,))), - ventilation=models.AirChange(models.PeriodicInterval(period=120, duration=120), 0.), + ventilation=models.AirChange(models.PeriodicInterval(period=120, duration=120), 0.25-(1e-6)), CO2_emitters=models.Population( number=5, presence=models.SpecificInterval((([0., 4.], ))), From a15f32d2a7eb434e32edffc94952405dec3642c3 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 24 Feb 2023 11:22:42 +0100 Subject: [PATCH 12/24] reversed tab namming --- caimira/apps/expert.py | 5 +++-- caimira/apps/simulator.py | 7 +++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/caimira/apps/expert.py b/caimira/apps/expert.py index 842fd7a3..b93ec3f1 100644 --- a/caimira/apps/expert.py +++ b/caimira/apps/expert.py @@ -940,7 +940,8 @@ class ExpertApplication(Controller): self.comparison_view.widget, # self._debug_output, )) - self._results_tab.titles = ['Current scenario', 'Scenario comparison', "Debug"] + 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, @@ -1036,7 +1037,7 @@ class MultiModelView(View): self.add_tab(scenario_name, model) model_scenario_ids.append(id(model)) tab_index = self._tab_model_ids.index(id(model)) - self.widget.titles = [scenario_name for (scenario_name, _) in model_scenarios] + self.widget.set_title(tab_index, scenario_name) # Any remaining model_scenario_ids are no longer needed, so remove # their tabs. diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 17879927..b0a952f8 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -201,9 +201,8 @@ class CO2Application(Controller): 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._results_tab.titles = ['Current scenario', 'Scenario comparison', "Debug"] + 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, @@ -663,7 +662,7 @@ class MultiModelView(View): self.add_tab(scenario_name, model) model_scenario_ids.append(id(model)) tab_index = self._tab_model_ids.index(id(model)) - self.widget.titles = [scenario_name for (scenario_name, _) in model_scenarios] + self.widget.set_title(tab_index, scenario_name) # Any remaining model_scenario_ids are no longer needed, so remove # their tabs. From 25cea295322ffed3530f2f374c918559ff4ada06 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 24 Feb 2023 15:45:03 +0100 Subject: [PATCH 13/24] updated correct package versions --- caimira/apps/expert.py | 9 ++++++--- caimira/apps/simulator.py | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/caimira/apps/expert.py b/caimira/apps/expert.py index b93ec3f1..6bc721d3 100644 --- a/caimira/apps/expert.py +++ b/caimira/apps/expert.py @@ -253,8 +253,9 @@ class ExposureComparissonResult(View): self.update_plot(exp_models, updated_labels) def update_plot(self, exp_models: typing.Tuple[models.ExposureModel, ...], labels: typing.Tuple[str, ...]): - [line.remove() for line in self.ax.lines] - [line.remove() for line in self.ax2.lines] + self.ax.lines = [] + self.ax2.lines = [] + start, finish = models_start_end(exp_models) colors=['blue', 'red', 'orange', 'yellow', 'pink', 'purple', 'green', 'brown', 'black' ] ts = np.linspace(start, finish, num=250) @@ -1105,7 +1106,9 @@ class MultiModelView(View): # 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)) + # TODO put back the delete button. + # return widgets.VBox(children=(buttons, rename_text_field)) + return widgets.VBox(children=(duplicate_button, rename_text_field)) def models_start_end(models: typing.Sequence[models.ExposureModel]) -> typing.Tuple[float, float]: diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index b0a952f8..123b4cfa 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -161,7 +161,7 @@ class ExposureComparissonResult(View): self.update_plot(CO2_models, updated_labels) def update_plot(self, CO2_models: typing.Tuple[models.CO2ConcentrationModel, ...], labels: typing.Tuple[str, ...]): - [line.remove() for line in self.ax.lines] + self.ax.lines = [] start, finish = models_start_end(CO2_models) colors=['blue', 'red', 'orange', 'yellow', 'pink', 'purple', 'green', 'brown', 'black' ] @@ -203,6 +203,7 @@ class CO2Application(Controller): )) 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, @@ -227,7 +228,6 @@ class CO2Application(Controller): self._active_scenario = len(self._model_scenarios) - 1 model.dcs_observe(self.notify_model_values_changed) self.notify_scenarios_changed() - self.notify_model_values_changed() def _find_model_id(self, model_id): for index, (name, model) in enumerate(list(self._model_scenarios)): @@ -730,7 +730,9 @@ class MultiModelView(View): # 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)) + # TODO put back the delete button. + # return widgets.VBox(children=(buttons, rename_text_field)) + return widgets.VBox(children=(duplicate_button, rename_text_field)) def models_start_end(models: typing.Sequence[models.CO2ConcentrationModel]) -> typing.Tuple[float, float]: From 80144976994f3f0cfacb3cd9a7155e23b57292a4 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 27 Feb 2023 12:25:30 +0100 Subject: [PATCH 14/24] added handles for labels. remove button is working. fixed ventilation start time --- caimira/apps/expert.py | 19 ++++++++++--------- caimira/apps/simulator.py | 37 +++++++++++++++++++------------------ 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/caimira/apps/expert.py b/caimira/apps/expert.py index 6bc721d3..9214ad3a 100644 --- a/caimira/apps/expert.py +++ b/caimira/apps/expert.py @@ -253,9 +253,9 @@ class ExposureComparissonResult(View): self.update_plot(exp_models, updated_labels) def update_plot(self, exp_models: typing.Tuple[models.ExposureModel, ...], labels: typing.Tuple[str, ...]): - self.ax.lines = [] - self.ax2.lines = [] - + [line.remove() for line in self.ax.lines] + [line.remove() for line in self.ax2.lines] + start, finish = models_start_end(exp_models) colors=['blue', 'red', 'orange', 'yellow', 'pink', 'purple', 'green', 'brown', 'black' ] ts = np.linspace(start, finish, num=250) @@ -276,7 +276,10 @@ class ExposureComparissonResult(View): cumulative_top = max([max(cumulative_dose) for cumulative_dose in cumulative_doses]) self.ax2.set_ylim(bottom=0., top=cumulative_top) - self.ax.legend() + handles, labels = self.figure.gca().get_legend_handles_labels() + by_label = dict(zip(labels, handles)) + self.ax.legend(by_label.values(), by_label.keys()) + self.figure.canvas.draw() @@ -943,6 +946,7 @@ class ExpertApplication(Controller): )) 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, @@ -1069,8 +1073,7 @@ class MultiModelView(View): 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): @@ -1106,9 +1109,7 @@ class MultiModelView(View): # 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 - # TODO put back the delete button. - # return widgets.VBox(children=(buttons, rename_text_field)) - return widgets.VBox(children=(duplicate_button, rename_text_field)) + return widgets.VBox(children=(buttons, rename_text_field)) def models_start_end(models: typing.Sequence[models.ExposureModel]) -> typing.Tuple[float, float]: diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 123b4cfa..22b64913 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -13,7 +13,7 @@ from .expert import collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder baseline_model = models.CO2ConcentrationModel( room=models.Room(volume=75, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))), ventilation=models.SlidingWindow( - active=models.PeriodicInterval(period=120, duration=15, start=8.), + active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)), outside_temp=models.PiecewiseConstant((0., 24.), (283.15,)), window_height=1.6, opening_length=0.6, ), @@ -144,8 +144,7 @@ class ExposureComparissonResult(View): 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.spines[['right', 'top']].set_visible(False) ax.set_xlabel('Time (hours)') ax.set_ylabel('CO₂ concentration (ppm)') @@ -161,11 +160,12 @@ class ExposureComparissonResult(View): self.update_plot(CO2_models, updated_labels) def update_plot(self, CO2_models: typing.Tuple[models.CO2ConcentrationModel, ...], labels: typing.Tuple[str, ...]): - self.ax.lines = [] + self.ax.cla() start, finish = models_start_end(CO2_models) colors=['blue', 'red', 'orange', 'yellow', 'pink', 'purple', 'green', 'brown', 'black' ] ts = np.linspace(start, finish, num=250) + concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in CO2_models] for label, concentration, color in zip(labels, concentrations, colors): self.ax.plot(ts, concentration, label=label, color=color) @@ -182,7 +182,8 @@ class ExposureComparissonResult(View): self.ax.hlines([800, 1500], xmin=start*0.95, xmax=finish*1.05, colors=['limegreen', 'salmon'], linestyles='dashed') self.ax.legend() - self.figure.canvas.draw() + self.figure.canvas.draw_idle() + self.figure.canvas.flush_events() class CO2Application(Controller): @@ -244,8 +245,9 @@ class CO2Application(Controller): 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.multi_model_view.remove_tab(index) + + model.dcs_observe(self.notify_model_values_changed) self.notify_scenarios_changed() def set_active_scenario(self, model_id): @@ -266,8 +268,8 @@ class CO2Application(Controller): """ Occurs when *any* value in *any* of the scenarios has been modified. """ - self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].dcs_instance()) self.comparison_view.scenarios_updated(self._model_scenarios, self._active_scenario) + self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].dcs_instance()) class ModelWidgets(View): @@ -283,7 +285,7 @@ class ModelWidgets(View): def _build_widget(self, node): self.widget.children += (self._build_room(node.room),) self.widget.children += (self._build_population(node.CO2_emitters, node.ventilation),) - self.widget.children += (self._build_ventilation(node.ventilation),) + self.widget.children += (self._build_ventilation(node.ventilation, node.CO2_emitters),) def _build_population(self, node, ventilation_node): return collapsible([widgets.VBox([ @@ -352,8 +354,8 @@ class ModelWidgets(View): presence_finish = widgets.FloatRangeSlider(value = node.present_times[1], min = 13., max=18., step=0.1) def on_presence_start_change(change): + ventilation_node.active.start = change['new'][0] - ventilation_node.active.duration / 60 node.present_times = (change['new'], presence_finish.value) - ventilation_node.active.start = node.present_times[0][0] def on_presence_finish_change(change): node.present_times = (presence_start.value, change['new']) @@ -372,9 +374,10 @@ class ModelWidgets(View): state.DataclassStateNamed[models.Ventilation], state.DataclassStateNamed[models.MultipleVentilation], ], + emitters_node: models.Population, ) -> widgets.Widget: ventilation_widgets = { - 'Natural': self._build_window(node), + 'Natural': self._build_window(node, emitters_node), 'HVACMechanical': self._build_mechanical(node), 'HEPAFilter': self._build_HEPA(node), } @@ -460,7 +463,7 @@ class ModelWidgets(View): def _build_sliding_window(self, node): return widgets.HBox([]) - def _build_window(self, node) -> WidgetGroup: + def _build_window(self, node, emitters_node) -> WidgetGroup: window_widgets = { 'Natural': self._build_sliding_window(node._states['Natural']), 'Hinged window': self._build_hinged_window(node._states['Hinged window']), @@ -504,6 +507,7 @@ class ModelWidgets(View): duration.max = change['new'] - 1 def on_duration_change(change): + node.active.start = emitters_node.presence.present_times[0][0] - change['new'] / 60 node.active.duration = change['new'] def on_opening_length_change(change): @@ -693,8 +697,7 @@ class MultiModelView(View): 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): @@ -730,10 +733,8 @@ class MultiModelView(View): # 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 - # TODO put back the delete button. - # return widgets.VBox(children=(buttons, rename_text_field)) - return widgets.VBox(children=(duplicate_button, rename_text_field)) - + + return widgets.VBox(children=(buttons, rename_text_field)) def models_start_end(models: typing.Sequence[models.CO2ConcentrationModel]) -> typing.Tuple[float, float]: """ From b01a4efd463317ef7b879a9da8814e7530711a19 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 27 Feb 2023 23:32:52 +0100 Subject: [PATCH 15/24] added co2 atmospheric concentration widget --- caimira/apps/simulator.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 22b64913..a44694a4 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -112,7 +112,7 @@ class ExposureModelResult(View): (model.CO2_emitters.presence.boundaries()[1][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[1][1]))) concentration_top = max(np.array(concentration)) - self.ax.set_ylim(bottom=400., top=concentration_top*1.1) + self.ax.set_ylim(bottom=model.CO2_atmosphere_concentration * 0.9, top=concentration_top*1.1) self.ax.set_xlim(left = min(model.CO2_emitters.presence.boundaries()[0])*0.95, right = max(model.CO2_emitters.presence.boundaries()[1])*1.05) @@ -171,8 +171,9 @@ class ExposureComparissonResult(View): self.ax.plot(ts, concentration, label=label, color=color) concentration_top = max([max(np.array(concentration)) for concentration in concentrations]) + concentration_min = min([model.CO2_atmosphere_concentration for model in CO2_models]) - self.ax.set_ylim(bottom=400., top=concentration_top*1.1) + self.ax.set_ylim(bottom=concentration_min * 0.9, top=concentration_top*1.1) self.ax.set_xlim(left = start*0.95, right = finish*1.05) if 1500 < concentration_top: @@ -285,7 +286,13 @@ class ModelWidgets(View): def _build_widget(self, node): self.widget.children += (self._build_room(node.room),) self.widget.children += (self._build_population(node.CO2_emitters, node.ventilation),) + self.widget.children += (self._build_atmospheric_concentration(node),) self.widget.children += (self._build_ventilation(node.ventilation, node.CO2_emitters),) + + def _build_atmospheric_concentration(self, node): + return collapsible([widgets.VBox([ + self._build_co2_concentration(node), + ])], title="Carbon Dioxide") def _build_population(self, node, ventilation_node): return collapsible([widgets.VBox([ @@ -293,6 +300,16 @@ class ModelWidgets(View): self._build_activity(node.activity), self._build_population_presence(node.presence, ventilation_node) ])], title="Population") + + def _build_co2_concentration(self, node): + concentration = widgets.IntSlider(value=node.CO2_atmosphere_concentration, min=300, max=1000, step=10) + + def on_atmospheric_concentration_change(change): + node.CO2_atmosphere_concentration = change['new'] + # TODO: Link the state back to the widget, not just the other way around. + concentration.observe(on_atmospheric_concentration_change, names=['value']) + + return widgets.HBox([widgets.Label('Atmospheric Concentration (ppm) '), concentration], layout=widgets.Layout(justify_content='space-between')) def _build_room(self,node): room_volume = widgets.IntSlider(value=node.volume, min=5, max=200, step=5) From 9d52bd41ecfec5419379da513a96591978a35f06 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 27 Feb 2023 23:48:17 +0100 Subject: [PATCH 16/24] added scenario default values --- caimira/apps/simulator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index a44694a4..3db1938a 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -10,15 +10,19 @@ import matplotlib.lines as mlines import matplotlib.patches as patches from .expert import collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder + +# ventilation=models.HVACMechanical(active=models.PeriodicInterval(period=120, duration=120), q_air_mech=500), + + baseline_model = models.CO2ConcentrationModel( - room=models.Room(volume=75, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))), + room=models.Room(volume=120, humidity=0.5, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))), ventilation=models.SlidingWindow( active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)), outside_temp=models.PiecewiseConstant((0., 24.), (283.15,)), window_height=1.6, opening_length=0.6, ), CO2_emitters=models.Population( - number=2, + number=10, presence=models.SpecificInterval(((8., 12.), (13., 17.))), mask=models.Mask.types['No mask'], activity=models.Activity.types['Seated'], From 9c810e17e8e2ab0883fd23655b7ba347dcdd5f25 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 28 Feb 2023 17:04:09 +0100 Subject: [PATCH 17/24] updated ventilation scheme --- caimira/apps/simulator.py | 96 ++++++++++++++++++++++++------------- caimira/state.py | 2 - caimira/tests/test_state.py | 3 -- 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 3db1938a..722b59b9 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -11,16 +11,9 @@ import matplotlib.patches as patches from .expert import collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder -# ventilation=models.HVACMechanical(active=models.PeriodicInterval(period=120, duration=120), q_air_mech=500), - - baseline_model = models.CO2ConcentrationModel( room=models.Room(volume=120, humidity=0.5, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))), - ventilation=models.SlidingWindow( - active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)), - outside_temp=models.PiecewiseConstant((0., 24.), (283.15,)), - window_height=1.6, opening_length=0.6, - ), + ventilation=models.HVACMechanical(active=models.PeriodicInterval(period=120, duration=120), q_air_mech=500), CO2_emitters=models.Population( number=10, presence=models.SpecificInterval(((8., 12.), (13., 17.))), @@ -221,7 +214,7 @@ class CO2Application(Controller): def build_new_model(self) -> state.DataclassInstanceState[models.CO2ConcentrationModel]: default_model = state.DataclassInstanceState( models.CO2ConcentrationModel, - state_builder=CAIMIRAStateBuilder(), + state_builder=CAIMIRACO2StateBuilder(), ) default_model.dcs_update_from(baseline_model) return default_model @@ -251,7 +244,8 @@ class CO2Application(Controller): index, _, model = self._find_model_id(model_id) self._model_scenarios.pop(index) self.multi_model_view.remove_tab(index) - + self._active_scenario = index - 1 + model.dcs_observe(self.notify_model_values_changed) self.notify_scenarios_changed() @@ -310,7 +304,7 @@ class ModelWidgets(View): def on_atmospheric_concentration_change(change): node.CO2_atmosphere_concentration = change['new'] - # TODO: Link the state back to the widget, not just the other way around. + concentration.observe(on_atmospheric_concentration_change, names=['value']) return widgets.HBox([widgets.Label('Atmospheric Concentration (ppm) '), concentration], layout=widgets.Layout(justify_content='space-between')) @@ -365,7 +359,7 @@ class ModelWidgets(View): def on_population_number_change(change): node.number = change['new'] - # TODO: Link the state back to the widget, not just the other way around. + number.observe(on_population_number_change, names=['value']) return widgets.HBox([widgets.Label('Number of people in the room '), number], layout=widgets.Layout(justify_content='space-between')) @@ -398,12 +392,13 @@ class ModelWidgets(View): emitters_node: models.Population, ) -> widgets.Widget: ventilation_widgets = { - 'Natural': self._build_window(node, emitters_node), 'HVACMechanical': self._build_mechanical(node), - 'HEPAFilter': self._build_HEPA(node), + 'Sliding window': self._build_window(node, emitters_node), + 'HEPAFilter': self._build_HEPA(node._states['HEPAFilter']), + 'No ventilation': self._build_no_ventilation(node._states['No ventilation']), } - keys=[("Natural", "Natural"), ("Mechanical", "HVACMechanical"), ("No ventilation", "No ventilation"), ("HEPA Filter", "HEPAFilter")] + keys=[("Mechanical", "HVACMechanical"), ("Natural", "Sliding window"), ("No ventilation", "No ventilation"), ("HEPA Filter", "HEPAFilter")] for name, widget in ventilation_widgets.items(): widget.layout.visible = False @@ -417,12 +412,6 @@ class ModelWidgets(View): widget.layout.visible = False widget.layout.display = 'none' - if value == 'No ventilation': - node.dcs_select(value) - node.air_exch = 0.25 - - return - node.dcs_select(value) widget = ventilation_widgets[value] @@ -476,7 +465,6 @@ class ModelWidgets(View): def on_hinged_window_change(change): node.window_width = change['new'] - # TODO: Link the state back to the widget, not just the other way around. hinged_window.observe(on_hinged_window_change, names=['value']) return widgets.HBox([widgets.Label('Window width (meters) '), hinged_window], layout=widgets.Layout(justify_content='space-between', width='100%')) @@ -486,7 +474,7 @@ class ModelWidgets(View): def _build_window(self, node, emitters_node) -> WidgetGroup: window_widgets = { - 'Natural': self._build_sliding_window(node._states['Natural']), + 'Sliding window': self._build_sliding_window(node._states['Sliding window']), 'Hinged window': self._build_hinged_window(node._states['Hinged window']), } @@ -516,7 +504,7 @@ class ModelWidgets(View): number_of_windows= widgets.IntText(value= 1, min= 0, max= 5, step=1) frequency = widgets.IntSlider(value=node.active.period, min=0, max=120) - duration = widgets.IntSlider(value=node.active.duration, min=0, max=frequency.value-1) + duration = widgets.IntSlider(value=node.active.duration, min=0, max=frequency.value) opening_length = widgets.FloatSlider(value=node.opening_length, min=0, max=3, step=0.1) window_height = widgets.FloatSlider(value=node.window_height, min=0, max=3, step=0.1) @@ -525,7 +513,7 @@ class ModelWidgets(View): def on_period_change(change): node.active.period = change['new'] - duration.max = change['new'] - 1 + duration.max = change['new'] def on_duration_change(change): node.active.start = emitters_node.presence.present_times[0][0] - change['new'] / 60 @@ -537,7 +525,6 @@ class ModelWidgets(View): def on_window_height_change(change): node.window_height = change['new'] - # TODO: Link the state back to the widget, not just the other way around. number_of_windows.observe(on_value_change, names=['value']) frequency.observe(on_period_change, names=['value']) duration.observe(on_duration_change, names=['value']) @@ -602,7 +589,6 @@ class ModelWidgets(View): def on_q_air_mech_change(change): node.q_air_mech = change['new'] - # TODO: Link the state back to the widget, not just the other way around. q_air_mech.observe(on_q_air_mech_change, names=['value']) return widgets.HBox([q_air_mech, widgets.Label('m³/h')]) @@ -613,14 +599,13 @@ class ModelWidgets(View): def on_air_exch_change(change): node.air_exch = change['new'] - # TODO: Link the state back to the widget, not just the other way around. air_exch.observe(on_air_exch_change, names=['value']) return widgets.HBox([air_exch, widgets.Label('h⁻¹')]) def _build_mechanical(self, node): mechanical_widgets = { - 'HVACMechanical': self._build_q_air_mech(node._states['HVACMechanical']), + 'HVACMechanical': self._build_q_air_mech(node), 'AirChange': self._build_ach(node._states['AirChange']), } @@ -661,6 +646,10 @@ class ModelWidgets(View): return widgets.HBox([widgets.Label('HEPA Filtration (m³/h) '),HEPA_w], layout=widgets.Layout(justify_content='space-between')) + def _build_no_ventilation(self, node): + return widgets.HBox([]) + + class MultiModelView(View): def __init__(self, controller: CO2Application): self._controller = controller @@ -698,6 +687,7 @@ class MultiModelView(View): 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(ModelWidgets(model)) @@ -733,7 +723,7 @@ class MultiModelView(View): delete_button = widgets.Button(description='Delete Scenario', button_style='danger') rename_text_field = widgets.Text(description='Rename Scenario:', value=name, style={'description_width': 'auto'}) - duplicate_button = widgets.Button(description='Duplicate Scenario', button_style='success') + duplicate_button = widgets.Button(description='Replicate Scenario', button_style='success') model_id = id(model) def on_delete_click(b): @@ -750,12 +740,52 @@ class MultiModelView(View): delete_button.on_click(on_delete_click) duplicate_button.on_click(on_duplicate_click) rename_text_field.observe(on_rename_text_field, 'value') - # 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)) + return widgets.VBox(children=(buttons, rename_text_field)) + + +class CAIMIRACO2StateBuilder(CAIMIRAStateBuilder): + + def build_type__VentilationBase(self, _: dataclasses.Field): + s: state.DataclassStateNamed = state.DataclassStateNamed( + states={ + 'HVACMechanical': self.build_generic(models.HVACMechanical), + 'Sliding window': self.build_generic(models.WindowOpening), + 'No ventilation': self.build_generic(models.AirChange), + 'AirChange': self.build_generic(models.AirChange), + 'Hinged window': self.build_generic(models.WindowOpening), + 'HEPAFilter': self.build_generic(models.HEPAFilter), + }, + state_builder=self, + ) + s._states['Sliding window'].dcs_update_from( + models.SlidingWindow(active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)), + outside_temp=models.PiecewiseConstant((0,24.), (283.15,)), + window_height=1.6, opening_length=0.6, + ), + ) + #Initialise the "Hinged window" state + s._states['Hinged window'].dcs_update_from( + models.HingedWindow(active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)), + outside_temp=models.PiecewiseConstant((0,24.), (283.15,)), + window_height=1.6, opening_length=0.6, + window_width=10. + ), + ) + s._states['AirChange'].dcs_update_from( + models.AirChange(models.PeriodicInterval(period=24*60, duration=24*60), 10.) + ) + # Initialize the "No ventilation" state + s._states['No ventilation'].dcs_update_from( + models.AirChange(active=models.PeriodicInterval(period=60, duration=60), air_exch=0.) + ) + s._states['HEPAFilter'].dcs_update_from( + models.HEPAFilter(active=models.PeriodicInterval(period=60, duration=60), q_air_mech=500.) + ) + return s def models_start_end(models: typing.Sequence[models.CO2ConcentrationModel]) -> typing.Tuple[float, float]: """ diff --git a/caimira/state.py b/caimira/state.py index b990e7cf..6fecac0d 100644 --- a/caimira/state.py +++ b/caimira/state.py @@ -228,8 +228,6 @@ class DataclassInstanceState(DataclassState[Datamodel_T]): def dcs_set_instance_type(self, instance_dataclass: typing.Type[Datamodel_T]): if not dataclasses.is_dataclass(instance_dataclass): raise TypeError("The given class is not a valid dataclass") - if not issubclass(instance_dataclass, self._base): - raise TypeError(f"The dataclass type provided ({instance_dataclass}) must be a subclass of the base ({self._base})") self._instance_type = instance_dataclass # TODO: It is possible to cut observer connections by clearing like this. diff --git a/caimira/tests/test_state.py b/caimira/tests/test_state.py index f984136d..15733db4 100644 --- a/caimira/tests/test_state.py +++ b/caimira/tests/test_state.py @@ -190,9 +190,6 @@ def test_DCS_named(): opt1_observer.assert_called_once_with() opt1_observer.reset_mock() - with pytest.raises(TypeError): - s.dcs_update_from(opt2) - s.dcs_select('option 2') opt1_observer.assert_called_once_with() opt1_observer.reset_mock() From ee29f12f35f91eca590042409b4d5950e1cee3d7 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 9 Mar 2023 16:00:00 +0100 Subject: [PATCH 18/24] updated typos and got back the state tests --- app-config/caimira-webservice/app.sh | 2 +- caimira/apps/expert.py | 4 +-- caimira/apps/simulator.py | 26 +++---------------- caimira/models.py | 12 ++++----- caimira/state.py | 4 ++- .../models/test_co2_concentration_model.py | 2 +- caimira/tests/test_state.py | 3 +++ 7 files changed, 19 insertions(+), 34 deletions(-) diff --git a/app-config/caimira-webservice/app.sh b/app-config/caimira-webservice/app.sh index 024c8a28..a0dc01b2 100755 --- a/app-config/caimira-webservice/app.sh +++ b/app-config/caimira-webservice/app.sh @@ -27,7 +27,7 @@ elif [[ "$APP_NAME" == "caimira-voila" ]]; then echo "Starting the voila service" voila caimira/apps/expert/ --port=8080 --no-browser --base_url=/voila-server/ --tornado_settings 'allow_origin=*' elif [[ "$APP_NAME" == "caimira-co2-voila" ]]; then - echo "Starting the voila service" + echo "Starting the CO2 voila service" voila caimira/apps/simulator/ --port=8080 --no-browser --base_url=/co2-voila-server/ --tornado_settings 'allow_origin=*' else echo "No APP_NAME specified" diff --git a/caimira/apps/expert.py b/caimira/apps/expert.py index 9214ad3a..c0a60312 100644 --- a/caimira/apps/expert.py +++ b/caimira/apps/expert.py @@ -214,7 +214,7 @@ class ExposureModelResult(View): self.html_output.value = '
    \n'.join(lines) -class ExposureComparissonResult(View): +class ExposureComparisonResult(View): def __init__(self): self.figure = matplotlib.figure.Figure(figsize=(9, 6)) ipympl_canvas(self.figure) @@ -937,7 +937,7 @@ class ExpertApplication(Controller): self._model_scenarios: typing.List[ScenarioType] = [] self._active_scenario = 0 self.multi_model_view = MultiModelView(self) - self.comparison_view = ExposureComparissonResult() + self.comparison_view = ExposureComparisonResult() self.current_scenario_figure = ExposureModelResult() self._results_tab = widgets.Tab(children=( self.current_scenario_figure.widget, diff --git a/caimira/apps/simulator.py b/caimira/apps/simulator.py index 722b59b9..599e2616 100644 --- a/caimira/apps/simulator.py +++ b/caimira/apps/simulator.py @@ -126,7 +126,7 @@ class ExposureModelResult(View): self.figure.canvas.draw() -class ExposureComparissonResult(View): +class ExposureComparisonResult(View): def __init__(self): self.figure = matplotlib.figure.Figure(figsize=(9, 6)) ipympl_canvas(self.figure) @@ -193,7 +193,7 @@ class CO2Application(Controller): self._model_scenarios: typing.List[ScenarioType] = [] self._active_scenario = 0 self.multi_model_view = MultiModelView(self) - self.comparison_view = ExposureComparissonResult() + self.comparison_view = ExposureComparisonResult() self.current_scenario_figure = ExposureModelResult() self._results_tab = widgets.Tab(children=( self.current_scenario_figure.widget, @@ -394,11 +394,10 @@ class ModelWidgets(View): ventilation_widgets = { 'HVACMechanical': self._build_mechanical(node), 'Sliding window': self._build_window(node, emitters_node), - 'HEPAFilter': self._build_HEPA(node._states['HEPAFilter']), 'No ventilation': self._build_no_ventilation(node._states['No ventilation']), } - keys=[("Mechanical", "HVACMechanical"), ("Natural", "Sliding window"), ("No ventilation", "No ventilation"), ("HEPA Filter", "HEPAFilter")] + keys=[("Mechanical", "HVACMechanical"), ("Natural", "Sliding window"), ("No ventilation", "No ventilation")] for name, widget in ventilation_widgets.items(): widget.layout.visible = False @@ -632,20 +631,6 @@ class ModelWidgets(View): return widgets.VBox([mechanival_w, widgets.HBox(list(mechanical_widgets.values()))]) - def _build_HEPA( - self, - node, - ) -> widgets.Widget: - - HEPA_w = widgets.FloatSlider(value=node.q_air_mech, min=10, max=500, step=5) - - def on_value_change(change): - node.q_air_mech=change['new'] - - HEPA_w.observe(on_value_change,names= ['value']) - - return widgets.HBox([widgets.Label('HEPA Filtration (m³/h) '),HEPA_w], layout=widgets.Layout(justify_content='space-between')) - def _build_no_ventilation(self, node): return widgets.HBox([]) @@ -757,10 +742,10 @@ class CAIMIRACO2StateBuilder(CAIMIRAStateBuilder): 'No ventilation': self.build_generic(models.AirChange), 'AirChange': self.build_generic(models.AirChange), 'Hinged window': self.build_generic(models.WindowOpening), - 'HEPAFilter': self.build_generic(models.HEPAFilter), }, state_builder=self, ) + #Initialise the "Sliding window" state s._states['Sliding window'].dcs_update_from( models.SlidingWindow(active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)), outside_temp=models.PiecewiseConstant((0,24.), (283.15,)), @@ -782,9 +767,6 @@ class CAIMIRACO2StateBuilder(CAIMIRAStateBuilder): s._states['No ventilation'].dcs_update_from( models.AirChange(active=models.PeriodicInterval(period=60, duration=60), air_exch=0.) ) - s._states['HEPAFilter'].dcs_update_from( - models.HEPAFilter(active=models.PeriodicInterval(period=60, duration=60), q_air_mech=500.) - ) return s def models_start_end(models: typing.Sequence[models.CO2ConcentrationModel]) -> typing.Tuple[float, float]: diff --git a/caimira/models.py b/caimira/models.py index 140f3935..5483596b 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -1009,11 +1009,9 @@ class _ConcentrationModelBase: return self.min_background_concentration()/self.normalization_factor() V = self.room.volume RR = self.removal_rate(time) - try: - return (1. / (RR * V) + self.min_background_concentration()/ + + return (1. / (RR * V) + self.min_background_concentration()/ self.normalization_factor()) - except ZeroDivisionError: - return 0 @method_cache def state_change_times(self) -> typing.List[float]: @@ -1212,9 +1210,9 @@ class CO2ConcentrationModel(_ConcentrationModelBase): return self.CO2_emitters def removal_rate(self, time: float) -> _VectorisedFloat: - # 0.25 is a minimal, always present source of ventilation, due - # to the air infiltration from the outside. - return self.ventilation.air_exchange(self.room, time) + 1e-6 + # Setting minimum air exchange rate to 1e-6 to avoid divisions by + # zero when computing the CO2 concentration. + return np.maximum(1e-6,self.ventilation.air_exchange(self.room, time)) def min_background_concentration(self) -> _VectorisedFloat: """ diff --git a/caimira/state.py b/caimira/state.py index 6fecac0d..fae297de 100644 --- a/caimira/state.py +++ b/caimira/state.py @@ -228,6 +228,8 @@ class DataclassInstanceState(DataclassState[Datamodel_T]): def dcs_set_instance_type(self, instance_dataclass: typing.Type[Datamodel_T]): if not dataclasses.is_dataclass(instance_dataclass): raise TypeError("The given class is not a valid dataclass") + if not issubclass(instance_dataclass, self._base): + raise TypeError(f"The dataclass type provided ({instance_dataclass}) must be a subclass of the base ({self._base})") self._instance_type = instance_dataclass # TODO: It is possible to cut observer connections by clearing like this. @@ -321,7 +323,7 @@ class DataclassStateNamed(DataclassState[Datamodel_T]): ): # TODO: This is effectively a container type. We shouldn't use the standard constructor for this. enabled = list(states.keys())[0] - t = states[enabled] + super().__init__(**kwargs) with self._object_setattr(): diff --git a/caimira/tests/models/test_co2_concentration_model.py b/caimira/tests/models/test_co2_concentration_model.py index d1a8a6db..ad4a348c 100644 --- a/caimira/tests/models/test_co2_concentration_model.py +++ b/caimira/tests/models/test_co2_concentration_model.py @@ -8,7 +8,7 @@ from caimira import models def simple_co2_conc_model(): return models.CO2ConcentrationModel( room=models.Room(200, models.PiecewiseConstant((0., 24.), (293,))), - ventilation=models.AirChange(models.PeriodicInterval(period=120, duration=120), 0.25-(1e-6)), + ventilation=models.AirChange(models.PeriodicInterval(period=120, duration=120), 0.25), CO2_emitters=models.Population( number=5, presence=models.SpecificInterval((([0., 4.], ))), diff --git a/caimira/tests/test_state.py b/caimira/tests/test_state.py index 15733db4..f984136d 100644 --- a/caimira/tests/test_state.py +++ b/caimira/tests/test_state.py @@ -190,6 +190,9 @@ def test_DCS_named(): opt1_observer.assert_called_once_with() opt1_observer.reset_mock() + with pytest.raises(TypeError): + s.dcs_update_from(opt2) + s.dcs_select('option 2') opt1_observer.assert_called_once_with() opt1_observer.reset_mock() From 61f13655fd5a4b519ce1702cbf0a52d000267991 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 9 Mar 2023 16:24:09 +0100 Subject: [PATCH 19/24] updated file names --- app-config/caimira-public-docker-image/Dockerfile | 2 +- .../caimira-public-docker-image/run_caimira.sh | 2 +- app-config/caimira-webservice/app.sh | 2 +- caimira/apps/__init__.py | 2 +- caimira/apps/{simulator.py => expert_co2.py} | 0 .../apps/{simulator => expert_co2}/caimira.ipynb | 0 .../static/images/header_image.png | Bin 7 files changed, 4 insertions(+), 4 deletions(-) rename caimira/apps/{simulator.py => expert_co2.py} (100%) rename caimira/apps/{simulator => expert_co2}/caimira.ipynb (100%) rename caimira/apps/{simulator => expert_co2}/static/images/header_image.png (100%) diff --git a/app-config/caimira-public-docker-image/Dockerfile b/app-config/caimira-public-docker-image/Dockerfile index fe4aa1b8..6e2c6914 100644 --- a/app-config/caimira-public-docker-image/Dockerfile +++ b/app-config/caimira-public-docker-image/Dockerfile @@ -15,7 +15,7 @@ COPY ./app-config/caimira-public-docker-image/run_caimira.sh /opt/caimira/start. # In the best case this will be a no-op. RUN cd /opt/caimira/src/ && /opt/caimira/app/bin/pip install -r /opt/caimira/src/requirements.txt RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/expert/*.ipynb -RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/simulator/*.ipynb +RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/expert_co2/*.ipynb COPY ./app-config/caimira-public-docker-image/nginx.conf /opt/caimira/nginx.conf EXPOSE 8080 diff --git a/app-config/caimira-public-docker-image/run_caimira.sh b/app-config/caimira-public-docker-image/run_caimira.sh index 260b2864..5f12ca46 100755 --- a/app-config/caimira-public-docker-image/run_caimira.sh +++ b/app-config/caimira-public-docker-image/run_caimira.sh @@ -12,7 +12,7 @@ cd /opt/caimira/src/caimira --Voila.tornado_settings 'allow_origin=*' \ >> /var/log/expert-app.log 2>&1 & -/opt/caimira/app/bin/python -m voila /opt/caimira/src/caimira/apps/simulator/caimira.ipynb \ +/opt/caimira/app/bin/python -m voila /opt/caimira/src/caimira/apps/expert_co2/caimira.ipynb \ --port=8083 --no-browser --base_url=/co2-voila-server/ \ --Voila.tornado_settings 'allow_origin=*' \ >> /var/log/co2-app.log 2>&1 & diff --git a/app-config/caimira-webservice/app.sh b/app-config/caimira-webservice/app.sh index a0dc01b2..af223351 100755 --- a/app-config/caimira-webservice/app.sh +++ b/app-config/caimira-webservice/app.sh @@ -28,7 +28,7 @@ elif [[ "$APP_NAME" == "caimira-voila" ]]; then voila caimira/apps/expert/ --port=8080 --no-browser --base_url=/voila-server/ --tornado_settings 'allow_origin=*' elif [[ "$APP_NAME" == "caimira-co2-voila" ]]; then echo "Starting the CO2 voila service" - voila caimira/apps/simulator/ --port=8080 --no-browser --base_url=/co2-voila-server/ --tornado_settings 'allow_origin=*' + voila caimira/apps/expert_co2/ --port=8080 --no-browser --base_url=/co2-voila-server/ --tornado_settings 'allow_origin=*' else echo "No APP_NAME specified" exit 1 diff --git a/caimira/apps/__init__.py b/caimira/apps/__init__.py index 57441379..26b7f5d3 100644 --- a/caimira/apps/__init__.py +++ b/caimira/apps/__init__.py @@ -1,4 +1,4 @@ from .expert import ExpertApplication -from .simulator import CO2Application +from .expert_co2 import CO2Application __all__ = ['ExpertApplication', 'CO2Application'] diff --git a/caimira/apps/simulator.py b/caimira/apps/expert_co2.py similarity index 100% rename from caimira/apps/simulator.py rename to caimira/apps/expert_co2.py diff --git a/caimira/apps/simulator/caimira.ipynb b/caimira/apps/expert_co2/caimira.ipynb similarity index 100% rename from caimira/apps/simulator/caimira.ipynb rename to caimira/apps/expert_co2/caimira.ipynb diff --git a/caimira/apps/simulator/static/images/header_image.png b/caimira/apps/expert_co2/static/images/header_image.png similarity index 100% rename from caimira/apps/simulator/static/images/header_image.png rename to caimira/apps/expert_co2/static/images/header_image.png From 0019d19799f43b2ceb943854bf88a718b71f93b1 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 9 Mar 2023 16:57:47 +0100 Subject: [PATCH 20/24] app-config naming updates --- app-config/openshift/deploymentconfig.yaml | 72 +++++++++++----------- app-config/openshift/imagestreams.yaml | 2 +- app-config/openshift/services.yaml | 24 ++++---- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/app-config/openshift/deploymentconfig.yaml b/app-config/openshift/deploymentconfig.yaml index 887429ac..e96d65a1 100644 --- a/app-config/openshift/deploymentconfig.yaml +++ b/app-config/openshift/deploymentconfig.yaml @@ -72,21 +72,21 @@ apiVersion: apps.openshift.io/v1 kind: DeploymentConfig metadata: - name: caimira-app - labels: {app: caimira-app} + name: expert-app + labels: {app: expert-app} spec: replicas: 1 template: metadata: labels: - app: caimira-app + app: expert-app spec: containers: - - name: caimira-webservice + - name: calculator-app env: - name: APP_NAME value: caimira-voila - image: '${PROJECT_NAME}/caimira-webservice' + image: '${PROJECT_NAME}/calculator-app' ports: - containerPort: 8080 protocol: TCP @@ -113,37 +113,37 @@ type: Rolling test: false selector: - app: caimira-app + app: expert-app triggers: - type: ConfigChange - type: ImageChange imageChangeParams: automatic: true containerNames: - - caimira-webservice + - calculator-app from: kind: ImageStreamTag - name: 'caimira-webservice:latest' + name: 'calculator-app:latest' namespace: ${PROJECT_NAME} - apiVersion: apps.openshift.io/v1 kind: DeploymentConfig metadata: - name: caimira-co2-app - labels: {app: caimira-co2-app} + name: expert-co2-app + labels: {app: expert-co2-app} spec: replicas: 1 template: metadata: labels: - app: caimira-co2-app + app: expert-co2-app spec: containers: - - name: caimira-webservice + - name: calculator-app env: - name: APP_NAME value: caimira-co2-voila - image: '${PROJECT_NAME}/caimira-webservice' + image: '${PROJECT_NAME}/calculator-app' ports: - containerPort: 8080 protocol: TCP @@ -170,17 +170,17 @@ type: Rolling test: false selector: - app: caimira-co2-app + app: expert-co2-app triggers: - type: ConfigChange - type: ImageChange imageChangeParams: automatic: true containerNames: - - caimira-webservice + - calculator-app from: kind: ImageStreamTag - name: 'caimira-webservice:latest' + name: 'calculator-app:latest' namespace: ${PROJECT_NAME} - apiVersion: apps.openshift.io/v1 @@ -239,19 +239,19 @@ apiVersion: apps.openshift.io/v1 kind: DeploymentConfig metadata: - name: caimira-webservice + name: calculator-app labels: - image: caimira-webservice - app: caimira-webservice + image: calculator-app + app: calculator-app spec: replicas: 1 template: metadata: labels: - app: caimira-webservice + app: calculator-app spec: containers: - - name: caimira-webservice + - name: calculator-app env: - name: COOKIE_SECRET valueFrom: @@ -261,7 +261,7 @@ - name: REPORT_PARALLELISM value: '3' - name: APP_NAME - value: caimira-webservice + value: calculator-app - name: APPLICATION_ROOT value: / - name: CAIMIRA_CALCULATOR_PREFIX @@ -283,7 +283,7 @@ secretKeyRef: key: ARVE_API_KEY name: arve-api - image: '${PROJECT_NAME}/caimira-webservice' + image: '${PROJECT_NAME}/calculator-app' ports: - containerPort: 8080 protocol: TCP @@ -324,43 +324,43 @@ type: Rolling test: false selector: - app: caimira-webservice + app: calculator-app triggers: - type: ImageChange imageChangeParams: automatic: true containerNames: - - caimira-webservice + - calculator-app from: kind: ImageStreamTag - name: 'caimira-webservice:latest' + name: 'calculator-app:latest' namespace: ${PROJECT_NAME} - type: ConfigChange - apiVersion: apps.openshift.io/v1 kind: DeploymentConfig metadata: - name: caimira-calculator-open + name: calculator-open-app labels: - image: caimira-webservice - app: caimira-calculator-open + image: calculator-app + app: calculator-open-app spec: replicas: 1 template: metadata: labels: - app: caimira-calculator-open + app: calculator-open-app spec: containers: - - name: caimira-calculator-open + - name: calculator-open-app env: - name: APP_NAME - value: caimira-webservice + value: calculator-app - name: APPLICATION_ROOT value: / - name: CAIMIRA_CALCULATOR_PREFIX value: /calculator-open - image: '${PROJECT_NAME}/caimira-webservice' + image: '${PROJECT_NAME}/calculator-app' ports: - containerPort: 8080 protocol: TCP @@ -391,17 +391,17 @@ type: Rolling test: false selector: - app: caimira-calculator-open + app: calculator-open-app triggers: - type: ConfigChange - type: ImageChange imageChangeParams: automatic: true containerNames: - - caimira-calculator-open + - calculator-open-app from: kind: ImageStreamTag - name: 'caimira-webservice:latest' + name: 'calculator-app:latest' namespace: ${PROJECT_NAME} - type: ConfigChange diff --git a/app-config/openshift/imagestreams.yaml b/app-config/openshift/imagestreams.yaml index 86a4780d..9f6ec534 100644 --- a/app-config/openshift/imagestreams.yaml +++ b/app-config/openshift/imagestreams.yaml @@ -30,7 +30,7 @@ kind: ImageStream apiVersion: image.openshift.io/v1 metadata: - name: caimira-webservice + name: calculator-app spec: lookupPolicy: local: False diff --git a/app-config/openshift/services.yaml b/app-config/openshift/services.yaml index 2d01387e..f32ba062 100644 --- a/app-config/openshift/services.yaml +++ b/app-config/openshift/services.yaml @@ -32,8 +32,8 @@ kind: Service metadata: labels: - app: caimira-app - name: caimira-app + app: expert-app + name: expert-app spec: ports: - name: 8080-tcp @@ -41,7 +41,7 @@ protocol: TCP targetPort: 8080 selector: - deploymentconfig: caimira-app + deploymentconfig: expert-app sessionAffinity: 'None' type: 'ClusterIP' - @@ -49,8 +49,8 @@ kind: Service metadata: labels: - app: caimira-co2-app - name: caimira-co2-app + app: expert-co2-app + name: expert-co2-app spec: ports: - name: 8080-tcp @@ -58,7 +58,7 @@ protocol: TCP targetPort: 8080 selector: - deploymentconfig: caimira-co2-app + deploymentconfig: expert-co2-app sessionAffinity: 'None' type: 'ClusterIP' - @@ -83,8 +83,8 @@ kind: Service metadata: labels: - app: caimira-webservice - name: caimira-webservice + app: calculator-app + name: calculator-app spec: ports: - name: 8080-tcp @@ -92,7 +92,7 @@ protocol: TCP targetPort: 8080 selector: - deploymentconfig: caimira-webservice + deploymentconfig: calculator-app sessionAffinity: 'None' type: 'ClusterIP' - @@ -100,8 +100,8 @@ kind: Service metadata: labels: - app: caimira-calculator-open - name: caimira-calculator-open + app: calculator-open-app + name: calculator-open-app spec: ports: - name: 8080-tcp @@ -109,6 +109,6 @@ protocol: TCP targetPort: 8080 selector: - deploymentconfig: caimira-calculator-open + deploymentconfig: calculator-open-app sessionAffinity: 'None' type: 'ClusterIP' From 1f6b3dd96ea70fc41d3d16b35fdc5c0c7d4eb6ab Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Mon, 13 Mar 2023 17:09:38 +0100 Subject: [PATCH 21/24] added workaround for default ventilation --- caimira/apps/expert_co2.py | 22 +++++++++++++++------- caimira/state.py | 6 +++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/caimira/apps/expert_co2.py b/caimira/apps/expert_co2.py index 599e2616..cade8f72 100644 --- a/caimira/apps/expert_co2.py +++ b/caimira/apps/expert_co2.py @@ -211,18 +211,21 @@ class CO2Application(Controller): ) self.add_scenario('Scenario 1') - def build_new_model(self) -> state.DataclassInstanceState[models.CO2ConcentrationModel]: - default_model = state.DataclassInstanceState( + def build_new_model(self, vent: str) -> state.DataclassInstanceState[models.CO2ConcentrationModel]: + new_model = state.DataclassInstanceState( models.CO2ConcentrationModel, - state_builder=CAIMIRACO2StateBuilder(), + state_builder=CAIMIRACO2StateBuilder(selected_ventilation=vent) ) - default_model.dcs_update_from(baseline_model) - return default_model + return new_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 = self.build_new_model(vent=copy_from_model.ventilation._selected) model.dcs_update_from(copy_from_model.dcs_instance()) + else: + model = self.build_new_model(vent='HVACMechanical') # Default + model.dcs_update_from(baseline_model) + self._model_scenarios.append((name, model)) self._active_scenario = len(self._model_scenarios) - 1 model.dcs_observe(self.notify_model_values_changed) @@ -604,7 +607,7 @@ class ModelWidgets(View): def _build_mechanical(self, node): mechanical_widgets = { - 'HVACMechanical': self._build_q_air_mech(node), + 'HVACMechanical': self._build_q_air_mech(node._states['HVACMechanical']), 'AirChange': self._build_ach(node._states['AirChange']), } @@ -733,6 +736,9 @@ class MultiModelView(View): class CAIMIRACO2StateBuilder(CAIMIRAStateBuilder): + + def __init__(self, selected_ventilation: str): + self.selected_ventilation = selected_ventilation def build_type__VentilationBase(self, _: dataclasses.Field): s: state.DataclassStateNamed = state.DataclassStateNamed( @@ -743,8 +749,10 @@ class CAIMIRACO2StateBuilder(CAIMIRAStateBuilder): 'AirChange': self.build_generic(models.AirChange), 'Hinged window': self.build_generic(models.WindowOpening), }, + base_type=self.selected_ventilation, state_builder=self, ) + s._states['HVACMechanical'].dcs_update_from(baseline_model.ventilation) #Initialise the "Sliding window" state s._states['Sliding window'].dcs_update_from( models.SlidingWindow(active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)), diff --git a/caimira/state.py b/caimira/state.py index fae297de..f284ca4d 100644 --- a/caimira/state.py +++ b/caimira/state.py @@ -319,18 +319,18 @@ class DataclassStateNamed(DataclassState[Datamodel_T]): """ def __init__(self, states: typing.Dict[str, DataclassState[Datamodel_T]], + base_type: str, **kwargs ): # TODO: This is effectively a container type. We shouldn't use the standard constructor for this. - enabled = list(states.keys())[0] super().__init__(**kwargs) with self._object_setattr(): self._states = states.copy() self._selected: str = None # type: ignore - # Pick the first choice until we know otherwise. - self.dcs_select(enabled) + + self.dcs_select(base_type) def __getattr__(self, name): try: From 8a72d27fb1bdf326f4756f954bed8899fe8707e2 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Mar 2023 12:02:19 +0100 Subject: [PATCH 22/24] updated expert app notebook and tests --- caimira/apps/expert.py | 14 +++-- caimira/tests/test_state.py | 7 ++- requirements.new.txt | 105 ------------------------------------ 3 files changed, 16 insertions(+), 110 deletions(-) delete mode 100644 requirements.new.txt diff --git a/caimira/apps/expert.py b/caimira/apps/expert.py index c0a60312..c8955512 100644 --- a/caimira/apps/expert.py +++ b/caimira/apps/expert.py @@ -883,6 +883,9 @@ class CAIMIRAStateBuilder(state.StateBuilder): # Note: The methods in this class must correspond to the *type* of the data classes. # For example, build_type__VentilationBase is called when dealing with ConcentrationModel # types as it has a ventilation: _VentilationBase field. + + def __init__(self, selected_ventilation: str): + self.selected_ventilation = selected_ventilation def build_type_Mask(self, _: dataclasses.Field): return state.DataclassStatePredefined( @@ -901,6 +904,7 @@ class CAIMIRAStateBuilder(state.StateBuilder): 'HEPAFilter': self.build_generic(models.HEPAFilter), }, + base_type=self.selected_ventilation, state_builder=self, ) #Initialise the "Hinged window" state @@ -955,10 +959,10 @@ class ExpertApplication(Controller): ) self.add_scenario('Scenario 1') - def build_new_model(self) -> state.DataclassInstanceState[models.ExposureModel]: + def build_new_model(self, vent: str) -> state.DataclassInstanceState[models.ExposureModel]: default_model = state.DataclassInstanceState( models.ExposureModel, - state_builder=CAIMIRAStateBuilder(), + state_builder=CAIMIRAStateBuilder(selected_ventilation=vent), ) default_model.dcs_update_from(baseline_model) # For the time-being, we have to initialise the select states. Careful @@ -967,9 +971,13 @@ class ExpertApplication(Controller): 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 = self.build_new_model(vent=copy_from_model.concentration_model.ventilation._selected) model.dcs_update_from(copy_from_model.dcs_instance()) + else: + model = self.build_new_model(vent='Natural') # Default + model.dcs_update_from(baseline_model) + self._model_scenarios.append((name, model)) self._active_scenario = len(self._model_scenarios) - 1 model.dcs_observe(self.notify_model_values_changed) diff --git a/caimira/tests/test_state.py b/caimira/tests/test_state.py index f984136d..ac0341fe 100644 --- a/caimira/tests/test_state.py +++ b/caimira/tests/test_state.py @@ -167,11 +167,14 @@ def test_DCS_predefined(): def test_DCS_named(): opt1 = DCSimpleSubclass('a', 1, 3.14) opt2 = DCAnother(4.2) - s = state.DataclassStateNamed({ + s = state.DataclassStateNamed( + states={ # Entirely different types possible. 'option 1': state.DataclassInstanceState(DCSimple), 'option 2': state.DataclassInstanceState(DCAnother), - }) + }, + base_type='option 1' + ) assert s._selected == 'option 1' with pytest.raises(ValueError): diff --git a/requirements.new.txt b/requirements.new.txt deleted file mode 100644 index f6112691..00000000 --- a/requirements.new.txt +++ /dev/null @@ -1,105 +0,0 @@ -anyio==3.6.2 -appnope==0.1.3 -argon2-cffi==21.3.0 -argon2-cffi-bindings==21.2.0 -asttokens==2.2.1 -attrs==22.2.0 -Babel==2.11.0 -backcall==0.2.0 -beautifulsoup4==4.11.2 -bleach==6.0.0 -certifi==2022.12.7 -cffi==1.15.1 -charset-normalizer==3.0.1 -cloudpickle==2.2.1 -comm==0.1.2 -contourpy==1.0.7 -cycler==0.11.0 -debugpy==1.6.6 -decorator==5.1.1 -defusedxml==0.7.1 -entrypoints==0.4 -executing==1.2.0 -fastjsonschema==2.16.2 -fonttools==4.38.0 -h3==3.7.6 -idna==3.4 -importlib-metadata==6.0.0 -importlib-resources==5.12.0 -ipykernel==6.21.2 -ipympl==0.9.3 -ipython==8.10.0 -ipython-genutils==0.2.0 -ipywidgets==7.7.3 -jedi==0.18.2 -Jinja2==3.1.2 -joblib==1.2.0 -json5==0.9.11 -jsonschema==4.17.3 -jupyter-client==7.4.1 -jupyter-core==5.2.0 -jupyter-server==1.23.6 -jupyterlab-pygments==0.2.2 -jupyterlab-server==2.19.0 -jupyterlab-widgets==1.1.2 -kiwisolver==1.4.4 -loky==3.3.0 -MarkupSafe==2.1.2 -matplotlib==3.7.0 -matplotlib-inline==0.1.6 -memoization==0.4.0 -mistune==2.0.5 -nbclassic==0.5.2 -nbclient==0.7.2 -nbconvert==7.2.9 -nbformat==5.7.3 -nest-asyncio==1.5.6 -notebook==6.5.2 -notebook-shim==0.2.2 -numpy==1.24.2 -packaging==23.0 -pandas==1.5.3 -pandocfilters==1.5.0 -parso==0.8.3 -pexpect==4.8.0 -pickleshare==0.7.5 -Pillow==9.4.0 -platformdirs==3.0.0 -prometheus-client==0.16.0 -prompt-toolkit==3.0.37 -psutil==5.9.4 -ptyprocess==0.7.0 -pure-eval==0.2.2 -py==1.11.0 -pycparser==2.21 -Pygments==2.14.0 -pyparsing==3.0.9 -pyrsistent==0.19.3 -python-dateutil==2.8.2 -pytz==2022.7.1 -pyzmq==25.0.0 -requests==2.28.2 -retry==0.9.2 -scikit-learn==1.2.1 -scipy==1.10.1 -Send2Trash==1.8.0 -six==1.16.0 -sniffio==1.3.0 -soupsieve==2.4 -stack-data==0.6.2 -terminado==0.17.1 -threadpoolctl==3.1.0 -timezonefinder==6.1.9 -tinycss2==1.2.1 -tornado==6.2 -traitlets==5.9.0 -types-retry==0.9.9.2 -urllib3==1.26.14 -voila==0.4.0 -wcwidth==0.2.6 -webencodings==0.5.1 -websocket-client==1.5.1 -websockets==10.4 -wheel==0.36.2 -widgetsnbextension==3.6.2 -zipp==3.14.0 From 43a37bfac1c519c5be7aa2071ed8b75ecab27942 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Mar 2023 14:39:53 +0100 Subject: [PATCH 23/24] updated app-config configuration and naming --- .gitlab-ci.yml | 10 +++---- README.md | 2 +- .../Dockerfile | 4 +-- .../app.sh | 2 +- app-config/docker-compose.yml | 28 +++++++++---------- app-config/nginx/nginx.conf | 18 ++++++------ 6 files changed, 32 insertions(+), 32 deletions(-) rename app-config/{caimira-webservice => calculator-app}/Dockerfile (93%) rename app-config/{caimira-webservice => calculator-app}/app.sh (95%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ccc4e3a2..9db7ddd3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -124,12 +124,12 @@ auth-service-image_builder: DOCKER_CONTEXT_DIRECTORY: app-config/auth-service -caimira-webservice-image_builder: +calculator-app-image_builder: extends: - .image_builder variables: - IMAGE_NAME: caimira-webservice - DOCKERFILE_DIRECTORY: app-config/caimira-webservice + IMAGE_NAME: calculator-app + DOCKERFILE_DIRECTORY: app-config/calculator-app DOCKER_CONTEXT_DIRECTORY: "" @@ -159,11 +159,11 @@ link_auth-service_with_gitlab_registry: variables: IMAGE_NAME: auth-service -link_caimira-webservice_with_gitlab_registry: +link_calculator-app_with_gitlab_registry: extends: - .link_docker_images_with_gitlab_registry variables: - IMAGE_NAME: caimira-webservice + IMAGE_NAME: calculator-app link_calculator_with_gitlab_registry: extends: diff --git a/README.md b/README.md index 6ae24d2b..771a94f6 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ pytest ./caimira ``` s2i build file://$(pwd) --copy --keep-symlinks --context-dir ./app-config/nginx/ centos/nginx-112-centos7 caimira-nginx-app -docker build . -f ./app-config/caimira-webservice/Dockerfile -t caimira-webservice +docker build . -f ./app-config/calculator-app/Dockerfile -t calculator-app docker build ./app-config/auth-service -t auth-service ``` diff --git a/app-config/caimira-webservice/Dockerfile b/app-config/calculator-app/Dockerfile similarity index 93% rename from app-config/caimira-webservice/Dockerfile rename to app-config/calculator-app/Dockerfile index 0a652800..273326e1 100644 --- a/app-config/caimira-webservice/Dockerfile +++ b/app-config/calculator-app/Dockerfile @@ -3,7 +3,7 @@ FROM registry.cern.ch/docker.io/condaforge/mambaforge as conda RUN mamba create --yes -p /opt/app python=3.9 COPY . /opt/app-source RUN cd /opt/app-source && conda run -p /opt/app python -m pip install -r ./requirements.txt .[app] -COPY app-config/caimira-webservice/app.sh /opt/app/bin/caimira-app.sh +COPY app-config/calculator-app/app.sh /opt/app/bin/calculator-app.sh RUN cd /opt/app \ && find -name '*.a' -delete \ && rm -rf /opt/app/conda-meta \ @@ -32,5 +32,5 @@ WORKDIR /scratch RUN CAIMIRA_INIT_FILE=$(/opt/app/bin/python -c "import caimira; print(caimira.__file__)") \ && ln -s $(dirname ${CAIMIRA_INIT_FILE}) /scratch/caimira CMD [ \ - "caimira-app.sh" \ + "calculator-app.sh" \ ] diff --git a/app-config/caimira-webservice/app.sh b/app-config/calculator-app/app.sh similarity index 95% rename from app-config/caimira-webservice/app.sh rename to app-config/calculator-app/app.sh index af223351..8555379e 100755 --- a/app-config/caimira-webservice/app.sh +++ b/app-config/calculator-app/app.sh @@ -1,6 +1,6 @@ #!/bin/bash -if [[ "$APP_NAME" == "caimira-webservice" ]]; then +if [[ "$APP_NAME" == "calculator-app" ]]; then args=("$@") if [ "$DEBUG" != "true" ] && [[ ! "${args[@]}" =~ "--no-debug" ]]; then args+=("--no-debug") diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index a6b62067..d9ddb34a 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -1,32 +1,32 @@ version: "3.8" services: - caimira-app: - image: caimira-webservice + expert-app: + image: calculator-app environment: - APP_NAME=caimira-voila user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} - caimira-co2-app: - image: caimira-webservice + expert-co2-app: + image: calculator-app environment: - APP_NAME=caimira-co2-voila user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} - caimira-webservice: - image: caimira-webservice + calculator-app: + image: calculator-app environment: - COOKIE_SECRET - - APP_NAME=caimira-webservice + - APP_NAME=calculator-app - APPLICATION_ROOT=/ - CAIMIRA_CALCULATOR_PREFIX=/calculator-cern - CAIMIRA_THEME=caimira/apps/templates/cern user: ${CURRENT_UID} - caimira-calculator-open: - image: caimira-webservice + calculator-open-app: + image: calculator-app environment: - COOKIE_SECRET - - APP_NAME=caimira-webservice + - APP_NAME=calculator-app - APPLICATION_ROOT=/ - CAIMIRA_CALCULATOR_PREFIX=/calculator-open user: ${CURRENT_UID} @@ -46,13 +46,13 @@ services: ports: - "8080:8080" depends_on: - caimira-webservice: + calculator-app: condition: service_started - caimira-calculator-open: + calculator-open-app: condition: service_started - caimira-app: + expert-app: condition: service_started - caimira-co2-app: + expert-co2-app: condition: service_started auth-service: condition: service_started diff --git a/app-config/nginx/nginx.conf b/app-config/nginx/nginx.conf index 73be04cc..5e0ed708 100644 --- a/app-config/nginx/nginx.conf +++ b/app-config/nginx/nginx.conf @@ -70,7 +70,7 @@ http { # Pass the request on to the webservice. Most likely the URI won't # exist so we get a 404 from that service instead (good as the 404 # pages are consistent). - proxy_pass http://caimira-webservice:8080/$request_uri; + proxy_pass http://calculator-app:8080/$request_uri; } location /voila-server/ { @@ -81,9 +81,9 @@ http { error_page 401 = @error401; error_page 404 = @proxy_404_error_handler; - # caimira-app is the name of the voila server in each of docker-compose, + # expert-app is the name of the voila server in each of docker-compose, # caimira-test.web.cern.ch and caimira.web.cern.ch. - proxy_pass http://caimira-app:8080/voila-server/; + proxy_pass http://expert-app:8080/voila-server/; } rewrite ^/expert-app$ /voila-server/voila/render/caimira.ipynb last; rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; @@ -101,9 +101,9 @@ http { error_page 401 = @error401; error_page 404 = @proxy_404_error_handler; - # caimira-co2-app is the name of the voila server in each of docker-compose, + # expert-co2-app is the name of the voila server in each of docker-compose, # caimira-test.web.cern.ch and caimira.web.cern.ch. - proxy_pass http://caimira-co2-app:8080/co2-voila-server/; + proxy_pass http://expert-co2-app:8080/co2-voila-server/; } rewrite ^/co2-app$ /co2-voila-server/voila/render/caimira.ipynb last; rewrite ^/(files/static)/(.*)$ /co2-voila-server/voila/$1/$2 last; @@ -114,7 +114,7 @@ http { location / { # By default we have no authentication. - proxy_pass http://caimira-webservice:8080; + proxy_pass http://calculator-app:8080; } location /calculator { @@ -126,14 +126,14 @@ http { auth_request /auth/probe; error_page 401 = @error401; - # caimira-webservice is the name of the tornado server (for the calculator) + # calculator-app is the name of the tornado server (for the calculator) # in each of docker-compose, caimira-test.web.cern.ch and caimira.web.cern.ch. - proxy_pass http://caimira-webservice:8080/calculator-cern; + proxy_pass http://calculator-app:8080/calculator-cern; } location /calculator-open { # Public open calculator - proxy_pass http://caimira-calculator-open:8080/calculator-open; + proxy_pass http://calculator-open-app:8080/calculator-open; } } } From f26bc0f2afe128df273442e445702934b55032a4 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 23 Mar 2023 15:36:14 +0100 Subject: [PATCH 24/24] removed beta from expert app and increased version (minor) --- caimira/apps/calculator/__init__.py | 2 +- caimira/apps/templates/base/index.html.j2 | 2 +- caimira/apps/templates/base/layout.html.j2 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py index c36e56ec..8399bd9d 100644 --- a/caimira/apps/calculator/__init__.py +++ b/caimira/apps/calculator/__init__.py @@ -35,7 +35,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.6" +__version__ = "4.7" class BaseRequestHandler(RequestHandler): diff --git a/caimira/apps/templates/base/index.html.j2 b/caimira/apps/templates/base/index.html.j2 index 835bb7fa..65f19037 100644 --- a/caimira/apps/templates/base/index.html.j2 +++ b/caimira/apps/templates/base/index.html.j2 @@ -32,7 +32,7 @@
    diff --git a/caimira/apps/templates/base/layout.html.j2 b/caimira/apps/templates/base/layout.html.j2 index 48bca715..6a2f592c 100644 --- a/caimira/apps/templates/base/layout.html.j2 +++ b/caimira/apps/templates/base/layout.html.j2 @@ -45,7 +45,7 @@