<jupyter_start><jupyter_text>MOSViz jdaviz conceptThis notebook provides a *concept* of how the mosviz data-loading and basic interaction workflow operates.Note that there is more discussion about this concept notebook in this PR: https://github.com/spacetelescope/jdaviz/pull/100 - to fully include all thoughts on this notebook it is necessary to read the comment threads there.<jupyter_code>from jdaviz.app import Application

mosapp = Application('mosviz')
mosapp<jupyter_output><empty_output><jupyter_text>The MOSViz app should now appear in the output of the cell above, although initially with no data loaded. The below is a hacked-together matplotlib layout that shows how the viewers should be laid out (although initially it would appear without data).<jupyter_code>import numpy as np
from matplotlib import pyplot as plt
fake_x = np.linspace(6000, 8000, 1024)
fake_y = np.random.randn(len(fake_x)) + 10*np.exp(-0.001*(fake_x-6563)**2) + fake_x/500
fake_2d = fake_y[np.newaxis] + np.random.randn(100, 1024)
fake_r_im = np.sum((np.mgrid[:128, :128]-64)**2,axis=0)**0.5
fake_im = np.random.randn(128, 128) + np.exp(-0.005*fake_r_im**2)*10

fig = plt.figure(constrained_layout=True, figsize=(12, 6))
gs = fig.add_gridspec(3, 3)

ax_im = fig.add_subplot(gs[1:, :1])
ax_im.imshow(fake_im)
ax_im.add_artist(plt.Rectangle((62, 4), 5, 120, edgecolor='red', facecolor='none'))
ax_2d = fig.add_subplot(gs[1, 1:])
ax_2d.imshow(fake_2d)
ax_1d = fig.add_subplot(gs[2, 1:])
ax_1d.plot(fake_x, fake_y)
ax_1d.set_xlim(fake_x[0], fake_x[-1])
ax_table = fig.add_subplot(gs[0, :])
ax_table.text(0.5, 0.5, 'TABLE\nOF SPECTRA\n(clicking on a table element updates the view below)', ha='center', va='center')
ax_table.set_xticks([])
ax_table.set_yticks([]);<jupyter_output><empty_output><jupyter_text>Loading data   Load JWST (NIRSpec) data Ideallly JWST NIRSpec MSA data should just be as simple as:<jupyter_code>mosapp.load_data('/path/to/downloaded/nirspec/data')<jupyter_output><empty_output><jupyter_text>And that should load up *everything* - images, spectra, and the data table.  Load other MOS dataFor other data sets MOSViz doesn't natively understand, the user will have to provide the data directly. It should be possible to load things piecemeal, and just have only the relevant viewers "recognize" their existence.  They should leverage astropy objects where possible so that the I/O doesn't have to be re-implemented:<jupyter_code>from specutils import SpectrumCollection

# this also be a complicated data-loading cell, but the result should be a SpectrumCollection with N spectra
spectra = SpectrumCollection.read('/path/to/my/deimos/spectra')
mosapp.load_spectra(spectra)<jupyter_output><empty_output><jupyter_text>This would activate the 1d spectrum view and the table view (which would just have a single column like "index") but not the image or 2d spectrum view.  This is the "minimally useful" MOSViz. If the user also has 2d spectra available, they can do:<jupyter_code>from astropy.nddata import CCDData

spectra2d = []
for fn in filenames_2dspectra: #filenames_2dspectra is an array of length N, in the *same order* as the SpectrumCollection
    # CCDData should or at least NDData should be the internal representation, but potentially the user
    # can just pass in a raw numpy array if they don't want to bother with NDData stuff.  But then we
    # have to construct the WCS assuming a pixel-to-pixel 
    spectra2d.append(CCDData.read(fn))

mosapp.load_2d_spectra(spectra2d)<jupyter_output><empty_output><jupyter_text>Doing the above then activates the 2d spectrum part of the view.  If the 2d spectra have wcs, the wcs is used to tie the 1d and 2d spectra together.  If not, assume a pixel-to-pixel mapping along the x-axis of the spectrum and the images.A similar idiom works for the 2d images:<jupyter_code>images = []
for fn in filenames_images: #filenames_images is an array of length N, in the *same order* as the SpectrumCollection
    # CCDData should or at least NDData should be the internal representation, but potentially the user
    # can just pass in a raw numpy array if they don't want to bother with NDData stuff
    images.append(CCDData.read(fn))

mosapp.load_images(images)<jupyter_output><empty_output><jupyter_text>The assumption given no other information is that the center of the image is the center of the slit.  But until the data table is loaded we can't do anything with that, so it just shows the image as-is at this point.Additionally *or* independently from the 2d spectra/image lists, the user can provide the table:<jupyter_code>from astropy import Table

table = Table.read('/path/to/my/slit/catalog.ecsv')  # the table must have the same number of rows as spectra in the SpectrumCollection
mosapp.load_table(table)<jupyter_output><empty_output><jupyter_text>Now the table viewer would show all columns in the table in the table viewer.  Additionally, the 2d image view should now be updated based on the presence of certain columns in the table.  Specifically:* if there is a `SkyCoord` object in the table (probably with a special name like 'coordinates'), the 2d image view should be centered on that coordinate by default.* columns defining slit geometry should lead to a slit appearing in the image view.  This would be perhaps "slit_width", "slit_height", and "slit_angle".  They would all need to have units, and then `astropy.regions` can be used to actually make the slits.*Question: how well does this map to NIRSpec data's slit information?  I'm fairly sure that's embedded in the spectrum itself.  So probably the rule is that if there's slit information in the spectrum, use that, but if there's *also* table information, that takes precedence because the user likely put it in intentionally to override the spectrum?*  Alternative approach to loading with a "master table"If for whatever reason the above approach does not work, the data table itself can be viewed as a "master dataset".  Assuming the data objects are already loaded as above, that would look something like this:<jupyter_code>table = Table.read('/path/to/my/slit/catalog.ecsv')

table['mosviz_spectra'] = spectra
table['mosviz_2d_spectra'] = spectra2d
table['mosviz_images'] = images

mosapp.load_data(table)<jupyter_output><empty_output><jupyter_text>Which would load the same state as the three method calls above.  If a user wanted the "limited" version (i.e., missing images or 2d spectra), they just don't add the relevant column to the table.This works because Astropy has a concept of "mixin columns" which let python objects with the appropriate leading dimension masquerade as table columns, so this might basically work as-is.  But it's not clear to me that all of the necessary data types support mixin columns right now, so this might require substantial underlying library work.  Custom loaders for "other" data  We should support the option of using an I/O registry for custom loaders.  So then a user can write I/O functions like this:<jupyter_code>from glob import glob

@jdaviz.mosvizloader
def deimos_loader(mosvizapp, data_directory):
    spectrafn = glob(data_directory + _name'spec1d*.fits')
    SpectrumCollection([Spectrum1d.read(fn) for fn in spectrafn])
    
    spectra2dfns = glob(data_directory + _name'spec2d*.fits')
    spectra2d = [CCDData.read(fn, unit=u.adu) for fn in spectra2dfns]
    
    # there's no "standard" image format for deimos data, so we don't include images
    
    table = Table.read(data_directory + 'zspec.fits')
    
    mosvizapp.load_spectra(spectra)
    mosvizapp.load_2d_spectra(spectra2d)
    mosvizapp.load_table(table)<jupyter_output><empty_output><jupyter_text>Which they would use like:<jupyter_code>mosapp.load_data('/path/to/deimos/data/directory', format='deimos')<jupyter_output><empty_output><jupyter_text>And then have it load the state just like the above.  They might then optionally do their own custom:<jupyter_code>mosapp.load_images(images)<jupyter_output><empty_output><jupyter_text>Which is science case-specific because there's no standard format.Then we can encourage users to contribute their own loaders to `jdaviz` and build a library of community-provided MOS viewers, just like for `specutils` and the astropy I/O machinery.  Important UI interactions Table interactions* When the user clicks on a table row, that should update the other views to show the spectrum from the selected row* At least some columns should be editable, e.g. a "redshift" column or a "spectrum quality" column, which the user should be able to access and write out the table like this:<jupyter_code>tab = mosapp.get_data('table')
tab['redshift']  #<- a regular Astropy table column but with whatever the current state of the table in the UI
tab.write('output.ecsv')<jupyter_output><empty_output><jupyter_text>* In the desktop app (but *not* the notebook, if possible) there can be a save button that does exactly the above cell.  Spectrum interactions* When the 1d spectrum is zoomed along the x axis, the 2d spectrum should be matched to it (and vice versa).  Y-axis of both should be independent. Keyboard interactions* There need to be keyboard shortcuts ('{'/'}', or ''?  Or tab/ctrl-tab?) that advance to the next spectrum.* There needs to be a way to quickly update a column in a table. e.g. you press "1" to label it quickly as "good spectrum" or "2" as "bad spectrum". (this might be a plugin?)* The user should be able to customize that "quickly update column".  As a suggestion for how this might work:<jupyter_code>mosapp.set_keyboard_column_mappings({'1': ('spectrum_quality', 'good'), 
                                     '2': ('spectrum_quality', 'bad'), })
mosapp.set_keyboard_column_mappings('spectrum_quality')<jupyter_output><empty_output><jupyter_text>Which tells mosviz that when the user presses the '1' key, that row's 'spectrum_quality' column should be updated with the value 'good', whereas '2' should update it to 'bad'.* This custom mapping might also be implemented as a plugin, but that should come *after* getting the notebook version working.  Additional notebook Glue selection interactions  If the user wants the "full power" of Glue working with the table, a quick pathway is to have them create additional viewers to create subsets.  For example, suppose the `table` has two rows, one for 'rmag' and another for 'gmag', indicating the 'r' and 'g'-band magnitudes of the objects the spectra are of.  The user could then do:<jupyter_code>tab = mosapp.get_data('table')
tab['color'] = tab['gmag'] - tab['rmag']  #<- this should update the data table...

mosapp.scatter2d(x='color', y='rmag')<jupyter_output><empty_output><jupyter_text>Now the user can use glue's selection capabilities to draw a box around a part of the color-magnitude diagram, which highlights only those in the MOSViz UI. Initially this would *replace* the "small panel" of the Qt MOSviz with all the glue capabilities.  But eventually we would want this workflow to be possible using the more "general" Glue UI when that's working.  Example of a "mosviz app-specific" method interactions In addition to data-loading, one could imagine wanting to implement things like this:<jupyter_code>mosapp.rescale_both_spectra()
mosapp.switch_to_spectrum_slits()
#vs
mosapp.switch_to_table_slits()
spec1d, spec2d, image2d, tablerow = mosapp.get_current_spectrum()<jupyter_output><empty_output><jupyter_text>The third cell above in particular is nice because it would otherwise require each viewer being accessed individually, which in the glue-way might be rather awkward to deal with. Or going even further to specific science workflows:<jupyter_code>mosapp.compute_redshift_on_all_spectra(...)<jupyter_output><empty_output><jupyter_text>Although I (Erik) am *not* a fan of that complex of a workflow in one high-level call, it *might* be desirable by the wider science community.  "Helper" class alternative (see https://github.com/spacetelescope/jdaviz/issues/104 for context)<jupyter_code>from jdaviz.app import Application
from jdaviz.mosviz import MosVizHelper

app = Application()
mosviz = MosViz(app)

mosviz.app.show()<jupyter_output><empty_output><jupyter_text>A quicker version of the above should be available, which is purely syntactic sugar for the above:<jupyter_code>mosviz = MosViz()  #<- default app=None would mean "make a new empty glue Application"
mosviz  # <- MosViz._repr_html_ just yields whatever the .app.show() gives<jupyter_output><empty_output><jupyter_text>Then operations that are viz app-specific would be a method on the helper class (for an explanation of these methods, so the above sections of the notebook):<jupyter_code># this *looks* like the mosviz.app.add_data, but is different in that 
# it raises an exception if the data is not something mosviz understands

mosviz.load_data('/path/to/downloaded/nirspec/data')<jupyter_output><empty_output><jupyter_text>And all of the other data-loading methods described in detail above are definitely unique to mosviz:<jupyter_code>mosviz.load_spectra(spectra)

...

mosvizapp.load_2d_spectra(spectra2d)

...

mosapp.load_images(images)

...

mosapp.load_table(table)<jupyter_output><empty_output><jupyter_text>As are some of the "operations" one wants to do with mosviz that are specific to that workflow:<jupyter_code>mosviz.set_keyboard_column_mappings({'1': ('spectrum_quality', 'good'), 
                                     '2': ('spectrum_quality', 'bad'), })
mosapp.rescale_both_spectra()

spec1d, spec2d, image2d, tablerow = mosapp.get_current_object_data()

... do something with the above objects ...<jupyter_output><empty_output><jupyter_text>Then anything that requires using the glue application in a more general mode is done like:<jupyter_code># Assuming the user has done something with a glue plugin that adds another spectrum to the spectrum view

currspec = mosviz.app.get_data_from_viewer('spectrum-viewer', 'spectrum-added-by-plugin')

... do something with currspec ...<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.08133218982275586, 'fraction_numerical': 0.010220125786163521, 'mean_word_length': 5.2833408172429275, 'pattern_counts': {'":': 0, '<': 93, '<?xml version=': 0, '>': 89, 'https://': 2, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 6, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '22', 'dir': 'jupyter-structured-clean-dedup', 'id': '59604', 'license': 'BSD-3-Clause', 'n_tokens_mistral': 4342, 'n_tokens_neox': 4150, 'n_words': 1664, 'path': 'notebooks/concepts/Mosviz concept notebook.ipynb', 'repo_name': 'Jenneh/jdaviz', 'size': '13992'}