diff --git a/app/cara.ipynb b/app/cara.ipynb index d3cb17e5..b8e43cb7 100644 --- a/app/cara.ipynb +++ b/app/cara.ipynb @@ -15,19 +15,48 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, + "metadata": { + "pycharm": { + "name": "#%%\n" + }, + "scrolled": false + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "6e33ae6d23704c188c91237976fe0255", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(Accordion(children=(VBox(children=(HBox(children=(VBox(children=(Label(value='Room volume'),)),…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import cara.apps\n", + "\n", + "app = cara.apps.ExpertApplication()\n", + "app.widget" + ] + }, + { + "cell_type": "code", + "execution_count": 5, "metadata": { - "scrolled": false, "pycharm": { "name": "#%%\n" } }, "outputs": [], "source": [ - "import cara.apps\n", - "\n", - "app = cara.apps.ExpertApplication()\n", - "app.widget" + "import cara.models\n", + "app.model_state.room.dcs_update_from(cara.models.Room(volume=75))\n" ] } ], diff --git a/cara/apps.py b/cara/apps.py index eb2b530a..ea1f8b19 100644 --- a/cara/apps.py +++ b/cara/apps.py @@ -40,7 +40,7 @@ class ConcentrationFigure: [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.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) @@ -49,8 +49,9 @@ class ConcentrationFigure: 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) + # ax.set_ylim(bottom=0) else: + self.ax.ignore_existing_data_limits = True self.line.set_data(ts, concentration) self.ax.relim() self.ax.autoscale_view() @@ -74,6 +75,8 @@ class WidgetView: #: The widgets that this view produces (inputs and outputs together) self.widget = widgets.VBox([]) self.widgets = {} + self.out = widgets.Output() + self.widget.children += (self.out, ) self.plots = [] self.construct_widgets() # Trigger the first result. @@ -106,26 +109,35 @@ class WidgetView: 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) + self.widget.children += (self._build_room(node.room),) + self.widget.children += (self._build_ventilation(node.ventilation),) + self.widget.children += (self._build_infected(node.infected),) + self.widget.children += (self._build_exposed(node),) + + def _build_exposed(self, node): + return collapsible( + [self._build_activity(node.exposed_activity)], + title="Exposed" + ) + + def _build_infected(self, node): + return collapsible([widgets.HBox([ + self._build_mask(node.mask), + self._build_activity(node.activity), + self._build_expiration(node.expiration), + ])], title="Infected") 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']) + def on_state_change(): + room_volume.value = node.volume + node.dcs_observe(on_state_change) widget = collapsible( [widget_group( @@ -135,10 +147,100 @@ class WidgetView: ) return widget + def _build_window(self, node): + period = widgets.IntSlider(value=node.period, min=0, max=240) + interval = widgets.IntSlider(value=node.duration, min=0, max=240) + + def on_period_change(change): + node.period = change['new'] + + def on_interval_change(change): + node.duration = change['new'] + + # TODO: Link the state back to the widget, not just the other way around. + period.observe(on_period_change, names=['value']) + interval.observe(on_interval_change, names=['value']) + + return widget_group( + [ + [widgets.Label('Open every n minutes'), period], + [widgets.Label('For how long'), interval], + ] + ) + + def _build_hepa(self, node): + period = widgets.IntSlider(value=node.period, min=0, max=240) + interval = widgets.IntSlider(value=node.duration, min=0, max=240) + + def on_period_change(change): + node.period = change['new'] + + def on_interval_change(change): + node.duration = change['new'] + + # TODO: Link the state back to the widget, not just the other way around. + period.observe(on_period_change, names=['value']) + interval.observe(on_interval_change, names=['value']) + + return widget_group( + [ + [widgets.Label('On every n minutes'), period], + [widgets.Label('For how long'), interval], + ] + ) + + def _build_activity(self, node): + activity = node.dcs_instance() + for name, activity_ in models.Activity.types.items(): + if activity == activity_: + break + activity = widgets.Select(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 widget_group( + [[widgets.Label("Activity"), activity]] + ) + + def _build_mask(self, node): + mask = node.dcs_instance() + for name, mask_ in models.Mask.types.items(): + if mask == mask_: + break + mask_choice = widgets.Select(options=list(models.Mask.types.keys()), value=name) + + def on_mask_change(change): + mask = models.Mask.types[change['new']] + node.dcs_update_from(mask) + mask_choice.observe(on_mask_change, names=['value']) + + return widget_group( + [[widgets.Label("Mask"), mask_choice]] + ) + + def _build_expiration(self, node): + expiration = node.dcs_instance() + for name, expiration_ in models.Expiration.types.items(): + if expiration == expiration_: + break + expiration_choice = widgets.Select(options=list(models.Expiration.types.keys()), value=name) + + def on_expiration_change(change): + expiration = models.Expiration.types[change['new']] + node.dcs_update_from(expiration) + expiration_choice.observe(on_expiration_change, names=['value']) + + return widget_group( + [[widgets.Label("Expiration"), expiration_choice]] + ) + 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.') + 'Natural': self._build_window(node), + # 'HEPA': self._build_hepa(node) } for name, widget in ventilation_widgets.items(): widget.layout.visible = False @@ -150,8 +252,11 @@ class WidgetView: 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) + # if value == 'Natural': + # node.dcs_update_from(models.PeriodicWindow()) + # elif value == 'HEPA': + # node.dcs_update_from(models.PeriodicHEPA()) + widget = ventilation_widgets[value] widget.layout.visible = True widget.layout.display = 'block' @@ -191,11 +296,8 @@ baseline_model = models.Model( class ExpertApplication: def __init__(self): self.model_state = state.DataclassState(models.Model) - self.model_state.dcs_update_from( - baseline_model - ) + self.model_state.dcs_update_from(baseline_model) self.view = WidgetView(self.model_state) - # self._widget = widgets.Text("WIP") @property def widget(self): diff --git a/cara/models.py b/cara/models.py index 31649aa4..f8e984bf 100644 --- a/cara/models.py +++ b/cara/models.py @@ -21,7 +21,11 @@ class Ventilation: @abstractmethod def air_exchange(self, room: Room, time: float) -> float: - # Returns the rate at which air is being exchanged in the given room per cubic meter at a given time + """ + Returns the rate at which air is being exchanged in the given room per + cubic meter at a given time (in hours). + + """ pass @@ -42,10 +46,11 @@ class PeriodicWindow(Ventilation): cd_b: float = 0.6 #: Discharge coefficient: what portion effective area is used to exchange air (0 <= cd_b <= 1) def air_exchange(self, room: Room, time: float) -> float: + period = self.period / 60. + duration = self.duration / 60. # Returns the rate at which air is being exchanged in the given room per cubic meter at a given time - # If the window is closed, no air is being exchanged - if time % self.period < (self.period - self.duration): + if (time % period) < (period - duration): return 0 root = np.sqrt(9.81 * self.window_height * (abs(self.inside_temp - self.outside_temp)) / self.outside_temp) diff --git a/cara/state.py b/cara/state.py index 97dc7a0f..ae542431 100644 --- a/cara/state.py +++ b/cara/state.py @@ -79,6 +79,8 @@ class DataclassState: self._data = {} self._observers: typing.List[callable] = [] self._state_builder = state_builder + self._held_events = [] + self._hold_fire = False self.dcs_set_instance_type(dataclass) @@ -91,22 +93,36 @@ class DataclassState: def dcs_observe(self, callback: typing.Callable): self._observers.append(callback) + @contextmanager + def dcs_state_transaction(self): + self._hold_fire = True + yield + self._hold_fire = False + if self._held_events: + self._held_events.clear() + self._fire_observers() + def dcs_update_from(self, data: dataclass_instance): - self.dcs_set_instance_type(data.__class__) - for field in dataclasses.fields(data): - attr = field.name - current_value = self._data.get(attr, None) - new_value = getattr(data, attr) - if dataclasses.is_dataclass(field.type): - assert isinstance(current_value, DataclassState) - current_value.dcs_update_from(new_value) - else: - self._data[attr] = new_value + with self.dcs_state_transaction(): + self.dcs_set_instance_type(data.__class__) + for field in dataclasses.fields(data): + attr = field.name + current_value = self._data.get(attr, None) + new_value = getattr(data, attr) + if dataclasses.is_dataclass(field.type): + assert isinstance(current_value, DataclassState) + current_value.dcs_update_from(new_value) + else: + self._data[attr] = new_value + self._fire_observers() def _fire_observers(self): - self._instance = None - for observer in self._observers: - observer() + if self._hold_fire: + self._held_events.append(True) + else: + self._instance = None + for observer in self._observers: + observer() @contextmanager def _object_setattr(self):