diff --git a/app/cara.ipynb b/app/cara.ipynb index 931d3197..d3cb17e5 100644 --- a/app/cara.ipynb +++ b/app/cara.ipynb @@ -13,259 +13,22 @@ "
" ] }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "%matplotlib widget\n", - "import ipywidgets as widgets\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import typing" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "import cara.models\n", - "\n", - "\n", - "def prepare_model(volume, n_infected=1, n_exposed=10, mask='Type I') -> cara.models.Model:\n", - " \"\"\"\n", - " Transform configurable values into a cara model instance.\n", - " \n", - " \"\"\"\n", - " model = cara.models.Model(\n", - " room=cara.models.Room(volume=volume),\n", - " ventilation=cara.models.PeriodicWindow(period=120, duration=120, inside_temp=293, outside_temp=283,\n", - " window_height=1.6, opening_length=0.6, cd_b=0.6),\n", - " infected=cara.models.InfectedPerson(\n", - " virus=cara.models.Virus.types['SARS_CoV_2'],\n", - " present_times=((0, 4), (5, 8)),\n", - " mask=cara.models.Mask.types[mask],\n", - " activity=cara.models.Activity.types['Light exercise'],\n", - " expiration=cara.models.Expiration.types['Unmodulated Vocalization'],\n", - " ),\n", - " infected_occupants=n_infected,\n", - " exposed_occupants=n_exposed,\n", - " exposed_activity=cara.models.Activity.types['Light exercise'],\n", - " )\n", - " return model" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "# Setup our plotting environment.\n", - "plt.interactive(False)\n", - "fig_concentration_over_time = plt.figure()" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "# Define some useful widget machinery.\n", - "\n", - "def collapsible(widgets_to_collapse: typing.List, title: str, start_collapsed=True):\n", - " collapsed = widgets.Accordion([widgets.VBox(widgets_to_collapse)])\n", - " collapsed.set_title(0, title)\n", - " if start_collapsed:\n", - " collapsed.selected_index = None\n", - " return collapsed\n", - "\n", - "\n", - "def widget_group(label_widget_pairs):\n", - " labels, widgets_ = zip(*label_widget_pairs) \n", - " labels_w = widgets.VBox(labels)\n", - " widgets_w = widgets.VBox(widgets_)\n", - " return widgets.HBox([labels_w, widgets_w])" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "eb109c0f63e149d69e763aec5d404db2", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Accordion(children=(VBox(children=(HBox(children=(VBox(children=(Label(value='Room volume'),)), VBox(children=…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "room_volume = widgets.IntSlider(value=75, min=10, max=150)\n", - "mask_used = widgets.Checkbox(value=True, description='Mask worn')\n", - "\n", - "collapsible(\n", - " [widget_group(\n", - " [[widgets.Label('Room volume'), room_volume]]\n", - " )],\n", - " title='Specification of workplace', start_collapsed=False,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "49ad604786f546f58dba54f1f6e7eded", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Accordion(children=(VBox(children=(HBox(children=(VBox(children=(Label(value='Ventilation type'),)), VBox(chil…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "ventilation_widgets = {\n", - " 'Natural': widgets.Label('Currently hard-coded to window-example from mathematica notebook'),\n", - " 'other': widgets.Label('Not yet implemented.')\n", - "}\n", - "for name, widget in ventilation_widgets.items():\n", - " widget.layout.visible = False\n", - "\n", - "ventilation_w = widgets.ToggleButtons(\n", - " options=['Natural'], # cara.models.Ventilation.types.keys(),\n", - ")\n", - "def toggle_ventilation(value):\n", - " for name, widget in ventilation_widgets.items():\n", - " widget.layout.display = 'none'\n", - " other = ventilation_widgets['other']\n", - " widget = ventilation_widgets.get(value, other)\n", - " widget.layout.visible = True\n", - " widget.layout.display = 'block'\n", - "\n", - "ventilation_w.observe(lambda event: toggle_ventilation(event['new']), 'value')\n", - "toggle_ventilation(ventilation_w.value)\n", - "\n", - "\n", - "collapsible(\n", - " [widget_group([[widgets.Label('Ventilation type'), ventilation_w]])]\n", - " + list(ventilation_widgets.values()),\n", - " title='Ventilation scheme'\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "8e76a49d0212462d81200a3959dcd3ff", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Accordion(children=(VBox(children=(HBox(children=(Canvas(footer_visible=False, header_visible=False, toolbar=T…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "line = None\n", - "# plt.ioff()\n", - "# fig = plt.figure()\n", - "fig = fig_concentration_over_time\n", - "\n", - "def plot_concentrations(_):\n", - " global line\n", - " model = prepare_model(room_volume.value)\n", - "\n", - " ts = np.arange(0, 10., 0.01)\n", - " concentration = [model.concentration(t) for t in ts]\n", - "\n", - " ax = fig.gca()\n", - " \n", - " plt.text(0.5, 0.9, 'Without masks & window open', transform=ax.transAxes, ha='center')\n", - " if line is None:\n", - " ax.spines['right'].set_visible(False)\n", - " ax.spines['top'].set_visible(False)\n", - " [line] = plt.plot(ts, concentration)\n", - " ax.set_xlabel('Time (hours)')\n", - " ax.set_ylabel('Concentration ($q/m^3$)')\n", - " plt.title('Concentration of infectious quanta aerosols')\n", - " \n", - " ax.set_ymargin(0.2)\n", - " ax.set_ylim(bottom=0)\n", - " else:\n", - " line.set_data(ts, concentration)\n", - " ax.relim()\n", - " ax.autoscale_view()\n", - " \n", - " plt.draw()\n", - "\n", - "# print(f'Probability of infection: {np.round(model.[\"P\"], 1)}')\n", - "# print(f'Expected number of new cases: {prepared[\"R0\"]}')\n", - " \n", - "\n", - "# widgets.interact(\n", - "# plot_concentrations,\n", - "# volume=room_volume,\n", - "# n_exposed=widgets.IntSlider(value=10, min=0, max=25),\n", - "# n_infected=widgets.IntSlider(value=1, min=1, max=5),\n", - "# );\n", - "\n", - "\n", - "for observable in [room_volume]:\n", - " observable.observe(plot_concentrations)\n", - "\n", - "plot_concentrations(1)\n", - "\n", - "fig.canvas.toolbar_visible = True\n", - "fig.canvas.toolbar.collapsed = True\n", - "fig.canvas.footer_visible = False\n", - "fig.canvas.header_visible = False\n", - "\n", - "\n", - "collapsible([\n", - " widgets.HBox([\n", - " fig.canvas,\n", - " # text_report,\n", - " ])\n", - "], 'Report', start_collapsed=False)\n" - ] - }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "scrolled": false, + "pycharm": { + "name": "#%%\n" + } + }, "outputs": [], - "source": [] + "source": [ + "import cara.apps\n", + "\n", + "app = cara.apps.ExpertApplication()\n", + "app.widget" + ] } ], "metadata": { diff --git a/cara/apps.py b/cara/apps.py new file mode 100644 index 00000000..eb2b530a --- /dev/null +++ b/cara/apps.py @@ -0,0 +1,202 @@ +import typing +import uuid + +import ipympl.backend_nbagg +import ipywidgets as widgets +import numpy as np +import matplotlib +import matplotlib.figure + +from cara import models +from cara import state + + +def collapsible(widgets_to_collapse: typing.List, title: str, start_collapsed=True): + collapsed = widgets.Accordion([widgets.VBox(widgets_to_collapse)]) + collapsed.set_title(0, title) + if start_collapsed: + collapsed.selected_index = None + return collapsed + + +def widget_group(label_widget_pairs): + labels, widgets_ = zip(*label_widget_pairs) + labels_w = widgets.VBox(labels) + widgets_w = widgets.VBox(widgets_) + return widgets.HBox([labels_w, widgets_w]) + + +class ConcentrationFigure: + def __init__(self): + self.figure = matplotlib.figure.Figure(figsize=(9, 6)) + self.ax = self.figure.add_subplot(1, 1, 1) + self.line = None + + def update(self, model: models.Model): + resolution = 600 + ts = np.linspace(0, 10, resolution) + concentration = [model.concentration(t) for t in ts] + if self.line is None: + [self.line] = self.ax.plot(ts, concentration) + ax = self.ax + + ax.text(0.5, 0.9, 'Without masks & window open', transform=ax.transAxes, ha='center') + + ax.spines['right'].set_visible(False) + ax.spines['top'].set_visible(False) + + ax.set_xlabel('Time (hours)') + ax.set_ylabel('Concentration ($q/m^3$)') + ax.set_title('Concentration of infectious quanta aerosols') + ax.set_ymargin(0.2) + ax.set_ylim(bottom=0) + else: + self.line.set_data(ts, concentration) + self.ax.relim() + self.ax.autoscale_view() + self.figure.canvas.draw() + + +def ipympl_canvas(figure: matplotlib.figure.Figure): + # Make a plain matplotlib figure render as a Jupyter widget. + matplotlib.interactive(False) + ipympl.backend_nbagg.new_figure_manager_given_figure(uuid.uuid1(), figure) + figure.canvas.toolbar_visible = True + figure.canvas.toolbar.collapsed = True + figure.canvas.footer_visible = False + figure.canvas.header_visible = False + + +class WidgetView: + def __init__(self, model_state: state.DataclassState): + self.model_state = model_state + self.model_state.dcs_observe(self.update) + #: The widgets that this view produces (inputs and outputs together) + self.widget = widgets.VBox([]) + self.widgets = {} + self.plots = [] + self.construct_widgets() + # Trigger the first result. + self.update() + + def construct_widgets(self): + # Build the input widgets. + self._build_widget(self.model_state) + + # And the output widget figure. + concentration = ConcentrationFigure() + self.plots.append(concentration) + ipympl_canvas(concentration.figure) + + self.widgets['results'] = collapsible([ + widgets.HBox([ + concentration.figure.canvas, + ]) + ], 'Results', start_collapsed=False) + + # Join inputs and outputs together in a single widget for convenience. + self.widget.children += (self.widgets['results'], ) + + def prepare_output(self): + pass + + def update(self): + model = self.model_state.dcs_instance() + for plot in self.plots: + plot.update(model) + + def _build_widget(self, node): + if isinstance(node, state.DataclassState): + if node._base == models.Ventilation: + self.widget.children += (self._build_ventilation(node), ) + elif node._base == models.Room: + self.widget.children += (self._build_room(node), ) + else: + # Don't do anything with this state, but recurse down in case + # its children want widgets. + for name, child in node._data.items(): + self._build_widget(child) + + def _build_room(self, node): + room_volume = widgets.IntSlider(value=node.volume, min=10, max=150) + mask_used = widgets.Checkbox(value=True, description='Mask worn') + + def on_value_change(change): + node.volume = change['new'] + + # TODO: Link the state back to the widget, not just the other way around. + room_volume.observe(on_value_change, names=['value']) + + widget = collapsible( + [widget_group( + [[widgets.Label('Room volume'), room_volume]] + )], + title='Specification of workplace', start_collapsed=False, + ) + return widget + + def _build_ventilation(self, node): + ventilation_widgets = { + 'Natural': widgets.Label('Currently hard-coded to window-example from mathematica notebook'), + 'other': widgets.Label('Not yet implemented.') + } + for name, widget in ventilation_widgets.items(): + widget.layout.visible = False + + ventilation_w = widgets.ToggleButtons( + options=ventilation_widgets.keys(), + ) + + def toggle_ventilation(value): + for name, widget in ventilation_widgets.items(): + widget.layout.display = 'none' + other = ventilation_widgets['other'] + widget = ventilation_widgets.get(value, other) + widget.layout.visible = True + widget.layout.display = 'block' + + ventilation_w.observe(lambda event: toggle_ventilation(event['new']), 'value') + toggle_ventilation(ventilation_w.value) + + w = collapsible( + [widget_group([[widgets.Label('Ventilation type'), ventilation_w]])] + + list(ventilation_widgets.values()), + title='Ventilation scheme' + ) + return w + + def present(self): + return self.widget + + +baseline_model = models.Model( + room=models.Room(volume=75), + ventilation=models.PeriodicWindow( + period=120, duration=120, inside_temp=293, outside_temp=283, cd_b=0.6, + window_height=1.6, opening_length=0.6, + ), + infected=models.InfectedPerson( + virus=models.Virus.types['SARS_CoV_2'], + present_times=((0, 4), (5, 8)), + mask=models.Mask.types['No mask'], + activity=models.Activity.types['Light exercise'], + expiration=models.Expiration.types['Unmodulated Vocalization'], + ), + infected_occupants=1, + exposed_occupants=10, + exposed_activity=models.Activity.types['Light exercise'], +) + + +class ExpertApplication: + def __init__(self): + self.model_state = state.DataclassState(models.Model) + self.model_state.dcs_update_from( + baseline_model + ) + self.view = WidgetView(self.model_state) + # self._widget = widgets.Text("WIP") + + @property + def widget(self): + return self.view.present() diff --git a/cara/state.py b/cara/state.py new file mode 100644 index 00000000..97dc7a0f --- /dev/null +++ b/cara/state.py @@ -0,0 +1,229 @@ +""" +This module is entirely in support of providing a convenient mutable counterpart +to frozen dataclasses. Significant effort went into to trying to use traitlets +for this purpose, but the need to define class-level attributes proved to be a +limitation that meant we could not mutate the state from one subclass to another +after the state was instantiated. + +This module MUST not import other parts of cara as this would point at a +leaky abstraction. + +""" +from contextlib import contextmanager +import dataclasses +import typing + + +Datamodel_T = typing.Type +dataclass_instance = typing.Any + + +class StateBuilder: + def visit(self, field: dataclasses.Field): + builder = self.resolve_builder(field) + return builder(field.type) + + def resolve_builder(self, field: dataclasses.Field): + method_name = [ + f'build_name_{field.name}', + f'build_type_{field.type.__name__}', + ] + for name in method_name: + method = getattr(self, name, None) + if method is not None: + return method + return self.build_generic + + def build_generic(self, type_to_build: typing.Type): + return DataclassState(type_to_build, self) + + +class DataclassState: + """ + Represents the state of a frozen dataclass. + No type checking of the attributes is attempted. + + Setting the state can be done with: + + setattr(state, attr, value) + + Accessing the instance that this state represents can be done with: + + state.dcs_instance() + + Changing the type to a subclass of the base that this state represents can be done with: + + state.dcs_set_instance_type(ASubclassOfBase) + + """ + + def __init__(self, dataclass: Datamodel_T, state_builder=StateBuilder()): + # Note that the constructor does *not* insert any data by default. It + # therefore doesn't build nested DataclassState instances when a dataclass contains another. + # For that, use the build classmethod. + if not dataclasses.is_dataclass(dataclass): + raise TypeError("The given class is not a valid dataclass") + if not isinstance(dataclass, type): + raise TypeError("A dataclass type must be provided, not an instance of one") + + with self._object_setattr(): + #: The base instance which this state must support. + self._base = dataclass + #: The actual instance type that this state represents (i.e. may be a + #: subclass of _base). + self._instance_type = dataclass + + #: The instance of dataclass which this state represents. Undefined until + #: sufficient data is provided. + self._instance = None + self._data = {} + self._observers: typing.List[callable] = [] + self._state_builder = state_builder + + self.dcs_set_instance_type(dataclass) + + def __repr__(self): + return f"