ctools 2.1.0
Loading...
Searching...
No Matches
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# ==========================================================================
21import sys
22import math
23import gammalib
24import ctools
25from cscripts import obsutils
26from cscripts import modutils
27from cscripts import mputils
28
29
30# ============ #
31# cssens class #
32# ============ #
33class 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
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# ======================== #
1213if __name__ == '__main__':
1214
1215 # Create instance of application
1216 app = cssens(sys.argv)
1217
1218 # Run application
1219 app.execute()
_set_obs_ebounds(self, emin, emax)
Definition cssens.py:307
_set_obs(self, emin, emax)
Definition cssens.py:240
_predict_flux(self, results, ts)
Definition cssens.py:919
_median(self, array)
Definition cssens.py:178
__init__(self, *argv)
Definition cssens.py:46
_sim_evt_excess(self, enumbins, binsz, npix, n_bck_evts)
Definition cssens.py:841
_simulate_events(self, obs, rad, enumbins, binsz, npix)
Definition cssens.py:778
_set_src_prefactor(self, test_model, crab_unit, test_crab_flux)
Definition cssens.py:724
_e_bin(self, ieng)
Definition cssens.py:991
_get_crab_flux(self, emin, emax)
Definition cssens.py:357
_get_sensitivity(self, emin, emax, test_model)
Definition cssens.py:383
__setstate__(self, state)
Definition cssens.py:94
_create_fits(self, results)
Definition cssens.py:1027