# Licensed under a 3-clause BSD style license - see LICENSE.rstimportcollectionsimportcopyimportloggingimportnumpyasnpfromastropyimportunitsasufromastropy.coordinatesimportAltAz,Angle,SkyCoordfromastropy.coordinates.angle_utilitiesimportangular_separationfromastropy.ioimportfitsfromastropy.tableimportTablefromastropy.tableimportvstackasvstack_tablesfromastropy.visualizationimportquantity_supportimportmatplotlib.pyplotaspltfromgammapy.mapsimportMapAxis,MapCoord,RegionGeom,WcsNDMapfromgammapy.utils.fitsimportearth_location_from_dictfromgammapy.utils.scriptsimportmake_pathfromgammapy.utils.testingimportCheckerfromgammapy.utils.timeimporttime_ref_from_dictfrom.gtiimportGTI__all__=["EventList"]log=logging.getLogger(__name__)
[docs]classEventList:"""Event list. Event list data is stored as ``table`` (`~astropy.table.Table`) data member. The most important reconstructed event parameters are available as the following columns: - ``TIME`` - Mission elapsed time (sec) - ``RA``, ``DEC`` - ICRS system position (deg) - ``ENERGY`` - Energy (usually MeV for Fermi and TeV for IACTs) Note that ``TIME`` is usually sorted, but sometimes it is not. E.g. when simulating data, or processing it in certain ways. So generally any analysis code should assume ``TIME`` is not sorted. Other optional (columns) that are sometimes useful for high level analysis: - ``GLON``, ``GLAT`` - Galactic coordinates (deg) - ``DETX``, ``DETY`` - Field of view coordinates (deg) Note that when reading data for analysis you shouldn't use those values directly, but access them via properties which create objects of the appropriate class: - `time` for ``TIME`` - `radec` for ``RA``, ``DEC`` - `energy` for ``ENERGY`` - `galactic` for ``GLON``, ``GLAT`` Parameters ---------- table : `~astropy.table.Table` Event list table Examples -------- >>> from gammapy.data import EventList >>> events = EventList.read("$GAMMAPY_DATA/cta-1dc/data/baseline/gps/gps_baseline_110380.fits") >>> print(events) EventList --------- <BLANKLINE> Instrument : None Telescope : CTA Obs. ID : 110380 <BLANKLINE> Number of events : 106217 Event rate : 59.273 1 / s <BLANKLINE> Time start : 59235.5 Time stop : 59235.52074074074 <BLANKLINE> Min. energy : 3.00e-02 TeV Max. energy : 1.46e+02 TeV Median energy : 1.02e-01 TeV <BLANKLINE> Max. offset : 5.0 deg <BLANKLINE> """def__init__(self,table):self.table=table
[docs]@classmethoddefread(cls,filename,**kwargs):"""Read from FITS file. Format specification: :ref:`gadf:iact-events` Parameters ---------- filename : `pathlib.Path`, str Filename """filename=make_path(filename)kwargs.setdefault("hdu","EVENTS")table=Table.read(filename,**kwargs)returncls(table=table)
[docs]defto_table_hdu(self,format="gadf"):""" Convert event list to a `~astropy.io.fits.BinTableHDU` Parameters ---------- format: str Output format, currently only "gadf" is supported Returns ------- hdu: `astropy.io.fits.BinTableHDU` EventList converted to FITS representation """ifformat!="gadf":raiseValueError(f"Only the 'gadf' format supported, got {format}")returnfits.BinTableHDU(self.table,name="EVENTS")
[docs]defwrite(self,filename,gti=None,overwrite=False,format="gadf"):"""Write the event list to a FITS file. If a GTI object is provided, it is saved into a second extension in the file. Parameters ---------- filename : `pathlib.Path`, str Filename gti : `~gammapy.data.GTI` Good Time Intervals object to save to the same file. Default is None. overwrite : bool Overwrite existing file? format : str, optional FITS format convention. By default files will be written to the gamma-astro-data-formats (GADF) format. """ifformat!="gadf":raiseValueError(f"{format} is not a valid EventList format.")meta_dict=self.table.metaif"HDUCLAS1"inmeta_dict.keys()andmeta_dict["HDUCLAS1"]!="EVENTS":raiseValueError("The HDUCLAS1 keyword must be 'EVENTS' for an EventList")else:meta_dict["HDUCLAS1"]="EVENTS"if"HDUCLASS"inmeta_dict.keys()andmeta_dict["HDUCLASS"]!="GADF":raiseValueError("The HDUCLASS must be 'GADF' for format 'gadf'")else:meta_dict["HDUCLASS"]="GADF"filename=make_path(filename)primary_hdu=fits.PrimaryHDU()hdu_evt=self.to_table_hdu(format=format)hdu_all=fits.HDUList([primary_hdu,hdu_evt])ifgtiisnotNone:ifnotisinstance(gti,GTI):raiseTypeError("gti must be an instance of GTI")hdu_gti=gti.to_table_hdu(format=format)hdu_all.append(hdu_gti)hdu_all.writeto(filename,overwrite=overwrite)
[docs]@classmethoddeffrom_stack(cls,event_lists,**kwargs):"""Stack (concatenate) list of event lists. Calls `~astropy.table.vstack`. Parameters ---------- event_lists : list list of `~gammapy.data.EventList` to stack """tables=[_.tablefor_inevent_lists]stacked_table=vstack_tables(tables,**kwargs)returncls(stacked_table)
[docs]defstack(self,other):"""Stack with another EventList in place. Calls `~astropy.table.vstack`. Parameters ---------- other : `~gammapy.data.EventList` Event list to stack to self """self.table=vstack_tables([self.table,other.table])
def__str__(self):info=self.__class__.__name__+"\n"info+="-"*len(self.__class__.__name__)+"\n\n"instrument=self.table.meta.get("INSTRUME")info+=f"\tInstrument : {instrument}\n"telescope=self.table.meta.get("TELESCOP")info+=f"\tTelescope : {telescope}\n"obs_id=self.table.meta.get("OBS_ID","")info+=f"\tObs. ID : {obs_id}\n\n"info+=f"\tNumber of events : {len(self.table)}\n"rate=len(self.table)/self.observation_time_durationinfo+=f"\tEvent rate : {rate:.3f}\n\n"info+=f"\tTime start : {self.observation_time_start}\n"info+=f"\tTime stop : {self.observation_time_stop}\n\n"info+=f"\tMin. energy : {np.min(self.energy):.2e}\n"info+=f"\tMax. energy : {np.max(self.energy):.2e}\n"info+=f"\tMedian energy : {np.median(self.energy):.2e}\n\n"ifself.is_pointed_observation:offset_max=np.max(self.offset)info+=f"\tMax. offset : {offset_max:.1f}\n"returninfo.expandtabs(tabsize=2)@propertydeftime_ref(self):"""Time reference (`~astropy.time.Time`)."""returntime_ref_from_dict(self.table.meta)@propertydeftime(self):"""Event times (`~astropy.time.Time`). Notes ----- Times are automatically converted to 64-bit floats. With 32-bit floats times will be incorrect by a few seconds when e.g. adding them to the reference time. """met=u.Quantity(self.table["TIME"].astype("float64"),"second")returnself.time_ref+met@propertydefobservation_time_start(self):"""Observation start time (`~astropy.time.Time`)."""returnself.time_ref+u.Quantity(self.table.meta["TSTART"],"second")@propertydefobservation_time_stop(self):"""Observation stop time (`~astropy.time.Time`)."""returnself.time_ref+u.Quantity(self.table.meta["TSTOP"],"second")@propertydefradec(self):"""Event RA / DEC sky coordinates (`~astropy.coordinates.SkyCoord`)."""lon,lat=self.table["RA"],self.table["DEC"]returnSkyCoord(lon,lat,unit="deg",frame="icrs")@propertydefgalactic(self):"""Event Galactic sky coordinates (`~astropy.coordinates.SkyCoord`). Always computed from RA / DEC using Astropy. """returnself.radec.galactic@propertydefenergy(self):"""Event energies (`~astropy.units.Quantity`)."""returnself.table["ENERGY"].quantity@propertydefgalactic_median(self):"""Median position in radec"""galactic=self.galacticmedian_lon=np.median(galactic.l.wrap_at("180d"))median_lat=np.median(galactic.b)returnSkyCoord(median_lon,median_lat,frame="galactic")
[docs]defselect_row_subset(self,row_specifier):"""Select table row subset. Parameters ---------- row_specifier : slice, int, or array of ints Specification for rows to select, passed on to ``self.table[row_specifier]``. Returns ------- event_list : `EventList` New event list with table row subset selected Examples -------- >>> from gammapy.data import EventList >>> import numpy as np >>> filename = "$GAMMAPY_DATA/cta-1dc/data/baseline/gps/gps_baseline_110380.fits" >>> events = EventList.read(filename) >>> #Use a boolean mask as ``row_specifier``: >>> mask = events.table['MC_ID'] == 1 >>> events2 = events.select_row_subset(mask) >>> print(len(events2.table)) 97978 >>> #Use row index array as ``row_specifier``: >>> idx = np.where(events.table['MC_ID'] == 1)[0] >>> events2 = events.select_row_subset(idx) >>> print(len(events2.table)) 97978 """table=self.table[row_specifier]returnself.__class__(table=table)
[docs]defselect_energy(self,energy_range):"""Select events in energy band. Parameters ---------- energy_range : `~astropy.units.Quantity` Energy range ``[energy_min, energy_max)`` Returns ------- event_list : `EventList` Copy of event list with selection applied. Examples -------- >>> from astropy import units as u >>> from gammapy.data import EventList >>> filename = "$GAMMAPY_DATA/fermi_3fhl/fermi_3fhl_events_selected.fits.gz" >>> event_list = EventList.read(filename) >>> energy_range =[1, 20] * u.TeV >>> event_list = event_list.select_energy(energy_range=energy_range) """energy=self.energymask=energy_range[0]<=energymask&=energy<energy_range[1]returnself.select_row_subset(mask)
[docs]defselect_time(self,time_interval):"""Select events in time interval. Parameters ---------- time_interval : `astropy.time.Time` Start time (inclusive) and stop time (exclusive) for the selection. Returns ------- events : `EventList` Copy of event list with selection applied. """time=self.timemask=time_interval[0]<=timemask&=time<time_interval[1]returnself.select_row_subset(mask)
[docs]defselect_region(self,regions,wcs=None):"""Select events in given region. Parameters ---------- regions : str, `~regions.Region` or list of `~regions.Region` Region or list of regions (pixel or sky regions accepted). A region can be defined as a string ind DS9 format as well. See http://ds9.si.edu/doc/ref/region.html for details. wcs : `~astropy.wcs.WCS` World coordinate system transformation Returns ------- event_list : `EventList` Copy of event list with selection applied. """geom=RegionGeom.from_regions(regions,wcs=wcs)mask=geom.contains(self.radec)returnself.select_row_subset(mask)
[docs]defselect_parameter(self,parameter,band):"""Select events with respect to a specified parameter. Parameters ---------- parameter : str Parameter used for the selection. Must be present in `self.table`. band : tuple or `astropy.units.Quantity` Min and max value for the parameter to be selected (min <= parameter < max). If parameter is not dimensionless you have to provide a Quantity. Returns ------- event_list : `EventList` Copy of event list with selection applied. Examples -------- >>> from astropy import units as u >>> from gammapy.data import EventList >>> filename = "$GAMMAPY_DATA/fermi_3fhl/fermi_3fhl_events_selected.fits.gz" >>> event_list = EventList.read(filename) >>> zd = (0, 30) * u.deg >>> event_list = event_list.select_parameter(parameter='ZENITH_ANGLE', band=zd) >>> print(len(event_list.table)) 123944 """mask=band[0]<=self.table[parameter].quantitymask&=self.table[parameter].quantity<band[1]returnself.select_row_subset(mask)
[docs]defplot_energy(self,ax=None,**kwargs):"""Plot counts as a function of energy. Parameters ---------- ax : `~matplotlib.axes.Axes` or None Axes **kwargs : dict Keyword arguments passed to `~matplotlib.pyplot.hist` Returns ------- ax : `~matplotlib.axes.Axes` or None Axes """ax=plt.gca()ifaxisNoneelseaxenergy_axis=self._default_plot_energy_axiskwargs.setdefault("log",True)kwargs.setdefault("histtype","step")kwargs.setdefault("bins",energy_axis.edges)withquantity_support():ax.hist(self.energy,**kwargs)energy_axis.format_plot_xaxis(ax=ax)ax.set_ylabel("Counts")ax.set_yscale("log")returnax
[docs]defplot_time(self,ax=None,**kwargs):"""Plots an event rate time curve. Parameters ---------- ax : `~matplotlib.axes.Axes` or None Axes **kwargs : dict Keyword arguments passed to `~matplotlib.pyplot.errorbar` Returns ------- ax : `~matplotlib.axes.Axes` Axes """ax=plt.gca()ifaxisNoneelseax# Note the events are not necessarily in time ordertime=self.table["TIME"]time=time-np.min(time)ax.set_xlabel("Time [sec]")ax.set_ylabel("Counts")y,x_edges=np.histogram(time,bins=20)xerr=np.diff(x_edges)/2x=x_edges[:-1]+xerryerr=np.sqrt(y)kwargs.setdefault("fmt","none")ax.errorbar(x=x,y=y,xerr=xerr,yerr=yerr,**kwargs)returnax
[docs]defplot_offset2_distribution(self,ax=None,center=None,**kwargs):"""Plot offset^2 distribution of the events. The distribution shown in this plot is for this quantity:: offset = center.separation(events.radec).deg offset2 = offset ** 2 Note that this method is just for a quicklook plot. If you want to do computations with the offset or offset^2 values, you can use the line above. As an example, here's how to compute the 68% event containment radius using `numpy.percentile`:: import numpy as np r68 = np.percentile(offset, q=68) Parameters ---------- ax : `~matplotlib.axes.Axes` (optional) Axes center : `astropy.coordinates.SkyCoord` Center position for the offset^2 distribution. Default is the observation pointing position. **kwargs : Extra keyword arguments are passed to `~matplotlib.pyplot.hist`. Returns ------- ax : `~matplotlib.axes.Axes` Axes Examples -------- Load an example event list: >>> from gammapy.data import EventList >>> from astropy import units as u >>> filename = "$GAMMAPY_DATA/hess-dl3-dr1/data/hess_dl3_dr1_obs_id_023523.fits.gz" >>> events = EventList.read(filename) >>> #Plot the offset^2 distribution wrt. the observation pointing position >>> #(this is a commonly used plot to check the background spatial distribution): >>> events.plot_offset2_distribution() # doctest: +SKIP Plot the offset^2 distribution wrt. the Crab pulsar position (this is commonly used to check both the gamma-ray signal and the background spatial distribution): >>> import numpy as np >>> from astropy.coordinates import SkyCoord >>> center = SkyCoord(83.63307, 22.01449, unit='deg') >>> bins = np.linspace(start=0, stop=0.3 ** 2, num=30) * u.deg ** 2 >>> events.plot_offset2_distribution(center=center, bins=bins) # doctest: +SKIP Note how we passed the ``bins`` option of `matplotlib.pyplot.hist` to control the histogram binning, in this case 30 bins ranging from 0 to (0.3 deg)^2. """ax=plt.gca()ifaxisNoneelseaxifcenterisNone:center=self._plot_centeroffset2=center.separation(self.radec)**2kwargs.setdefault("histtype","step")kwargs.setdefault("bins",30)withquantity_support():ax.hist(offset2,**kwargs)ax.set_xlabel(f"Offset^2 [{ax.xaxis.units}]")ax.set_ylabel("Counts")returnax
[docs]defplot_energy_offset(self,ax=None,center=None,**kwargs):"""Plot counts histogram with energy and offset axes Parameters ---------- ax : `~matplotlib.pyplot.Axis` Plot axis center : `~astropy.coordinates.SkyCoord` Sky coord from which offset is computed **kwargs : dict Keyword arguments forwarded to `~matplotlib.pyplot.pcolormesh` Returns ------- ax : `~matplotlib.pyplot.Axis` Plot axis """frommatplotlib.colorsimportLogNormax=plt.gca()ifaxisNoneelseaxifcenterisNone:center=self._plot_centerenergy_axis=self._default_plot_energy_axisoffset=center.separation(self.radec)offset_axis=MapAxis.from_bounds(0*u.deg,offset.max(),nbin=30,name="offset")counts=np.histogram2d(x=self.energy,y=offset,bins=(energy_axis.edges,offset_axis.edges),)[0]kwargs.setdefault("norm",LogNorm())withquantity_support():ax.pcolormesh(energy_axis.edges,offset_axis.edges,counts.T,**kwargs)energy_axis.format_plot_xaxis(ax=ax)offset_axis.format_plot_yaxis(ax=ax)returnax
[docs]defcheck(self,checks="all"):"""Run checks. This is a generator that yields a list of dicts. """checker=EventListChecker(self)returnchecker.run(checks=checks)
[docs]defmap_coord(self,geom):"""Event map coordinates for a given geometry. Parameters ---------- geom : `~gammapy.maps.Geom` Geometry Returns ------- coord : `~gammapy.maps.MapCoord` Coordinates """coord={"skycoord":self.radec}cols={k.upper():vfork,vinself.table.columns.items()}foraxisingeom.axes:try:col=cols[axis.name.upper()]coord[axis.name]=u.Quantity(col).to(axis.unit)exceptKeyError:raiseKeyError(f"Column not found in event list: {axis.name!r}")returnMapCoord.create(coord)
@propertydefobservatory_earth_location(self):"""Observatory location (`~astropy.coordinates.EarthLocation`)."""returnearth_location_from_dict(self.table.meta)@propertydefobservation_time_duration(self):"""Observation time duration in seconds (`~astropy.units.Quantity`). This is a keyword related to IACTs The wall time, including dead-time. """time_delta=(self.observation_time_stop-self.observation_time_start).secreturnu.Quantity(time_delta,"s")@propertydefobservation_live_time_duration(self):"""Live-time duration in seconds (`~astropy.units.Quantity`). The dead-time-corrected observation time. - In Fermi-LAT it is automatically provided in the header of the event list. - In IACTs is computed as ``t_live = t_observation * (1 - f_dead)`` where ``f_dead`` is the dead-time fraction. """returnu.Quantity(self.table.meta["LIVETIME"],"second")@propertydefobservation_dead_time_fraction(self):"""Dead-time fraction (float). This is a keyword related to IACTs Defined as dead-time over observation time. Dead-time is defined as the time during the observation where the detector didn't record events: http://en.wikipedia.org/wiki/Dead_time https://ui.adsabs.harvard.edu/abs/2004APh....22..285F The dead-time fraction is used in the live-time computation, which in turn is used in the exposure and flux computation. """return1-self.table.meta["DEADC"]@propertydefaltaz_frame(self):"""ALT / AZ frame (`~astropy.coordinates.AltAz`)."""returnAltAz(obstime=self.time,location=self.observatory_earth_location)@propertydefaltaz(self):"""ALT / AZ position computed from RA / DEC (`~astropy.coordinates.SkyCoord`)."""returnself.radec.transform_to(self.altaz_frame)@propertydefaltaz_from_table(self):"""ALT / AZ position from table (`~astropy.coordinates.SkyCoord`)."""lon=self.table["AZ"]lat=self.table["ALT"]returnSkyCoord(lon,lat,unit="deg",frame=self.altaz_frame)@propertydefpointing_radec(self):"""Pointing RA / DEC sky coordinates (`~astropy.coordinates.SkyCoord`)."""info=self.table.metalon,lat=info["RA_PNT"],info["DEC_PNT"]returnSkyCoord(lon,lat,unit="deg",frame="icrs")@propertydefoffset(self):"""Event offset from the array pointing position (`~astropy.coordinates.Angle`)."""position=self.radeccenter=self.pointing_radecoffset=center.separation(position)returnAngle(offset,unit="deg")@propertydefoffset_from_median(self):"""Event offset from the median position (`~astropy.coordinates.Angle`)."""position=self.radeccenter=self.galactic_medianoffset=center.separation(position)returnAngle(offset,unit="deg")
[docs]defselect_offset(self,offset_band):"""Select events in offset band. Parameters ---------- offset_band : `~astropy.coordinates.Angle` offset band ``[offset_min, offset_max)`` Returns ------- event_list : `EventList` Copy of event list with selection applied. Examples -------- >>> from gammapy.data import EventList >>> import astropy.units as u >>> filename = "$GAMMAPY_DATA/cta-1dc/data/baseline/gps/gps_baseline_110380.fits" >>> events = EventList.read(filename) >>> selected_events = events.select_offset([0.3, 0.9]*u.deg) >>> len(selected_events.table) 12688 """offset=self.offsetmask=offset_band[0]<=offsetmask&=offset<offset_band[1]returnself.select_row_subset(mask)
[docs]defselect_rad_max(self,rad_max,position=None):"""Select energy dependent offset Parameters ---------- rad_max : `~gamapy.irf.RadMax2D` Rad max definition position : `~astropy.coordinates.SkyCoord` Center position. By default the pointing position is used. Returns ------- event_list : `EventList` Copy of event list with selection applied. """ifpositionisNone:position=self.pointing_radecoffset=position.separation(self.pointing_radec)separation=position.separation(self.radec)rad_max_for_events=rad_max.evaluate(method="nearest",energy=self.energy,offset=offset)selected=separation<=rad_max_for_eventsreturnself.select_row_subset(selected)
@propertydefis_pointed_observation(self):"""Whether observation is pointed"""return"RA_PNT"inself.table.meta
[docs]defpeek(self,allsky=False):"""Quick look plots. Parameters ---------- allsky : bool Whether to look at the events allsky """importmatplotlib.gridspecasgridspecifallsky:gs=gridspec.GridSpec(nrows=2,ncols=2)fig=plt.figure(figsize=(8,8))else:gs=gridspec.GridSpec(nrows=2,ncols=3)fig=plt.figure(figsize=(12,8))# energy plotax_energy=fig.add_subplot(gs[1,0])self.plot_energy(ax=ax_energy)# offset plotsifnotallsky:ax_offset=fig.add_subplot(gs[0,1])self.plot_offset2_distribution(ax=ax_offset)ax_energy_offset=fig.add_subplot(gs[0,2])self.plot_energy_offset(ax=ax_energy_offset)# time plotax_time=fig.add_subplot(gs[1,1])self.plot_time(ax=ax_time)# image plotm=self._counts_image(allsky=allsky)ifallsky:ax_image=fig.add_subplot(gs[0,:],projection=m.geom.wcs)else:ax_image=fig.add_subplot(gs[0,0],projection=m.geom.wcs)m.plot(ax=ax_image,stretch="sqrt",vmin=0)plt.subplots_adjust(wspace=0.3)
[docs]defplot_image(self,ax=None,allsky=False):"""Quick look counts map sky plot. Parameters ---------- ax : `~matplotlib.pyplot.Axes` Axes to plot on. allsky : bool, Whether to plot on an all sky geom """ifaxisNone:ax=plt.gca()m=self._counts_image(allsky=allsky)m.plot(ax=ax,stretch="sqrt")
[docs]defcopy(self):"""Copy event list (`EventList`)"""returncopy.deepcopy(self)
classEventListChecker(Checker):"""Event list checker. Data format specification: ref:`gadf:iact-events` Parameters ---------- event_list : `~gammapy.data.EventList` Event list """CHECKS={"meta":"check_meta","columns":"check_columns","times":"check_times","coordinates_galactic":"check_coordinates_galactic","coordinates_altaz":"check_coordinates_altaz",}accuracy={"angle":Angle("1 arcsec"),"time":u.Quantity(1,"microsecond")}# https://gamma-astro-data-formats.readthedocs.io/en/latest/events/events.html#mandatory-header-keywords # noqa: E501meta_required=["HDUCLASS","HDUDOC","HDUVERS","HDUCLAS1","OBS_ID","TSTART","TSTOP","ONTIME","LIVETIME","DEADC","RA_PNT","DEC_PNT",# TODO: what to do about these?# They are currently listed as required in the spec,# but I think we should just require ICRS and those# are irrelevant, should not be used.# 'RADECSYS',# 'EQUINOX',"ORIGIN","TELESCOP","INSTRUME","CREATOR",# https://gamma-astro-data-formats.readthedocs.io/en/latest/general/time.html#time-formats # noqa: E501"MJDREFI","MJDREFF","TIMEUNIT","TIMESYS","TIMEREF",# https://gamma-astro-data-formats.readthedocs.io/en/latest/general/coordinates.html#coords-location # noqa: E501"GEOLON","GEOLAT","ALTITUDE",]_col=collections.namedtuple("col",["name","unit"])columns_required=[_col(name="EVENT_ID",unit=""),_col(name="TIME",unit="s"),_col(name="RA",unit="deg"),_col(name="DEC",unit="deg"),_col(name="ENERGY",unit="TeV"),]def__init__(self,event_list):self.event_list=event_listdef_record(self,level="info",msg=None):obs_id=self.event_list.table.meta["OBS_ID"]return{"level":level,"obs_id":obs_id,"msg":msg}defcheck_meta(self):meta_missing=sorted(set(self.meta_required)-set(self.event_list.table.meta))ifmeta_missing:yieldself._record(level="error",msg=f"Missing meta keys: {meta_missing!r}")defcheck_columns(self):t=self.event_list.tableiflen(t)==0:yieldself._record(level="error",msg="Events table has zero rows")forname,unitinself.columns_required:ifnamenotint.colnames:yieldself._record(level="error",msg=f"Missing table column: {name!r}")else:ifu.Unit(unit)!=(t[name].unitor""):yieldself._record(level="error",msg=f"Invalid unit for column: {name!r}")defcheck_times(self):dt=(self.event_list.time-self.event_list.observation_time_start).secifdt.min()<self.accuracy["time"].to_value("s"):yieldself._record(level="error",msg="Event times before obs start time")dt=(self.event_list.time-self.event_list.observation_time_stop).secifdt.max()>self.accuracy["time"].to_value("s"):yieldself._record(level="error",msg="Event times after the obs end time")ifnp.min(np.diff(dt))<=0:yieldself._record(level="error",msg="Events are not time-ordered.")defcheck_coordinates_galactic(self):"""Check if RA / DEC matches GLON / GLAT."""t=self.event_list.tableif"GLON"notint.colnames:returngalactic=SkyCoord(t["GLON"],t["GLAT"],unit="deg",frame="galactic")separation=self.event_list.radec.separation(galactic).to("arcsec")ifseparation.max()>self.accuracy["angle"]:yieldself._record(level="error",msg="GLON / GLAT not consistent with RA / DEC")defcheck_coordinates_altaz(self):"""Check if ALT / AZ matches RA / DEC."""t=self.event_list.tableif"AZ"notint.colnames:returnaltaz_astropy=self.event_list.altazseparation=angular_separation(altaz_astropy.data.lon,altaz_astropy.data.lat,t["AZ"].quantity,t["ALT"].quantity,)ifseparation.max()>self.accuracy["angle"]:yieldself._record(level="error",msg="ALT / AZ not consistent with RA / DEC")