Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add functionality to load catalogs from a file #3359

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Imviz
- Catalog Search plugin now exposes a maximum sources limit for all catalogs and resolves an edge case
when loading a catalog from a file that only contains one source. [#3337]

- Enhance the Catalog Search plugin to support additional columns when loading catalog data from files. [#3359]

Mosviz
^^^^^^

Expand Down
4 changes: 2 additions & 2 deletions docs/imviz/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ To load a catalog from a supported `JWST ECSV catalog file <https://jwst-pipelin
The file must be able to be parsed by `astropy.table.Table.read` and contains the following columns:

* ``'sky_centroid'``: Column with `~astropy.coordinates.SkyCoord` sky coordinates of the sources.
* ``'label'``: Column with string identifiers of the sources. If you have numerical identifiers,
they will be recast as string.
* ``'label(optional)'``: Column with string identifiers of the sources. If not provided, unique string identifiers will be generated automatically.
If you have numerical identifiers, they will be recast as strings.

Clicking :guilabel:`SEARCH` will show markers for any entry within the filtered zoom window.

Expand Down
66 changes: 41 additions & 25 deletions jdaviz/configs/imviz/plugins/catalogs/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ class Catalogs(PluginTemplateMixin, ViewerSelectMixin, HasFileImportSelect, Tabl
'Object ID': np.nan,
'id': np.nan,
'x_coord': np.nan,
'y_coord': np.nan}
'y_coord': np.nan
}

headers = ['Right Ascension (degrees)', 'Declination (degrees)',
'Object ID', 'x_coord', 'y_coord']

@property
def user_api(self):
Expand All @@ -70,11 +74,8 @@ def __init__(self, *args, **kwargs):
self._marker_name = 'catalog_results'

# initializing the headers in the table that is displayed in the UI
headers = ['Right Ascension (degrees)', 'Declination (degrees)',
'Object ID', 'x_coord', 'y_coord']

self.table.headers_avail = headers
self.table.headers_visible = headers
self.table.headers_avail = self.headers
self.table.headers_visible = self.headers
self.table._default_values_by_colname = self._default_table_values
self.table._selected_rows_changed_callback = lambda msg: self.plot_selected_points()
self.table.item_key = 'id'
Expand All @@ -88,13 +89,15 @@ def __init__(self, *args, **kwargs):
def _file_parser(path):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs say that the label column must exist, but we don't actually enforce that so you are able to load with just the sky_centroid column. This results in the Object ID column = N/A, so I think we should add a condition here so we make sure that column also exists.

try:
table = QTable.read(path)
except Exception:
return 'Could not parse file with astropy.table.QTable.read', {}
if not table.colnames: # Ensure the file has columns
return "File contains no columns", {}

if 'sky_centroid' not in table.colnames:
return 'Table does not contain required sky_centroid column', {}
if 'sky_centroid' not in table.colnames:
return 'Table does not contain required sky_centroid column', {}

return '', {path: table}
return '', {path: table}
except Exception:
return 'Could not parse file with astropy.table.QTable.read', {}

@with_spinner()
def search(self, error_on_fail=False):
Expand All @@ -113,7 +116,7 @@ def search(self, error_on_fail=False):

"""
# calling clear in the case the user forgot after searching
self.clear()
self.clear_table()

# gets the current viewer
viewer = self.viewer.selected_obj
Expand Down Expand Up @@ -202,6 +205,11 @@ def search(self, error_on_fail=False):
# all exceptions when going through the UI should have prevented setting this path
# but this exceptions might be raised here if setting from_file from the UI
table = self.catalog.selected_obj
column_names = list(table.colnames)
self.table.headers_avail = self.headers + [
col for col in column_names if col not in self.headers and col not in ["sky_centroid", "label"] # noqa:E501
]
self.table.headers_visible = self.headers
self.app._catalog_source_table = table
if len(table['sky_centroid']) > self.max_sources:
skycoord_table = table['sky_centroid'][:self.max_sources]
Expand Down Expand Up @@ -269,16 +277,19 @@ def search(self, error_on_fail=False):
if len(self.app._catalog_source_table) == 1 or self.max_sources == 1:
x_coordinates = [x_coordinates]
y_coordinates = [y_coordinates]
for idx, (row, x_coord, y_coord) in enumerate(zip(self.app._catalog_source_table, x_coordinates, y_coordinates)): # noqa:E501
row_info = {
'Right Ascension (degrees)': row['sky_centroid'].ra.deg,
'Declination (degrees)': row['sky_centroid'].dec.deg,
'Object ID': str(row.get('label', f"{idx + 1}")),
'id': idx+1,
'x_coord': x_coord,
'y_coord': y_coord,
}
for col in table.colnames:
if col not in ['label', 'sky_centroid']: # Skip already processed columns
row_info[col] = row[col]

for row, x_coord, y_coord in zip(self.app._catalog_source_table,
x_coordinates, y_coordinates):
# Check if the row contains the required keys
row_info = {'Right Ascension (degrees)': row['sky_centroid'].ra.deg,
'Declination (degrees)': row['sky_centroid'].dec.deg,
'Object ID': str(row.get('label', 'N/A')),
'id': len(self.table),
'x_coord': x_coord.item() if x_coord.size == 1 else x_coord,
'y_coord': y_coord.item() if y_coord.size == 1 else y_coord}
self.table.add_item(row_info)

filtered_skycoord_table = viewer.state.reference_data.coords.pixel_to_world(x_coordinates,
Expand Down Expand Up @@ -326,7 +337,8 @@ def plot_selected_points(self):
def vue_zoom_in(self, *args, **kwargs):
"""This function will zoom into the image based on the selected points"""
selected_rows = self.table.selected_rows

if not selected_rows: # Check if no rows are selected
return
x = [float(coord['x_coord']) for coord in selected_rows]
y = [float(coord['y_coord']) for coord in selected_rows]

Expand Down Expand Up @@ -361,9 +373,13 @@ def vue_do_search(self, *args, **kwargs):
# calls self.search() which handles all of the searching logic
self.search()

def clear(self, hide_only=True):
def clear_table(self, hide_only=True):
# gets the current viewer
viewer = self.viewer.selected_obj
# Clear the table before performing a new search
self.table.items = []
self.table.selected_rows = []
self.table.selected_indices = []

if not hide_only and self._marker_name in self.app.data_collection.labels:
# resetting values
Expand All @@ -385,5 +401,5 @@ def clear(self, hide_only=True):
if layer_is_table_data(lyr.layer) and lyr.layer.label == self._marker_name:
lyr.visible = False

def vue_do_clear(self, *args, **kwargs):
self.clear()
def vue_do_clear_table(self, *args, **kwargs):
self.clear_table()
18 changes: 10 additions & 8 deletions jdaviz/configs/imviz/plugins/catalogs/catalogs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@
See the <j-external-link link='https://astroquery.readthedocs.io/en/latest/gaia/gaia.html' linktext='astropy.gaia docs'></j-external-link> for details on the query defaults.
</j-docs-link>
</v-row>

<v-row v-if="catalog_selected && catalog_selected.endsWith('.ecsv')">
<v-select
v-model="selected_columns"
:items="column_names"
label="Select Columns"
multiple
hint="Select columns to display in the table."
/>
</v-row>

<v-row>
<v-text-field
Expand All @@ -53,14 +63,6 @@
</v-row>

<v-row class="row-no-outside-padding">
<v-col>
<plugin-action-button
:results_isolated_to_plugin="true"
@click="do_clear"
>
Clear
</plugin-action-button>
</v-col>
<v-col>
<plugin-action-button
:results_isolated_to_plugin="true"
Expand Down
34 changes: 23 additions & 11 deletions jdaviz/configs/imviz/tests/test_catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from astropy.nddata import NDData
from astropy.coordinates import SkyCoord
from astropy.table import Table, QTable
from numpy.testing import assert_allclose


@pytest.mark.remote_data
Expand Down Expand Up @@ -113,7 +114,7 @@ def test_plugin_image_with_result(self, imviz_helper, tmp_path):
prev_results = catalogs_plugin.number_of_results

# testing that every variable updates accordingly when markers are cleared
catalogs_plugin.vue_do_clear()
catalogs_plugin.vue_do_clear_table()

assert not catalogs_plugin.results_available

Expand Down Expand Up @@ -157,12 +158,16 @@ def test_plugin_image_with_result(self, imviz_helper, tmp_path):
assert imviz_helper.viewers['imviz-0']._obj.state.y_min == -0.5
assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 1488.5

# First select a row
catalogs_plugin.table.selected_rows = [
catalogs_plugin.table.items[0]]
# Then zoom in to the selected
catalogs_plugin.vue_zoom_in()

assert imviz_helper.viewers['imviz-0']._obj.state.x_min == 858.24969
assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 958.38461
assert imviz_helper.viewers['imviz-0']._obj.state.y_min == 278.86265
assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 378.8691
assert imviz_helper.viewers['imviz-0']._obj.state.x_min == 1022.5631800000001
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did the test results change?

assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 1122.56318
assert imviz_helper.viewers['imviz-0']._obj.state.y_min == 675.29611
assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 775.29611


def test_from_file_parsing(imviz_helper, tmp_path):
Expand Down Expand Up @@ -237,23 +242,30 @@ def test_offline_ecsv_catalog(imviz_helper, image_2d_wcs, tmp_path):
assert catalogs_plugin.number_of_results == n_entries
assert len(imviz_helper.app.data_collection) == 2 # image + markers

catalogs_plugin.clear()
catalogs_plugin.clear_table()

assert not catalogs_plugin.results_available
assert len(imviz_helper.app.data_collection) == 2 # markers still there, just hidden

catalogs_plugin.clear(hide_only=False)
catalogs_plugin.clear_table(hide_only=False)
assert not catalogs_plugin.results_available
assert len(imviz_helper.app.data_collection) == 1 # markers gone for good

assert imviz_helper.viewers['imviz-0']._obj.state.x_min == -0.5
assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 9.5
assert imviz_helper.viewers['imviz-0']._obj.state.y_min == -0.5
assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 9.5
# Re-populate the table with a new search
out_tbl = catalogs_plugin.search()
assert len(out_tbl) > 0
# Ensure at least one row is selected before zooming
catalogs_plugin.table.selected_rows = [catalogs_plugin.table.items[0]]
assert len(catalogs_plugin.table.selected_rows) > 0

# Now zoom in
catalogs_plugin.vue_zoom_in()

assert imviz_helper.viewers['imviz-0']._obj.state.x_min == -49.99966
assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 50.00034
assert imviz_helper.viewers['imviz-0']._obj.state.y_min == -48.99999
assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 51.00001
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.x_min, -49.99966, rtol=1e-6)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.x_max, 50.00034, rtol=1e-6)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.y_min, -48.99999, rtol=1e-6)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.y_max, 51.00001, rtol=1e-6)
14 changes: 12 additions & 2 deletions jdaviz/core/template_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4691,12 +4691,20 @@ def float_precision(column, item):
return ''
elif isinstance(item, tuple) and np.all([np.isnan(i) for i in item]):
return ''

elif isinstance(item, float):
return float_precision(column, item)
elif isinstance(item, (list, tuple)):
return [float_precision(column, i) if isinstance(i, float) else i for i in item]

elif isinstance(item, (np.float32, np.float64)):
return float(item)
elif isinstance(item, u.Quantity):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way we lost the unit info, is that important?

Also I wonder if we can take advantage of the JsonCsutomEncoder that astropy already has for some of these: https://github.com/astropy/astropy/blob/93b6f551b9ed00c4a422b754eae0261f1a7552c9/astropy/utils/misc.py#L366

return {"value": item.value.tolist() if item.size > 1 else item.value, "unit": str(item.unit)} # noqa: E501
elif isinstance(item, np.bool_):
return bool(item)
elif isinstance(item, np.ndarray):
return item.tolist()
elif isinstance(item, tuple):
return tuple(json_safe(v) for v in item)
return item

if isinstance(item, QTable):
Expand Down Expand Up @@ -4738,6 +4746,8 @@ def clear_table(self):
Clear all entries/markers from the current table.
"""
self.items = []
self.selected_rows = []
self.selected_indices = []
self._qtable = None
self._plugin.session.hub.broadcast(PluginTableModifiedMessage(sender=self))

Expand Down
Loading