Source code for tardis.visualization.widgets.line_info

"""Class to create and display Line Info Widget."""

import re

from astropy import units as u
import numpy as np
import pandas as pd
from plotly import graph_objects as go
from plotly.callbacks import BoxSelector
import ipywidgets as ipw

from tardis.analysis import LastLineInteraction
from tardis.util.base import (
    species_tuple_to_string,
    species_string_to_tuple,
    is_notebook,
)
from tardis.visualization.widgets.util import (
    create_table_widget,
    TableSummaryLabel,
)
from tardis.visualization import plot_util as pu


[docs] class LineInfoWidget: """ Widget to explore atomic lines that produced features in the simulated spectrum. It allows selection of a wavelength range in the spectrum to display a table giving the fraction of packets that experienced their last interaction with each species. Using toggle buttons, users can specify whether to filter the selected range by emitted or absorbed wavelengths of packets. Clicking on a row in the fractional species interactions table shows packet counts for each last line interaction of the selected species, which can be grouped in several ways using the dropdown menu. """ FILTER_MODES = ("packet_out_nu", "packet_in_nu") FILTER_MODES_DESC = ("Emitted Wavelength", "Absorbed Wavelength") GROUP_MODES = ("both", "exc", "de-exc") GROUP_MODES_DESC = ( "Both excitation line (absorption) and de-excitation line (emission)", "Only excitation line (absorption)", "Only de-excitation line (emission)", ) COLORS = {"selection_area": "lightpink", "selection_border": "salmon"} def __init__( self, lines_data, line_interaction_analysis, spectrum_wavelength, spectrum_luminosity_density_lambda, virt_spectrum_wavelength, virt_spectrum_luminosity_density_lambda, ): """ Initialize the LineInfoWidget with line interaction and spectrum data. Parameters ---------- lines_data : pd.DataFrame Data about the atomic lines present in simulation model's plasma line_interaction_analysis : dict of tardis.analysis.LastLineInteraction Dictionary in which keys are the FILTER_MODES and values are the LastLineInteraction objects initialized with corresponding modes spectrum_wavelength : astropy.Quantity Wavelength values of a real spectrum, having unit of Angstrom spectrum_luminosity_density_lambda : astropy.Quantity Luminosity density lambda values of a real spectrum, having unit of (erg/s)/Angstrom virt_spectrum_wavelength : astropy.Quantity Wavelength values of a virtual spectrum, having unit of Angstrom virt_spectrum_luminosity_density_lambda : astropy.Quantity Luminosity density lambda values of a virtual spectrum, having unit of (erg/s)/Angstrom """ self.lines_data = lines_data self.line_interaction_analysis = line_interaction_analysis # Widgets ------------------------------------------------ max_rows_option = {"maxVisibleRows": 9} self.species_interactions_table = create_table_widget( data=self.get_species_interactions(None), col_widths=[35, 65], table_options=max_rows_option, ) line_counts_col_widths = [75, 25] self.last_line_counts_table = create_table_widget( data=self.get_last_line_counts(None), col_widths=line_counts_col_widths, table_options=max_rows_option, ) self.total_packets_label = TableSummaryLabel( target_table=self.last_line_counts_table, table_col_widths=line_counts_col_widths, label_key="Total Packets", label_value=0, ) self.figure_widget = self.plot_spectrum( spectrum_wavelength, spectrum_luminosity_density_lambda, virt_spectrum_wavelength, virt_spectrum_luminosity_density_lambda, ) self.filter_mode_buttons = ipw.ToggleButtons( options=self.FILTER_MODES_DESC, index=0 ) self.group_mode_dropdown = ipw.Dropdown( options=self.GROUP_MODES_DESC, index=0 )
[docs] @classmethod def from_simulation(cls, sim): """ Create an instance of LineInfoWidget from a TARDIS simulation object. Parameters ---------- sim : tardis.simulation.Simulation TARDIS Simulation object produced by running a simulation Returns ------- LineInfoWidget object """ spectrum_solver = sim.spectrum_solver return cls( lines_data=sim.plasma.lines.reset_index().set_index("line_id"), line_interaction_analysis={ filter_mode: LastLineInteraction.from_simulation( sim, filter_mode ) for filter_mode in cls.FILTER_MODES }, spectrum_wavelength=spectrum_solver.spectrum_real_packets.wavelength, spectrum_luminosity_density_lambda=spectrum_solver.spectrum_real_packets.luminosity_density_lambda.to( "erg/(s AA)" ), virt_spectrum_wavelength=spectrum_solver.spectrum_virtual_packets.wavelength, virt_spectrum_luminosity_density_lambda=spectrum_solver.spectrum_virtual_packets.luminosity_density_lambda.to( "erg/(s AA)" ), )
[docs] def get_species_interactions( self, wavelength_range, filter_mode=FILTER_MODES[0] ): """ Get fractional species interactions in specified wavelength range. Fractional species interactions means fraction of packets present in the specified wavelength range which experienced their last interaction with a species. The packets to consider are filtered by the specified filter mode. Parameters ---------- wavelength_range : list-like or None A list of two float values to specify the wavelength range - first for the range start and second for the range end. None specifies that no wavelength range is selected and will return empty dataframe filter_mode : str, optional Filter mode of the LastLineInteraction object to use for filtering the selected wavelength range (more details in Notes section). Allowed values are given by the class variable :code:`FILTER_MODES` (default value is :code:`FILTER_MODES[0]`) Returns ------- pandas.DataFrame Dataframe containing species symbols and corresponding fractions of packets interacting with them Notes ----- This method depends on tardis.analysis.LastLineInteraction object for doing computations. So there is a member variable in this class - :code:`line_interaction_analysis` which is a dictionary of such objects (each of them differ in how they filter the selected wavelength range). Thus we have to specify which object to use by specifying the filter_mode parameter. """ if wavelength_range: self.line_interaction_analysis[filter_mode].wavelength_start = ( wavelength_range[0] * u.AA ) self.line_interaction_analysis[filter_mode].wavelength_end = ( wavelength_range[1] * u.AA ) # Obtain species group from last_line_in dataframe selected_species_group = self.line_interaction_analysis[ filter_mode ].last_line_in.groupby(["atomic_number", "ion_number"]) if selected_species_group.groups: selected_species_symbols = [ species_tuple_to_string(item) for item in selected_species_group.groups.keys() ] # Normalize each group's count to find fractions of interactions fractional_species_interactions = ( selected_species_group.size() / self.line_interaction_analysis[ filter_mode ].last_line_in.shape[0] ) else: # No species could be selected in specified wavelength_range # qgrid cannot show empty dataframe properly, # so create one row with empty strings selected_species_symbols = [""] fractional_species_interactions = pd.Series([""]) else: # wavelength_range is None selected_species_symbols = [""] fractional_species_interactions = pd.Series([""]) fractional_species_interactions.index = pd.Index( selected_species_symbols, name="Species" ) fractional_species_interactions.name = "Fraction of packets interacting" return fractional_species_interactions.sort_values( ascending=False ).to_frame()
[docs] def get_last_line_counts( self, selected_species, filter_mode=FILTER_MODES[0], group_mode=GROUP_MODES[0], ): """ Get packet counts of each last line interaction of a species. Parameters ---------- selected_species : str Valid symbol of a species (e.g Si II) selected from the species data returned by :code:`get_species_interactions` (see Notes section) filter_mode : str, optional Filter mode of the LastLineInteraction object to use for fetching the data of last lines interacted (more details in Notes section). Allowed values are given by the class variable :code:`FILTER_MODES` (default value is :code:`FILTER_MODES[0]`) group_mode : str, optional Group mode to use for grouping last line interactions by excitation lines, de-excitation lines or both. Allowed values are given by the class variable :code:`GROUP_MODES` (default value is :code:`GROUP_MODES[0]` i.e. both) Returns ------- pd.DataFrame DataFrame containing last line interactions and corresponding packet counts. Notes ----- This method depends on tardis.analysis.LastLineInteraction object for doing computations. So there is a member variable in this class - :code:`line_interaction_analysis` which is a dictionary of such objects (each of them differ in how they filter the selected wavelength range). Thus we have to specify which object to use by specifying the filter_mode parameter. This method should always be called after calling :code:`get_species_interactions` method which sets a wavelength range on LastLineInteraction object. So selected_species should be one present within that range, otherwise it will result an error. """ if selected_species: selected_species_tuple = species_string_to_tuple(selected_species) try: # Get selected species' rows from last_line_in dataframe current_last_lines_in = ( self.line_interaction_analysis[filter_mode] .last_line_in.xs( key=selected_species_tuple, level=["atomic_number", "ion_number"], drop_level=False, ) .reset_index() ) # Get selected species' rows from last_line_out dataframe current_last_lines_out = ( self.line_interaction_analysis[filter_mode] .last_line_out.xs( key=selected_species_tuple, level=["atomic_number", "ion_number"], drop_level=False, ) .reset_index() ) assert ( current_last_lines_in.empty & current_last_lines_out.empty == False ) except (KeyError, AssertionError): # selected_species is invalid allowed_species = [ species_tuple_to_string(species) for species in self.line_interaction_analysis[filter_mode] .last_line_in.groupby(["atomic_number", "ion_number"]) .groups.keys() ] raise ValueError( "Invalid value of selected_species, it must be one present " "within the currently selected wavelength range in your " f"LineInfoWidget instance, which are {allowed_species}" ) last_line_interaction_string = [] interacting_packets_count = [] if group_mode == "both": # Group by both exc. line ids and de-exc. line ids current_last_lines_in[ "line_id_out" ] = current_last_lines_out.line_id grouped_line_interactions = current_last_lines_in.groupby( ["line_id", "line_id_out"] ) # Iterate over each group's key and size and append them to list for ( line_id, count, ) in grouped_line_interactions.size().items(): current_line_in = self.lines_data.loc[line_id[0]] current_line_out = self.lines_data.loc[line_id[1]] last_line_interaction_string.append( f"exc. {int(current_line_in.level_number_lower):02d}-" f"{int(current_line_in.level_number_upper):02d} " f"({current_line_in.wavelength:.2f} A) " f"de-exc. {int(current_line_out.level_number_upper):02d}-" f"{int(current_line_out.level_number_lower):02d} " f"({current_line_out.wavelength:.2f} A)" ) interacting_packets_count.append(count) elif group_mode == "exc": grouped_line_interactions = current_last_lines_in.groupby( "line_id" ) # Iterate over each group's key and size and append them to list for ( line_id, count, ) in grouped_line_interactions.size().items(): current_line_in = self.lines_data.loc[line_id] last_line_interaction_string.append( f"exc. {int(current_line_in.level_number_lower):02d}-" f"{int(current_line_in.level_number_upper):02d} " f"({current_line_in.wavelength:.2f} A)" ) interacting_packets_count.append(count) elif group_mode == "de-exc": grouped_line_interactions = current_last_lines_out.groupby( "line_id" ) # Iterate over each group's key and size and append them to list for ( line_id, count, ) in grouped_line_interactions.size().items(): current_line_out = self.lines_data.loc[line_id] last_line_interaction_string.append( f"de-exc. {int(current_line_out.level_number_upper):02d}-" f"{int(current_line_out.level_number_lower):02d} " f"({current_line_out.wavelength:.2f} A)" ) interacting_packets_count.append(count) else: raise ValueError( "Invalid value passed to group_mode argument. " f"Allowed values are {self.GROUP_MODES}" ) else: # species_selected is None # qgrid cannot show empty dataframe properly, # so create one row with empty strings interacting_packets_count = [""] last_line_interaction_string = [""] last_line_counts = pd.Series(interacting_packets_count) last_line_counts.name = "No. of packets" last_line_counts.index = pd.Index( last_line_interaction_string, name="Last Line Interaction" ) return last_line_counts.sort_values(ascending=False).to_frame()
[docs] @staticmethod def get_middle_half_edges(arr): """ Get edges of the middle half range of an array. Parameters ---------- arr : np.array Returns ------- list """ arr = np.sort(arr) return [ (arr[-1] - arr[0]) / 4 + arr[1], (arr[-1] - arr[0]) * 3 / 4 + arr[1], ]
[docs] def plot_spectrum( self, wavelength, luminosity_density_lambda, virt_wavelength, virt_luminosity_density_lambda, ): """ Produce a plotly figure widget by plotting the spectrum of model. Parameters ---------- wavelength : astropy.Quantity Wavelength values of a real spectrum, having unit of Angstrom luminosity_density_lambda : astropy.Quantity Luminosity density lambda values of a real spectrum, having unit of (erg/s)/Angstrom virt_wavelength : astropy.Quantity Wavelength values of a virtual spectrum, having unit of Angstrom virt_luminosity_density_lambda : astropy.Quantity Luminosity density lambda values of a virtual spectrum, having unit of (erg/s)/Angstrom Returns ------- plotly.graph_objects.FigureWidget """ # Initially zoomed range in rangeslider should be middle half of spectrum initial_zoomed_range = self.get_middle_half_edges(wavelength.value) # The scatter point should be a middle point in spectrum otherwise # the extra padding around it will be oddly visible when near the edge scatter_point_idx = pu.get_mid_point_idx(wavelength.value) return go.FigureWidget( [ go.Scatter( x=wavelength, y=luminosity_density_lambda, name="Real packets", ), go.Scatter( x=virt_wavelength, y=virt_luminosity_density_lambda, name="Virtual packets", ), # Hide a one point scatter trace, to bring boxselect in modebar go.Scatter( x=wavelength[scatter_point_idx], y=luminosity_density_lambda[scatter_point_idx], mode="markers", marker=dict(opacity=0), showlegend=False, ), ], layout=go.Layout( title="Spectrum", xaxis=dict( title=pu.axis_label_in_latex("Wavelength", wavelength.unit), exponentformat="none", rangeslider=dict(visible=True), range=initial_zoomed_range, ), yaxis=dict( title=pu.axis_label_in_latex( "Luminosity", luminosity_density_lambda.unit, ), exponentformat="e", fixedrange=False, ), dragmode="select", selectdirection="h", height=400, margin=dict(t=50, b=60), ), )
def _update_species_interactions(self, wavelength_range, filter_mode): """ Update data in species_interactions_table. The parameters are exact same as that of :code:`get_species_interactions`. Besides, it also does selection of 1st row in this table to trigger update in last_line_counts_table. """ # Update data in species_interactions_table self.species_interactions_table.df = self.get_species_interactions( wavelength_range, filter_mode ) # Get index of 0th row in species_interactions_table species0 = self.species_interactions_table.df.index[0] # Also update last_line_counts_table by triggering its event listener if self.species_interactions_table.get_selected_rows() == [0]: # Listener won't trigger if last row selected in # species_interactions_table was also 0th, so unselect the rows self.species_interactions_table.change_selection([]) # Select 0th row in this table to trigger _update_last_line_counts self.species_interactions_table.change_selection([species0]) def _add_selection_box(self, selector): """ Draw a shape on plotly figure widget to represent the selection. Parameters ---------- selector : plotly.callbacks.BoxSelector The object containing data about current selection made on plot (x-axis and y-axis range of selection box) """ self.figure_widget.layout.shapes = [ dict( type="rect", xref="x", yref="y", x0=selector.xrange[0], y0=selector.yrange[0], x1=selector.xrange[1], y1=selector.yrange[1], line=dict( color=self.COLORS["selection_border"], width=1, ), fillcolor=self.COLORS["selection_area"], opacity=0.5, ) ] def _update_last_line_counts(self, species, filter_mode, group_mode): """ Update data in last_line_counts_table and associated total_packets_label. The parameters are exact same as that of :code:`get_last_line_counts`. """ # Update data in line counts table self.last_line_counts_table.df = self.get_last_line_counts( species, filter_mode, group_mode ) # Update its corresponding total_packets_label if species: self.total_packets_label.update_and_resize( self.last_line_counts_table.df.iloc[:, 0].sum() ) else: # Line counts table will be empty self.total_packets_label.update_and_resize(0) def _spectrum_selection_handler(self, trace, points, selector): """ Event handler for selection of spectrum in plotly figure widget. This method has the expected signature of the callback function passed to :code:`on_selection` method of a plotly trace as explained in `their docs <https://plotly.com/python-api-reference/generated/plotly.html#plotly.basedatatypes.BaseTraceType.on_selection>`_. """ if isinstance(selector, BoxSelector): self._add_selection_box(selector) self._update_species_interactions( selector.xrange, self.FILTER_MODES[self.filter_mode_buttons.index], ) def _filter_mode_toggle_handler(self, change): """ Event handler for toggle in filter_mode_buttons. This method has the expected signature of the callback function passed to :code:`observe` method of ipywidgets as explained in `their docs <https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Events.html#Signatures>`_. """ try: wavelength_range = [ self.figure_widget.layout.shapes[0][x] for x in ("x0", "x1") ] except IndexError: # No selection is made on figure widget return self._update_species_interactions( wavelength_range, self.FILTER_MODES[self.filter_mode_buttons.index], ) def _species_intrctn_selection_handler(self, event, qgrid_widget): """ Event handler for selection in species_interactions_table. This method has the expected signature of the function passed to :code:`handler` argument of :code:`on_selection` method of qgrid.QgridWidget as explained in `their docs <https://qgrid.readthedocs.io/en/latest/#qgrid.QgridWidget.on>`_. """ # Don't execute function if no row was selected implicitly (by api) if event["new"] == [] and event["source"] == "api": return # Get species from the selected row in species_interactions_table species_selected = self.species_interactions_table.df.index[ event["new"][0] ] if species_selected == "": # when species_interactions_table is empty species_selected = None self._update_last_line_counts( species_selected, self.FILTER_MODES[self.filter_mode_buttons.index], self.GROUP_MODES[self.group_mode_dropdown.index], ) def _group_mode_dropdown_handler(self, change): """ Event handler for selection in group_mode_dropdown. This method has the expected signature of the callback function passed to :code:`observe` method of ipywidgets as explained in `their docs <https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Events.html#Signatures>`_. """ try: selected_row_idx = ( self.species_interactions_table.get_selected_rows()[0] ) species_selected = self.species_interactions_table.df.index[ selected_row_idx ] except IndexError: # No row is selected in species_interactions_table return self._update_last_line_counts( species_selected, self.FILTER_MODES[self.filter_mode_buttons.index], self.GROUP_MODES[self.group_mode_dropdown.index], )
[docs] @staticmethod def ui_control_description(text): """Get description label of a UI control with increased font size.""" return ipw.HTML(f"<span style='font-size: 1.15em;'>{text}:</span>")
[docs] def display(self): """ Display the fully-functional line info widget. It puts together all component widgets nicely together and enables interaction between all the components. Returns ------- ipywidgets.Box Line info widget containing all component widgets """ if not is_notebook(): print("Please use a notebook to display the widget") else: # Set widths of widgets self.species_interactions_table.layout.width = "350px" self.last_line_counts_table.layout.width = "450px" self.total_packets_label.update_and_resize(0) self.group_mode_dropdown.layout.width = "auto" # Attach event listeners to widgets spectrum_trace = self.figure_widget.data[0] spectrum_trace.on_selection(self._spectrum_selection_handler) self.filter_mode_buttons.observe( self._filter_mode_toggle_handler, names="index" ) self.species_interactions_table.on( "selection_changed", self._species_intrctn_selection_handler ) self.group_mode_dropdown.observe( self._group_mode_dropdown_handler, names="index" ) selection_box_symbol = ( "<span style='display: inline-block; " f"background-color: {self.COLORS['selection_area']}; " f"border: 1px solid {self.COLORS['selection_border']}; " "width: 0.8em; height: 1.2em; vertical-align: middle;'></span>" ) table_container_left = ipw.VBox( [ self.ui_control_description( "Filter selected wavelength range " f"( {selection_box_symbol} ) by" ), self.filter_mode_buttons, self.species_interactions_table, ], layout=dict(margin="0px 15px"), ) table_container_right = ipw.VBox( [ self.ui_control_description("Group packet counts by"), self.group_mode_dropdown, self.last_line_counts_table, self.total_packets_label.widget, ], layout=dict(margin="0px 15px"), ) return ipw.VBox( [ self.figure_widget, ipw.Box( [ table_container_left, table_container_right, ], layout=dict( display="flex", align_items="flex-start", justify_content="center", height="420px", ), ), ] )