ctools 2.1.0
Loading...
Searching...
No Matches
csspec.py
Go to the documentation of this file.
1#! /usr/bin/env python
2# ==========================================================================
3# Generates a spectrum.
4#
5# Copyright (C) 2014-2022 Michael Mayer
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 tempfile # Kludge for file function generation
24import gammalib
25import ctools
26from cscripts import mputils
27
28
29# ============ #
30# csspec class #
31# ============ #
32class csspec(ctools.csobservation):
33 """
34 Generates a spectrum
35
36 This class implements the generation of a Spectral Energy Distribution
37 (SED) from gamma-ray observations.
38 """
39
40 # Constructors and destructors
41 def __init__(self, *argv):
42 """
43 Constructor
44 """
45 # Initialise application by calling the appropriate class constructor
46 self._init_csobservation(self.__class__.__name__, ctools.__version__, argv)
47
48 # Initialise data members
49 self._ebounds = gammalib.GEbounds()
50 self._fits = None
51 self._binned_mode = False
52 self._onoff_mode = False
53 self._method = 'AUTO'
54 self._nthreads = 0
55
56 # Return
57 return
58
59 def __del__(self):
60 """
61 Destructor
62 """
63 # Return
64 return
65
66 # State methods for pickling
67 def __getstate__(self):
68 """
69 Extend ctools.csobservation getstate method to include some members
70
71 Returns
72 -------
73 state : dict
74 Pickled instance
75 """
76 # Set pickled dictionary
77 state = {'base' : ctools.csobservation.__getstate__(self),
78 'ebounds' : self._ebounds,
79 'fits' : self._fits,
80 'binned_mode' : self._binned_mode,
81 'onoff_mode' : self._onoff_mode,
82 'method' : self._method,
83 'nthreads' : self._nthreads}
84
85 # Return pickled dictionary
86 return state
87
88 def __setstate__(self, state):
89 """
90 Extend ctools.csobservation setstate method to include some members
91
92 Parameters
93 ----------
94 state : dict
95 Pickled instance
96 """
97 ctools.csobservation.__setstate__(self, state['base'])
98 self._ebounds = state['ebounds']
99 self._fits = state['fits']
100 self._binned_mode = state['binned_mode']
101 self._onoff_mode = state['onoff_mode']
102 self._method = state['method']
103 self._nthreads = state['nthreads']
104
105 # Return
106 return
107
108
109 # Private methods
111 """
112 Get parameters from parfile and setup the observation
113 """
114 # Set observation if not done before
115 if self.obs().is_empty():
116 self._require_inobs('csspec::get_parameters()')
117 self.obs(self._get_observations())
118
119 # Set observation statistic
120 self._set_obs_statistic(gammalib.toupper(self['statistic'].string()))
121
122 # Set models if we have none
123 if self.obs().models().is_empty():
124 self.obs().models(self['inmodel'].filename())
125
126 # Query source name
127 self['srcname'].string()
128
129 # Get spectrum generation method
130 self._method = self['method'].string()
131
132 # Collect number of unbinned, binned and On/Off observations in
133 # observation container
134 n_unbinned = 0
135 n_binned = 0
136 n_onoff = 0
137 for obs in self.obs():
138 if obs.classname() == 'GCTAObservation':
139 if obs.eventtype() == 'CountsCube':
140 n_binned += 1
141 else:
142 n_unbinned += 1
143 elif obs.classname() == 'GCTAOnOffObservation':
144 n_onoff += 1
145 n_cta = n_unbinned + n_binned + n_onoff
146 n_other = self.obs().size() - n_cta
147
148 # If spectrum method is not "NODES" or 'BINS" then set spectrum method
149 # and script mode according to type of observations
150 if self._method != 'NODES' and self._method != 'BINS':
151 if n_other > 0:
152 self._method = 'NODES'
153 else:
154 if n_unbinned == 0 and n_binned != 0 and n_onoff == 0:
155 self._binned_mode = True
156 self._method = 'SLICE'
157 elif n_unbinned == 0 and n_binned == 0 and n_onoff != 0:
158 self._onoff_mode = True
159 self._method = 'SLICE'
160 elif n_unbinned == 0 and n_binned != 0 and n_onoff != 0:
161 msg = 'Mix of binned and On/Off CTA observations found ' \
162 'in observation container. csscript does not support ' \
163 'this mix.'
164 raise RuntimeError(msg)
165 elif n_unbinned != 0 and (n_binned != 0 or n_onoff != 0):
166 msg = 'Mix of unbinned and binned or On/Off CTA observations ' \
167 'found in observation container. csscript does not ' \
168 'support this mix.'
169 raise RuntimeError(msg)
170 elif n_unbinned != 0:
171 self._method = 'SLICE'
172
173 # Set ebounds
174 self._set_ebounds()
175
176 # Query other parameeters
177 self['edisp'].boolean()
178 self['calc_ts'].boolean()
179 self['calc_ulim'].boolean()
180 self['confidence'].real()
181 self['fix_bkg'].boolean()
182 self['fix_srcs'].boolean()
183 self['bingamma'].real()
184
185 # Setup dlog-likelihood parameters
186 self['dll_sigstep'].real()
187 self['dll_sigmax'].real()
188 self['dll_freenodes'].boolean()
189
190 # Read ahead output parameters
191 if self._read_ahead():
192 self['outfile'].filename()
193
194 # Write input parameters into logger
195 self._log_parameters(gammalib.TERSE)
196
197 # Set number of processes for multiprocessing
198 self._nthreads = mputils.nthreads(self)
199
200 # Write spectrum method header and parameters
201 self._log_header1(gammalib.TERSE, 'Spectrum method')
202 self._log_value(gammalib.TERSE, 'Unbinned CTA observations', n_unbinned)
203 self._log_value(gammalib.TERSE, 'Binned CTA observations', n_binned)
204 self._log_value(gammalib.TERSE, 'On/off CTA observations', n_onoff)
205 self._log_value(gammalib.TERSE, 'Other observations', n_other)
206
207 # If there are a mix of CTA and non-CTA observations and the method
208 # is 'SLICE' then log a warning that non-CTA observations will be
209 # ignored
210 warning = False
211 if n_cta > 0 and n_other > 0 and self._method == 'SLICE':
212 warning = True
213
214 # If there are only non-CTA observations and the method is 'SLICE'
215 # then stop now
216 elif n_other > 0:
217 if self._method == 'SLICE':
218 msg = 'Selected "SLICE" method but none of the observations ' \
219 'is a CTA observation. Please select "AUTO", "NODES" ' \
220 'or "BINS" if no CTA observation is provided.'
221 raise RuntimeError(msg)
222 elif self._method == 'AUTO':
223 self._method = 'NODES'
224
225 # Log selected spectrum method
226 self._log_value(gammalib.TERSE, 'Selected spectrum method', self._method)
227
228 # Signal warning
229 if warning:
230 self._log_string(gammalib.TERSE, ' WARNING: Only CTA observation '
231 'can be handled with the "SLICE" method, all '
232 'non-CTA observation will be ignored.')
233
234 # Return
235 return
236
237 def _set_ebounds(self):
238 """
239 Set energy boundaries
240 """
241 # If we are in binned or On/Off mode then align the energy boundaries
242 # with the cube of RMF spectrum
243 if self._binned_mode or self._onoff_mode:
244
245 # Initialise energy boundaries for spectrum
246 self._ebounds = gammalib.GEbounds()
247
248 # Create energy boundaries according to user parameters
249 ebounds = self._create_ebounds()
250
251 # Extract binned energy boundaries from first observation in
252 # container. This assumes that all observations have the same
253 # energy binning. But even if this is not the case, the script
254 # should work reasonably well since for each observation the
255 # relevant energy bins will be selected.
256 if self._binned_mode:
257 cube_ebounds = self.obs()[0].events().ebounds()
258 else:
259 cube_ebounds = self.obs()[0].rmf().emeasured()
260
261 # Loop over user energy boundaries and collect all energy bins
262 # that overlap
263 for i in range(ebounds.size()):
264
265 # Extract minimum and maximum energy of user energy bin,
266 # including some rounding tolerance
267 emin = ebounds.emin(i).TeV() * 0.999 # Rounding tolerance
268 emax = ebounds.emax(i).TeV() * 1.001 # Rounding tolerance
269
270 # Set number of overlapping energy bins
271 nbins = 0
272
273 # Search first cube bin that is comprised within user energy
274 # bin
275 emin_value = -1.0
276 for k in range(cube_ebounds.size()):
277 if cube_ebounds.emin(k).TeV() >= emin and \
278 cube_ebounds.emax(k).TeV() <= emax:
279 emin_value = cube_ebounds.emin(k).TeV()
280 break
281 if emin_value < 0.0:
282 continue
283
284 # Search last cube bin that is comprised within user energy bin
285 for k in range(cube_ebounds.size()):
286 if cube_ebounds.emin(k).TeV() >= emin and \
287 cube_ebounds.emax(k).TeV() <= emax:
288 emax_value = cube_ebounds.emax(k).TeV()
289 nbins += 1
290
291 # Append energy bin if there are overlapping bins in the
292 # counts cube
293 if nbins > 0:
294 self._ebounds.append(gammalib.GEnergy(emin_value, 'TeV'),
295 gammalib.GEnergy(emax_value, 'TeV'))
296
297 # Raise an exception if there are no overlapping layers
298 if (len(self._ebounds) == 0):
299 msg = 'Energy range ['+str(cube_ebounds.emin())+ \
300 ', '+str(cube_ebounds.emax())+'] of counts '+ \
301 'cube does not overlap with specified energy '+ \
302 'range ['+ \
303 str(ebounds.emin())+', '+str(ebounds.emax())+'].'+ \
304 ' Specify overlapping energy range.'
305 raise RuntimeError(msg)
306
307 # Unbinned mode
308 else:
309 self._ebounds = self._create_ebounds()
310
311 # Return
312 return
313
315 """
316 Log spectral binning
317 """
318 # Write header
319 self._log_header1(gammalib.TERSE, 'Spectral binning')
320
321 # Log counts cube energy range for binned mode
322 if self._binned_mode:
323 cube_ebounds = self.obs()[0].events().ebounds()
324 value = '%s - %s' % (str(cube_ebounds.emin()),
325 str(cube_ebounds.emax()))
326 self._log_value(gammalib.TERSE, 'Counts cube energy range', value)
327
328 # Log RMF energy range for On/Off mode
329 elif self._onoff_mode:
330 etrue = self.obs()[0].rmf().etrue()
331 ereco = self.obs()[0].rmf().emeasured()
332 vtrue = '%s - %s' % (str(etrue.emin()), str(etrue.emax()))
333 vreco = '%s - %s' % (str(ereco.emin()), str(ereco.emax()))
334 self._log_value(gammalib.TERSE, 'RMF true energy range', vtrue)
335 self._log_value(gammalib.TERSE, 'RMF measured energy range', vreco)
336
337 # Log energy bins
338 for i in range(self._ebounds.size()):
339 value = '%s - %s' % (str(self._ebounds.emin(i)),
340 str(self._ebounds.emax(i)))
341 self._log_value(gammalib.TERSE, 'Bin %d' % (i+1), value)
342
343 # Return
344 return
345
347 """
348 Adjust model parameters
349 """
350 # Write header
351 self._log_header1(gammalib.TERSE, 'Adjust model parameters')
352
353 # Adjust model parameters dependent on input user parameters
354 for model in self.obs().models():
355
356 # Initialise TS flag for all models to false
357 model.tscalc(False)
358
359 # Log model name
360 self._log_header3(gammalib.NORMAL, model.name())
361
362 # Deal with the source of interest
363 if model.name() == self['srcname'].string():
364
365 # Fix all model parameters
366 for par in model:
367 if par.is_free():
368 self._log_string(gammalib.NORMAL,
369 ' Fixing "'+par.name()+'"')
370 par.fix()
371
372 # Convert spectral model into a file function. The file
373 # function is logarithmically sampled in energy, with
374 # 50 times the number of spectral bins. We do not use
375 # the GModelSpectralFunc constructor to avoid non-positive
376 # values that may occur in particular due to rounding
377 # errors in energy if the model is a bin spectrum.
378 self._log_string(gammalib.NORMAL, ' Converting spectral model '
379 'into file function')
380 nbins = self._ebounds.size()
381 num = nbins * 50
382 energies = gammalib.GEnergies(num, self._ebounds.emin(0),
383 self._ebounds.emax(nbins-1))
384 #spectral = gammalib.GModelSpectralFunc(model.spectral(), energies)
385 spectral = gammalib.GModelSpectralFunc()
386 spectral.reserve(num)
387 last_value = 0.0
388 for energy in energies:
389 value = model.spectral().eval(energy)
390 if value <= 0.0:
391 if last_value != 0.0:
392 new_value = last_value
393 else:
394 new_value = 1.0e-50
395 self._log_string(gammalib.NORMAL,
396 ' Spectrum('+str(energy)+')='+str(value)+'. '
397 'Set value to '+str(new_value)+'.')
398 value = new_value
399 spectral.append(energy, value)
400 last_value = value
401 model.spectral(spectral)
402
403 # Free the normalisation parameter which is assumed to be
404 # the first spectral parameter
405 normpar = model.spectral()[0]
406 if normpar.is_fixed():
407 self._log_string(gammalib.NORMAL,
408 ' Freeing "'+normpar.name()+'"')
409 normpar.free()
410
411 # Optionally compute Test Statistic value
412 if self['calc_ts'].boolean():
413 model.tscalc(True)
414
415 # Deal with background models
416 elif self['fix_bkg'].boolean() and \
417 not model.classname() == 'GModelSky':
418 for par in model:
419 if par.is_free():
420 self._log_string(gammalib.NORMAL,
421 ' Fixing "'+par.name()+'"')
422 par.fix()
423
424 # Deal with source models
425 elif self['fix_srcs'].boolean() and \
426 model.classname() == 'GModelSky':
427 for par in model:
428 if par.is_free():
429 self._log_string(gammalib.NORMAL,
430 ' Fixing "'+par.name()+'"')
431 par.fix()
432
433 # Return
434 return
435
437 """
438 Replace source spectrum by node function
439 """
440 # Initialise model container
441 models = gammalib.GModels()
442
443 # Loop over model containers
444 for model in self.obs().models():
445
446 # If we deal with source model then replace the spectral model
447 # by a node function
448 if model.name() == self['srcname'].string():
449
450 # Setup energies at log mean energies of bins
451 energies = gammalib.GEnergies()
452 for i in range(self._ebounds.size()):
453 energies.append(self._ebounds.elogmean(i))
454
455 # Setup spectral node function
456 spectrum = gammalib.GModelSpectralNodes(model.spectral(), energies)
457 spectrum.autoscale()
458
459 # Make sure that all nodes are positive. Autoscale all
460 # parameters so that their nominal value is unity.
461 for i in range(spectrum.nodes()):
462 par = spectrum[i*2+1]
463 par.autoscale()
464 value = par.value()
465 minimum = 1.0e-20 * value
466 if minimum <= 0.0:
467 minimum = 1.0e-20
468 if minimum < value:
469 value = minimum
470 par.value(value)
471 par.min(minimum)
472
473 # Set spectral component of source model
474 model.spectral(spectrum)
475
476 # Make sure that TS computation is disabled (makes computation
477 # faster)
478 model.tscalc(False)
479
480 # Append model
481 models.append(model)
482
483 # ... otherwise just append model
484 else:
485 models.append(model)
486
487 # Put new model in observation containers
488 self.obs().models(models)
489
490 # Return
491 return
492
494 """
495 Replace source spectrum by bin function
496 """
497 # Initialise model container
498 models = gammalib.GModels()
499
500 # Loop over model containers
501 for model in self.obs().models():
502
503 # If we deal with source model then replace the spectral model
504 # by a bin function
505 if model.name() == self['srcname'].string():
506
507 # Get spectral bins index
508 bingamma = self['bingamma'].real()
509
510 # Setup spectral bin function
511 spectrum = gammalib.GModelSpectralBins(model.spectral(),
512 self._ebounds,
513 bingamma)
514 spectrum.autoscale()
515
516 # Make sure that all bins are positive. Autoscale all
517 # parameters so that their nominal value is unity.
518 for i in range(spectrum.bins()):
519 name = 'Intensity%d' % i
520 par = spectrum[name]
521 par.autoscale()
522 value = par.value()
523 minimum = 1.0e-20 * value
524 if minimum <= 0.0:
525 minimum = 1.0e-20
526 if minimum < value:
527 value = minimum
528 par.value(value)
529 par.min(minimum)
530
531 # Set spectral component of source model
532 model.spectral(spectrum)
533
534 # Make sure that TS computation is disabled (makes computation
535 # faster)
536 model.tscalc(False)
537
538 # Append model
539 models.append(model)
540
541 # ... otherwise just append model
542 else:
543 models.append(model)
544
545 # Put new model in observation containers
546 self.obs().models(models)
547
548 # Return
549 return
550
551 def _select_onoff_obs(self, obs, emin, emax):
552 """
553 Select an energy interval from one CTA On/Off observation
554
555 Parameters
556 ----------
557 obs : `~gammalib.GCTAOnOffObservation`
558 Minimum energy
559 emin : `~gammalib.GEnergy()`
560 Minimum energy
561 emax : `~gammalib.GEnergy()`
562 Maximum energy
563
564 Returns
565 -------
566 obs : `~gammalib.GCTAOnOffObservation`
567 CTA On/Off observation
568 """
569 # Select energy bins in etrue and ereco. All etrue energy bins are
570 # selected. A 0.1% margin is added for reconstructed energies to
571 # accomodate for rounding errors.
572 etrue = obs.rmf().etrue()
573 ereco = gammalib.GEbounds()
574 itrue = [i for i in range(obs.rmf().etrue().size())]
575 ireco = []
576 for i in range(obs.rmf().emeasured().size()):
577 ereco_bin_min = obs.rmf().emeasured().emin(i)
578 ereco_bin_max = obs.rmf().emeasured().emax(i)
579 if ereco_bin_min * 1.001 >= emin and ereco_bin_max * 0.999 <= emax:
580 ereco.append(ereco_bin_min, ereco_bin_max)
581 ireco.append(i)
582
583 # Extract PHA
584 pha_on = gammalib.GPha(ereco)
585 pha_off = gammalib.GPha(ereco)
586 pha_on.exposure(obs.on_spec().exposure())
587 pha_off.exposure(obs.on_spec().exposure())
588 for idst, isrc in enumerate(ireco):
589 # On
590 pha_on[idst] = obs.on_spec()[isrc]
591 pha_on.areascal(idst, obs.on_spec().areascal(isrc))
592 pha_on.backscal(idst, obs.on_spec().backscal(isrc))
593 # Off
594 pha_off[idst] = obs.off_spec()[isrc]
595 pha_off.areascal(idst, obs.off_spec().areascal(isrc))
596 pha_off.backscal(idst, obs.off_spec().backscal(isrc))
597
598 # Extract BACKRESP
599 pha_backresp = obs.off_spec()['BACKRESP']
600 backresp = []
601 for idst, isrc in enumerate(ireco):
602 backresp.append(pha_backresp[isrc])
603 pha_off.append('BACKRESP', backresp)
604
605 # Extract ARF
606 arf = gammalib.GArf(etrue)
607 for idst, isrc in enumerate(itrue):
608 arf[idst] = obs.arf()[isrc]
609
610 # Extract RMF
611 rmf = gammalib.GRmf(etrue, ereco)
612 for idst_true, isrc_true in enumerate(itrue):
613 for idst_reco, isrc_reco in enumerate(ireco):
614 rmf[idst_true, idst_reco] = obs.rmf()[isrc_true, isrc_reco]
615
616 # Set On/Off observations
617 obsid = obs.id()
618 statistic = obs.statistic()
619 instrument = obs.instrument()
620 obs = gammalib.GCTAOnOffObservation(pha_on, pha_off, arf, rmf)
621 obs.id(obsid)
622 obs.statistic(statistic)
623 obs.instrument(instrument)
624
625 # Return observation
626 return obs
627
628 def _select_obs(self, emin, emax):
629 """
630 Select observations for energy interval
631
632 Parameters
633 ----------
634 emin : `~gammalib.GEnergy()`
635 Minimum energy
636 emax : `~gammalib.GEnergy()`
637 Maximum energy
638
639 Returns
640 -------
641 obs : `~gammalib.GObservations`
642 Observation container
643 """
644 # Use ctcubemask for binned analysis
645 if self._binned_mode:
646
647 # Write header
648 self._log_header3(gammalib.EXPLICIT, 'Filtering cube')
649
650 # Select layers
651 cubemask = ctools.ctcubemask(self.obs())
652 cubemask['regfile'] = 'NONE'
653 cubemask['ra'] = 'UNDEFINED'
654 cubemask['dec'] = 'UNDEFINED'
655 cubemask['rad'] = 'UNDEFINED'
656 cubemask['emin'] = emin.TeV()
657 cubemask['emax'] = emax.TeV()
658
659 # If chatter level is verbose and debugging is requested then
660 # switch also on the debug model in ctcubemask
661 if self._logVerbose() and self._logDebug():
662 cubemask['debug'] = True
663
664 # Select layers
665 cubemask.run()
666
667 # Set new binned observation
668 obs = cubemask.obs().copy()
669
670 # Use ...
671 elif self._onoff_mode:
672
673 # Write header
674 self._log_header3(gammalib.EXPLICIT, 'Filtering PHA, ARF and RMF')
675
676 # Initialise observation container
677 obs = gammalib.GObservations()
678
679 # Loop over all input observations and select energy bins for
680 # all On/Off observations
681 for run in self.obs():
682 if run.classname() == 'GCTAOnOffObservation':
683 obs.append(self._select_onoff_obs(run, emin, emax))
684
685 # Append models
686 obs.models(self.obs().models())
687
688 # Use ctselect for unbinned analysis
689 else:
690
691 # Write header
692 self._log_header3(gammalib.EXPLICIT, 'Selecting events')
693
694 # Select events
695 select = ctools.ctselect(self.obs())
696 select['ra'] = 'UNDEFINED'
697 select['dec'] = 'UNDEFINED'
698 select['rad'] = 'UNDEFINED'
699 select['emin'] = emin.TeV()
700 select['emax'] = emax.TeV()
701 select['tmin'] = 'UNDEFINED'
702 select['tmax'] = 'UNDEFINED'
703
704 # If chatter level is verbose and debugging is requested then
705 # switch also on the debug model in ctselect
706 if self._logVerbose() and self._logDebug():
707 select['debug'] = True
708
709 # Run ctselect
710 select.run()
711
712 # Retrieve observation
713 obs = select.obs().copy()
714
715 # Return observation container
716 return obs
717
718 def _fit_energy_bin(self, i):
719 """
720 Fit data for one energy bin
721
722 Parameters
723 ----------
724 i : int
725 Energy bin index
726
727 Returns
728 -------
729 result : dict
730 Dictionary with fit results
731 """
732
733 # Write header for energy bin
734 self._log_header2(gammalib.EXPLICIT, 'Energy bin ' + str(i + 1))
735
736 # Get energy boundaries
737 emin = self._ebounds.emin(i)
738 emax = self._ebounds.emax(i)
739 elogmean = self._ebounds.elogmean(i)
740 e_scale = elogmean.MeV() * elogmean.MeV() * gammalib.MeV2erg
741
742 # Select observations for energy bin
743 obs = self._select_obs(emin, emax)
744
745 # Initialise dictionary
746 result = {'energy': elogmean.TeV(),
747 'energy_low': (elogmean - emin).TeV(),
748 'energy_high': (emax - elogmean).TeV(),
749 'flux': 0.0,
750 'e2dnde': 0.0,
751 'flux_err': 0.0,
752 'TS': 0.0,
753 'ulimit': 0.0,
754 'Npred': 0.0,
755 'logL': 0.0,
756 'dloglike': [],
757 'norm_scan': []}
758
759 # Write header for fitting
760 self._log_header3(gammalib.EXPLICIT, 'Performing fit in energy bin')
761
762 # Setup maximum likelihood fit
763 like = ctools.ctlike(obs)
764 like['edisp'] = self['edisp'].boolean()
765 like['nthreads'] = 1 # Avoids OpenMP conflict
766
767 # If chatter level is verbose and debugging is requested then
768 # switch also on the debug model in ctlike
769 if self._logVerbose() and self._logDebug():
770 like['debug'] = True
771
772 # Perform maximum likelihood fit
773 like.run()
774
775 # Write model results for explicit chatter level
776 self._log_string(gammalib.EXPLICIT, str(like.obs().models()))
777
778 # Continue only if log-likelihood is non-zero
779 if like.obs().logL() != 0.0:
780
781 # Get results
782 fitted_models = like.obs().models()
783 source = fitted_models[self['srcname'].string()]
784
785 # Compute delta log-likelihood
786 if self['dll_sigstep'].real() > 0.0:
787
788 # Extract the normalization scan values
789 parname = source.spectral()[0].name()
790 (norm_scan, dlogL, loglike) = self._profile_logL(like.copy(), parname, elogmean)
791 result['norm_scan'] = norm_scan
792 result['dloglike'] = dlogL
793 result['logL'] = loglike
794
795 # Extract Test Statistic value
796 if self['calc_ts'].boolean():
797 result['TS'] = source.ts()
798
799 # Compute Npred value (only works for unbinned analysis)
800 if not self._binned_mode and not self._onoff_mode:
801 for observation in like.obs():
802 result['Npred'] += observation.npred(source)
803
804 # Compute upper flux limit
805 ulimit_value = -1.0
806 if self['calc_ulim'].boolean():
807
808 # Logging information
809 self._log_header3(gammalib.EXPLICIT,
810 'Computing upper limit for energy bin')
811
812 # Create upper limit object
813 ulimit = ctools.ctulimit(like.obs())
814 ulimit['srcname'] = self['srcname'].string()
815 ulimit['eref'] = elogmean.TeV()
816 ulimit['confidence'] = self['confidence'].real()
817
818 # If chatter level is verbose and debugging is requested
819 # then switch also on the debug model in ctulimit
820 if self._logVerbose() and self._logDebug():
821 ulimit['debug'] = True
822
823 # Try to run upper limit and catch exceptions
824 try:
825 ulimit.run()
826 ulimit_value = ulimit.diff_ulimit()
827 except:
828 self._log_string(gammalib.EXPLICIT, 'Upper limit '
829 'calculation failed.')
830 ulimit_value = -1.0
831
832 # Compute upper limit
833 if ulimit_value > 0.0:
834 result['ulimit'] = ulimit_value
835
836 # Compute differential flux and flux error
837 fitted_flux = source.spectral().eval(elogmean)
838 parvalue = source.spectral()[0].value()
839 if parvalue != 0.0:
840 rel_error = source.spectral()[0].error() / parvalue
841 e_flux = fitted_flux * rel_error
842 else:
843 e_flux = 0.0
844
845 # If the source model is a cube then multiply-in the cube
846 # spectrum
847 if source.spatial().classname() == 'GModelSpatialDiffuseCube':
848 region = gammalib.GSkyRegionCircle(0.0, 0.0, 180.0)
849 source.spatial().mc_cone(region)
850 norm = source.spatial().spectrum().eval(elogmean)
851 fitted_flux *= norm
852 e_flux *= norm
853
854 # Convert differential flux and flux error to nuFnu
855 result['flux'] = fitted_flux
856 result['e2dnde'] = fitted_flux * e_scale
857 result['flux_err'] = e_flux
858
859 # Log information
860 value = '%e +/- %e' % (result['e2dnde'], result['flux_err']*e_scale)
861 if self['calc_ulim'].boolean() and result['ulimit'] > 0.0:
862 value += ' [< %e]' % (result['ulimit']*e_scale)
863 value += ' erg/cm2/s'
864 if self['calc_ts'].boolean() and result['TS'] > 0.0:
865 value += ' (TS = %.3f)' % (result['TS'])
866 self._log_value(gammalib.TERSE, 'Bin '+str(i+1), value)
867
868 # ... otherwise if logL is zero then signal that bin is
869 # skipped
870 else:
871 value = 'Likelihood is zero. Bin is skipped.'
872 self._log_value(gammalib.TERSE, 'Bin '+str(i+1), value)
873
874 # Return result
875 return result
876
878 """
879 Fit model to energy bins
880
881 Returns
882 -------
883 results : list of dict
884 List of dictionaries with fit results
885 """
886 # Write header
887 self._log_header1(gammalib.TERSE, 'Generate spectrum')
888 self._log_string(gammalib.TERSE, str(self._ebounds))
889
890 # Initialise results
891 results = []
892
893 # If more than a single thread is requested then use multiprocessing
894 if self._nthreads > 1:
895
896 # Compute energy bins
897 args = [(self, '_fit_energy_bin', i)
898 for i in range(self._ebounds.size())]
899 poolresults = mputils.process(self._nthreads, mputils.mpfunc, args)
900
901 # Construct results
902 for i in range(self._ebounds.size()):
903 results.append(poolresults[i][0])
904 self._log_string(gammalib.TERSE, poolresults[i][1]['log'], False)
905
906 # Otherwise, loop over energy bins
907 else:
908 for i in range(self._ebounds.size()):
909
910 # Fit energy bin
911 result = self._fit_energy_bin(i)
912
913 # Append results
914 results.append(result)
915
916 # Return results
917 return results
918
919 def _fit_model(self):
920 """
921 Fit model to observations
922
923 Returns
924 -------
925 results : list of dict
926 List of dictionaries with fit results
927 """
928 # Write header
929 self._log_header1(gammalib.TERSE, 'Generate spectrum')
930
931 # Write header for fitting
932 self._log_header3(gammalib.EXPLICIT, 'Performing model fit')
933
934 # Perform maximum likelihood fit
935 like = ctools.ctlike(self.obs())
936 like['edisp'] = self['edisp'].boolean()
937 like.run()
938
939 # Initialise fit results
940 results = []
941
942 # Extract fit results
943 model = like.obs().models()[self['srcname'].string()]
944 spectrum = model.spectral()
945 logL0 = like.obs().logL()
946
947 # Write model results for explicit chatter level
948 self._log_string(gammalib.EXPLICIT, str(like.obs().models()))
949
950 # Loop over all nodes or bins
951 for i in range(self._ebounds.size()):
952
953 # Get energy boundaries
954 emin = self._ebounds.emin(i)
955 emax = self._ebounds.emax(i)
956 elogmean = self._ebounds.elogmean(i)
957
958 # Initialise dictionary
959 result = {'energy': elogmean.TeV(),
960 'energy_low': (elogmean - emin).TeV(),
961 'energy_high': (emax - elogmean).TeV(),
962 'flux': 0.0,
963 'e2dnde': 0.0,
964 'flux_err': 0.0,
965 'TS': 0.0,
966 'ulimit': 0.0,
967 'Npred': 0.0,
968 'logL': 0.0,
969 'dloglike': [],
970 'norm_scan': []}
971
972 # Convert differential flux and flux error to nuFnu
973 norm = elogmean.MeV() * elogmean.MeV() * gammalib.MeV2erg
974 result['flux'] = spectrum.intensity(i)
975 result['e2dnde'] = spectrum.intensity(i) * norm
976 result['flux_err'] = spectrum.error(i)
977
978 # Compute upper flux limit
979 ulimit_value = -1.0
980 parname = 'Intensity%d' % i
981
982 # Compute delta log-likelihood
983 if self['dll_sigstep'].real() > 0.0:
984
985 # Extract the normalization scan values
986 (norm_scan, dlogL, loglike) = self._profile_logL(like.copy(),
987 parname,
988 elogmean)
989 result['norm_scan'] = norm_scan
990 result['dloglike'] = dlogL
991 result['logL'] = loglike
992
993 # Compute upper flux limit
994 if self['calc_ulim'].boolean():
995
996 # Logging information
997 self._log_header3(gammalib.EXPLICIT,
998 'Computing upper limit for node energy %f TeV' %
999 result['energy'])
1000
1001 # Copy observation container
1002 obs = like.obs().copy()
1003
1004 # Fix intensities of all nodes
1005 spectral = obs.models()[self['srcname'].string()].spectral()
1006 for par in spectral:
1007 par.fix()
1008
1009 # Create upper limit object
1010 ulimit = ctools.ctulimit(obs)
1011 ulimit['srcname'] = self['srcname'].string()
1012 ulimit['parname'] = parname
1013 ulimit['eref'] = elogmean.TeV()
1014 ulimit['confidence'] = self['confidence'].real()
1015 ulimit['tol'] = 1.0e-3
1016
1017 # Try to run upper limit and catch exceptions
1018 try:
1019 ulimit.run()
1020 ulimit_value = ulimit.diff_ulimit()
1021 except:
1022 self._log_string(gammalib.EXPLICIT, 'Upper limit '
1023 'calculation failed.')
1024 ulimit_value = -1.0
1025
1026 # Compute upper limit
1027 if ulimit_value > 0.0:
1028 result['ulimit'] = ulimit_value
1029
1030 # Compute TS
1031 if self['calc_ts'].boolean():
1032
1033 # Copy observation container
1034 obs = like.obs().copy()
1035
1036 # Set intensity of node to tiny value by scaling the value
1037 # by a factor 1e-8.
1038 par = obs.models()[self['srcname'].string()].spectral()[parname]
1039 par.autoscale()
1040 par.factor_min(1.0e-8)
1041 par.factor_value(1.0e-8)
1042 par.autoscale()
1043 par.fix()
1044
1045 # Perform maximum likelihood fit
1046 tslike = ctools.ctlike(obs)
1047 tslike['edisp'] = self['edisp'].boolean()
1048 tslike.run()
1049
1050 # Store Test Statistic
1051 model = tslike.obs().models()[self['srcname'].string()]
1052 logL1 = tslike.obs().logL()
1053 result['TS'] = 2.0 * (logL1 - logL0)
1054
1055 # Log information
1056 value = '%e +/- %e' % (result['e2dnde'], result['flux_err']*norm)
1057 if self['calc_ulim'].boolean() and result['ulimit'] > 0.0:
1058 value += ' [< %e]' % (result['ulimit']*norm)
1059 value += ' erg/cm2/s'
1060 if self['calc_ts'].boolean() and result['TS'] > 0.0:
1061 value += ' (TS = %.3f)' % (result['TS'])
1062 self._log_value(gammalib.TERSE, 'Bin '+str(i+1), value)
1063
1064 # Append results
1065 results.append(result)
1066
1067 # Return results
1068 return results
1069
1070 def _profile_logL(self, like, parname, elogmean):
1071 """
1072 Computes the delta log-likelihood profile in a single energy bin
1073
1074 Parameters
1075 ----------
1076 like : `~ctools.ctlike()`
1077 ctlike fitter containing prefit model
1078 parname : str
1079 Name of the spectral parameter to be fit
1080 elogmean : `~gammalib.GEnergy()`
1081 Energy at which the model is to be evaluated
1082
1083 Returns
1084 -------
1085 norm_scan : list
1086 Normalization values
1087 dloglike_scan : list
1088 Computed loglikelihood values
1089 loglike: float
1090 Computed reference log-likelihood for dloglike_scan
1091 """
1092 # Compute number of steps
1093 dll_sigmax = self['dll_sigmax'].real()
1094 dll_sigstep = self['dll_sigstep'].real()
1095 sigsteps = int(2 * (dll_sigmax/dll_sigstep) + 1)
1096
1097 # Setup the source model for fitting
1098 source = like.obs().models()[self['srcname'].string()]
1099 spectral = source.spectral()
1100 flux_par = spectral[parname]
1101 norm = flux_par.value()
1102 norm_err = flux_par.error()
1103 source.tscalc(False)
1104
1105 # Fix all parameters in the spectral model
1106 if (self._method != 'NODES' and self._method != 'BINS') or \
1107 (not self['dll_freenodes'].boolean()):
1108 for par in spectral:
1109 par.fix()
1110
1111 # Re-compute the log-likelihood
1112 like.run()
1113 loglike = like.obs().logL()
1114
1115 # Store the resulting log-likelihood
1116 norm_scan = []
1117 dloglike = []
1118 ref_norm = norm
1119 if self._method == 'SLICE':
1120 ref_norm = spectral.eval(elogmean)
1121
1122 # Compute the flux values to evaluate loglike at
1123 log_norm = math.log10(norm)
1124 if (norm_err > 0.0) and (norm > norm_err):
1125 log_step = log_norm - math.log10(norm-norm_err)
1126 log_start = log_norm - (sigsteps/2) * log_step
1127 else:
1128 # For an upper limit bin use a broad range of steps in flux
1129 # from [10^-24, 10^-14] or [norm, 10^-14] whichever is broader
1130 log_start = log_norm
1131 if log_start > -24.0:
1132 log_start = -24.0
1133 log_step = (-14.0 - log_start) / (sigsteps-1)
1134 norm_vals = [10.0 ** (log_start + i*log_step) for i in range(sigsteps)]
1135
1136 # Loop through normalizations
1137 flux_par.factor_min(0.0)
1138 flux_par.factor_max(1e30)
1139 for new_norm in norm_vals:
1140 flux_par.value(new_norm)
1141
1142 # Re-run the fit
1143 like.run()
1144
1145 # Store dlikelihood & norm
1146 dloglike.append(loglike - like.obs().logL())
1147 if self._method == 'SLICE':
1148 norm_scan.append(spectral.eval(elogmean))
1149 else:
1150 norm_scan.append(new_norm)
1151
1152 # Return
1153 return (norm_scan, dloglike, -loglike)
1154
1155 def _create_fits(self, results):
1156 """
1157 Create FITS file
1158
1159 Parameters
1160 ----------
1161 results : list of dict
1162 List of result dictionaries
1163 """
1164 # Create FITS table columns
1165 nrows = self._ebounds.size()
1166 ncols = len(results[0]['norm_scan'])
1167 energy = gammalib.GFitsTableDoubleCol('e_ref', nrows)
1168 energy_low = gammalib.GFitsTableDoubleCol('e_min', nrows)
1169 energy_high = gammalib.GFitsTableDoubleCol('e_max', nrows)
1170 norm = gammalib.GFitsTableDoubleCol('norm', nrows)
1171 norm_err = gammalib.GFitsTableDoubleCol('norm_err', nrows)
1172 norm_ul = gammalib.GFitsTableDoubleCol('norm_ul', nrows)
1173 e2dnde = gammalib.GFitsTableDoubleCol('ref_e2dnde', nrows)
1174 dnde = gammalib.GFitsTableDoubleCol('ref_dnde', nrows)
1175 Npred_values = gammalib.GFitsTableDoubleCol('ref_npred', nrows)
1176 TSvalues = gammalib.GFitsTableDoubleCol('ts', nrows)
1177 loglike = gammalib.GFitsTableDoubleCol('loglike', nrows)
1178 norm_scan = gammalib.GFitsTableDoubleCol('norm_scan', nrows, ncols)
1179 dloglike_scan= gammalib.GFitsTableDoubleCol('dloglike_scan', nrows, ncols)
1180 energy.unit('TeV')
1181 energy_low.unit('TeV')
1182 energy_high.unit('TeV')
1183 norm.unit('')
1184 norm_err.unit('')
1185 norm_ul.unit('')
1186 e2dnde.unit('erg/cm2/s')
1187 dnde.unit('counts/MeV/cm2/s')
1188 Npred_values.unit('counts')
1189 loglike.unit('')
1190 norm_scan.unit('')
1191 dloglike_scan.unit('')
1192
1193 # File FITS table columns
1194 for i, result in enumerate(results):
1195 energy[i] = result['energy']
1196 energy_low[i] = result['energy_low']
1197 energy_high[i] = result['energy_high']
1198 norm[i] = 1.0
1199 if result['flux'] != 0.0:
1200 norm_err[i] = result['flux_err'] / result['flux']
1201 norm_ul[i] = result['ulimit'] / result['flux']
1202 else:
1203 norm_err[i] = 0.0
1204 norm_ul[i] = 0.0
1205 e2dnde[i] = result['e2dnde']
1206 dnde[i] = result['flux']
1207 Npred_values[i] = result['Npred']
1208 TSvalues[i] = result['TS']
1209 loglike[i] = result['logL']
1210
1211 # Add likelihood scan values
1212 for fbin in range(ncols):
1213 dloglike_scan[i,fbin] = result['dloglike'][fbin]
1214 if result['flux'] != 0.0:
1215 norm_scan[i,fbin] = result['norm_scan'][fbin] / result['flux']
1216 else:
1217 norm_scan[i,fbin] = 0.0
1218
1219 # Initialise FITS Table with extension "SPECTRUM"
1220 table = gammalib.GFitsBinTable(nrows)
1221 table.extname('SPECTRUM')
1222
1223 # Add Header for compatibility with gammalib.GMWLSpectrum
1224 table.card('INSTRUME', 'CTA', 'Name of Instrument')
1225 table.card('TELESCOP', 'CTA', 'Name of Telescope')
1226
1227 # Append filled columns to fits table
1228 table.append(energy)
1229 table.append(energy_low)
1230 table.append(energy_high)
1231 table.append(norm)
1232 table.append(norm_err)
1233 table.append(norm_ul)
1234 table.append(e2dnde)
1235 table.append(dnde)
1236 table.append(TSvalues)
1237 table.append(Npred_values)
1238
1239 # Set keywords
1240 table.card('SED_TYPE', 'norm,e2dnde,dnde,npred', 'SED type')
1241 table.card('UL_CONF', self['confidence'].real(), 'Confidence level of upper limits')
1242
1243 # Add the likelihood data
1244 if ncols > 0:
1245 table.card('SED_TYPE').value('likelihood,e2dnde,dnde,npred')
1246 table.append(loglike)
1247 table.append(norm_scan)
1248 table.append(dloglike_scan)
1249
1250 # Stamp table
1251 self._stamp(table)
1252
1253 # Add metadata keywords
1254 table.card('OBJECT', self['srcname'].string(), 'Source name')
1255 table.card('METHOD', self._method, 'Spectrum generation method')
1256 if self._method == 'BINS':
1257 table.card('BINGAMMA', self['bingamma'].real(), 'Spectral index for BINS method')
1258 table.card('STAT', self['statistic'].string(), 'Optimization statistic')
1259 table.card('EDISP', self['edisp'].boolean(), 'Use energy dispersion?')
1260 table.card('CALCULIM', self['calc_ulim'].boolean(), 'Upper limits computation')
1261 table.card('CALCTS', self['calc_ts'].boolean(), 'Test statistic computation')
1262 table.card('FIXBKG', self['fix_bkg'].boolean(), 'Fix background parameters')
1263 table.card('FIXSRCS', self['fix_srcs'].boolean(), 'Fix other source parameters')
1264
1265 # Create the FITS file now
1266 self._fits = gammalib.GFits()
1267 self._fits.append(table)
1268
1269 # Return
1270 return
1271
1272
1273 # Public methods
1274 def process(self):
1275 """
1276 Process the script
1277 """
1278 # Get parameters
1279 self._get_parameters()
1280
1281 # Write input observation container into logger
1282 self._log_observations(gammalib.NORMAL, self.obs(), 'Input observation')
1283
1284 # Write input models into logger
1285 self._log_models(gammalib.VERBOSE, self.obs().models(), 'Input model')
1286
1287 # Write spectral binning into logger
1289
1290 # Adjust model parameters dependent on input user parameters
1291 self._adjust_model_pars()
1292
1293 # Case A: "SLICE" method
1294 if self._method == 'SLICE':
1295
1296 # Fit energy bins
1297 results = self._fit_energy_bins()
1298
1299 # Case B: "NODES" method
1300 elif self._method == 'NODES':
1301
1302 # Replace source spectrum by nodes function
1304
1305 # Write replaced models into logger
1306 self._log_models(gammalib.VERBOSE, self.obs().models(),
1307 '"NODES" spectrum generation model')
1308
1309 # Fit model
1310 results = self._fit_model()
1311
1312 # Case C: "BINS" method
1313 elif self._method == 'BINS':
1314
1315 # Replace source spectrum by bins function
1317
1318 # Write replaced models into logger
1319 self._log_models(gammalib.VERBOSE, self.obs().models(),
1320 '"BINS" spectrum generation model')
1321
1322 # Fit model
1323 results = self._fit_model()
1324
1325 # Create FITS file
1326 self._create_fits(results)
1327
1328 # Optionally publish spectrum
1329 if self['publish'].boolean():
1330 self.publish()
1331
1332 # Return
1333 return
1334
1335 def save(self):
1336 """
1337 Save spectrum
1338 """
1339 # Write header
1340 self._log_header1(gammalib.TERSE, 'Save spectrum')
1341
1342 # Continue only if FITS file is valid
1343 if self._fits != None:
1344
1345 # Get outmap parameter
1346 outfile = self['outfile'].filename()
1347
1348 # Log file name
1349 self._log_value(gammalib.NORMAL, 'Spectrum file', outfile.url())
1350
1351 # Save spectrum
1352 self._fits.saveto(outfile, self['clobber'].boolean())
1353
1354 # Return
1355 return
1356
1357 def publish(self, name=''):
1358 """
1359 Publish spectrum
1360
1361 Parameters
1362 ----------
1363 name : str, optional
1364 Name of spectrum
1365 """
1366 # Write header
1367 self._log_header1(gammalib.TERSE, 'Publish spectrum')
1368
1369 # Continue only if FITS file is valid
1370 if self._fits != None:
1371
1372 # Continue only if spectrum is valid
1373 if self._fits.contains('SPECTRUM'):
1374
1375 # Set default name is user name is empty
1376 if not name:
1377 user_name = self._name()
1378 else:
1379 user_name = name
1380
1381 # Log file name
1382 self._log_value(gammalib.NORMAL, 'Spectrum name', user_name)
1383
1384 # Publish spectrum
1385 self._fits.publish('SPECTRUM', user_name)
1386
1387 # Return
1388 return
1389
1390 def spectrum(self):
1391 """
1392 Return spectrum FITS file
1393
1394 Returns:
1395 FITS file containing spectrum
1396 """
1397 # Return
1398 return self._fits
1399
1400 def models(self, models):
1401 """
1402 Set model
1403 """
1404 # Copy models
1405 self.obs().models(models.clone())
1406
1407 # Return
1408 return
1409
1410
1411# ======================== #
1412# Main routine entry point #
1413# ======================== #
1414if __name__ == '__main__':
1415
1416 # Create instance of application
1417 app = csspec(sys.argv)
1418
1419 # Execute application
1420 app.execute()
_set_replace_src_spectrum_by_nodes(self)
Definition csspec.py:436
_log_spectral_binning(self)
Definition csspec.py:314
_set_replace_src_spectrum_by_bins(self)
Definition csspec.py:493
_select_onoff_obs(self, obs, emin, emax)
Definition csspec.py:551
__init__(self, *argv)
Definition csspec.py:41
_profile_logL(self, like, parname, elogmean)
Definition csspec.py:1070
_create_fits(self, results)
Definition csspec.py:1155
models(self, models)
Definition csspec.py:1400
_fit_energy_bin(self, i)
Definition csspec.py:718
_select_obs(self, emin, emax)
Definition csspec.py:628
publish(self, name='')
Definition csspec.py:1357
__setstate__(self, state)
Definition csspec.py:88