diff --git a/glue/config.py b/glue/config.py index 2d4c20066..328162777 100644 --- a/glue/config.py +++ b/glue/config.py @@ -615,11 +615,11 @@ class LinkHelperRegistry(Registry): """Stores helper objects that compute many ComponentLinks at once - The members property is a list of (object, info_string, - input_labels) tuples. `Object` is the link helper. `info_string` - describes what `object` does. `input_labels` is a list labeling - the inputs. ``category`` is a category in which the link funtion will appear - (defaults to 'General'). + The members property is a list of (object, info_string, input_labels, + output_labels) tuples. `Object` is the link helper. `info_string` describes + what `object` does. `input_labels` is a list labeling the inputs, and + `output_labels` is a list labeling the outputs. ``category`` is a category + in which the link funtion will appear (defaults to 'General'). Each link helper takes a list of ComponentIDs as inputs, and returns an iterable object (e.g. list) of ComponentLinks. @@ -627,16 +627,16 @@ class LinkHelperRegistry(Registry): New helpers can be registered via @link_helper('Links degrees and arcseconds in both directions', - ['degree', 'arcsecond']) + input_labels=['degree'], output_labels=['arcsecond']) def new_helper(degree, arcsecond): return [ComponentLink([degree], arcsecond, using=lambda d: d*3600), ComponentLink([arcsecond], degree, using=lambda a: a/3600)] """ - item = namedtuple('LinkHelper', 'helper info input_labels category') + item = namedtuple('LinkHelper', 'helper info input_labels output_labels category') - def __call__(self, info, input_labels, category='General'): + def __call__(self, info, input_labels, output_labels, category='General'): def adder(func): - self.add(self.item(func, info, input_labels, category)) + self.add(self.item(func, info, input_labels, output_labels, category)) return func return adder diff --git a/glue/core/link_helpers.py b/glue/core/link_helpers.py index 0365e7f05..38f3c0427 100644 --- a/glue/core/link_helpers.py +++ b/glue/core/link_helpers.py @@ -112,12 +112,13 @@ class MultiLink(LinkCollection): cids = None - def __init__(self, *args): - self.cids = args + def __init__(self, cids_left, cids_right): + self.cids_left = cids_left + self.cids_right = cids_right def create_links(self, cids_left, cids_right, forwards=None, backwards=None): - if self.cids is None: + if self.cids_left is None or self.cids_right is None: raise Exception("MultiLink.__init__ was not called before creating links") if forwards is None and backwards is None: @@ -134,15 +135,17 @@ def create_links(self, cids_left, cids_right, forwards=None, backwards=None): self.append(ComponentLink(cids_right, l, func)) def __gluestate__(self, context): - return {'cids': [context.id(cid) for cid in self.cids]} + return {'cids_left': [context.id(cid) for cid in self.cids_left], + 'cids_right': [context.id(cid) for cid in self.cids_right]} @classmethod def __setgluestate__(cls, rec, context): - return cls(*[context.object(cid) for cid in rec['cids']]) + return cls([context.object(cid) for cid in rec['cids_left']], + [context.object(cid) for cid in rec['cids_right']]) def multi_link(cids_left, cids_right, forwards=None, backwards=None): - ml = MultiLink(cids_left + cids_right) + ml = MultiLink(cids_left, cids_right) ml.create_links(cids_left, cids_right, forwards=forwards, backwards=backwards) return ml diff --git a/glue/dialogs/component_manager/qt/derived_creator.py b/glue/dialogs/component_manager/qt/derived_creator.py new file mode 100644 index 000000000..9b1906e45 --- /dev/null +++ b/glue/dialogs/component_manager/qt/derived_creator.py @@ -0,0 +1,62 @@ + +from __future__ import absolute_import, division, print_function + +import os +from collections import deque + +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Qt + +from glue.external.echo import CallbackProperty +from glue.core.parse import InvalidTagError, ParsedCommand, TAG_RE +from glue.utils.qt import load_ui, CompletionTextEdit + +__all__ = ['EquationEditorDialog'] + + +class DerivedComponentEditor(QtWidgets.QDialog): + + def __init__(self, equation=None, references=None, parent=None): + + super(DerivedComponentEditor, self).__init__(parent=parent) + + self.ui = load_ui('derived_creator.ui', self, + directory=os.path.dirname(__file__)) + + # Populate component combo + for label, cid in self.references.items(): + self.ui.combosel_component.addItem(label, userData=cid) + + # Set up labels for auto-completion + labels = ['{' + label + '}' for label in self.references] + self.ui.expression.set_word_list(labels) + + # Set initial equation + self.ui.expression.insertPlainText(equation) + + self.ui.button_insert.clicked.connect(self._insert_component) + + self.ui.expression.updated.connect(self._update_status) + self._update_status() + + + # Get mapping from label to component ID + if references is not None: + self.references = references + elif data is not None: + self.references = OrderedDict() + for cid in data.primary_components: + self.references[cid.label] = cid + +if __name__ == "__main__": # pragma: nocover + + from glue.main import load_plugins + from glue.utils.qt import get_qapp + + app = get_qapp() + load_plugins() + + from glue.core.data import Data + d = Data(label='test1', x=[1, 2, 3], y=[2, 3, 4], z=[3, 4, 5]) + widget = EquationEditorDialog(d, '') + widget.exec_() diff --git a/glue/dialogs/component_manager/qt/derived_creator.ui b/glue/dialogs/component_manager/qt/derived_creator.ui new file mode 100644 index 000000000..8e76791c5 --- /dev/null +++ b/glue/dialogs/component_manager/qt/derived_creator.ui @@ -0,0 +1,157 @@ + + + Dialog + + + + 0 + 0 + 535 + 378 + + + + Derived Component Editor + + + + 5 + + + QLayout::SetDefaultConstraint + + + 10 + + + 5 + + + 10 + + + 5 + + + + + Derived components are components that are evaluated on-the-fly based on other components. You can either define these using free-form expressions, or existing pre-defined transformations. + + + Qt::AlignJustify|Qt::AlignVCenter + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 10 + 5 + + + + + + + + 0 + + + + Pre-defined transformations + + + + 5 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + Free-from expression + + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + + + + + + status + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + OK + + + true + + + + + + + + + + diff --git a/glue/dialogs/component_manager/qt/equation_editor.py b/glue/dialogs/component_manager/qt/equation_editor.py index 1b99ea9b3..58d0a4743 100644 --- a/glue/dialogs/component_manager/qt/equation_editor.py +++ b/glue/dialogs/component_manager/qt/equation_editor.py @@ -1,11 +1,12 @@ from __future__ import absolute_import, division, print_function import os -from collections import deque, OrderedDict +from collections import deque from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt +from glue.external.echo import CallbackProperty from glue.core.parse import InvalidTagError, ParsedCommand, TAG_RE from glue.utils.qt import load_ui, CompletionTextEdit @@ -88,25 +89,18 @@ def format_components(m): self._cache = self.toPlainText() -class EquationEditorDialog(QtWidgets.QDialog): +class EquationEditorDialog(QtWidgets.QWidget): - def __init__(self, data=None, equation=None, references=None, parent=None): + valid = CallbackProperty('') + message = CallbackProperty('') + + def __init__(self, equation=None, references=None, parent=None): super(EquationEditorDialog, self).__init__(parent=parent) self.ui = load_ui('equation_editor.ui', self, directory=os.path.dirname(__file__)) - self.equation = equation - - # Get mapping from label to component ID - if references is not None: - self.references = references - elif data is not None: - self.references = OrderedDict() - for cid in data.primary_components: - self.references[cid.label] = cid - # Populate component combo for label, cid in self.references.items(): self.ui.combosel_component.addItem(label, userData=cid) @@ -115,11 +109,9 @@ def __init__(self, data=None, equation=None, references=None, parent=None): labels = ['{' + label + '}' for label in self.references] self.ui.expression.set_word_list(labels) + # Set initial equation self.ui.expression.insertPlainText(equation) - self.ui.button_ok.clicked.connect(self.accept) - self.ui.button_cancel.clicked.connect(self.reject) - self.ui.button_insert.clicked.connect(self._insert_component) self.ui.expression.updated.connect(self._update_status) @@ -129,35 +121,35 @@ def _insert_component(self): label = self.ui.combosel_component.currentText() self.expression.insertPlainText('{' + label + '}') - def _update_status(self): + def _update_status(self, event=None): # If the text hasn't changed, no need to check again - if hasattr(self, '_cache') and self._get_raw_command() == self._cache: + if hasattr(self, '_cache') and self._get_raw_command() == self._cache and event is None: return if self._get_raw_command() == "": - self.ui.label_status.setText("") - self.ui.button_ok.setEnabled(False) + self.message = "" + self.valid = True else: try: pc = self._get_parsed_command() - pc.evaluate_test() + result = pc.evaluate_test() except SyntaxError: - self.ui.label_status.setStyleSheet('color: red') - self.ui.label_status.setText("Incomplete or invalid syntax") - self.ui.button_ok.setEnabled(False) + self.valid = False + self.message = "Incomplete or invalid syntax" except InvalidTagError as exc: - self.ui.label_status.setStyleSheet('color: red') - self.ui.label_status.setText("Invalid component: {0}".format(exc.tag)) - self.ui.button_ok.setEnabled(False) + self.valid = False + self.message = "Invalid component: {0}".format(exc.tag) except Exception as exc: - self.ui.label_status.setStyleSheet('color: red') - self.ui.label_status.setText(str(exc)) - self.ui.button_ok.setEnabled(False) + self.valid = False + self.message = str(exc) else: - self.ui.label_status.setStyleSheet('color: green') - self.ui.label_status.setText("Valid expression") - self.ui.button_ok.setEnabled(True) + if result is None: + self.valid = False + self.message = "Expression should not return None" + else: + self.valid = True + self.message = "Valid expression" self._cache = self._get_raw_command() @@ -167,23 +159,3 @@ def _get_raw_command(self): def _get_parsed_command(self): expression = self._get_raw_command() return ParsedCommand(expression, self.references) - - def accept(self): - self.final_expression = self._get_parsed_command()._cmd - super(EquationEditorDialog, self).accept() - - def reject(self): - self.final_expression = None - super(EquationEditorDialog, self).reject() - - -if __name__ == "__main__": # pragma: nocover - - from glue.utils.qt import get_qapp - - app = get_qapp() - - from glue.core.data import Data - d = Data(label='test1', x=[1, 2, 3], y=[2, 3, 4], z=[3, 4, 5]) - widget = EquationEditorDialog(d, '') - widget.exec_() diff --git a/glue/dialogs/component_manager/qt/equation_editor.ui b/glue/dialogs/component_manager/qt/equation_editor.ui index e4ae41a20..92a3e5922 100644 --- a/glue/dialogs/component_manager/qt/equation_editor.ui +++ b/glue/dialogs/component_manager/qt/equation_editor.ui @@ -1,38 +1,35 @@ Dialog - + 0 0 - 384 - 256 + 501 + 398 Derived Component Editor - - - 5 - + 10 - 5 + 10 10 - 5 + 10 - Derived components are components that are evaluated on-the-fly based on other components. In the box below, you can compose mathematical expressions that include components from the data. To add a component, select it in the dropdown list and click on 'Insert'. You can also type component names directly, provided that you surround them by curly braces, e.g. {x}. A status message below indicates whether the expression is valid. You can use any variable defined inside your config.py file, as well as numpy.<function>, np.<function> and math.<function> (e.g. np.log10 or math.sqrt). Once you click 'OK', you will be able to name the new component. + In the box below, you can compose mathematical expressions that include components from the data. To add a component, select it in the dropdown list and click on 'Insert'. You can also type component names directly, provided that you surround them by curly braces, e.g. {x}. A status message below indicates whether the expression is valid. You can use any variable defined inside your config.py file, as well as numpy.<function>, np.<function> and math.<function> (e.g. np.log10 or math.sqrt). Once you click 'OK', you will be able to name the new component. Qt::AlignJustify|Qt::AlignVCenter @@ -65,7 +62,7 @@ - + 0 0 @@ -79,16 +76,26 @@ - + - + - status + Derived component name: + + + + + + + + 100 + 16777215 + - + Qt::Horizontal @@ -100,23 +107,6 @@ - - - - Cancel - - - - - - - OK - - - true - - - diff --git a/glue/dialogs/component_manager/qt/function_editor.py b/glue/dialogs/component_manager/qt/function_editor.py new file mode 100644 index 000000000..aab5a689b --- /dev/null +++ b/glue/dialogs/component_manager/qt/function_editor.py @@ -0,0 +1,188 @@ +from __future__ import absolute_import, division, print_function + +import os +from collections import OrderedDict + +try: + from inspect import getfullargspec +except ImportError: # Python 2.7 + from inspect import getargspec as getfullargspec + +from qtpy import QtWidgets + +from glue.config import link_function, link_helper +from glue.utils.qt import load_ui, update_combobox + +__all__ = ['FunctionEditorDialog'] + + +def get_function_name(item): + if hasattr(item, 'display') and item.display is not None: + return item.display + else: + return item.__name__ + + +def function_label(function): + """ Provide a label for a function + + :param function: A member from the glue.config.link_function registry + """ + args = getfullargspec(function.function)[0] + args = ', '.join(args) + output = function.output_labels + output = ', '.join(output) + label = "Link from %s to %s" % (args, output) + return label + + +def helper_label(helper): + """ Provide a label for a link helper + + :param helper: A member from the glue.config.link_helper registry + """ + return helper.info + + +class FunctionEditorDialog(QtWidgets.QDialog): + + def __init__(self, data=None, references=None, parent=None): + + super(FunctionEditorDialog, self).__init__(parent=parent) + + self.ui = load_ui('function_editor.ui', self, + directory=os.path.dirname(__file__)) + + # Get mapping from label to component ID + if references is not None: + self.references = references + elif data is not None: + self.references = OrderedDict() + for cid in data.primary_components: + self.references[cid.label] = cid + + # Populate category combo + f = [f for f in link_function.members if len(f.output_labels) == 1] + categories = sorted(set(l.category for l in f + link_helper.members)) + for category in categories: + self.ui.combosel_category.addItem(category) + self.ui.combosel_category.setCurrentIndex(0) + self.ui.combosel_category.currentIndexChanged.connect(self._populate_function_combo) + self._populate_function_combo() + + self.ui.combosel_function.setCurrentIndex(0) + self.ui.combosel_function.currentIndexChanged.connect(self._setup_inputs) + self._setup_inputs() + + @property + def category(self): + return self.ui.combosel_category.currentText() + + @property + def function(self): + return self.ui.combosel_function.currentData() + + @property + def is_helper(self): + return self.function is not None and type(self.function).__name__ == 'LinkHelper' + + @property + def is_function(self): + return self.function is not None and type(self.function).__name__ == 'LinkFunction' + + def _setup_inputs(self, event=None): + + if self.is_function: + label = function_label(self.function) + input_labels = getfullargspec(self.function.function)[0] + + else: + label = helper_label(self.function) + input_labels = self.function.input_labels + + self.ui.label_info.setText(label) + + self._clear_input_output_layouts() + + input_message = "The function above takes the following input(s):" + + if len(input_labels) > 1: + input_message = input_message.replace('(s)', 's') + else: + input_message = input_message.replace('(s)', '') + + self.ui.layout_inout.addWidget(QtWidgets.QLabel(input_message), 0, 1, 1, 3) + + spacer1 = QtWidgets.QSpacerItem(10, 5, + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed) + spacer2 = QtWidgets.QSpacerItem(10, 5, + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed) + self.ui.layout_inout.addItem(spacer1, 0, 0) + self.ui.layout_inout.addItem(spacer2, 0, 4) + + row = 0 + for a in input_labels: + row += 1 + self._add_input_widget(a, row) + + output_message = "This function produces the following output(s) - you can set the label(s) here:" + + if len(self.function.output_labels) > 1: + output_message = output_message.replace('(s)', 's') + else: + output_message = output_message.replace('(s)', '') + + row += 1 + self.ui.layout_inout.addWidget(QtWidgets.QLabel(output_message), row, 1, 1, 3) + + for a in self.function.output_labels: + row += 1 + self._add_output_widget(a, row) + + def _clear_input_output_layouts(self): + + for row in range(self.ui.layout_inout.rowCount()): + for col in range(self.ui.layout_inout.columnCount()): + item = self.ui.layout_inout.itemAtPosition(row, col) + if item is not None: + self.ui.layout_inout.removeItem(item) + if item.widget() is not None: + item.widget().setParent(None) + + def _add_input_widget(self, name, row): + label = QtWidgets.QLabel(name) + combo = QtWidgets.QComboBox() + update_combobox(combo, list(self.references.items())) + self.ui.layout_inout.addWidget(label, row, 1) + self.ui.layout_inout.addWidget(combo, row, 2) + + def _add_output_widget(self, name, row): + label = QtWidgets.QLabel(name) + edit = QtWidgets.QLineEdit() + self.ui.layout_inout.addWidget(label, row, 1) + self.ui.layout_inout.addWidget(edit, row, 2) + + def _populate_function_combo(self, event=None): + """ + Add name of functions to function combo box + """ + f = [f for f in link_function.members if len(f.output_labels) == 1] + functions = ((get_function_name(l[0]), l) for l in f + link_helper.members if l.category == self.category) + update_combobox(self.ui.combosel_function, functions) + self._setup_inputs() + + +if __name__ == "__main__": # pragma: nocover + + from glue.main import load_plugins + from glue.utils.qt import get_qapp + + app = get_qapp() + load_plugins() + + from glue.core.data import Data + d = Data(label='test1', x=[1, 2, 3], y=[2, 3, 4], z=[3, 4, 5]) + widget = FunctionEditorDialog(d) + widget.exec_() diff --git a/glue/dialogs/component_manager/qt/function_editor.ui b/glue/dialogs/component_manager/qt/function_editor.ui new file mode 100644 index 000000000..fd29d0a97 --- /dev/null +++ b/glue/dialogs/component_manager/qt/function_editor.ui @@ -0,0 +1,111 @@ + + + Dialog + + + + 0 + 0 + 532 + 402 + + + + Derived Component Editor + + + + 5 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 75 + true + + + + Info + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + ColorizedCompletionTextEdit + QTextEdit +
glue.dialogs.component_manager.qt.equation_editor
+
+
+ + +
diff --git a/glue/plugins/coordinate_helpers/link_helpers.py b/glue/plugins/coordinate_helpers/link_helpers.py index 7afeb82f3..7cdbe9d0e 100644 --- a/glue/plugins/coordinate_helpers/link_helpers.py +++ b/glue/plugins/coordinate_helpers/link_helpers.py @@ -23,9 +23,9 @@ class BaseCelestialMultiLink(MultiLink): frame_in = None frame_out = None - def __init__(self, in_lon, in_lat, out_lon, out_lat): - super(BaseCelestialMultiLink, self).__init__(in_lon, in_lat, out_lon, out_lat) - self.create_links([in_lon, in_lat], [out_lon, out_lat], + def __init__(self, cids_left, cids_right): + super(BaseCelestialMultiLink, self).__init__(cids_left, cids_right) + self.create_links(cids_left, cids_right, forwards=self.forward, backwards=self.backward) def forward(self, in_lon, in_lat): @@ -40,7 +40,8 @@ def backward(self, in_lon, in_lat): @link_helper('Link Galactic and FK5 (J2000) Equatorial coordinates', - input_labels=['l', 'b', 'ra (fk5)', 'dec (fk5)'], + input_labels=['l', 'b'], + output_labels=['ra (fk5)', 'dec (fk5)'], category='Astronomy') class Galactic_to_FK5(BaseCelestialMultiLink): display = "Galactic <-> FK5 (J2000)" @@ -49,7 +50,8 @@ class Galactic_to_FK5(BaseCelestialMultiLink): @link_helper('Link FK4 (B1950) and FK5 (J2000) Equatorial coordinates', - input_labels=['ra (fk4)', 'dec (fk4)', 'ra (fk5)', 'dec (fk5)'], + input_labels=['ra (fk4)', 'dec (fk4)'], + output_labels=['ra (fk5)', 'dec (fk5)'], category='Astronomy') class FK4_to_FK5(BaseCelestialMultiLink): display = "FK4 (B1950) <-> FK5 (J2000)" @@ -58,7 +60,8 @@ class FK4_to_FK5(BaseCelestialMultiLink): @link_helper('Link ICRS and FK5 (J2000) Equatorial coordinates', - input_labels=['ra (icrs)', 'dec (icrs)', 'ra (fk5)', 'dec (fk5)'], + input_labels=['ra (icrs)', 'dec (icrs)'], + output_labels=['ra (fk5)', 'dec (fk5)'], category='Astronomy') class ICRS_to_FK5(BaseCelestialMultiLink): display = "ICRS <-> FK5 (J2000)" @@ -67,7 +70,8 @@ class ICRS_to_FK5(BaseCelestialMultiLink): @link_helper('Link Galactic and FK4 (B1950) Equatorial coordinates', - input_labels=['l', 'b', 'ra (fk4)', 'dec (fk4)'], + input_labels=['l', 'b'], + output_labels=['ra (fk4)', 'dec (fk4)'], category='Astronomy') class Galactic_to_FK4(BaseCelestialMultiLink): display = "Galactic <-> FK4 (B1950)" @@ -76,7 +80,8 @@ class Galactic_to_FK4(BaseCelestialMultiLink): @link_helper('Link ICRS and FK4 (B1950) Equatorial coordinates', - input_labels=['ra (icrs)', 'dec (icrs)', 'ra (fk4)', 'dec (fk4)'], + input_labels=['ra (icrs)', 'dec (icrs)'], + output_labels=['ra (fk4)', 'dec (fk4)'], category='Astronomy') class ICRS_to_FK4(BaseCelestialMultiLink): display = "ICRS <-> FK4 (B1950)" @@ -85,7 +90,8 @@ class ICRS_to_FK4(BaseCelestialMultiLink): @link_helper('Link ICRS and Galactic coordinates', - input_labels=['ra (icrs)', 'dec (icrs)', 'l', 'b'], + input_labels=['ra (icrs)', 'dec (icrs)'], + output_labels=['l', 'b'], category='Astronomy') class ICRS_to_Galactic(BaseCelestialMultiLink): display = "ICRS <-> Galactic" @@ -94,15 +100,16 @@ class ICRS_to_Galactic(BaseCelestialMultiLink): @link_helper('Link 3D Galactocentric and Galactic coordinates', - input_labels=['x (kpc)', 'y (kpc)', 'z (kpc)', 'l (deg)', 'b (deg)', 'distance (kpc)'], + input_labels=['x (kpc)', 'y (kpc)', 'z (kpc)'], + output_labels=['l (deg)', 'b (deg)', 'distance (kpc)'], category='Astronomy') class GalactocentricToGalactic(MultiLink): display = "3D Galactocentric <-> Galactic" - def __init__(self, x_id, y_id, z_id, l_id, b_id, d_id): - super(GalactocentricToGalactic, self).__init__(x_id, y_id, z_id, l_id, b_id, d_id) - self.create_links([x_id, y_id, z_id], [l_id, b_id, d_id], + def __init__(self, cids_left, cids_right): + super(GalactocentricToGalactic, self).__init__(cids_left, cids_right) + self.create_links(cids_left, cids_right, self.forward, self.backward) def forward(self, x_kpc, y_kpc, z_kpc): diff --git a/glue/plugins/coordinate_helpers/tests/test_link_helpers.py b/glue/plugins/coordinate_helpers/tests/test_link_helpers.py index 145ffccc3..324f73b16 100644 --- a/glue/plugins/coordinate_helpers/tests/test_link_helpers.py +++ b/glue/plugins/coordinate_helpers/tests/test_link_helpers.py @@ -32,7 +32,7 @@ @pytest.mark.parametrize(('conv_class', 'expected'), list(EXPECTED.items())) def test_conversion(conv_class, expected): - result = conv_class(lon1, lat1, lon2, lat2) + result = conv_class([lon1, lat1], [lon2, lat2]) assert len(result) == 4