ctools  2.0.0
 All Classes Namespaces Files Functions Variables Macros Pages
csresspec.py
Go to the documentation of this file.
1 #! /usr/bin/env python
2 # ==========================================================================
3 # Generates a residual spectrum.
4 #
5 # Copyright (C) 2017-2022 Luigi Tibaldo
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 #
20 # ==========================================================================
21 import sys
22 import gammalib
23 import ctools
24 from cscripts import obsutils
25 
26 
27 # =============== #
28 # csresspec class #
29 # =============== #
30 class csresspec(ctools.csobservation):
31  """
32  Generates a residual spectrum
33  """
34  # Constructor
35  def __init__(self, *argv):
36  """
37  Constructor
38  """
39  # Initialise application by calling the appropriate class constructor
40  self._init_csobservation(self.__class__.__name__, ctools.__version__,
41  argv)
42 
43  # Initialise class members
44  self._use_maps = False
45  self._stack = False
46  self._mask = False
47  self._fits = gammalib.GFits()
48 
49  # Return
50  return
51 
52  # Private methods
53  def _get_parameters(self):
54  """
55  Get parameters from parfile and setup the observation
56  """
57  # Setup observations (require response and allow event list as well as
58  # counts cube)
59  self._setup_observations(self.obs(), True, True, True)
60 
61  # Set observation statistic
62  self._set_obs_statistic(gammalib.toupper(self['statistic'].string()))
63 
64  # Collect number of unbinned, binned and On/Off observations in
65  # observation container
66  n_unbinned = 0
67  n_binned = 0
68  n_onoff = 0
69  for obs in self.obs():
70  if obs.classname() == 'GCTAObservation':
71  if obs.eventtype() == 'CountsCube':
72  n_binned += 1
73  else:
74  n_unbinned += 1
75  elif obs.classname() == 'GCTAOnOffObservation':
76  n_onoff += 1
77  n_cta = n_unbinned + n_binned + n_onoff
78  n_other = self.obs().size() - n_cta
79 
80  # Query whether to compute model for individual components
81  components = self['components'].boolean()
82 
83  # If there is only one binned observation and no model for individual
84  # components is required, query for precomputed model file and set
85  # use_maps to True
86  if self.obs().size() == 1 and n_binned == 1 and not components:
87  self._use_maps = self['modcube'].is_valid()
88 
89  # If there are unbinned observations query the energy binning parameters
90  if n_unbinned != 0:
91  self['ebinalg'].string()
92  if self['ebinalg'].string() == 'FILE':
93  self['ebinfile'].filename()
94  else:
95  self['emin'].real()
96  self['emax'].real()
97  self['enumbins'].integer()
98  if self['ebinalg'].string() == 'POW':
99  self['ebingamma'].real()
100  if n_cta > n_unbinned:
101  n_notunbin = n_cta - n_unbinned
102 
103  # If there is more than one observation, and observations are all
104  # unbinned or all onoff query user to know if they wish stacked results
105  if self.obs().size() > 1 and \
106  (n_unbinned == self.obs().size() or n_onoff == self.obs().size()):
107  self._stack = self['stack'].boolean()
108  # If we are to stack event lists query parameters for cube creation
109  if self._stack and n_unbinned == self.obs().size():
110  self['coordsys'].string()
111  self['proj'].string()
112  self['xref'].real()
113  self['yref'].real()
114  self['nxpix'].integer()
115  self['nypix'].integer()
116  self['binsz'].real()
117 
118  # If we are not using a precomputed model and no models are available
119  # in the observation container query input XML model file
120  if not self._use_maps and self.obs().models().size() == 0:
121  self.obs().models(self['inmodel'].filename())
122 
123  # Unless all observations are On/Off query for mask definition
124  if n_onoff == n_cta:
125  pass
126  else:
127  self._mask = self['mask'].boolean()
128  if self._mask:
129  self['ra'].real()
130  self['dec'].real()
131  self['rad'].real()
132  self['regfile'].query()
133 
134  # Unless all observations are On/Off, or we are using precomputed model
135  # maps query whether to use energy dispersion
136  if n_onoff == n_cta or self._use_maps:
137  pass
138  else:
139  self['edisp'].boolean()
140 
141  # Query algorithm for residual computation
142  self['algorithm'].string()
143 
144  # Read ahead output parameters
145  if self._read_ahead():
146  self['outfile'].filename()
147 
148  # Write input parameters into logger
149  self._log_parameters(gammalib.TERSE)
150 
151  # Write header for observation census
152  self._log_header1(gammalib.TERSE, 'Observation census')
153 
154  # Log census of input observations
155  self._log_value(gammalib.NORMAL, 'Unbinned CTA observations', n_unbinned)
156  self._log_value(gammalib.NORMAL, 'Binned CTA observations', n_binned)
157  self._log_value(gammalib.NORMAL, 'On/off CTA observations', n_onoff)
158  self._log_value(gammalib.NORMAL, 'Other observations', n_other)
159  if n_other > 0:
160  msg = 'WARNING: Only CTA observation can be handled, all non-CTA ' \
161  + 'observations will be ignored.'
162  self._log_string(gammalib.TERSE, msg)
163 
164  # Log for unbinned observations
165  if n_unbinned != 0:
166  msg = ' User defined energy binning will be used for %d unbinned ' \
167  'observations.' % (n_unbinned)
168  self._log_string(gammalib.TERSE, msg)
169  if n_cta > n_unbinned:
170  msg = ' The intrinsic binning will be used for the remaining ' \
171  '%d CTA observations.' % (n_notunbin)
172  self._log_string(gammalib.TERSE, msg)
173 
174  # Signal how energy dispersion is applied
175  if n_onoff == n_cta or self._use_maps:
176  msg = ' Energy dispersion is applied based on the input data/model ' \
177  + 'and not according to the edisp parameter'
178  self._log_string(gammalib.TERSE, msg)
179 
180  # Return
181  return
182 
183  def _bin_evlist(self, obs):
184  """
185  Turn single event list into counts cube
186 
187  Parameters
188  ----------
189  obs : `~gammalib.GObservations`
190  Observation container with single event list
191 
192  Returns
193  -------
194  obs, info : `~gammalib.GObservations`, dict
195  Binned observation container and dictionary with event list ROI
196  and energy range information
197  """
198  # Retrieve information about ROI in event list
199  roi = obs[0].roi()
200  ra = roi.centre().dir().ra_deg()
201  dec = roi.centre().dir().dec_deg()
202  rad = roi.radius()
203 
204  # We will cover the whole ROI with 0.02 deg binning
205  npix = int(2.0 * rad / 0.02) + 1
206 
207  # Log binning of events
208  self._log_string(gammalib.EXPLICIT, 'Binning events')
209 
210  # Bin events
211  cntcube = ctools.ctbin(obs)
212  cntcube['xref'] = ra
213  cntcube['yref'] = dec
214  cntcube['binsz'] = 0.02
215  cntcube['nxpix'] = npix
216  cntcube['nypix'] = npix
217  cntcube['proj'] = 'TAN'
218  cntcube['coordsys'] = 'CEL'
219  cntcube['ebinalg'] = self['ebinalg'].string()
220  if self['ebinalg'].string() == 'FILE':
221  cntcube['ebinfile'] = self['ebinfile'].filename()
222  else:
223  cntcube['enumbins'] = self['enumbins'].integer()
224  cntcube['emin'] = self['emin'].real()
225  cntcube['emax'] = self['emax'].real()
226  if self['ebinalg'].string() == 'POW':
227  cntcube['ebingamma'] = self['ebingamma'].real()
228  cntcube.run()
229 
230  # Retrieve the binned observation container
231  binned_obs = cntcube.obs().copy()
232 
233  # Check if energy boundaries provided by user extend beyond the
234  # content of the event list
235  if self['emin'].real() > obs[0].events().emin().TeV():
236  emin = 'INDEF'
237  else:
238  emin = obs[0].events().emin().TeV()
239  if self['emax'].real() < obs[0].events().emax().TeV():
240  emax = 'INDEF'
241  else:
242  emax = obs[0].events().emax().TeV()
243 
244  # Log energy range
245  self._log_value(gammalib.EXPLICIT, 'Minimum energy (TeV)', emin)
246  self._log_value(gammalib.EXPLICIT, 'Maximum energy (TeV)', emax)
247 
248  # Put ROI and E bound info in dictionary
249  info = {'was_list': True, 'roi_ra': ra, 'roi_dec': dec, 'roi_rad': rad,
250  'emin': emin, 'emax': emax}
251 
252  # Return new oberservation container
253  return binned_obs, info
254 
255  def _masked_cube(self, cube, ra, dec, rad, emin='INDEF', emax='INDEF',
256  regfile='NONE'):
257  """
258  Mask an event cube and returns the masked cube
259 
260  Parameters
261  ----------
262  cube : `~gammalib.GCTAEventCube`
263  Event cube
264  ra : float (str 'INDEF' for no selection on direction)
265  Right Ascension (deg)
266  dec : float (str 'INDEF' for no selection on direction)
267  Declination (deg)
268  rad : float (str 'INDEF' for no selection on direction)
269  Radius (deg)
270  emin : float (str 'INDEF' for no selection on energy)
271  Minimum energy (TeV)
272  emax : float (str 'INDEF' for no selection on energy)
273  Maximum energy (TeV)
274 
275  Returns
276  -------
277  cube : `~gammalib.GCTAEventCube`
278  Event cube
279  """
280  # Turn cube into observation container to feed to ctcubemask
281  obs = gammalib.GCTAObservation()
282  obs.events(cube)
283  obs_cont = gammalib.GObservations()
284  obs_cont.append(obs)
285 
286  # Use ctcubemask to mask event cube pixels
287  cubemask = ctools.ctcubemask(obs_cont)
288  cubemask['ra'] = ra
289  cubemask['dec'] = dec
290  cubemask['rad'] = rad
291  cubemask['emin'] = emin
292  cubemask['emax'] = emax
293  cubemask['regfile'] = regfile
294  cubemask.run()
295 
296  # Extract copy of cube from observation container (copy is needed to
297  # avoid memory leaks in SWIG)
298  cube = cubemask.obs()[0].events().copy()
299 
300  # Return cube
301  return cube
302 
303  def _cube_to_spectrum(self, cube, evlist_info):
304  """
305  Derive from event cube a count spectrum
306 
307  If data come from event list use only the ROI and energy range of
308  the original data. Apply user defined mask if requested.
309 
310  Parameters
311  ----------
312  cube : `~gammalib.GCTAEventCube`
313  Event cube
314  evlist_info : dict
315  Dictionary with information on original event list
316 
317  Returns
318  -------
319  array : `~gammalib.GNdarray'
320  Counts spectrum
321  """
322  # If we started from event list mask the ROI only
323  # for residual computation
324  if evlist_info['was_list']:
325  msg = 'Masking ROI from original event list'
326  self._log_string(gammalib.EXPLICIT, msg)
327  cube = self._masked_cube(cube, evlist_info['roi_ra'],
328  evlist_info['roi_dec'],
329  evlist_info['roi_rad'],
330  emin=evlist_info['emin'],
331  emax=evlist_info['emax'])
332 
333  # Apply user mask
334  if self._mask:
335  if self['regfile'].is_valid():
336  regfile = self['regfile']
337  else:
338  regfile = 'NONE'
339  msg = 'Masking ROI requested by user'
340  self._log_string(gammalib.EXPLICIT, msg)
341  cube = self._masked_cube(cube, self['ra'], self['dec'],
342  self['rad'],
343  regfile=regfile)
344 
345  # Extract skymap and clip at 0 to null masked areas
346  counts = cube.counts().copy()
347  counts = counts.clip(0.)
348 
349  # Convert skymap into GNdarray count spectrum
350  counts = counts.counts()
351 
352  # Return
353  return counts
354 
355  def _residuals_table(self, obs_id, ebounds, counts, model, residuals):
356  """
357  Create a Fits Table and store counts, model, and residuals
358 
359  Parameters
360  ----------
361  obs_id : str
362  Observation id
363  ebounds : `~gammalib.GEbounds'
364  Energy boundaries
365  counts : `~gammalib.GNdarray'
366  Counts spectrum
367  model : `~gammalib.GNdarray'
368  Model spectrum
369  residuals : `~gammalib.GNdarray'
370  Residual spectrum
371 
372  Returns
373  -------
374  table : `~gammalib.GFitsBinTable'
375  Residual spectrum as FITS binary table
376  """
377  # Create FITS table columns
378  nrows = ebounds.size()
379  energy_low = gammalib.GFitsTableDoubleCol('Emin', nrows)
380  energy_high = gammalib.GFitsTableDoubleCol('Emax', nrows)
381  counts_col = gammalib.GFitsTableDoubleCol('Counts', nrows)
382  model_col = gammalib.GFitsTableDoubleCol('Model', nrows)
383  resid_col = gammalib.GFitsTableDoubleCol('Residuals', nrows)
384  energy_low.unit('TeV')
385  energy_high.unit('TeV')
386 
387  # Fill FITS table columns
388  for i in range(nrows):
389  energy_low[i] = ebounds.emin(i).TeV()
390  energy_high[i] = ebounds.emax(i).TeV()
391  counts_col[i] = counts[i]
392  model_col[i] = model[i]
393  resid_col[i] = residuals[i]
394 
395  # Initialise FITS Table with extension set to obs id
396  table = gammalib.GFitsBinTable(nrows)
397  table.extname('RESIDUALS' + obs_id)
398 
399  # Add Header card to specify algorithm used for residual computation
400  table.card('ALGORITHM', self['algorithm'].string(),
401  'Algorithm used for computation of residuals')
402 
403  # Append filled columns to fits table
404  table.append(energy_low)
405  table.append(energy_high)
406  table.append(counts_col)
407  table.append(model_col)
408  table.append(resid_col)
409 
410  # Return binary table
411  return table
412 
413  def _append_column(self, table, name, data):
414  """
415  Append optional column to residual table
416 
417  Parameters
418  ----------
419  table : `~gammalib.GFitsBinTable'
420  FITS binary table
421  name : str
422  Column name
423  data : float
424  Data to be filled into new column
425 
426  Returns
427  -------
428  table : `~gammalib.GFitsBinTable'
429  FITS binary table
430  """
431  # If size is incompatible then throw an error
432  if table.nrows() != data.size():
433  msg = 'csresspec._append_column: FITS table and data have ' \
434  'incompatible size.'
435  raise RuntimeError(msg)
436 
437  # Create column
438  column = gammalib.GFitsTableDoubleCol(name, table.nrows())
439 
440  # Fill data
441  for i, value in enumerate(data):
442  column[i] = value
443 
444  # Append new column to table
445  table.append(column)
446 
447  # Return modified table
448  return table
449 
450  def _results2table(self, result):
451  """
452  Turn results into FITS table
453 
454  Parameters
455  ----------
456  result : dict
457  Result dictionary
458 
459  Returns
460  -------
461  table : `~gammalib.GFitsBinTable`
462  FITS binary table
463  """
464  # Log action
465  msg = 'Filling residual table'
466  self._log_string(gammalib.NORMAL, msg)
467 
468  # Fill results table
469  table = self._residuals_table(result['obs_id'],
470  result['ebounds'],
471  result['counts_on'],
472  result['model'],
473  result['residuals_on'])
474 
475  # Optionally add Off spectrum to table
476  if 'counts_off' in result:
477  table = self._append_column(table, 'Counts_Off', result['counts_off'])
478 
479  # Optionally add background/Off model to table
480  if 'background' in result:
481  table = self._append_column(table, 'Model_Off', result['background'])
482 
483  # Optionally add Off residuals to table
484  if 'residuals_off' in result:
485  table = self._append_column(table, 'Residuals_Off', result['residuals_off'])
486 
487  # Add components
488  for component in result:
489  if 'component_' in component:
490  colname = component[10:]
491  table = self._append_column(table, colname, result[component])
492 
493  # Return FITS table
494  return table
495 
496  def _stack_results(self, results):
497  """
498  Stack results
499 
500  Parameters
501  ----------
502  results : list of dict
503  Residual spectra results
504 
505  Returns
506  -------
507  results : list of dict
508  Stacked result
509  """
510  # Loop over results
511  for i, result in enumerate(results):
512 
513  # Copy results for first iteration
514  if i == 0:
515  stacked_result = result.copy()
516 
517  # ... otherwise add results
518  else:
519  stacked_result['obs_id'] = ''
520  stacked_result['counts_on'] += result['counts_on']
521  stacked_result['model'] += result['model']
522  stacked_result['residuals_on'] += result['residuals_on']
523  if 'counts_off' in result:
524  stacked_result['counts_off'] += result['counts_off']
525  if 'background' in result:
526  stacked_result['background'] += result['background']
527  if 'residuals_off' in result:
528  stacked_result['residuals_off'] += result['residuals_off']
529  for component in result:
530  if 'component_' in component:
531  stacked_result[component] += result[component]
532 
533  # Create list of stacked results
534  results = [stacked_result]
535 
536  # Return stacked results
537  return results
538 
539  def _residuals_3D(self, obs, models, obs_id, ccube=None):
540  """
541  Calculate residuals for 3D observation
542 
543  Parameters
544  ----------
545  obs : `~gammalib.GCTAObservation`
546  CTA observation
547  models : `~gammalib.GModels`
548  Models
549  obs_id : str
550  Observation ID
551  ccube : `~gammalib.GCTAEventCube', optional
552  Count cube with stacked events lists
553 
554  Returns
555  -------
556  result : dict
557  Residual result dictionary
558  """
559  # Create observation container with observation
560  obs_container = gammalib.GObservations()
561  obs_container.append(obs)
562  obs_container.models(models)
563 
564  # If binned data already exist set the evlist_info dictionary to have
565  # attribute was_list False
566  if obs.eventtype() == 'CountsCube' or ccube is not None:
567  evlist_info = {'was_list': False}
568 
569  # ... otherwise bin now
570  else:
571  # we remember if we binned an event list so that we can
572  # mask only the ROI for residual calculation
573  msg = 'Setting up binned observation'
574  self._log_string(gammalib.NORMAL, msg)
575  obs_container, evlist_info = self._bin_evlist(obs_container)
576 
577  # Calculate Model and residuals. If model cube is provided load
578  # it
579  if self._use_maps:
580  modcube = gammalib.GCTAEventCube(self['modcube'].filename())
581 
582  # ... otherwise calculate it now
583  else:
584  msg = 'Computing model cube'
585  self._log_string(gammalib.NORMAL, msg)
586  modelcube = ctools.ctmodel(obs_container)
587  if ccube is not None:
588  modelcube.cube(ccube)
589  modelcube['edisp'] = self['edisp'].boolean()
590  modelcube.run()
591  modcube = modelcube.cube().copy()
592 
593  # Extract cntcube for residual computation
594  if ccube is not None:
595  cntcube = ccube
596  else:
597  cntcube = obs_container[0].events().copy()
598 
599  # Derive count spectra from cubes
600  msg = 'Computing counts, model, and residual spectra'
601  self._log_string(gammalib.NORMAL, msg)
602  counts = self._cube_to_spectrum(cntcube, evlist_info)
603  model = self._cube_to_spectrum(modcube, evlist_info)
604 
605  # Calculate residuals
606  residuals = obsutils.residuals(self, counts, model)
607 
608  # Extract energy bounds
609  ebounds = cntcube.ebounds().copy()
610 
611  # Set result dictionary
612  result = {'obs_id': obs_id,
613  'ebounds': ebounds,
614  'counts_on': counts,
615  'model': model,
616  'residuals_on': residuals}
617 
618  # Calculate models of individual components if requested
619  if self['components'].boolean():
620 
621  # Loop over components
622  for component in models:
623 
624  # Log action
625  self._log_value(gammalib.NORMAL,
626  'Computing model component', component.name())
627 
628  # Set model cube models to individual component
629  model_cont = gammalib.GModels()
630  model_cont.append(component)
631  modelcube.obs().models(model_cont)
632 
633  # Reset base cube that was modified internally by ctmodel
634  if ccube is not None:
635  modelcube.cube(ccube)
636 
637  # Run model cube
638  modelcube['edisp'] = self['edisp'].boolean()
639  modelcube.run()
640 
641  # Extract spectrum of individual component
642  modcube = modelcube.cube().copy()
643  model = self._cube_to_spectrum(modcube, evlist_info)
644 
645  # Append to results
646  result['component_%s' % component.name()] = model
647 
648  # Return result
649  return result
650 
651  def _residuals_OnOff(self, obs, models, obs_id):
652  """
653  Calculate residual for OnOff observation
654 
655  Parameters
656  ----------
657  obs : `~gammalib.GOnOffObservation`
658  OnOff observation
659  models : `~gammalib.GModels`
660  Models
661  obs_id : str
662  Observation ID
663 
664  Returns
665  -------
666  result : dict
667  Residual result dictionary
668  """
669  # Log action
670  msg = 'Computing counts, model, and residual spectra'
671  self._log_string(gammalib.NORMAL, msg)
672 
673  # On spectrum
674  counts = obs.on_spec().counts_spectrum()
675 
676  # Model for On region
677  background = obs.model_background(models).counts_spectrum()
678  alpha = obs.on_spec().backscal_spectrum()
679  model = background.copy()
680  model *= alpha
681  model += obs.model_gamma(models).counts_spectrum()
682 
683  # On Residuals
684  residuals = obsutils.residuals(self, counts, model)
685 
686  # Extract energy bounds
687  ebounds = obs.on_spec().ebounds()
688 
689  # Get Off spectrum and add to table
690  msg = 'Computing counts, model, and residual spectra for Off regions'
691  self._log_string(gammalib.NORMAL, msg)
692  counts_off = obs.off_spec().counts_spectrum()
693 
694  # Calculate Off residuals and add to table
695  residuals_off = obsutils.residuals(self, counts_off, background)
696 
697  # Set result dictionary
698  result = {'obs_id': obs_id,
699  'ebounds': ebounds,
700  'counts_on': counts,
701  'model': model,
702  'residuals_on': residuals,
703  'counts_off': counts_off,
704  'background': background,
705  'residuals_off': residuals_off}
706 
707  # Calculate models of individual components if requested
708  if self['components'].boolean():
709 
710  # Loop over model components
711  for component in models:
712 
713  # If the component is a background model then pass. We always
714  # add the background at the end so that we accommodate WSTAT
715  # for which the background is not mandatory in the model
716  if component.classname() == 'GCTAModelAeffBackground' or \
717  component.classname() == 'GCTAModelBackground' or \
718  component.classname() == 'GCTAModelCubeBackground' or \
719  component.classname() == 'GCTAModelIrfBackground' or \
720  component.classname() == 'GCTAModelRadialAcceptance':
721  pass
722 
723  # ... otherwise calculate gamma component and append to Table
724  else:
725  self._log_value(gammalib.NORMAL,
726  'Computing model for component',
727  component.name())
728 
729  # Create observation container for individual components
730  model_cont = gammalib.GModels()
731  model_cont.append(component)
732 
733  # Calculate gamma model
734  model = obs.model_gamma(model_cont)
735  model = model.counts_spectrum()
736 
737  # Append to results
738  result['component_%s' % component.name()] = model
739 
740  # Add now the background that is already calculated
741  bkg = background.copy()
742  backscal = obs.on_spec().backscal_spectrum()
743  bkg *= backscal
744  result['component_Background'] = bkg
745 
746  # Return result
747  return result
748 
749  # Public methods
750  def process(self):
751  """
752  Process the script
753  """
754  # Get parameters
755  self._get_parameters()
756 
757  # Write observation into logger
758  self._log_observations(gammalib.NORMAL, self.obs(), 'Observation')
759 
760  # Log processing header
761  self._log_header1(gammalib.TERSE, 'Processing Observations')
762 
763  # Initialise list of results
764  results = []
765 
766  # Loop over observations and calculate residuals
767  for i, obs in enumerate(self.obs()):
768 
769  # Retrieve and store obs id
770  obs_id = obs.id()
771 
772  # If observation id is empty and there is more than one observation
773  # replace with incremental number
774  if obs_id == '' and self.obs().size() > 1:
775  obs_id = '%6.6d' % i
776 
777  # Log processing of observation
778  if self.obs().size() > 1:
779  self._log_header2(gammalib.NORMAL,
780  'Processing observation %s' % obs_id)
781 
782  # If 3D observation
783  if obs.classname() == 'GCTAObservation':
784  result = self._residuals_3D(obs, self.obs().models(), obs_id)
785 
786  # otherwise, if On/Off
787  elif obs.classname() == 'GCTAOnOffObservation':
788  result = self._residuals_OnOff(obs, self.obs().models(), obs_id)
789 
790  # Append result to list of results
791  results.append(result)
792 
793  # If no stacking is requested the append table to FITS file
794  if not self._stack:
795  table = self._results2table(results[i])
796  self._fits.append(table)
797 
798  # If stacking is requested then stack results and append table to
799  # FITS file
800  if self._stack:
801  results = self._stack_results(results)
802  table = self._results2table(results[0])
803  self._fits.append(table)
804 
805  # Stamp FITS file
806  self._stamp(self._fits)
807 
808  # Return
809  return
810 
811  def save(self):
812  """
813  Save residuals
814  """
815  # Write header
816  self._log_header1(gammalib.TERSE, 'Save residuals')
817 
818  # Get outfile parameter
819  outfile = self['outfile'].filename()
820 
821  # Log file name
822  self._log_value(gammalib.NORMAL, 'Residuals file', outfile.url())
823 
824  # Save residuals
825  self._fits.saveto(outfile, self['clobber'].boolean())
826 
827  # Return
828  return
829 
830  def resspec(self):
831  """
832  Return residual FITS file
833 
834  Returns
835  -------
836  fits : `~gammalib.GFits'
837  FITS file containing residuals
838  """
839  # Return
840  return self._fits
841 
842 
843 # ======================== #
844 # Main routine entry point #
845 # ======================== #
846 if __name__ == '__main__':
847 
848  # Create instance of application
849  app = csresspec(sys.argv)
850 
851  # Execute application
852  app.execute()