From afe060623d1b2bb9acfec099fe2cc9b8ce8401b6 Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 05:44:41 +0000 Subject: [PATCH 1/8] Add protocol selector widget --- aurora/common/models/battery_experiment.py | 52 ++- aurora/experiment/protocols/__init__.py | 2 + aurora/experiment/protocols/selector.py | 428 +++++++++++++++++++++ 3 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 aurora/experiment/protocols/selector.py diff --git a/aurora/common/models/battery_experiment.py b/aurora/common/models/battery_experiment.py index 0fe5a6ae..25aa6ab6 100644 --- a/aurora/common/models/battery_experiment.py +++ b/aurora/common/models/battery_experiment.py @@ -1,6 +1,6 @@ import json import logging -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union import pandas as pd from aiida_aurora.schemas.battery import (BatterySampleJsonTypes, @@ -41,6 +41,8 @@ def __init__(self): self.selected_samples = pd.DataFrame() self.selected_protocol = ElectroChemSequence(method=[]) + self.selected_protocols: Dict[int, ElectroChemSequence] = {} + self.add_protocol_step() # initialize sequence with first default step def reset_inputs(self): @@ -368,6 +370,10 @@ def display_query_results(self, query: dict) -> None: # ----------------------------------------------------------------------# # METHODS RELATED TO PROTOCOLS # ----------------------------------------------------------------------# + def reset_selected_protocols(self): + """Resets all inputs.""" + self.selected_protocols = {} + def _count_technique_occurencies(self, technique): return [type(step) for step in self.selected_protocol.method].count(technique) @@ -428,6 +434,50 @@ def save_protocol(self): except OSError as err: print(f"Failed to save protocols => '{str(err)}'") + def add_selected_protocols(self, indices: List[int]) -> None: + """Add selected protocols to local cache. + + Parameters + ---------- + `indices` : `List[int]` + The `SelectMultiple` indices of the selected protocols. + """ + for index in indices: + self.add_selected_protocol(index) + + def add_selected_protocol(self, index: int) -> None: + """Add selected protocol to local cache. + + Parameters + ---------- + `index` : `int` + The `SelectMultiple` index of the selected protocol. + """ + if index not in self.selected_protocols: + self.selected_protocols[index] = self.available_protocols[index] + + def remove_selected_protocols(self, indices: List[int]) -> None: + """Remove selected protocol from local cache. + + Parameters + ---------- + `index` : `int` + The `SelectMultiple` index of the selected protocol. + """ + for index in indices: + self.remove_selected_protocol(index) + + def remove_selected_protocol(self, index: int) -> None: + """Remove selected protocol from local cache. + + Parameters + ---------- + `index` : `int` + The `SelectMultiple` index of the selected protocol. + """ + if index in self.selected_protocols: + del self.selected_protocols[index] + def update_available_protocols( self, source_file=AVAILABLE_PROTOCOLS_FILE, diff --git a/aurora/experiment/protocols/__init__.py b/aurora/experiment/protocols/__init__.py index 3a44a647..450198a4 100644 --- a/aurora/experiment/protocols/__init__.py +++ b/aurora/experiment/protocols/__init__.py @@ -1,7 +1,9 @@ from .custom import CyclingCustom +from .selector import ProtocolSelector from .standard import CyclingStandard __all__ = [ "CyclingCustom", + "ProtocolSelector", "CyclingStandard", ] diff --git a/aurora/experiment/protocols/selector.py b/aurora/experiment/protocols/selector.py new file mode 100644 index 00000000..07dd8563 --- /dev/null +++ b/aurora/experiment/protocols/selector.py @@ -0,0 +1,428 @@ +from typing import Callable, List, Tuple + +import ipywidgets as ipw +from aiida_aurora.schemas.cycling import (ElectroChemPayloads, + ElectroChemSequence) + +from aurora.common.models.battery_experiment import BatteryExperimentModel + + +class ProtocolSelector(ipw.VBox): + """ + docstring + """ + + LABEL_LAYOUT = { + 'margin': '0 auto', + } + + CONTROLS_LAYOUT = { + 'width': '80px', + } + + BUTTON_LAYOUT = { + 'width': '35px', + 'margin': '5px auto', + } + + BOX_STYLE = { + 'description_width': '5%', + } + + BOX_LAYOUT = { + 'flex': '1', + } + + def __init__( + self, + experiment_model: BatteryExperimentModel, + validate_callback_f: Callable, + ) -> None: + """docstring""" + + if not callable(validate_callback_f): + raise TypeError( + "validate_callback_f should be a callable function") + + self.experiment_model = experiment_model + + selection_container = self._build_selection_container() + + self.w_validate = ipw.Button( + style={ + 'description_width': '30%', + }, + layout={ + 'margin': '5px', + }, + description="Validate", + button_style='success', + tooltip="Validate the selected sample", + icon='check', + disabled=True, + ) + + self.reset_button = ipw.Button( + layout={}, + style={}, + description="Reset", + button_style='danger', + tooltip="Clear selection", + icon='times', + ) + + super().__init__( + layout={}, + children=[ + selection_container, + ipw.HBox( + layout={ + "align_items": "center", + }, + children=[ + self.w_validate, + self.reset_button, + ], + ), + ], + ) + + self._initialize_selectors() + + self._set_event_listeners(validate_callback_f) + + @property + def selected_protocols(self) -> List[ElectroChemSequence]: + """docstring""" + ids = get_ids(self.w_selected_list.options) + return self.experiment_model.query_available_protocols(ids) + + ######### + # widgets + ######### + + def _build_selection_container(self) -> ipw.HBox: + """docstring""" + + selection_section = self._build_selection_section() + + deselection_section = self._build_selected_section() + + self.selection_details = ipw.Tab(layout={ + "width": "50%", + "max_height": "338px", + }) + + return ipw.HBox( + layout={ + 'width': 'auto', + 'margin': '2px', + 'padding': '10px', + 'border': 'solid darkgrey 1px' + }, + children=[ + ipw.VBox( + layout={ + "width": "50%", + }, + children=[ + selection_section, + deselection_section, + ], + ), + self.selection_details, + ], + ) + + def _build_selection_section(self) -> ipw.VBox: + """docstring""" + + w_selection_label = ipw.HTML( + value="Protocol:", + layout=self.LABEL_LAYOUT, + ) + + selection_controls = self._build_selection_controls() + + self.w_protocol_list = ipw.SelectMultiple( + rows=10, + style=self.BOX_STYLE, + layout=self.BOX_LAYOUT, + ) + + return ipw.VBox( + layout={}, + children=[ + ipw.HBox( + layout={}, + children=[ + ipw.VBox( + layout=self.CONTROLS_LAYOUT, + children=[ + w_selection_label, + selection_controls, + ], + ), + self.w_protocol_list, + ], + ), + ], + ) + + def _build_selection_controls(self) -> ipw.VBox: + """docstring""" + + self.w_update = ipw.Button( + description="", + button_style='', + tooltip="Update available samples", + icon='refresh', + layout=self.BUTTON_LAYOUT, + ) + + self.w_select = ipw.Button( + description="", + button_style='', + tooltip="Select chosen sample", + icon='fa-angle-down', + layout=self.BUTTON_LAYOUT, + ) + + self.w_select_all = ipw.Button( + description="", + button_style='', + tooltip="Select all samples", + icon='fa-angle-double-down', + layout=self.BUTTON_LAYOUT, + ) + + return ipw.VBox( + layout={}, + children=[ + self.w_update, + self.w_select, + self.w_select_all, + ], + ) + + def _build_selected_section(self) -> ipw.VBox: + """docstring""" + + w_selected_label = ipw.HTML( + value="Selected ID:", + layout=self.LABEL_LAYOUT, + ) + + deselection_controls = self._build_deselection_controls() + + self.w_selected_list = ipw.SelectMultiple( + rows=10, + style=self.BOX_STYLE, + layout=self.BOX_LAYOUT, + ) + + return ipw.VBox( + layout={}, + children=[ + ipw.HBox( + layout={}, + children=[ + ipw.VBox( + layout=self.CONTROLS_LAYOUT, + children=[ + w_selected_label, + deselection_controls, + ], + ), + self.w_selected_list, + ], + ), + ], + ) + + def _build_deselection_controls(self) -> ipw.VBox: + """docstring""" + + self.w_deselect = ipw.Button( + description="", + button_style='', + tooltip="Deselect chosen sample", + icon='fa-angle-up', + layout=self.BUTTON_LAYOUT, + ) + + self.w_deselect_all = ipw.Button( + description="", + button_style='', + tooltip="Deselect all samples", + icon='fa-angle-double-up', + layout=self.BUTTON_LAYOUT, + ) + + return ipw.VBox( + layout={}, + children=[ + self.w_deselect_all, + self.w_deselect, + ], + ) + + ########################### + # TODO migrate to presenter + ########################### + + def update_protocol_options(self) -> None: + """docstring""" + self.on_deselect_all_button_click() + self.experiment_model.update_available_protocols() + self._build_protocol_options() + + def on_update_button_click(self, _=None) -> None: + """docstring""" + self.update_protocol_options() + + def on_select_list_click(self, change: dict) -> None: + """docstring""" + self.reset_selection_details() + self.add_selection_detail_tabs(change["new"]) + + def on_select_button_click(self, _=None) -> None: + """docstring""" + if indices := self.w_protocol_list.value: + self.experiment_model.add_selected_protocols(indices) + self.update_selected_list_options() + + def on_select_all_button_click(self, _=None) -> None: + """docstring""" + if indices := get_ids(self.w_protocol_list.options): + self.experiment_model.add_selected_protocols(indices) + self.update_selected_list_options() + + def on_selected_list_change(self, _=None) -> None: + """docstring""" + self.update_validate_button_state() + + def on_deselect_button_click(self, _=None) -> None: + """docstring""" + if indices := self.w_selected_list.value: + self.experiment_model.remove_selected_protocols(indices) + self.update_selected_list_options() + + def on_deselect_all_button_click(self, _=None) -> None: + """docstring""" + if indices := get_ids(self.w_selected_list.options): + self.experiment_model.remove_selected_protocols(indices) + self.update_selected_list_options() + + def on_validate_button_click(self, callback_function: Callable): + """docstring""" + return callback_function(self) + + def update_validate_button_state(self) -> None: + """docstring""" + self.w_validate.disabled = not self.w_selected_list.options + + def update_selected_list_options(self) -> None: + """docstring""" + selected_protocols = self.experiment_model.selected_protocols.items() + options = [(p.name, i) for i, p in selected_protocols] + self.w_selected_list.options = options + + def reset_selection_details(self) -> None: + """docstring""" + self.selection_details.children = [] + self.selection_details.selected_index = None + + def add_selection_detail_tabs(self, indices: List[int]) -> None: + """docstring""" + + if not indices: + return + + protocols = self.experiment_model.query_available_protocols(indices) + + for protocol in protocols: + + output = ipw.Output() + + self.selection_details.children += (output, ) + index = len(self.selection_details.children) - 1 + self.selection_details.set_title(index, protocol.name) + + self.display_protocol_details(output, protocol) + + self.selection_details.selected_index = 0 + + def display_protocol_details( + self, + output: ipw.Output, + protocol: ElectroChemPayloads, + ) -> None: + """docstring""" + with output: + for step in protocol.method: + print(f"{step.name} ({step.technique})") + for label, param in step.parameters: + default = param.default_value + value = default if param.value is None else param.value + units = "" if value is None else param.units + print(f"{label} = {value} {units}") + print() + + def reset(self, _=None) -> None: + """docstring""" + self.w_protocol_list.value = [] + self.w_selected_list.options = [] + self.experiment_model.reset_selected_protocols() + + def _build_protocol_options(self) -> None: + """docstring""" + available_protocols = self.experiment_model.query_available_protocols() + options = [(p.name, i) for i, p in enumerate(available_protocols)] + self.w_protocol_list.options = options + + def _initialize_selectors(self) -> None: + """docstring""" + self.w_protocol_list.value = [] + self.w_selected_list.value = [] + self.on_update_button_click() + + def _set_event_listeners(self, validate_callback_f) -> None: + """docstring""" + + self.w_update.on_click(self.on_update_button_click) + self.w_select.on_click(self.on_select_button_click) + self.w_select_all.on_click(self.on_select_all_button_click) + self.w_deselect.on_click(self.on_deselect_button_click) + self.w_deselect_all.on_click(self.on_deselect_all_button_click) + + self.w_protocol_list.observe( + names="value", + handler=self.on_select_list_click, + ) + + self.w_selected_list.observe( + names='options', + handler=self.on_selected_list_change, + ) + + self.w_validate.on_click( + lambda arg: self.on_validate_button_click(validate_callback_f)) + + self.reset_button.on_click(self.reset) + + +def get_ids(options: Tuple[Tuple[str, int], ...]) -> List[int]: + """Extract indices from option tuples. + + Parameters + ---------- + options : `Tuple[Tuple[str, int], ...]` + A tuple of `ipywidgets.SelectMultiple` option tuples. + + Returns + ------- + `List[int]` + A list of option indices. + """ + return [i for _, i in options] From 9f65f6368c9e8cfabccd2c5df703cfe6b089097a Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 05:49:03 +0000 Subject: [PATCH 2/8] Connect protocol selector to experiment builder --- aurora/experiment/submit_experiment.py | 48 +++++++++++--------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/aurora/experiment/submit_experiment.py b/aurora/experiment/submit_experiment.py index 5b7670be..52f43b95 100644 --- a/aurora/experiment/submit_experiment.py +++ b/aurora/experiment/submit_experiment.py @@ -8,7 +8,7 @@ from aurora.common.models import AvailableSamplesModel, BatteryExperimentModel from aurora.engine import submit_experiment -from .protocols import CyclingCustom, CyclingStandard +from .protocols import CyclingCustom, ProtocolSelector from .samples import SampleFromId, SampleFromRecipe, SampleFromSpecs from .tomato import TomatoSettings @@ -39,9 +39,9 @@ class ExperimentBuilder(ipw.VBox): 'recipe', ] - _METHOD_LABELS = [ - 'Standardized', - 'Customized', + _PROTOCOL_TAB_LABELS = [ + 'Select', + 'Create', ] _SAMPLE_BOX_LAYOUT = { @@ -131,32 +131,26 @@ def _build_sample_selection_section(self) -> None: def _build_cycling_protocol_section(self) -> None: """Build the cycling protocol section.""" - self.w_test_sample_label = ipw.HTML("Selected samples:") - self.w_test_sample_preview = ipw.Output(layout=self._SAMPLE_BOX_LAYOUT) - - self.w_test_standard = CyclingStandard(lambda x: x) + self.protocol_selector = ProtocolSelector( + experiment_model=self.experiment_model, + validate_callback_f=self.confirm_protocols_selection, + ) - self.w_test_custom = CyclingCustom( + self.protocol_creator = CyclingCustom( experiment_model=self.experiment_model, validate_callback_f=self.return_selected_protocol, ) - self.w_test_method_tab = ipw.Tab( + self.w_protocols_tab = ipw.Tab( children=[ - self.w_test_standard, - self.w_test_custom, + self.protocol_selector, + self.protocol_creator, ], - selected_index=1, + selected_index=0, ) - for i, title in enumerate(self._METHOD_LABELS): - self.w_test_method_tab.set_title(i, title) - - self.w_test_tab = ipw.VBox([ - self.w_test_sample_label, - self.w_test_sample_preview, - self.w_test_method_tab, - ]) + for i, title in enumerate(self._PROTOCOL_TAB_LABELS): + self.w_protocols_tab.set_title(i, title) def _build_job_settings_section(self) -> None: """Build the job settings section.""" @@ -278,9 +272,9 @@ def selected_recipe(self): return self._selected_battery_recipe @property - def selected_cycling_protocol(self): + def selected_cycling_protocols(self): "The Cycling Specs selected. Used by a BatteryCyclerExperiment." - return self.experiment_model.selected_protocol + return self.experiment_model.selected_protocols.values() # return self._selected_cycling_protocol @property @@ -338,11 +332,9 @@ def return_selected_protocol(self, cycling_widget_obj): self._selected_cycling_protocol = cycling_widget_obj.protocol_steps_list self.post_protocol_selection() - def post_protocol_selection(self): + def confirm_protocols_selection(self, _=None): "Switch to Tomato settings accordion tab." - if self.selected_battery_samples is None: - raise ValueError("A Battery sample was not selected!") - # self.w_settings_tab.set_default_calcjob_node_label(self.selected_battery_sample_node.label, self.selected_cycling_protocol_node.label) # TODO: uncomment this + self.w_settings_tab.update_protocol_options() self.w_main_accordion.selected_index = 2 ####################################################################################### @@ -414,6 +406,7 @@ def presubmission_checks_preview(self, _=None) -> None: @w_submission_output.capture() def submit_job(self, dummy=None): self.w_submit_button.disabled = True + for index, battery_sample in self.experiment_model.selected_samples.iterrows( ): json_stuff = dict_to_formatted_json(battery_sample) @@ -485,6 +478,7 @@ def reset(self, dummy=None): # TODO: properly reinitialize each widget self.reset_all_inputs() self.w_sample_from_id.reset() + self.protocol_selector.reset() self.w_settings_tab.reset() self.w_submission_output.clear_output() self.w_main_accordion.selected_index = 0 From f23d7c873b70b01558b5dae7b3c1efa0d8e528e7 Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 05:55:16 +0000 Subject: [PATCH 3/8] Update settings widget for multiple protocols --- aurora/experiment/submit_experiment.py | 52 +++++----- aurora/experiment/tomato/settings.py | 128 ++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 34 deletions(-) diff --git a/aurora/experiment/submit_experiment.py b/aurora/experiment/submit_experiment.py index 52f43b95..c2236831 100644 --- a/aurora/experiment/submit_experiment.py +++ b/aurora/experiment/submit_experiment.py @@ -1,8 +1,10 @@ import json -from typing import Optional +from copy import deepcopy +from typing import Dict, Optional import ipywidgets as ipw from aiida_aurora.schemas.battery import BatterySample +from aiida_aurora.schemas.dgbowl import Tomato_0p2 from aiida_aurora.schemas.utils import dict_to_formatted_json from aurora.common.models import AvailableSamplesModel, BatteryExperimentModel @@ -155,7 +157,9 @@ def _build_cycling_protocol_section(self) -> None: def _build_job_settings_section(self) -> None: """Build the job settings section.""" self.w_settings_tab = TomatoSettings( - validate_callback_f=self.return_selected_settings) + experiment_model=self.experiment_model, + validate_callback_f=self.return_selected_settings, + ) def _build_job_submission_section(self) -> None: """Build the job submission section.""" @@ -274,23 +278,25 @@ def selected_recipe(self): @property def selected_cycling_protocols(self): "The Cycling Specs selected. Used by a BatteryCyclerExperiment." - return self.experiment_model.selected_protocols.values() + return list(self.experiment_model.selected_protocols.values()) # return self._selected_cycling_protocol @property - def selected_tomato_settings(self): + def selected_tomato_settings(self) -> Dict[str, Tomato_0p2]: "The Tomato Settings selected. Used by a BatteryCyclerExperiment." - return self._selected_tomato_settings + return deepcopy(self._selected_tomato_settings) @property - def selected_monitor_settings(self): + def selected_monitor_settings(self) -> Dict[str, dict]: "The Tomato Monitor Settings selected. Used by a job monitor." - return self._selected_monitor_settings + return deepcopy(self._selected_monitor_settings) @property - def calcjob_node_label(self): - "The label assigned the submitted BatteryCyclerExperiment CalcJob." - return self._calcjob_node_label + def workchain_node_label(self): + """The label assigned to the submitted workchain node. The + label used in the workflow as a prefix for each submitted + protocol calculation node.""" + return self._workchain_node_label ####################################################################################### # SAMPLE SELECTION @@ -340,11 +346,11 @@ def confirm_protocols_selection(self, _=None): ####################################################################################### # TOMATO SETTINGS SELECTION ####################################################################################### - def return_selected_settings(self, settings_widget_obj): - self._selected_tomato_settings = settings_widget_obj.selected_tomato_settings - self._selected_monitor_settings = settings_widget_obj.selected_monitor_settings - self._calcjob_node_label = settings_widget_obj.calcjob_node_label - settings_widget_obj.reset_controls() + def return_selected_settings(self, settings_widget: TomatoSettings): + self._selected_tomato_settings = deepcopy(settings_widget.settings) + self._selected_monitor_settings = deepcopy(settings_widget.monitors) + self._workchain_node_label = settings_widget.workchain_node_label + settings_widget.reset_controls() self.post_settings_selection() def post_settings_selection(self): @@ -415,13 +421,13 @@ def submit_job(self, dummy=None): self.process = submit_experiment( sample=current_battery, - method=self.selected_cycling_protocol, - tomato_settings=self.selected_tomato_settings, - monitor_settings=self.selected_monitor_settings, + protocols=self.selected_cycling_protocols, + settings=self.selected_tomato_settings.values(), + monitors=self.selected_monitor_settings.values(), code_name=self.w_code.value, sample_node_label="", - method_node_label="", - calcjob_node_label="") + protocol_node_label="", + workchain_node_label="") self.w_main_accordion.selected_index = None @@ -469,9 +475,9 @@ def reset_all_inputs(self, dummy=None): self._selected_battery_specs = None self._selected_recipe = None self._selected_cycling_protocol = None - self._selected_tomato_settings = None - self._selected_monitor_settings = None - self._calcjob_node_label = None + self._selected_tomato_settings = {} + self._selected_monitor_settings = {} + self._workchain_node_label = None def reset(self, dummy=None): "Reset the interface." diff --git a/aurora/experiment/tomato/settings.py b/aurora/experiment/tomato/settings.py index 52292f7d..c5e1c1a5 100644 --- a/aurora/experiment/tomato/settings.py +++ b/aurora/experiment/tomato/settings.py @@ -8,6 +8,8 @@ from aiida_aurora.schemas.dgbowl import Tomato_0p2 from aiida_aurora.schemas.utils import remove_empties_from_dict_decorator +from aurora.common.models.battery_experiment import BatteryExperimentModel + class TomatoSettings(ipw.VBox): @@ -22,16 +24,42 @@ class TomatoSettings(ipw.VBox): 'padding': '5px' } - def __init__(self, validate_callback_f): + def __init__( + self, + experiment_model: BatteryExperimentModel, + validate_callback_f, + ) -> None: if not callable(validate_callback_f): raise TypeError( "validate_callback_f should be a callable function") + self.experiment_model = experiment_model + + self.settings: Dict[str, Tomato_0p2] = {} + self.monitors: Dict[str, dict] = {} + # initialize job settings self.defaults: Dict[ipw.ValueWidget, Any] = {} + self.protocol_selector = ipw.Dropdown( + layout={}, + value=None, + description="Protocol:", + ) + self.defaults[self.protocol_selector] = self.protocol_selector.value + + self.save_button = ipw.Button( + layout=self.BUTTON_LAYOUT, + style=self.BUTTON_STYLE, + disabled=True, + button_style="success", + description="Save", + tooltip="Assign settings/monitors to selected protocol") + + self.save_notice = ipw.HTML() + self.w_job_header = ipw.HTML("

Tomato Job configuration:

") self.unlock_when_done = ipw.Checkbox( @@ -91,9 +119,9 @@ def __init__(self, validate_callback_f): self.w_monitor_parameters = ipw.VBox() - self.w_job_calcjob_node_label = ipw.Text( - description="AiiDA CalcJob node label:", - placeholder="Enter a name for the BatteryCyclerExperiment node", + self.w_workchain_node_label = ipw.Text( + description="AiiDA WorkChain node label:", + placeholder="Enter a name for the CyclingSequenceWorkChain node", layout={ 'width': 'auto', "margin": "5px 0", @@ -103,6 +131,7 @@ def __init__(self, validate_callback_f): button_style='success', tooltip="Validate the settings", icon='check', + disabled=True, style=self.BUTTON_STYLE, layout=self.BUTTON_LAYOUT) @@ -118,13 +147,23 @@ def __init__(self, validate_callback_f): # initialize widgets super().__init__() self.children = [ + ipw.HBox( + layout={ + "align_items": "center", + }, + children=[ + self.protocol_selector, + self.save_button, + self.save_notice, + ], + ), self.w_job_header, ipw.VBox([self.unlock_when_done, self.verbosity], layout=self.BOX_LAYOUT_2), self.w_monitor_header, ipw.VBox([self.is_monitored, self.w_monitor_parameters], layout=self.BOX_LAYOUT_2), - self.w_job_calcjob_node_label, + self.w_workchain_node_label, ipw.HBox( layout={ "align_items": "center", @@ -137,13 +176,26 @@ def __init__(self, validate_callback_f): ] # setup automations + + self.protocol_selector.observe( + names="value", + handler=self.update_save_button_states, + ) + + self.protocol_selector.observe( + names="options", + handler=self.update_validate_button_states, + ) + + self.save_button.on_click(self.save_state_to_protocol) + # job monitored checkbox self.is_monitored.observe(self._build_job_monitor_parameters, names="value") # validate protocol self.w_validate.on_click( - lambda arg: self.callback_call(validate_callback_f)) + lambda arg: self.validate(validate_callback_f)) self.reset_button.on_click(self.reset) @@ -152,6 +204,7 @@ def __init__(self, validate_callback_f): def init(self) -> None: """Initialize widget.""" self._build_job_monitor_parameters() + self.update_protocol_options() @property @remove_empties_from_dict_decorator @@ -186,8 +239,37 @@ def selected_tomato_settings(self): return Tomato_0p2.parse_obj(self.selected_tomato_settings_dict) @property - def calcjob_node_label(self): - return self.w_job_calcjob_node_label.value, + def workchain_node_label(self): + return self.w_workchain_node_label.value, + + def update_protocol_options(self) -> None: + """Rebuild protocol dropdown menu.""" + self.reset() + self._build_protocol_options() + + def update_save_button_states(self, change: dict) -> None: + """docstring""" + self.save_button.disabled = not change["new"] + + def update_validate_button_states(self, change: dict) -> None: + """docstring""" + self.w_validate.disabled = not change["new"] + + def save_state_to_protocol(self, _=None) -> None: + """Save selected settings/monitors to selected protocol.""" + + selected_protocol = self.protocol_selector.value + + self.settings[selected_protocol] = self.selected_tomato_settings + + if self.selected_monitor_settings: + self.monitors[selected_protocol] = { + "capacity": self.selected_monitor_settings + } + else: + self.monitors[selected_protocol] = {} + + self.save_notice.value = f"Saved to {selected_protocol}!" def _build_job_monitor_parameters(self, dummy=None): if self.is_monitored.value: @@ -200,18 +282,42 @@ def _build_job_monitor_parameters(self, dummy=None): else: self.w_monitor_parameters.children = [] + def _build_protocol_options(self) -> None: + """Build protocol dropdown menu from selected protocols.""" + selected_protocols = self.experiment_model.selected_protocols.values() + options = [p.name for p in selected_protocols] + self.protocol_selector.options = options + self.protocol_selector.value = None + def reset_controls(self) -> None: """Reset controls and notices.""" + self.save_notice.value = "" for control, value in self.defaults.items(): control.value = value def reset(self, _=None) -> None: """Reset widget and registers.""" self.reset_controls() + self.settings.clear() + self.monitors.clear() def set_default_calcjob_node_label(self, sample_label, method_label): - self.w_job_calcjob_node_label.value = f"{sample_label}-{method_label}" + self.w_workchain_node_label.value = f"{sample_label}-{method_label}" + + def validate(self, callback_function): + "Finalize data and trigger callback." + + for protocol in self.protocol_selector.options: + if protocol not in self.settings: + self.select_defaults(protocol) - def callback_call(self, callback_function): - "Call a callback function and this class instance to it." return callback_function(self) + + def select_defaults(self, protocol: str) -> None: + """docstring""" + self.settings[protocol] = Tomato_0p2.parse_obj( + obj={ + 'unlock_when_done': self.defaults[self.unlock_when_done], + 'verbosity': self.defaults[self.verbosity], + }) + self.monitors[protocol] = {} From c6d80fc5aa3ac2b614aecd52cc6ee952621fe9dd Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 13:12:00 +0000 Subject: [PATCH 4/8] Update submit function for workflow --- INSTALLATION_GUIDE.md | 3 +- aurora/engine/submit.py | 187 ++++++++++++++++++------- aurora/experiment/submit_experiment.py | 4 +- 3 files changed, 139 insertions(+), 55 deletions(-) diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index d2a7fdd6..a47b6b18 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -234,8 +234,7 @@ ``` verdi group create BatterySamples verdi group create CyclingSpecs - verdi group create CalcJobs - verdi group create MonitorJobs + verdi group create WorkChains ``` ## 4. Aurora app installation diff --git a/aurora/engine/submit.py b/aurora/engine/submit.py index 6ebfd07e..b2236393 100644 --- a/aurora/engine/submit.py +++ b/aurora/engine/submit.py @@ -1,82 +1,167 @@ -# from time import sleep +from typing import Dict, List -import aiida from aiida.engine import submit from aiida.manage.configuration import load_profile -from aiida.orm import Dict, load_code, load_group +from aiida.orm import Dict as AiiDADict +from aiida.orm import List as AiiDAList +from aiida.orm import load_code, load_group +from aiida_aurora.data import (BatterySampleData, CyclingSpecsData, + TomatoSettingsData) +from aiida_aurora.schemas.battery import BatterySample +from aiida_aurora.schemas.cycling import ElectroChemSequence +from aiida_aurora.schemas.dgbowl import Tomato_0p2 +from aiida_aurora.workflows import CyclingSequenceWorkChain load_profile() -BatterySampleData = aiida.plugins.DataFactory('aurora.batterysample') -CyclingSpecsData = aiida.plugins.DataFactory('aurora.cyclingspecs') -TomatoSettingsData = aiida.plugins.DataFactory('aurora.tomatosettings') -BatteryCyclerExperiment = aiida.plugins.CalculationFactory('aurora.cycler') +SAMPLES_GROUP = load_group("BatterySamples") +PROTOCOLS_GROUP = load_group("CyclingSpecs") +WORKFLOWS_GROUP = load_group("WorkChains") -GROUP_SAMPLES = load_group("BatterySamples") -GROUP_METHODS = load_group("CyclingSpecs") -GROUP_CALCJOBS = load_group("CalcJobs") - -def submit_experiment(sample, - method, - tomato_settings, - monitor_settings, - code_name, - sample_node_label="", - method_node_label="", - calcjob_node_label=""): +def submit_experiment( + sample: BatterySample, + protocols: List[ElectroChemSequence], + settings: List[Tomato_0p2], + monitors: List[dict], + code_name: str, + sample_node_label="", + protocol_node_label="", + workchain_node_label="", +) -> CyclingSequenceWorkChain: """ sample : `aiida_aurora.schemas.battery.BatterySample` method : `aiida_aurora.schemas.cycling.ElectroChemSequence` tomato_settings : `aiida_aurora.schemas.dgbowl_schemas.Tomato_0p2` """ + inputs = get_inputs( + sample, + sample_node_label, + code_name, + protocols, + settings, + monitors, + protocol_node_label, + ) + + return submit_job(inputs, workchain_node_label) + + +def get_inputs( + sample: BatterySample, + sample_node_label: str, + code_name: str, + protocols: List[ElectroChemSequence], + settings: List[Tomato_0p2], + monitors: List[dict], + protocol_node_label: str, +) -> dict: + """Prepare input dictionaries for workflow.""" + + inputs = { + "battery_sample": build_sample_node(sample, sample_node_label), + "tomato_code": load_code(code_name), + "protocol_order": AiiDAList(), + "protocols": {}, + "control_settings": {}, + "monitor_settings": {}, + } + + # push cycler locking (if requested) to final workflow step + if any(not ts.unlock_when_done for ts in settings): + for ts in settings: + ts.unlock_when_done = True + settings[-1].unlock_when_done = False + + for protocol, settings, protocol_monitors in zip( + protocols, + settings, + monitors, + ): + + step = protocol.name + + inputs["protocol_order"].append(step) + + inputs["protocols"][step] = build_protocol_node( + protocol, + protocol_node_label, + ) + + inputs["control_settings"][step] = build_settings_node(settings) + + inputs["monitor_settings"][step] = build_monitors_input( + protocol, + protocol_monitors, + ) + + return inputs + + +def build_sample_node( + sample: BatterySample, + sample_node_label: str, +) -> BatterySampleData: + """Construct an AiiDA data node from battery sample data.""" sample_node = BatterySampleData(sample.dict()) - if sample_node_label: - sample_node.label = sample_node_label + sample_node.label = sample_node_label sample_node.store() - GROUP_SAMPLES.add_nodes(sample_node) + SAMPLES_GROUP.add_nodes(sample_node) + return sample_node + + +def build_protocol_node( + protocol: ElectroChemSequence, + protocol_node_label: str, +) -> CyclingSpecsData: + """Construct an AiiDA data node from cycling protocol data.""" + protocol_node = CyclingSpecsData(protocol.dict()) + protocol_node.label = protocol_node_label + protocol_node.store() + PROTOCOLS_GROUP.add_nodes(protocol_node) + return protocol_node + - method_node = CyclingSpecsData(method.dict()) - if method_node_label: - method_node.label = method_node_label - method_node.store() - GROUP_METHODS.add_nodes(method_node) +def build_settings_node(settings: Tomato_0p2) -> TomatoSettingsData: + """Construct an AiiDA data node from tomato settings data.""" + settings_node = TomatoSettingsData(settings.dict()) + settings_node.label = "" + settings_node.store() + return settings_node - tomato_settings_node = TomatoSettingsData(tomato_settings.dict()) - tomato_settings_node.label = "" # TODO? write default name generator - e.g. "tomato-True-monitor_600" - tomato_settings_node.store() - code = load_code(code_name) +def build_monitors_input( + protocol: ElectroChemSequence, + protocol_monitors: Dict[str, dict], +) -> dict: + """Construct a dictionary of `Dict` monitors for the protocol.""" - builder = BatteryCyclerExperiment.get_builder() - builder.battery_sample = sample_node - builder.code = code - builder.protocol = method_node - builder.control_settings = tomato_settings_node + monitors: Dict[str, dict] = {} - if monitor_settings: + for label, monitor_settings in protocol_monitors.items(): refresh_rate = monitor_settings.pop("refresh_rate", 600) - builder.monitors = { - "capacity": - Dict({ + monitor_name = f"{protocol.name}_{label}" + monitors[monitor_name] = AiiDADict( + dict={ "entry_point": "aurora.monitors.capacity_threshold", "minimum_poll_interval": refresh_rate, "kwargs": { "settings": monitor_settings, "filename": "snapshot.json", }, - }), - } + }) - job = submit(builder) - job.label = calcjob_node_label - print(f"Job <{job.pk}> submitted to AiiDA...") - GROUP_CALCJOBS.add_nodes(job) + return monitors - if monitor_settings: - job.set_extra('monitored', True) - else: - job.set_extra('monitored', False) - return job +def submit_job( + inputs: dict, + workchain_node_label: str, +) -> CyclingSequenceWorkChain: + """docstring""" + workchain = submit(CyclingSequenceWorkChain, **inputs) + workchain.label = workchain_node_label + print(f"Workflow <{workchain.pk}> submitted to AiiDA...") + WORKFLOWS_GROUP.add_nodes(workchain) + return workchain diff --git a/aurora/experiment/submit_experiment.py b/aurora/experiment/submit_experiment.py index c2236831..f551e784 100644 --- a/aurora/experiment/submit_experiment.py +++ b/aurora/experiment/submit_experiment.py @@ -422,8 +422,8 @@ def submit_job(self, dummy=None): self.process = submit_experiment( sample=current_battery, protocols=self.selected_cycling_protocols, - settings=self.selected_tomato_settings.values(), - monitors=self.selected_monitor_settings.values(), + settings=list(self.selected_tomato_settings.values()), + monitors=list(self.selected_monitor_settings.values()), code_name=self.w_code.value, sample_node_label="", protocol_node_label="", From d318d9aeaa2b73a2b63b222690bc72950f6a5b3a Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 15:53:40 +0000 Subject: [PATCH 5/8] Improve widget reset functionality --- aurora/common/models/battery_experiment.py | 4 ++-- aurora/experiment/submit_experiment.py | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/aurora/common/models/battery_experiment.py b/aurora/common/models/battery_experiment.py index 25aa6ab6..f9f18daa 100644 --- a/aurora/common/models/battery_experiment.py +++ b/aurora/common/models/battery_experiment.py @@ -47,8 +47,8 @@ def __init__(self): def reset_inputs(self): """Resets all inputs.""" - # Not implemented yet... - return None + self.reset_selected_samples() + self.reset_selected_protocols() # ----------------------------------------------------------------------# # METHODS RELATED TO OBSERVABLES diff --git a/aurora/experiment/submit_experiment.py b/aurora/experiment/submit_experiment.py index f551e784..dbad8a2e 100644 --- a/aurora/experiment/submit_experiment.py +++ b/aurora/experiment/submit_experiment.py @@ -470,10 +470,7 @@ def reset_sample_selection(self, dummy=None): def reset_all_inputs(self, dummy=None): "Reset all the selected inputs." - self.experiment_model.reset_inputs() - self._selected_battery_samples = None - self._selected_battery_specs = None - self._selected_recipe = None + self.reset_sample_selection() self._selected_cycling_protocol = None self._selected_tomato_settings = {} self._selected_monitor_settings = {} @@ -487,4 +484,4 @@ def reset(self, dummy=None): self.protocol_selector.reset() self.w_settings_tab.reset() self.w_submission_output.clear_output() - self.w_main_accordion.selected_index = 0 + self.w_main_accordion.selected_index = None From 66d6e92b26d2e9459e331be9d30c8471473e0033 Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 15:58:47 +0000 Subject: [PATCH 6/8] Update protocol creation widget --- aurora/experiment/protocols/custom.py | 147 ++++++++++++++----------- aurora/experiment/submit_experiment.py | 30 ++++- 2 files changed, 109 insertions(+), 68 deletions(-) diff --git a/aurora/experiment/protocols/custom.py b/aurora/experiment/protocols/custom.py index bd7978c5..8f6dd094 100644 --- a/aurora/experiment/protocols/custom.py +++ b/aurora/experiment/protocols/custom.py @@ -3,13 +3,11 @@ TODO: Enable the user to save a customized protocol. """ import logging -import os from typing import get_args import aiida_aurora.schemas.cycling import ipywidgets as ipw from aiida_aurora.schemas.cycling import ElectroChemPayloads -from ipyfilechooser import FileChooser from .technique_widget import TechniqueParametersWidget @@ -21,7 +19,9 @@ class CyclingCustom(ipw.VBox): BOX_LAYOUT = {'width': '95%'} BOX_LAYOUT_3 = {'width': '95%', 'padding': '5px', 'margin': '10px'} BUTTON_STYLE = {'description_width': '30%'} - BUTTON_LAYOUT = {'margin': '5px'} + BUTTON_LAYOUT = {'margin': '5px', 'width': '40%'} + SAVE_BUTTON_STYLE = {'description_width': '30%'} + SAVE_BUTTON_LAYOUT = {'margin': '5px', 'width': '135px'} BUTTON_LAYOUT_2 = {'width': '20%', 'margin': '5px'} # BUTTON_LAYOUT_3 = {'width': '43.5%', 'margin': '5px'} BUTTON_LAYOUT_3 = {'width': '10%', 'margin': '5px'} @@ -45,11 +45,45 @@ def __init__(self, experiment_model, validate_callback_f): # initialize widgets self.w_header = ipw.HTML(value="

Custom Protocol

") - self.w_protocol_label = ipw.HTML(value="Sequence:") - self.w_protocol_steps_list = ipw.Select(rows=10, - value=None, - description="", - layout={}) + + self.protocol_name_label = ipw.HTML(value="Protocol name:") + + self.protocol_name = ipw.Text( + layout={ + "width": "auto", + "margin": "0 2px", + }, + placeholder="Enter protocol name", + ) + + self.protocol_name_warning = ipw.Output() + + self.protocol_node_name_label = ipw.HTML( + value="Protocol node label (optional):") + + self.protocol_node_name = ipw.Text( + placeholder="Enter a name for the CyclingSpecsData node", + layout={ + "width": "auto", + "margin": "0 2px", + }, + style=self.BOX_STYLE_2, + ) + + self.w_protocol_sequence_label = ipw.HTML(value="Sequence:") + + self.w_protocol_steps_list = ipw.Select( + layout={ + "width": "auto", + "margin": "0 2px", + }, + rows=10, + value=None, + description="", + ) + + self.w_selected_step_params_action_info = ipw.HTML() + self.w_save_info = ipw.HTML() self._update_protocol_steps_list_widget_options() @@ -78,29 +112,13 @@ def __init__(self, experiment_model, validate_callback_f): style=self.BUTTON_STYLE, layout=self.BUTTON_LAYOUT_2) - self.w_button_load = ipw.Button(description="Load", - button_style='', - tooltip="Load protocol", - icon='', - style=self.BUTTON_STYLE, - layout=self.BUTTON_LAYOUT_3) self.w_button_save = ipw.Button(description="Save", - button_style='', + button_style='success', tooltip="Save protocol", icon='', - style=self.BUTTON_STYLE, - layout=self.BUTTON_LAYOUT_3) - home_directory = os.path.expanduser('~') - self.w_filepath_explorer = FileChooser( - home_directory, - layout={ - "flex": "1", - 'margin': '5px' - }, - ) - self.w_filepath_explorer.default_path = home_directory - self.w_filepath_explorer.default_filename = 'saved_protocol.json' - self.w_filepath_explorer.reset() + disabled=True, + style=self.SAVE_BUTTON_STYLE, + layout=self.SAVE_BUTTON_LAYOUT) # initialize protocol steps list # self._protocol_steps_list = ElectroChemSequence(method=[]) @@ -130,37 +148,10 @@ def __init__(self, experiment_model, validate_callback_f): style=self.BUTTON_STYLE, layout=self.BUTTON_LAYOUT_2) - self.w_method_node_label = ipw.Text( - description="AiiDA Method node label:", - placeholder="Enter a name for the CyclingSpecsData node", - layout={ - 'width': 'auto', - "margin": "5px 0", - }, - style=self.BOX_STYLE_2, - ) - - self.w_validate = ipw.Button(description="Validate", - button_style='success', - tooltip="Validate the selected test", - icon='check', - disabled=False, - style=self.BUTTON_STYLE, - layout=self.BUTTON_LAYOUT) - # initialize widgets super().__init__() self.children = [ self.w_header, - ipw.HBox( - layout={}, - children=[ - self.w_filepath_explorer, - self.w_button_load, - self.w_button_save, - ], - ), - self.w_protocol_label, ipw.HBox( layout={ 'width': 'auto', @@ -172,6 +163,11 @@ def __init__(self, experiment_model, validate_callback_f): "margin": "0 5px 0 0", }, children=[ + self.protocol_name_label, self.protocol_name, + self.protocol_name_warning, + self.protocol_node_name_label, + self.protocol_node_name, + self.w_protocol_sequence_label, self.w_protocol_steps_list, ipw.HBox( layout={"justify_content": "space-between"}, @@ -182,6 +178,13 @@ def __init__(self, experiment_model, validate_callback_f): self.w_button_down, ], ), + ipw.HBox( + layout={"align_items": "center"}, + children=[ + self.w_button_save, + self.w_save_info, + ], + ) ], ), ipw.VBox( @@ -194,21 +197,26 @@ def __init__(self, experiment_model, validate_callback_f): self.w_selected_step_technique_name, self.w_selected_step_parameters, ipw.HBox( - layout={}, + layout={"align_items": "center"}, children=[ self.w_selected_step_params_save_button, self.w_selected_step_params_discard_button, + self.w_selected_step_params_action_info, ], ), ], ) ], ), - self.w_method_node_label, - self.w_validate, ] # setup automations + + self.protocol_name.observe( + names='value', + handler=self.on_protocol_name_change, + ) + # steps list self.w_protocol_steps_list.observe( self._build_current_step_properties_widget, names='index') @@ -223,9 +231,6 @@ def __init__(self, experiment_model, validate_callback_f): self.w_button_up.on_click(self.w_button_up_click) self.w_button_down.on_click(self.w_button_down_click) - self.w_button_load.on_click(self.button_load_protocol_click) - self.w_button_save.on_click(self.button_save_protocol_click) - # current step's properties: # if technique type changes, we need to initialize a new technique from scratch the widget observer may detect a change # even when a new step is selected therefore we check whether the new technique is the same as the one stored in @@ -237,10 +242,16 @@ def __init__(self, experiment_model, validate_callback_f): self.save_current_step_properties) self.w_selected_step_params_discard_button.on_click( self.discard_current_step_properties) - # validate protocol - self.w_validate.on_click( + + # save protocol + self.w_button_save.on_click( lambda arg: self.callback_call(validate_callback_f)) + def on_protocol_name_change(self, change: dict) -> None: + """docstring""" + self.w_button_save.disabled = not change["new"] + self.reset_info_messages() + def update(self): """Receive updates from the model""" self._update_protocol_steps_list_widget_options() @@ -275,6 +286,7 @@ def _count_technique_occurencies(self, technique): # return [type(step) for step in self.protocol_steps_list.method].count(technique) def _update_protocol_steps_list_widget_options(self, new_index=None): + self.reset_info_messages() old_selected_index = self.w_protocol_steps_list.index self.w_protocol_steps_list.options = [ f"[{idx + 1}] - {step.name}" for idx, step in enumerate( @@ -338,6 +350,7 @@ def _build_current_step_properties_widget(self, dummy=None): def _build_technique_parameters_widgets(self, dummy=None): """Build widget of parameters for the given technique.""" + self.reset_info_messages() logging.debug("Building technique parameters") # check if the technique is the same as the one stored in the selected step if isinstance( @@ -372,12 +385,20 @@ def save_current_step_properties(self, dummy=None): f" Parameters saved: {self.w_selected_step_parameters.selected_tech_parameters.items()}" ) self._update_protocol_steps_list_widget_options() + self.w_selected_step_params_action_info.value = "Saved step!" def discard_current_step_properties(self, dummy=None): "Discard parameters of the selected step and reload them from the technique object" logging.debug("Discarding current step properties") # i.e. just rebuild the parameters widget self._build_current_step_properties_widget() + self.w_selected_step_params_action_info.value = "Discarded step!" + + def reset_info_messages(self) -> None: + """docstring""" + self.w_selected_step_params_action_info.value = "" + self.w_save_info.value = "" + self.protocol_name_warning.clear_output() def callback_call(self, callback_function): "Call a callback function and this class instance to it." diff --git a/aurora/experiment/submit_experiment.py b/aurora/experiment/submit_experiment.py index dbad8a2e..2e633750 100644 --- a/aurora/experiment/submit_experiment.py +++ b/aurora/experiment/submit_experiment.py @@ -1,4 +1,5 @@ import json +import re from copy import deepcopy from typing import Dict, Optional @@ -140,7 +141,7 @@ def _build_cycling_protocol_section(self) -> None: self.protocol_creator = CyclingCustom( experiment_model=self.experiment_model, - validate_callback_f=self.return_selected_protocol, + validate_callback_f=self.save_protocol_and_refresh_selector, ) self.w_protocols_tab = ipw.Tab( @@ -333,10 +334,29 @@ def sample_selection_method(self): ####################################################################################### # METHOD SELECTION ####################################################################################### - def return_selected_protocol(self, cycling_widget_obj): - self.experiment_model.selected_protocol = cycling_widget_obj.protocol_steps_list - self._selected_cycling_protocol = cycling_widget_obj.protocol_steps_list - self.post_protocol_selection() + def save_protocol_and_refresh_selector(self, custom: CyclingCustom): + custom.reset_info_messages() + name: str = custom.protocol_name.value + + # invalid name check + if not re.match(r"^[\w_]+$", name): + with custom.protocol_name_warning: + print("Only alphanumeric characters and underscores allowed") + return + + # existing name check + available_protocols = self.experiment_model.available_protocols + if any(protocol.name == name for protocol in available_protocols): + with custom.protocol_name_warning: + print("Protocol name already exists.\nPlease choose another.") + return + + new_protocol = self.experiment_model.selected_protocol + new_protocol.set_name(name) + self.experiment_model.save_protocol() + custom.w_save_info.value = "Saved!" + + self.protocol_selector.update_protocol_options() def confirm_protocols_selection(self, _=None): "Switch to Tomato settings accordion tab." From 72044291a6fb0647a0ec198b533dacdaf183ad5f Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 04:30:46 +0000 Subject: [PATCH 7/8] Ignore local JSON files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1386c743..934bbc22 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,4 @@ dmypy.json # Project-specific .aurora-*.ipynb +*.json From e81fd14cc96586bec3ac13926e2d3b7f1e7bd000 Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 4 Sep 2023 16:00:12 +0000 Subject: [PATCH 8/8] Redesign input preview section --- aurora/experiment/submit_experiment.py | 334 +++++++++++++++++-------- 1 file changed, 235 insertions(+), 99 deletions(-) diff --git a/aurora/experiment/submit_experiment.py b/aurora/experiment/submit_experiment.py index 2e633750..859efe24 100644 --- a/aurora/experiment/submit_experiment.py +++ b/aurora/experiment/submit_experiment.py @@ -1,4 +1,3 @@ -import json import re from copy import deepcopy from typing import Dict, Optional @@ -7,6 +6,7 @@ from aiida_aurora.schemas.battery import BatterySample from aiida_aurora.schemas.dgbowl import Tomato_0p2 from aiida_aurora.schemas.utils import dict_to_formatted_json +from IPython.display import display from aurora.common.models import AvailableSamplesModel, BatteryExperimentModel from aurora.engine import submit_experiment @@ -24,10 +24,10 @@ class ExperimentBuilder(ipw.VBox): _SECTION_TITLE = "Submit Experiment" _ACCORDION_STEPS = [ - 'Sample Selection', - 'Cycling Protocol', - 'Job Settings', - 'Submit Job', + 'Select samples', + 'Select protocols', + 'Configure tomato/monitoring', + 'Review input', ] _SAMPLE_INPUT_LABELS = [ @@ -55,11 +55,8 @@ class ExperimentBuilder(ipw.VBox): } _SUBMISSION_INPUT_LAYOUT = { - 'border': 'solid darkgrey 1px', 'margin': '5px', 'padding': '5px', - 'max_height': '500px', - 'overflow': 'scroll', } _SUBMISSION_OUTPUT_LAYOUT = { @@ -162,20 +159,71 @@ def _build_job_settings_section(self) -> None: validate_callback_f=self.return_selected_settings, ) - def _build_job_submission_section(self) -> None: + def _build_input_preview_section(self) -> None: """Build the job submission section.""" - # TODO: write better preview of the job inputs - self.w_job_preview = ipw.Output(layout=self._SUBMISSION_INPUT_LAYOUT) + self.input_preview = ipw.Output(layout={"margin": "0 0 10px"}) + + self.protocols_preview = ipw.Tab(layout={ + "min_height": "350px", + "max_height": "350px", + }) + + self.settings_preview = ipw.Output( + layout={ + "flex": "1", + "margin": "2px", + "border": "solid darkgrey 1px", + "overflow_y": "auto", + }) + + self.monitors_preview = ipw.Output( + layout={ + "flex": "1", + "margin": "2px", + "border": "solid darkgrey 1px", + "overflow_y": "auto", + }) + + self.valid_input_confirmation = ipw.HTML() + + self.input_preview_section = ipw.VBox( + layout={}, + children=[ + self.input_preview, + self.valid_input_confirmation, + ], + ) + + def _build_accordion(self) -> None: + """Combine the sections in the main accordion widget.""" + + self.w_main_accordion = ipw.Accordion( + layout={}, + children=[ + self.w_sample_selection_tab, + self.w_protocols_tab, + self.w_settings_tab, + self.input_preview_section, + ], + selected_index=None, + ) + + for i, title in enumerate(self._ACCORDION_STEPS): + self.w_main_accordion.set_title(i, title) + + def _build_submission_section(self) -> ipw.HBox: + """Build submission controls widgets.""" self.w_code = ipw.Dropdown( + layout={}, description="Select code:", options=[CODE_NAME], # TODO: get codes value=CODE_NAME, ) self.w_submit_button = ipw.Button( - description="SUBMIT", + description="Submit", button_style="success", tooltip="Submit the experiment", icon="play", @@ -184,16 +232,8 @@ def _build_job_submission_section(self) -> None: layout=self._BUTTON_LAYOUT, ) - self.w_submit_tab = ipw.VBox([ - self.w_job_preview, - self.w_code, - self.w_submit_button, - ]) - - def _build_reset_button(self) -> None: - """Build the reset button.""" self.w_reset_button = ipw.Button( - description="RESET", + description="Reset", button_style="danger", tooltip="Start over", icon="times", @@ -201,18 +241,16 @@ def _build_reset_button(self) -> None: layout=self._BUTTON_LAYOUT, ) - def _build_accordion(self) -> None: - """Combine the sections in the main accordion widget.""" - - self.w_main_accordion = ipw.Accordion(children=[ - self.w_sample_selection_tab, - self.w_test_tab, - self.w_settings_tab, - self.w_submit_tab, - ]) - - for i, title in enumerate(self._ACCORDION_STEPS): - self.w_main_accordion.set_title(i, title) + self.submission_controls = ipw.HBox( + layout={ + "align_items": "center", + }, + children=[ + self.w_code, + self.w_submit_button, + self.w_reset_button, + ], + ) def _build_widgets(self) -> None: """Build panel widgets.""" @@ -223,19 +261,20 @@ def _build_widgets(self) -> None: self._build_job_settings_section() - self._build_job_submission_section() - - self._build_reset_button() + self._build_input_preview_section() self._build_accordion() - super().__init__() + self._build_submission_section() - self.children = [ - self.w_main_accordion, - self.w_reset_button, - self.w_submission_output, - ] + super().__init__( + layout={}, + children=[ + self.w_main_accordion, + self.submission_controls, + self.w_submission_output, + ], + ) def _subscribe_observables(self) -> None: """Set up observables.""" @@ -246,6 +285,11 @@ def _subscribe_observables(self) -> None: names='selected_index', ) + self.protocols_preview.observe( + names="selected_index", + handler=self.display_settings_and_monitors, + ) + # trigger presubmission checks when we are in the "Submit Job" accordion tab self.w_main_accordion.observe( self.presubmission_checks_preview, @@ -381,6 +425,137 @@ def post_settings_selection(self): # SUBMIT JOB ####################################################################################### + def display_samples_preview(self) -> None: + """docstring""" + + samples = ipw.Output( + layout={ + "max_height": "300px", + "overflow_y": "scroll", + "align_items": "center", + }) + + display( + ipw.VBox( + layout={}, + children=[ + ipw.HTML("

Samples

"), + samples, + ], + )) + + with samples: + self.experiment_model.display_query_results({ + 'battery_id': [ + sample.battery_id + for sample in self.selected_battery_samples + ] + }) + + def display_protocols_preview(self) -> None: + """docstring""" + + self.protocols_preview.children = [] + self.protocols_preview.selected_index = None + + self.settings_preview.clear_output() + self.monitors_preview.clear_output() + + display( + ipw.HBox( + layout={ + "margin": "0 0 10px", + "grid_gap": "5px", + }, + children=[ + ipw.VBox( + layout={ + "width": "50%", + }, + children=[ + ipw.HTML("

Protocols

"), + self.protocols_preview, + ], + ), + ipw.VBox( + layout={ + "width": "50%", + "grid_gap": "5px", + }, + children=[ + ipw.VBox( + layout={ + "flex": "1", + }, + children=[ + ipw.HTML("

Settings

"), + self.settings_preview, + ], + ), + ipw.VBox( + layout={ + "flex": "1", + }, + children=[ + ipw.HTML("

Monitors

"), + self.monitors_preview, + ], + ), + ], + ) + ], + )) + + for protocol in self.selected_cycling_protocols: + + output = ipw.Output() + + self.protocols_preview.children += (output, ) + index = len(self.protocols_preview.children) - 1 + self.protocols_preview.set_title(index, protocol.name) + + with output: + for step in protocol.method: + print(f"{step.name} ({step.technique})") + for label, param in step.parameters: + default = param.default_value + value = default if param.value is None else param.value + units = "" if value is None else param.units + print(f"{label} = {value} {units}") + print() + + self.protocols_preview.selected_index = 0 + + def display_settings_and_monitors(self, change: dict) -> None: + """docstring""" + + if (index := change["new"]) is None: + return + + protocol = list(self.selected_cycling_protocols)[index] + settings = self.selected_tomato_settings.get(protocol.name) + monitors = self.selected_monitor_settings.get(protocol.name) + + if settings is None or monitors is None: + return + + self.settings_preview.clear_output() + with self.settings_preview: + for key, value in settings.dict().items(): + if isinstance(value, dict): + for k, v in value.items(): + print(f"{k}: {v}") + else: + print(f"{key}: {value}") + + self.monitors_preview.clear_output() + with self.monitors_preview: + for monitor, monitor_settings in monitors.items(): + print(f"name: {monitor}") + for key, value in monitor_settings.items(): + print(f"{key} = {value}") + print() + def presubmission_checks_preview(self, _=None) -> None: """ Verify that all the input is there and display preview. @@ -390,48 +565,36 @@ def presubmission_checks_preview(self, _=None) -> None: if self.w_main_accordion.selected_index != 3: return - self.w_job_preview.clear_output() - - with self.w_job_preview: - - if not self._has_valid_settings(): - return - - output_cycling_protocol = json.dumps( - self.selected_cycling_protocol.dict(), - indent=2, - ) - - output_tomato_settings = f'{self.selected_tomato_settings}' + self.input_preview.clear_output() - output_monitor_settings = f'{self.selected_monitor_settings}' + with self.input_preview: if not self.selected_battery_samples: - return + notice = "No battery samples selected!" + return self.signal_missing_input(notice) - print("Battery Samples:") + self.display_samples_preview() - query = { - 'battery_id': [ - sample.battery_id - for sample in self.selected_battery_samples - ] - } - self.experiment_model.display_query_results(query) + if not self.selected_cycling_protocols: + notice = "No cycling protocols selected!" + return self.signal_missing_input(notice) - print() - - print(f"Cycling Protocol:\n{output_cycling_protocol}\n") - print(f"Tomato Settings:\n{output_tomato_settings}\n") - print(f"Monitor Settings:{output_monitor_settings}\n") + self.display_protocols_preview() - print("✅ All good!") + if not self.selected_tomato_settings or \ + not self.selected_monitor_settings: + notice = "No protocol settings selected!" + return self.signal_missing_input(notice) + self.valid_input_confirmation.value = "✅ All good!" self.w_submit_button.disabled = False + def signal_missing_input(self, message: str) -> None: + """docstring""" + self.valid_input_confirmation.value = f"❌ {message}" + @w_submission_output.capture() def submit_job(self, dummy=None): - self.w_submit_button.disabled = True for index, battery_sample in self.experiment_model.selected_samples.iterrows( ): @@ -451,33 +614,6 @@ def submit_job(self, dummy=None): self.w_main_accordion.selected_index = None - def _has_valid_settings(self) -> bool: - """Validate job settings. - - Returns - ------- - `bool` - `True` if job settings are valid, `False` otherwise. - """ - - try: - - if self.selected_battery_samples is None: - raise ValueError("A Battery sample was not selected!") - - if self.selected_cycling_protocol is None: - raise ValueError("A Cycling protocol was not selected!") - - if self.selected_tomato_settings is None or self.selected_monitor_settings is None: - raise ValueError("Tomato settings were not selected!") - - return True - - except ValueError as err: - self.w_submit_button.disabled = True - print(f"❌ {err}") - return False - ####################################################################################### # RESET #######################################################################################