ctools  2.0.0
 All Classes Namespaces Files Functions Variables Macros Pages
cssens.py
Go to the documentation of this file.
1 #! /usr/bin/env python
2 # ==========================================================================
3 # Computes the array sensitivity using the Test Statistic for a test source
4 #
5 # Copyright (C) 2011-2022 Juergen Knoedlseder
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 math
23 import gammalib
24 import ctools
25 from cscripts import obsutils
26 from cscripts import modutils
27 from cscripts import mputils
28 
29 
30 # ============ #
31 # cssens class #
32 # ============ #
33 class cssens(ctools.csobservation):
34  """
35  Computes the CTA sensitivity
36 
37  This class computes the CTA sensitivity for a number of energy bins using
38  ctlike. Spectra are fitted in narrow energy bins to simulated data,
39  and the flux level is determined that leads to a particular significance.
40 
41  The significance is determined using the Test Statistic, defined as twice
42  the likelihood difference between fitting with and without the test source.
43  """
44 
45  # Constructor
46  def __init__(self, *argv):
47  """
48  Constructor
49  """
50  # Initialise application by calling the appropriate class constructor
51  self._init_csobservation(self.__class__.__name__, ctools.__version__, argv)
52 
53  # Initialise class members
54  self._ebounds = gammalib.GEbounds()
55  self._obs_ebounds = []
56  self._fits = None
57  self._srcname = ''
58  self._ra = None
59  self._dec = None
60  self._log_clients = False
61  self._models = gammalib.GModels()
62  self._seed = 1
63  self._nthreads = 0
64 
65  # Return
66  return
67 
68  # State methods por pickling
69  def __getstate__(self):
70  """
71  Extend ctools.csobservation getstate method to include some members
72 
73  Returns
74  -------
75  state : dict
76  Pickled instance
77  """
78  # Set pickled dictionary
79  state = {'base' : ctools.csobservation.__getstate__(self),
80  'ebounds' : self._ebounds,
81  'obs_ebounds' : self._obs_ebounds,
82  'fits' : self._fits,
83  'srcname' : self._srcname,
84  'ra' : self._ra,
85  'dec' : self._dec,
86  'log_clients' : self._log_clients,
87  'models' : self._models,
88  'seed' : self._seed,
89  'nthreads' : self._nthreads}
90 
91  # Return pickled dictionary
92  return state
93 
94  def __setstate__(self, state):
95  """
96  Extend ctools.csobservation setstate method to include some members
97 
98  Parameters
99  ----------
100  state : dict
101  Pickled instance
102  """
103  ctools.csobservation.__setstate__(self, state['base'])
104  self._ebounds = state['ebounds']
105  self._obs_ebounds = state['obs_ebounds']
106  self._fits = state['fits']
107  self._srcname = state['srcname']
108  self._ra = state['ra']
109  self._dec = state['dec']
110  self._log_clients = state['log_clients']
111  self._models = state['models']
112  self._seed = state['seed']
113  self._nthreads = state['nthreads']
114 
115  # Return
116  return
117 
118 
119  # Private methods
120  def _get_parameters(self):
121  """
122  Get user parameters from parfile
123  """
124  # Set observation if not done before
125  if self.obs().size() == 0:
126  self.obs(self._set_obs(self['emin'].real(), self['emax'].real()))
127 
128  # Set observation statistic
129  self._set_obs_statistic(gammalib.toupper(self['statistic'].string()))
130 
131  # Set models if we have none
132  if self.obs().models().size() == 0:
133  self.obs().models(self['inmodel'].filename())
134 
135  # Set source name and position
136  self._set_source()
137 
138  # Read further parameters
139  emin = self['emin'].real()
140  emax = self['emax'].real()
141  bins = self['bins'].integer()
142 
143  # Query parameters for binned if requested
144  enumbins = self['enumbins'].integer()
145  if not enumbins == 0:
146  self['npix'].integer()
147  self['binsz'].real()
148 
149  # Query input parameters
150  self['sigma'].real()
151  self['max_iter'].integer()
152  self['type'].string()
153  self['edisp'].boolean()
154  self['debug'].boolean()
155  self['mincounts'].integer()
156 
157  # Read seed
158  self._seed = self['seed'].integer()
159 
160  # Derive some parameters
161  self._ebounds = gammalib.GEbounds(bins,
162  gammalib.GEnergy(emin, 'TeV'),
163  gammalib.GEnergy(emax, 'TeV'))
164 
165  # Read ahead output parameters
166  if self._read_ahead():
167  self['outfile'].filename()
168 
169  # Write input parameters into logger
170  self._log_parameters(gammalib.TERSE)
171 
172  # Set number of processes for multiprocessing
173  self._nthreads = mputils.nthreads(self)
174 
175  # Return
176  return
177 
178  def _median(self, array):
179  """
180  Compute median value for an array
181 
182  Parameters
183  ----------
184  array : list of floats
185  Array
186 
187  Returns
188  -------
189  median : float
190  Median value of array
191  """
192  # Initialise median
193  median = 0.0
194 
195  # Get number of elements in array
196  n = len(array)
197 
198  # Continue only if there are elements in the array
199  if n > 0:
200 
201  # Sort the array
202  array.sort()
203 
204  # Get median
205  if n % 2 != 0:
206  median = array[int(n/2)]
207  else:
208  median = (array[int((n-1)/2)] + array[int(n/2)]) / 2.0
209 
210  # Return median
211  return median
212 
213  def _set_source(self):
214  """
215  Set source name and position
216  """
217  # Set source name
218  self._srcname = self['srcname'].string()
219 
220  # Set source position. If the test source has no position then set the
221  # source position to (RA,Dec)=(0,0)
222  source = self.obs().models()[self._srcname]
223  if source.has_par('RA') and source.has_par('DEC'):
224  self._ra = source['RA'].value()
225  self._dec = source['DEC'].value()
226  elif source.has_par('GLON') and source.has_par('GLAT'):
227  glon = source['GLON'].value()
228  glat = source['GLAT'].value()
229  srcdir = gammalib.GSkyDir()
230  srcdir.lb_deg(glon, glat)
231  self._ra = srcdir.ra_deg()
232  self._dec = srcdir.dec_deg()
233  else:
234  self._ra = 0.0
235  self._dec = 0.0
236 
237  # Return
238  return
239 
240  def _set_obs(self, emin, emax):
241  """
242  Set an observation container
243 
244  Parameters
245  ----------
246  emin : float
247  Minimum energy (TeV)
248  emax : float
249  Maximum energy (TeV)
250 
251  Returns
252  -------
253  obs : `~gammalib.GObservations`
254  Observation container
255  """
256  # If an observation was provided on input then load it from XML file
257  if self['inobs'].is_valid():
258  obs = self._get_observations()
259 
260  # ... otherwise allocate a single observation using the test source
261  # position as pointing direction, optionally offset by a certain
262  # amount
263  else:
264 
265  # Load models
266  models = gammalib.GModels(self['inmodel'].filename())
267 
268  # Get test source
269  source = models[self['srcname'].string()]
270 
271  # Set pointing direction to test source position. If test source
272  # has no position then set the pointing to (RA,Dec)=(0,0)
273  pntdir = gammalib.GSkyDir()
274  if source.has_par('RA') and source.has_par('DEC'):
275  pntdir.radec_deg(source['RA'].value(), source['DEC'].value())
276  elif source.has_par('GLON') and source.has_par('GLAT'):
277  pntdir.lb_deg(source['GLON'].value(), source['GLAT'].value())
278  else:
279  pntdir.radec_deg(0.0, 0.0)
280 
281  # Read other relevant user parameters
282  instrument = self['instrument'].string()
283  caldb = self['caldb'].string()
284  irf = self['irf'].string()
285  deadc = self['deadc'].real()
286  duration = self['duration'].real()
287  rad = self['rad'].real()
288  offset = self['offset'].real()
289 
290  # Add offset to pointing direction
291  pntdir.rotate_deg(0.0, offset)
292 
293  # Allocate observation container
294  obs = gammalib.GObservations()
295 
296  # Create CTA observation
297  run = obsutils.set_obs(pntdir, instrument=instrument, caldb=caldb, irf=irf,
298  duration=duration, deadc=deadc,
299  emin=emin, emax=emax, rad=rad)
300 
301  # Append observation to container
302  obs.append(run)
303 
304  # Return observation container
305  return obs
306 
307  def _set_obs_ebounds(self, emin, emax):
308  """
309  Set energy boundaries for all observations in container
310 
311  Parameters
312  ----------
313  emin : `~gammalib.GEnergy`
314  Minimum energy
315  emax : `~gammalib.GEnergy`
316  Maximum energy
317  """
318  # Loop over all observations in container
319  for i, obs in enumerate(self.obs()):
320 
321  # Get energy boundaries of the observation
322  obs_ebounds = self._obs_ebounds[i]
323 
324  # Get minimum and maximum energy of the observation
325  obs_emin = obs_ebounds.emin()
326  obs_emax = obs_ebounds.emax()
327 
328  # If [emin,emax] is fully contained in the observation energy range
329  # the use [emin,emax] as energy boundaries
330  if obs_emin <= emin and obs_emax >= emax:
331  ebounds = gammalib.GEbounds(emin, emax)
332 
333  # ... otherwise, if [emin,emax] is completely outside the
334  # observation energy range then set the energy boundaries to the
335  # zero-width interval [0,0]
336  elif emax < obs_emin or emin > obs_emax:
337  e0 = gammalib.GEnergy(0.0, 'TeV')
338  ebounds = gammalib.GEbounds(e0, e0)
339 
340  # ... otherwise, if [emin,emax] overlaps partially with the
341  # observation energy range then set the energy boundaries to the
342  # overlapping part
343  else:
344  # Set overlapping energy range
345  set_emin = max(emin, obs_emin)
346  set_emax = min(emax, obs_emax)
347 
348  # Set energy boundaries
349  ebounds = gammalib.GEbounds(set_emin, set_emax)
350 
351  # Set the energy boundaries as the boundaries of the observation
352  obs.events().ebounds(ebounds)
353 
354  # Return
355  return
356 
357  def _get_crab_flux(self, emin, emax):
358  """
359  Return Crab photon flux in a given energy interval
360 
361  Parameters
362  ----------
363  emin : `~gammalib.GEnergy`
364  Minimum energy
365  emax : `~gammalib.GEnergy`
366  Maximum energy
367 
368  Returns
369  -------
370  flux : float
371  Crab photon flux in specified energy interval (ph/cm2/s)
372  """
373  # Set Crab TeV spectral model based on a power law
374  crab = gammalib.GModelSpectralPlaw(5.7e-16, -2.48,
375  gammalib.GEnergy(0.3, 'TeV'))
376 
377  # Determine photon flux
378  flux = crab.flux(emin, emax)
379 
380  # Return photon flux
381  return flux
382 
383  def _get_sensitivity(self, emin, emax, test_model):
384  """
385  Determine sensitivity for given observations
386 
387  Parameters
388  ----------
389  emin : `~gammalib.GEnergy`
390  Minimum energy for fitting and flux computation
391  emax : `~gammalib.GEnergy`
392  Maximum energy for fitting and flux computation
393  test_model : `~gammalib.GModels`
394  Test source model
395 
396  Returns
397  -------
398  result : dict
399  Result dictionary
400  """
401  # Set TeV->erg conversion factor
402  tev2erg = 1.6021764
403 
404  # Set parameters
405  ts_thres = self['sigma'].real() * self['sigma'].real()
406  max_iter = self['max_iter'].integer()
407  enumbins = self['enumbins'].integer()
408  if not enumbins == 0:
409  npix = self['npix'].integer()
410  binsz = self['binsz'].real()
411  else:
412  npix = 200
413  binsz = 0.05
414 
415  # Keep track of the original value of edisp and switch-off energy
416  # dispersion for the first iterations to speed-up the computations
417  edisp_orig = self['edisp'].boolean()
418  self['edisp'] = False
419 
420  # Set flux ratio precision required for convergence to 5%
421  ratio_precision = 0.05
422 
423  # Write header for energy bin
424  self._log_string(gammalib.TERSE, '')
425  self._log_header2(gammalib.TERSE, 'Energies: '+str(emin)+' - '+str(emax))
426 
427  # Set energy boundaries
428  self._set_obs_ebounds(emin, emax)
429 
430  # Determine mean energy for energy boundary
431  e_mean = math.sqrt(emin.TeV()*emax.TeV())
432  loge = math.log10(e_mean)
433  erg_mean = e_mean * tev2erg
434  energy = gammalib.GEnergy(e_mean, 'TeV')
435 
436  # If spatial model is a map cube, make sure map cube spectrum
437  # was computed already and set flag
438  mapcube = False
439  if test_model[self._srcname].spatial().classname() == 'GModelSpatialDiffuseCube':
440  mapcube = True
441  if test_model[self._srcname].spatial().spectrum().nodes() == 0:
442  test_model[self._srcname].spatial().set_mc_cone(gammalib.GSkyDir(),180.0)
443 
444  # Compute initial source flux in that bin. If spatial model is a map cube,
445  # specific calculation is needed (intensity in map scaled by spectral model)
446  if mapcube:
447  src_flux = test_model[self._srcname].spectral().eval(energy) * \
448  test_model[self._srcname].spatial().spectrum().flux(emin, emax)
449  else:
450  src_flux = test_model[self._srcname].spectral().flux(emin, emax)
451 
452  # Compute Crab unit. This is the factor with which the Prefactor needs
453  # to be multiplied to get 1 Crab.
454  crab_flux = self._get_crab_flux(emin, emax)
455  crab_unit = crab_flux/src_flux
456 
457  # Write initial parameters
458  self._log_header3(gammalib.TERSE, 'Initial parameters')
459  self._log_value(gammalib.TERSE, 'Mapcube model', str(mapcube))
460  self._log_value(gammalib.TERSE, 'Crab flux', str(crab_flux)+' ph/cm2/s')
461  self._log_value(gammalib.TERSE, 'Source model flux', str(src_flux)+' ph/cm2/s')
462  self._log_value(gammalib.TERSE, 'Crab unit factor', crab_unit)
463 
464  # Initialise regression coefficient
465  regcoeff = 0.0
466 
467  # Initialise loop
468  results = []
469  iterations = 0
470  test_crab_flux = 0.1 # Initial test flux in Crab units (100 mCrab)
471 
472  # Write header for iterations for terse chatter level
473  if self._logTerse():
474  self._log_header3(gammalib.TERSE, 'Iterations')
475 
476  # Loop until we break
477  while True:
478 
479  # Update iteration counter
480  iterations += 1
481 
482  # Increment the seed value
483  self._seed += 1
484 
485  # Write header for iteration into logger
486  self._log_header2(gammalib.EXPLICIT, 'Iteration '+str(iterations))
487 
488  # Set Crab prefactor
489  crab_prefactor = self._set_src_prefactor(test_model, crab_unit, test_crab_flux)
490 
491  # Simulate events for the models. "sim" holds an observation
492  # container with observations containing the simulated events.
493  sim = obsutils.sim(self.obs(), nbins=enumbins, seed=self._seed,
494  binsz=binsz, npix=npix,
495  log=self._log_clients,
496  debug=self['debug'].boolean(),
497  edisp=self['edisp'].boolean(),
498  nthreads=1)
499 
500  # Determine number of events in simulation
501  nevents = sim.nobserved()
502 
503  # Write simulation results into logger
504  self._log_header3(gammalib.EXPLICIT, 'Simulation')
505  self._log_value(gammalib.EXPLICIT, 'Number of simulated events', nevents)
506 
507  # Fit test source to the simulated events in the observation
508  # container
509  fit = ctools.ctlike(sim)
510  fit['edisp'] = self['edisp'].boolean()
511  fit['nthreads'] = 1 # Avoids OpenMP conflict
512  fit['debug'] = self['debug'].boolean()
513  fit['chatter'] = self['chatter'].integer()
514  fit.run()
515 
516  # Get model fitting results
517  logL = fit.opt().value()
518  npred = fit.obs().npred()
519  models = fit.obs().models()
520  source = models[self._srcname]
521  ts = source.ts()
522 
523  # Get fitted Crab flux
524  prefactor = modutils.normalisation_parameter(source)
525  crab_flux = prefactor.value() / crab_prefactor
526 
527  # Compute best-fit spectrum value at central energy
528  # That is a differential flux for non-map cube models
529  # ...and a scaling factor otherwise
530  diff_flux = source.spectral().eval(energy)
531 
532  # Compute photon and energy fluxes. If spatial model is a map cube,
533  # specific calculation is needed (flux in map scaled by spectral model)
534  if mapcube:
535  photon_flux = diff_flux*source.spatial().spectrum().flux(emin, emax)
536  energy_flux = diff_flux*source.spatial().spectrum().eflux(emin, emax)
537  else:
538  photon_flux = source.spectral().flux(emin, emax)
539  energy_flux = source.spectral().eflux(emin, emax)
540 
541  # Compute differential sensitivity in unit erg/cm2/s by evaluating
542  # the spectral model at the "e_mean" energy and by multipling the
543  # result with the energy squared. Since the "eval()" method returns
544  # an intensity in units of ph/cm2/s/MeV we multiply by 1.0e6 to
545  # convert into ph/cm2/s/TeV, by "e_mean" to convert into ph/cm2/s,
546  # and finally by "erg_mean" to convert to erg/cm2/s.
547  # If spatial model is a map cube, specific calculation is needed
548  sensitivity = diff_flux*e_mean*erg_mean*1.0e6
549  if mapcube:
550  sensitivity *= source.spatial().spectrum().eval(energy)
551 
552  # Write fit results into logger
553  name = 'Iteration %d' % iterations
554  value = ('TS=%10.4f Sim=%9.4f mCrab Fit=%9.4f mCrab '
555  'Sens=%e erg/cm2/s' %
556  (ts, test_crab_flux*1000.0, crab_flux*1000.0, sensitivity))
557  self._log_value(gammalib.TERSE, name, value)
558 
559  # If TS was non-positive then increase the test flux and start over
560  if ts <= 0.0:
561 
562  # If the number of iterations was exceeded then stop
563  if (iterations >= max_iter):
564  self._log_string(gammalib.TERSE,
565  ' Test ended after %d iterations.' % max_iter)
566  break
567 
568  # Increase test flux
569  test_crab_flux *= 3.0
570 
571  # Signal start we start over
572  self._log_string(gammalib.EXPLICIT,
573  'Non positive TS, increase test flux and start over.')
574 
575  # ... and start over
576  continue
577 
578  # Append result entry to result list
579  result = {'ts': ts, 'crab_flux': crab_flux,
580  'photon_flux': photon_flux,
581  'energy_flux': energy_flux}
582  results.append(result)
583 
584  # Predict Crab flux at threshold TS using a linear regression of
585  # the log(TS) and log(crab_flux) values that have so far been
586  # computed. If not enough results are available then use a simple
587  # TS scaling relation.
588  correct = math.sqrt(ts_thres / ts)
589  if len(results) > 1:
590  try:
591  pred_crab_flux, regcoeff = self._predict_flux(results, ts_thres)
592  correct = pred_crab_flux / crab_flux
593  except:
594  self._log_value(gammalib.TERSE, 'Skipping failed regression',
595  'Retain simple TS scaling relation')
596 
597  # Compute extrapolated fluxes based on the flux correction factor
598  crab_flux = correct * crab_flux
599  photon_flux = correct * photon_flux
600  energy_flux = correct * energy_flux
601  sensitivity = correct * sensitivity
602 
603  # If we have at least 3 results then check if the flux determination
604  # at the TS threshold has converged
605  if len(results) > 3:
606  if test_crab_flux > 0:
607 
608  # Compute fractional change in the Crab flux between two
609  # iterations
610  ratio = crab_flux/test_crab_flux
611 
612  # If fractional change is smaller than the required position
613  # the iterations are stopped
614  if ratio > 1.0-ratio_precision and \
615  ratio < 1.0+ratio_precision:
616  value = ('TS=%10.4f Sim=%9.4f mCrab '
617  ' Sens=%e erg/cm2/s' %
618  (ts, crab_flux*1000.0, sensitivity))
619  self._log_value(gammalib.TERSE, 'Converged result', value)
620  self._log_value(gammalib.TERSE, 'Converged flux ratio', ratio)
621  self._log_value(gammalib.TERSE, 'Regression coefficient',
622  regcoeff)
623 
624  # If the flux has converged then check if the original
625  # value of edisp was set, and is not what has fo far
626  # been used
627  if edisp_orig and not self['edisp'].boolean():
628 
629  # Set edisp on and continue the calculation
630  self['edisp'] = True
631 
632  # Log action
633  self._log_value(gammalib.TERSE, 'Converged result',
634  'Now use energy dispersion after '
635  'initial convergence without it')
636 
637  # ... otherwise finish the calculation after convergence
638  else:
639  break
640 
641  # ... otherwise break with a zero flux
642  else:
643  self._log_value(gammalib.TERSE, 'Not converged', 'Flux is zero')
644  break
645 
646  # Set test flux for next iteration
647  test_crab_flux = crab_flux
648 
649  # Exit loop if number of trials exhausted
650  if (iterations >= max_iter):
651  self._log_string(gammalib.TERSE,
652  ' Test ended after %d iterations.' % max_iter)
653  break
654 
655  # Write header for iterations for terse chatter level
656  if self._logTerse():
657  self._log_header3(gammalib.TERSE, 'Iterations for source counts cuts')
658 
659  # Recover original energy dispersion setting
660  self['edisp'] = edisp_orig
661 
662  # Take into account the excess event cuts
663  n_bck_evts = None
664  for iterations in range(max_iter):
665 
666  # Simulate event excess
667  pass_evt_cut, n_bck_evts = self._sim_evt_excess(enumbins=enumbins,
668  binsz=binsz,
669  npix=npix,
670  n_bck_evts=n_bck_evts)
671 
672  # Write results into logger
673  name = 'Iteration %d' % iterations
674  value = 'Fit=%9.4f mCrab Sens=%e erg/cm2/s' % (crab_flux*1000.0, sensitivity)
675  self._log_value(gammalib.TERSE, name, value)
676 
677  # If event excess cuts are passed then break now
678  if pass_evt_cut:
679  break
680 
681  # ... otherwise increase the test flux
682  correct = 1.0 + ratio_precision
683  crab_flux = correct * crab_flux
684  photon_flux = correct * photon_flux
685  energy_flux = correct * energy_flux
686  sensitivity = correct * sensitivity
687 
688  # ...
689  _ = self._set_src_prefactor(test_model, crab_unit, crab_flux)
690 
691  # Write fit results into logger
692  self._log_header3(gammalib.TERSE, 'Fit results')
693  self._log_value(gammalib.TERSE, 'Photon flux',
694  str(photon_flux)+' ph/cm2/s')
695  self._log_value(gammalib.TERSE, 'Energy flux',
696  str(energy_flux)+' erg/cm2/s')
697  self._log_value(gammalib.TERSE, 'Crab flux',
698  str(crab_flux*1000.0)+' mCrab')
699  self._log_value(gammalib.TERSE, 'Differential sensitivity',
700  str(sensitivity)+' erg/cm2/s')
701  self._log_value(gammalib.TERSE, 'Number of simulated events', nevents)
702  self._log_header3(gammalib.TERSE, 'Test source model fitting')
703  self._log_value(gammalib.TERSE, 'log likelihood', logL)
704  self._log_value(gammalib.TERSE, 'Number of predicted events', npred)
705  for model in models:
706  self._log_value(gammalib.TERSE, 'Model', model.name())
707  for par in model:
708  self._log_string(gammalib.TERSE, str(par))
709 
710  # Restore energy boundaries of observation container
711  for i, obs in enumerate(self.obs()):
712  obs.events().ebounds(self._obs_ebounds[i])
713 
714  # Store result
715  result = {'loge': loge, 'emin': emin.TeV(), 'emax': emax.TeV(), \
716  'crab_flux': crab_flux, 'photon_flux': photon_flux, \
717  'energy_flux': energy_flux, \
718  'sensitivity': sensitivity, 'regcoeff': regcoeff, \
719  'nevents': nevents, 'npred': npred}
720 
721  # Return result
722  return result
723 
724  def _set_src_prefactor(self, test_model, crab_unit, test_crab_flux):
725  """
726  Create a copy of the test models, set the normalisation parameter
727  of the test source in the models, and append the models to the observation.
728 
729  Parameters
730  ----------
731  test_model : `~gammalib.GModels`
732  Test source model
733  crab_unit : float
734  Crab unit factor
735  test_crab_flux : float
736  Test flux in Crab units (100 mCrab)
737 
738  Returns
739  -------
740  crab_prefactor : float
741  the prefactor that corresponds to a flux of 1 Crab.
742  """
743  # Initialise variables
744  models = test_model.copy()
745  prefactor = modutils.normalisation_parameter(models[self._srcname])
746  crab_prefactor = prefactor.value() * crab_unit
747  val_margin = 0.01
748  min_pref = prefactor.min() * (1.0 + val_margin)
749  max_pref = prefactor.max() * (1.0 - val_margin)
750  val_now = crab_prefactor * test_crab_flux
751 
752  # Check whether prefactor is in valid range
753  if val_now < min_pref or val_now > max_pref:
754 
755  # Store old prefactor value for logging
756  val_old = val_now
757 
758  # Set new prefactor value
759  val_now = max(val_now, min_pref)
760  val_now = min(val_now, max_pref)
761  crab_prefactor = val_now / test_crab_flux
762 
763  # Log prefactor modification
764  self._log_value(gammalib.EXPLICIT, 'Prefactor range',
765  '['+str(min_pref)+','+str(max_pref)+']')
766  self._log_value(gammalib.EXPLICIT, 'Initial Prefactor', val_old)
767  self._log_value(gammalib.EXPLICIT, 'Updated Prefactor', val_now)
768 
769  # Store prefactor value
770  prefactor.value(val_now)
771 
772  # Update the models
773  self.obs().models(models)
774 
775  # Return Prefactor
776  return crab_prefactor
777 
778  def _simulate_events(self, obs, rad, enumbins, binsz, npix):
779  """
780  Simulate events
781 
782  Parameters
783  ----------
784  obs : `~gammalib.GObservations`
785  Observation container
786  rad : float
787  Simulation selection radius (degrees)
788  enumbins : integer
789  Number of energy bins
790  binsz : float
791  Pixel size
792  npix : integer
793  Number of pixels
794  """
795  # Initialise list of number of simulated events
796  n_sim_evts = []
797 
798  # Simulate events
799  for n_sim in range(15):
800 
801  # Increment the seed value
802  self._seed += 1
803 
804  # Simulate observations
805  sim = obsutils.sim(obs, nbins=enumbins, seed=self._seed, binsz=binsz,
806  npix=npix, log=self._log_clients,
807  debug=self['debug'].boolean(),
808  edisp=self['edisp'].boolean(), nthreads=1)
809 
810  # If a selection radius is given then select only the events within
811  # that selection radius
812  if rad > 0.0:
813 
814  # Run event selection tool
815  select = ctools.ctselect(sim)
816  select['rad'] = rad
817  select['emin'] = 'INDEF'
818  select['tmin'] = 'INDEF'
819  select.run()
820 
821  # Store number of events
822  n_sim_evts.append(select.obs().nobserved())
823 
824  # ... otherwise we simply store the number of simulated events
825  else:
826  n_sim_evts.append(sim.nobserved())
827 
828  # Determine median number of events
829  median = self._median(n_sim_evts)
830 
831  # Log results
832  if rad > 0.0:
833  name = 'Median source events'
834  else:
835  name = 'Median background events'
836  self._log_value(gammalib.EXPLICIT, name, median)
837 
838  # Return
839  return median
840 
841  def _sim_evt_excess(self, enumbins, binsz, npix, n_bck_evts):
842  """
843  Return the number of excess events for the source model, compared
844  to all other models
845 
846  Parameters
847  ----------
848  enumbins : int
849  Number of bins for the observation simulation
850  binsz : float
851  Bin size for the observation simulation
852  npix : int
853  Pixel size for the observation simulation
854  n_bck_evts : int
855  Number of background counts from previous call
856 
857  Returns
858  -------
859  pass_evt_cut : bool
860  Signals that the source passes the minimal excess criteria
861  n_bck_evts : int
862  Number of background counts
863  """
864  # Initialise results
865  pass_evt_cut = True
866  n_bck_evts = 0
867 
868  # Get user parameters
869  mincounts = self['mincounts'].integer()
870  bkgexcess = self['bkgexcess'].real()
871  bkgrad = self['bkgrad'].real()
872 
873  # Continue only if cuts were specified
874  if mincounts > 0 or bkgexcess > 0.0:
875 
876  # Get models and split them into source and background
877  models = self.obs().models()
878  src_model = gammalib.GModels()
879  bck_models = gammalib.GModels()
880  for model in models:
881  if model.name() == self._srcname:
882  src_model.append(model)
883  else:
884  bck_models.append(model)
885 
886  # Create observations for source and background
887  src_obs = self.obs().copy()
888  bck_obs = self.obs().copy()
889  src_obs.models(src_model)
890  bck_obs.models(bck_models)
891 
892  # Simulate source events
893  n_src_evts = self._simulate_events(src_obs, 0.0, enumbins, binsz, npix)
894 
895  # Simulate background events in case that a background fraction cut
896  # is applied and if the number of background events was not yet
897  # estimated
898  if bkgexcess > 0.0 and n_bck_evts == None:
899  n_bck_evts = self._simulate_events(bck_obs, bkgrad, enumbins, binsz, npix)
900 
901  # Set cut results
902  min_bkg_events = n_bck_evts * bkgexcess
903  has_min_evts = n_src_evts >= mincounts
904  is_above_bck = n_src_evts >= min_bkg_events
905  pass_evt_cut = has_min_evts and is_above_bck
906 
907  # Log results
908  self._log_value(gammalib.EXPLICIT, 'Pass minimum counts cut', has_min_evts)
909  self._log_value(gammalib.EXPLICIT, 'Pass background cut', is_above_bck)
910  self._log_value(gammalib.EXPLICIT, 'Pass event cut', pass_evt_cut)
911  self._log_value(gammalib.EXPLICIT, 'Minimum counts threshold', mincounts)
912  self._log_value(gammalib.EXPLICIT, 'Background threshold', min_bkg_events)
913  self._log_value(gammalib.EXPLICIT, 'Source events', n_src_evts)
914  self._log_value(gammalib.EXPLICIT, 'Background events', n_bck_evts)
915 
916  # Return passing flag and number of background events
917  return pass_evt_cut, n_bck_evts
918 
919  def _predict_flux(self, results, ts):
920  """
921  Predict Crab flux for a given TS value
922 
923  The Crab flux at a given Test Statistic value is predicted by doing a
924  linear regression of the log(TS) and log(crab_flux) values in a results
925  list.
926 
927  See https://en.wikipedia.org/wiki/Simple_linear_regression
928 
929  Parameters
930  ----------
931  results : list of dict
932  List of results
933  ts : float
934  Test Statistic value
935 
936  Returns
937  -------
938  crab_flux_prediction, regcoeff : tuple of float
939  Predicted Crab flux and regression coefficient
940  """
941  # Compute means and regression coefficient
942  mean_x = 0.0
943  mean_y = 0.0
944  mean_xy = 0.0
945  mean_xx = 0.0
946  mean_yy = 0.0
947  for result in results:
948  x = math.log(float(result['ts']))
949  y = math.log(float(result['crab_flux']))
950  mean_x += x
951  mean_y += y
952  mean_xy += x * y
953  mean_xx += x * x
954  mean_yy += y * y
955  norm = 1.0 / float(len(results))
956  mean_x *= norm
957  mean_y *= norm
958  mean_xy *= norm
959  mean_xx *= norm
960  mean_yy *= norm
961 
962  # Compute regression coefficient
963  rxy_norm = (mean_xx - mean_x * mean_x) * (mean_yy - mean_y * mean_y)
964  if rxy_norm < 1e-10:
965  rxy_norm = 1
966  else:
967  rxy_norm = math.sqrt(rxy_norm)
968  rxy = (mean_xy - mean_x * mean_y) / rxy_norm
969  regcoeff = rxy*rxy
970 
971  # Compute regression line slope
972  beta_nom = 0.0
973  beta_denom = 0.0
974  for result in results:
975  x = math.log(float(result['ts']))
976  y = math.log(float(result['crab_flux']))
977  beta_nom += (x - mean_x) * (y - mean_y)
978  beta_denom += (x - mean_x) * (x - mean_x)
979  beta = beta_nom / beta_denom
980 
981  # Compute regression line offset
982  alpha = mean_y - beta * mean_x
983 
984  # Predict Crab flux at TS threshold
985  log_ts_thres = math.log(ts)
986  crab_flux_prediction = math.exp(alpha + beta * log_ts_thres)
987 
988  # Return
989  return (crab_flux_prediction, regcoeff)
990 
991  def _e_bin(self, ieng):
992  """
993  Determines sensivity in energy bin
994 
995  Parameters
996  ----------
997  ieng : int
998  Energy bin number
999 
1000  Returns
1001  -------
1002  result : dict
1003  Result dictionary
1004  """
1005  # Get sensitivity type
1006  sensitivity_type = self['type'].string()
1007 
1008  # Set energies
1009  if sensitivity_type == 'Differential':
1010  emin = self._ebounds.emin(ieng)
1011  emax = self._ebounds.emax(ieng)
1012  elif sensitivity_type == 'Integral':
1013  emin = self._ebounds.emin(ieng)
1014  emax = self._ebounds.emax()
1015  else:
1016  msg = ('Invalid sensitivity type "%s" encountered. Either '
1017  'specify "Differential" or "Integral".' %
1018  sensitivity_type)
1019  raise RuntimeError(msg)
1020 
1021  # Determine sensitivity
1022  result = self._get_sensitivity(emin, emax, self._models)
1023 
1024  # Return results
1025  return result
1026 
1027  def _create_fits(self, results):
1028  """
1029  Create FITS file from results
1030 
1031  Parameters
1032  ----------
1033  results : list of dict
1034  List of result dictionaries
1035  """
1036  # Create FITS table columns
1037  nrows = len(results)
1038  e_mean = gammalib.GFitsTableDoubleCol('E_MEAN', nrows)
1039  e_min = gammalib.GFitsTableDoubleCol('E_MIN', nrows)
1040  e_max = gammalib.GFitsTableDoubleCol('E_MAX', nrows)
1041  flux_crab = gammalib.GFitsTableDoubleCol('FLUX_CRAB', nrows)
1042  flux_photon = gammalib.GFitsTableDoubleCol('FLUX_PHOTON', nrows)
1043  flux_energy = gammalib.GFitsTableDoubleCol('FLUX_ENERGY', nrows)
1044  sensitivity = gammalib.GFitsTableDoubleCol('SENSITIVITY', nrows)
1045  regcoeff = gammalib.GFitsTableDoubleCol('REGRESSION_COEFF', nrows)
1046  nevents = gammalib.GFitsTableDoubleCol('NEVENTS', nrows)
1047  npred = gammalib.GFitsTableDoubleCol('NPRED', nrows)
1048  e_mean.unit('TeV')
1049  e_min.unit('TeV')
1050  e_max.unit('TeV')
1051  flux_crab.unit('')
1052  flux_photon.unit('ph/cm2/s')
1053  flux_energy.unit('erg/cm2/s')
1054  sensitivity.unit('erg/cm2/s')
1055  regcoeff.unit('')
1056  nevents.unit('counts')
1057  npred.unit('counts')
1058 
1059  # Fill FITS table columns
1060  for i, result in enumerate(results):
1061  e_mean[i] = math.pow(10.0, result['loge'])
1062  e_min[i] = result['emin']
1063  e_max[i] = result['emax']
1064  flux_crab[i] = result['crab_flux']
1065  flux_photon[i] = result['photon_flux']
1066  flux_energy[i] = result['energy_flux']
1067  sensitivity[i] = result['sensitivity']
1068  regcoeff[i] = result['regcoeff']
1069  nevents[i] = result['nevents']
1070  npred[i] = result['npred']
1071 
1072  # Initialise FITS Table with extension "SENSITIVITY"
1073  table = gammalib.GFitsBinTable(nrows)
1074  table.extname('SENSITIVITY')
1075 
1076  # Add keywors for compatibility with gammalib.GMWLSpectrum
1077  table.card('INSTRUME', 'CTA', 'Name of Instrument')
1078  table.card('TELESCOP', 'CTA', 'Name of Telescope')
1079 
1080  # Stamp header
1081  self._stamp(table)
1082 
1083  # Add script keywords
1084  table.card('OBJECT', self._srcname, 'Source for which sensitivity was estimated')
1085  table.card('TYPE', self['type'].string(), 'Sensitivity type')
1086  table.card('SIGMA', self['sigma'].real(), '[sigma] Sensitivity threshold')
1087  table.card('MAX_ITER', self['max_iter'].integer(), 'Maximum number of iterations')
1088  table.card('STAT', self['statistic'].string(), 'Optimization statistic')
1089  table.card('MINCOUNT', self['mincounts'].integer(), 'Minimum number of source counts')
1090  table.card('BKGEXCES', self['bkgexcess'].real(), 'Background uncertainty fraction')
1091  table.card('BKGRAD', self['bkgrad'].real(), '[deg] Background radius')
1092  table.card('SEED', self['seed'].integer(), 'Seed value for random numbers')
1093 
1094  # Append filled columns to fits table
1095  table.append(e_mean)
1096  table.append(e_min)
1097  table.append(e_max)
1098  table.append(flux_crab)
1099  table.append(flux_photon)
1100  table.append(flux_energy)
1101  table.append(sensitivity)
1102  table.append(regcoeff)
1103  table.append(nevents)
1104  table.append(npred)
1105 
1106  # Create the FITS file now
1107  self._fits = gammalib.GFits()
1108  self._fits.append(table)
1109 
1110  # Return
1111  return
1112 
1113 
1114  # Public methods
1115  def process(self):
1116  """
1117  Process the script
1118  """
1119  # Get parameters
1120  self._get_parameters()
1121 
1122  # Loop over observations and store a deep copy of the energy
1123  # boundaries for later use
1124  for obs in self.obs():
1125  self._obs_ebounds.append(obs.events().ebounds().copy())
1126 
1127  # Initialise results
1128  results = []
1129 
1130  # Set test source model for this observation
1131  self._models = modutils.test_source(self.obs().models(), self._srcname,
1132  ra=self._ra, dec=self._dec)
1133 
1134  # If test source spatial model is a map cube, compute intensity in each map
1135  if self._models[self._srcname].spatial().classname() == 'GModelSpatialDiffuseCube':
1136  self._models[self._srcname].spatial().set_mc_cone(gammalib.GSkyDir(),180.0)
1137 
1138  # Write observation into logger
1139  self._log_observations(gammalib.NORMAL, self.obs(), 'Input observation')
1140 
1141  # Write models into logger
1142  self._log_models(gammalib.NORMAL, self._models, 'Input model')
1143 
1144  # Write header
1145  self._log_header1(gammalib.TERSE, 'Sensitivity determination')
1146  self._log_value(gammalib.TERSE, 'Type', self['type'].string())
1147 
1148  # If using multiprocessing
1149  if self._nthreads > 1 and self._ebounds.size() > 1:
1150 
1151  # Compute energy bins
1152  args = [(self, '_e_bin', i)
1153  for i in range(self._ebounds.size())]
1154  poolresults = mputils.process(self._nthreads, mputils.mpfunc, args)
1155 
1156  # Construct results
1157  for ieng in range(self._ebounds.size()):
1158  results.append(poolresults[ieng][0])
1159  self._log_string(gammalib.TERSE, poolresults[ieng][1]['log'], False)
1160 
1161  # Otherwise, loop over energy bins
1162  else:
1163  for ieng in range(self._ebounds.size()):
1164 
1165  # Run analysis in energy bin
1166  result = self._e_bin(ieng)
1167 
1168  # Append results
1169  results.append(result)
1170 
1171  # Create FITS file
1172  self._create_fits(results)
1173 
1174  # Return
1175  return
1176 
1177  def save(self):
1178  """
1179  Save sensitivity FITS file
1180  """
1181  # Write header
1182  self._log_header1(gammalib.TERSE, 'Save sensitivity curve')
1183 
1184  # Continue only if FITS file is valid
1185  if self._fits != None:
1186 
1187  # Get outmap parameter
1188  outfile = self['outfile'].filename()
1189 
1190  # Log file name
1191  self._log_value(gammalib.NORMAL, 'Sensitivity file', outfile.url())
1192 
1193  # Save sensitivity
1194  self._fits.saveto(outfile, self['clobber'].boolean())
1195 
1196  # Return
1197  return
1198 
1199  def sensitivity(self):
1200  """
1201  Return sensitivity FITS file
1202 
1203  Returns:
1204  FITS file containing sensitivity curve
1205  """
1206  # Return
1207  return self._fits
1208 
1209 
1210 # ======================== #
1211 # Main routine entry point #
1212 # ======================== #
1213 if __name__ == '__main__':
1214 
1215  # Create instance of application
1216  app = cssens(sys.argv)
1217 
1218  # Run application
1219  app.execute()