CTLearn 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctlearn/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from .data_loader import *
2
+ from .output_handler import *
3
+ from .run_model import *
4
+ from .utils import *
5
+
6
+ from .version import get_version
7
+
8
+ __version__ = get_version(pep440=False)
ctlearn/build_irf.py ADDED
@@ -0,0 +1,676 @@
1
+ """
2
+ Build IRFs and sensitivity curves from CTLearn DL2-like files using pyirf.
3
+ Edited from pyirf examples (Credits Noethe et al.):
4
+ https://github.com/cta-observatory/pyirf/blob/master/examples/calculate_eventdisplay_irfs.py
5
+ """
6
+ import argparse
7
+ import glob
8
+ import logging
9
+ import operator
10
+ import os
11
+
12
+ import numpy as np
13
+ from astropy import table
14
+ from astropy.table import QTable, MaskedColumn
15
+ import astropy.units as u
16
+ from astropy.io import fits
17
+ import pandas as pd
18
+
19
+ from pyirf.binning import (
20
+ bin_center,
21
+ create_bins_per_decade,
22
+ add_overflow_bins,
23
+ create_histogram_table,
24
+ )
25
+ from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut
26
+ from pyirf.sensitivity import calculate_sensitivity, estimate_background
27
+ from pyirf.simulations import SimulatedEventsInfo
28
+ from pyirf.utils import calculate_theta, calculate_source_fov_offset
29
+ from pyirf.benchmarks import energy_bias_resolution, angular_resolution
30
+
31
+ from pyirf.spectral import (
32
+ calculate_event_weights,
33
+ PowerLaw,
34
+ CRAB_HEGRA,
35
+ IRFDOC_PROTON_SPECTRUM,
36
+ IRFDOC_ELECTRON_SPECTRUM,
37
+ )
38
+ from pyirf.cut_optimization import optimize_gh_cut
39
+
40
+ from pyirf.irf import (
41
+ effective_area_per_energy,
42
+ energy_dispersion,
43
+ psf_table,
44
+ background_2d,
45
+ )
46
+
47
+ from pyirf.io import (
48
+ create_aeff2d_hdu,
49
+ create_psf_table_hdu,
50
+ create_energy_dispersion_hdu,
51
+ create_rad_max_hdu,
52
+ create_background_2d_hdu,
53
+ )
54
+
55
+
56
+ log = logging.getLogger("pyirf")
57
+
58
+ # Map the particle ids to the particle information
59
+ particles = {
60
+ 0: {
61
+ "name": "gamma",
62
+ "target_spectrum": CRAB_HEGRA,
63
+ "mc_header": pd.DataFrame(),
64
+ "events": QTable(),
65
+ },
66
+ 101: {
67
+ "name": "proton",
68
+ "target_spectrum": IRFDOC_PROTON_SPECTRUM,
69
+ "mc_header": pd.DataFrame(),
70
+ "events": QTable(),
71
+ },
72
+ 1: {
73
+ "name": "electron",
74
+ "target_spectrum": IRFDOC_ELECTRON_SPECTRUM,
75
+ "mc_header": pd.DataFrame(),
76
+ "events": QTable(),
77
+ },
78
+ }
79
+
80
+ # Map column names
81
+ name_mapping = {
82
+ "gammaness": "gh_score",
83
+ "source_alt": "true_alt",
84
+ "source_az": "true_az",
85
+ }
86
+ # Map units
87
+ unit_mapping = {
88
+ "true_energy": u.TeV,
89
+ "reco_energy": u.TeV,
90
+ "pointing_alt": u.rad,
91
+ "pointing_az": u.rad,
92
+ "true_alt": u.rad,
93
+ "true_az": u.rad,
94
+ "reco_alt": u.rad,
95
+ "reco_az": u.rad,
96
+ }
97
+
98
+
99
+ def main():
100
+ parser = argparse.ArgumentParser(
101
+ description=(
102
+ "Build IRFs and sensitivity curves from CTLearn DL2-like files using pyirf."
103
+ )
104
+ )
105
+ parser.add_argument(
106
+ "--input",
107
+ "-i",
108
+ help="Input directories; default is ./",
109
+ default=["./"],
110
+ nargs="+",
111
+ )
112
+ parser.add_argument(
113
+ "--pattern",
114
+ "-p",
115
+ help="Pattern to mask unwanted files from the data input directory; default is *.h5",
116
+ default=["*.h5"],
117
+ nargs="+",
118
+ )
119
+ parser.add_argument(
120
+ "--output",
121
+ "-o",
122
+ help="Output file; default is ./pyirf.fits.gz",
123
+ default="./pyirf.fits.gz",
124
+ )
125
+ parser.add_argument(
126
+ "--energy_range",
127
+ "-e",
128
+ help="Energy range in TeV; default is [0.03, 30.0]",
129
+ default=[0.03, 30.0],
130
+ nargs="+",
131
+ type=float,
132
+ )
133
+ parser.add_argument(
134
+ "--theta_range",
135
+ "-t",
136
+ help="Theta cut range in deg; default is [0.05, 0.3]",
137
+ default=[0.05, 0.3],
138
+ nargs="+",
139
+ type=float,
140
+ )
141
+ parser.add_argument(
142
+ "--obstime",
143
+ help="Observation time in hours; default is 50",
144
+ default=50,
145
+ )
146
+ parser.add_argument(
147
+ "--alpha",
148
+ help="Scaling between on and off region; default is 0.2",
149
+ default=0.2,
150
+ )
151
+ parser.add_argument(
152
+ "--fov_offset_min",
153
+ help="Minimum distance from the fov center for background events to be taken into account; default is 0.0",
154
+ default=0.0,
155
+ )
156
+ parser.add_argument(
157
+ "--fov_offset_max",
158
+ help="Maximum distance from the fov center in deg for background events to be taken into account; default is 1.0",
159
+ default=1.0,
160
+ )
161
+ parser.add_argument(
162
+ "--max_gh_cut_eff",
163
+ help="Maximum gamma/hadron cut efficiency; default is 0.9",
164
+ default=0.9,
165
+ )
166
+ parser.add_argument(
167
+ "--gh_cut_eff_step",
168
+ help="Gamma/hadron cut efficiency step; default is 0.01",
169
+ default=0.01,
170
+ )
171
+ parser.add_argument(
172
+ "--init_gh_cut_eff",
173
+ help="Initial gamma/hadron cut efficiency; default is 0.4",
174
+ default=0.4,
175
+ )
176
+ parser.add_argument(
177
+ "--quality_cuts",
178
+ "-c",
179
+ help="String of the quality cuts",
180
+ type=str,
181
+ )
182
+ parser.add_argument(
183
+ "--size_cut",
184
+ "-z",
185
+ help="Minimum size values",
186
+ nargs="+",
187
+ type=float,
188
+ )
189
+ parser.add_argument(
190
+ "--leakage_cut",
191
+ "-l",
192
+ help="Maximum leakage2 intensity values",
193
+ nargs="+",
194
+ type=float,
195
+ )
196
+ parser.add_argument(
197
+ "--energy_dependent_gh_efficiency",
198
+ help="Gamma/hadron efficiency cut for an energy dependent energy cut. Valid options: [0.0, 1.0]",
199
+ type=float,
200
+ )
201
+ parser.add_argument(
202
+ "--global_gh_cut",
203
+ help="Gamma/hadron cut for a global cut. Valid options: [0.0, 1.0]",
204
+ type=float,
205
+ )
206
+
207
+ args = parser.parse_args()
208
+
209
+ logging.basicConfig(level=logging.INFO)
210
+ logging.getLogger("pyirf").setLevel(logging.DEBUG)
211
+
212
+ T_OBS = args.obstime * u.hour
213
+
214
+ # scaling between on and off region.
215
+ # (Default) Make off region 5 times larger than on region for better
216
+ # background statistics
217
+ ALPHA = args.alpha
218
+
219
+ # Radius to use for calculating bg rate
220
+ FOV_OFFSET_MIN = args.fov_offset_min * u.deg
221
+ FOV_OFFSET_MAX = args.fov_offset_max * u.deg
222
+ MAX_GH_CUT_EFFICIENCY = args.max_gh_cut_eff
223
+ GH_CUT_EFFICIENCY_STEP = args.gh_cut_eff_step
224
+
225
+ # gh cut used for first calculation of the binned theta cuts
226
+ INITIAL_GH_CUT_EFFICENCY = args.init_gh_cut_eff
227
+
228
+ MIN_ENERGY = args.energy_range[0] * u.TeV
229
+ MAX_ENERGY = args.energy_range[-1] * u.TeV
230
+
231
+ MIN_THETA_CUT = args.theta_range[0] * u.deg
232
+ MAX_THETA_CUT = args.theta_range[-1] * u.deg
233
+
234
+ global_tel_ids = []
235
+ n_showers_factor = 1
236
+ for input in args.input:
237
+ abs_file_dir = os.path.abspath(input)
238
+ for pattern in args.pattern:
239
+ files = glob.glob(os.path.join(abs_file_dir, pattern))
240
+ if not files:
241
+ continue
242
+
243
+ for file in np.sort(files):
244
+ tel_ids = []
245
+ with pd.HDFStore(file, mode="r") as f:
246
+ file_keys = list(f.keys())
247
+ events = f["/dl2/reco"]
248
+ events = events.rename(columns=name_mapping)
249
+ particle_type = int(events["true_shower_primary_id"][0])
250
+ drop_cols = ["event_id", "obs_id", "true_shower_primary_id"]
251
+ for k in [key for key in file_keys if key.startswith("/dl1b/")]:
252
+ tel_ids_string = k.split("/")[-1].replace("tel_", "")
253
+ n_showers_factor = len(tel_ids_string.split("_"))
254
+ tel_ids.append(int(tel_ids_string))
255
+ parameters = f[k].rename(
256
+ lambda x: f"tel_{int(tel_ids_string)}_" + x, axis="columns"
257
+ )
258
+ drop_cols.extend(parameters.keys())
259
+ events = pd.concat([events, parameters], axis=1)
260
+
261
+ if not global_tel_ids:
262
+ global_tel_ids = tel_ids
263
+ else:
264
+ if global_tel_ids != tel_ids:
265
+ raise ValueError(
266
+ f"Tel ids inconsistent. '{global_tel_ids}' is not equal to '{tel_ids}' from '{file}'."
267
+ )
268
+
269
+ # Apply quality cuts
270
+ mask = None
271
+ if args.quality_cuts:
272
+ mask = args.quality_cuts
273
+
274
+ if args.size_cut:
275
+ for s, size in enumerate(args.size_cut):
276
+ if mask:
277
+ mask += f"& tel_{global_tel_ids[s]}_hillas_intensity > {size} "
278
+ else:
279
+ mask = f"tel_{global_tel_ids[s]}_hillas_intensity > {size} "
280
+ if args.leakage_cut:
281
+ for l, leakage in enumerate(args.leakage_cut):
282
+ if mask:
283
+ mask += f"& tel_{global_tel_ids[l]}_leakage_intensity_width_2 < {leakage} "
284
+ else:
285
+ mask = f"tel_{global_tel_ids[l]}_leakage_intensity_width_2 < {leakage} "
286
+ if mask:
287
+ events.query(mask, inplace=True)
288
+ events = events.drop(drop_cols, axis=1)
289
+ events = table.QTable.from_pandas(events)
290
+ for k, v in unit_mapping.items():
291
+ events[k] *= v
292
+
293
+ particles[particle_type]["events"] = table.vstack(
294
+ [particles[particle_type]["events"], events]
295
+ )
296
+
297
+ # Sims info
298
+ mc_header = f["/info/mc_header"]
299
+
300
+ # Check if ringwobbles then set the viewcone radius to zero
301
+ particles[particle_type]["mc_header"] = pd.concat(
302
+ [particles[particle_type]["mc_header"], mc_header],
303
+ ignore_index=True,
304
+ )
305
+
306
+ for particle_type, p in particles.items():
307
+ log.info(f'Simulated {p["name"]} Events:')
308
+
309
+ simulation_info = SimulatedEventsInfo(
310
+ n_showers=int(n_showers_factor * p["mc_header"]["n_showers"].sum()),
311
+ energy_min=u.Quantity(p["mc_header"]["energy_range_min"].min(), u.TeV),
312
+ energy_max=u.Quantity(p["mc_header"]["energy_range_max"].max(), u.TeV),
313
+ spectral_index=p["mc_header"]["spectral_index"][0],
314
+ max_impact=u.Quantity(p["mc_header"]["max_scatter_range"].max(), u.m),
315
+ viewcone=u.Quantity(
316
+ p["mc_header"]["max_viewcone_radius"][0]
317
+ - p["mc_header"]["min_viewcone_radius"][0],
318
+ u.deg,
319
+ ),
320
+ )
321
+ p["simulation_info"] = simulation_info
322
+ p["simulated_spectrum"] = PowerLaw.from_simulation(simulation_info, T_OBS)
323
+ p["events"]["weight"] = MaskedColumn(
324
+ data=calculate_event_weights(
325
+ p["events"]["true_energy"],
326
+ p["target_spectrum"],
327
+ p["simulated_spectrum"],
328
+ )
329
+ )
330
+ for prefix in ("true", "reco"):
331
+ k = f"{prefix}_source_fov_offset"
332
+ p["events"][k] = calculate_source_fov_offset(p["events"], prefix=prefix)
333
+
334
+ # calculate theta / distance between reco and true direction of the gamma-ray
335
+ p["events"]["theta"] = calculate_theta(
336
+ p["events"],
337
+ assumed_source_az=p["events"]["true_az"],
338
+ assumed_source_alt=p["events"]["true_alt"],
339
+ )
340
+ log.info(simulation_info)
341
+ log.info("")
342
+
343
+ gammas = particles[0]["events"]
344
+ # background table composed of both electrons and protons
345
+ background = table.vstack([particles[101]["events"], particles[1]["events"]])
346
+
347
+ mask_theta_cuts = np.full(len(gammas["theta"]), True)
348
+ if not bool(args.energy_dependent_gh_efficiency) or bool(args.global_gh_cut):
349
+ INITIAL_GH_CUT = np.quantile(gammas["gh_score"], (1 - INITIAL_GH_CUT_EFFICENCY))
350
+ log.info(f"Using fixed G/H cut of {INITIAL_GH_CUT} to calculate theta cuts")
351
+ mask_theta_cuts = gammas["gh_score"] >= INITIAL_GH_CUT
352
+
353
+ # event display uses much finer bins for the theta cut than
354
+ # for the sensitivity
355
+ theta_bins = add_overflow_bins(create_bins_per_decade(MIN_ENERGY, MAX_ENERGY, 50))
356
+ # same bins as event display uses
357
+ sensitivity_bins = add_overflow_bins(
358
+ create_bins_per_decade(MIN_ENERGY, MAX_ENERGY, bins_per_decade=5)
359
+ )
360
+
361
+ # theta cut is 68 percent containmente of the gammas
362
+ # for now with a fixed global, unoptimized score cut
363
+ # the cut is calculated in the same bins as the sensitivity,
364
+ # but then interpolated to 10x the resolution.
365
+
366
+ theta_cuts_coarse = calculate_percentile_cut(
367
+ gammas["theta"][mask_theta_cuts],
368
+ gammas["reco_energy"][mask_theta_cuts],
369
+ bins=sensitivity_bins,
370
+ min_value=MIN_THETA_CUT,
371
+ fill_value=MAX_THETA_CUT,
372
+ max_value=MAX_THETA_CUT,
373
+ percentile=68,
374
+ )
375
+
376
+ # interpolate to 50 bins per decade
377
+ theta_center = bin_center(theta_bins)
378
+ inter_center = bin_center(sensitivity_bins)
379
+ theta_cuts = table.QTable(
380
+ {
381
+ "low": theta_bins[:-1],
382
+ "high": theta_bins[1:],
383
+ "center": theta_center,
384
+ "cut": np.interp(
385
+ np.log10(theta_center / u.TeV),
386
+ np.log10(inter_center / u.TeV),
387
+ theta_cuts_coarse["cut"],
388
+ ),
389
+ }
390
+ )
391
+ # binnings for the irfs
392
+ true_energy_bins = add_overflow_bins(
393
+ create_bins_per_decade(MIN_ENERGY, MAX_ENERGY, 5)
394
+ )
395
+
396
+ reco_energy_bins = add_overflow_bins(
397
+ create_bins_per_decade(MIN_ENERGY, MAX_ENERGY, 5)
398
+ )
399
+
400
+ gh_center = bin_center(reco_energy_bins)
401
+ inter_center = bin_center(sensitivity_bins)
402
+
403
+ if args.energy_dependent_gh_efficiency:
404
+
405
+ log.info(
406
+ f"Performing a energy dependant G/H cut using gamma efficiency of {args.energy_dependent_gh_efficiency}"
407
+ )
408
+ gh_cuts_coarse = calculate_percentile_cut(
409
+ gammas["gh_score"],
410
+ gammas["reco_energy"],
411
+ bins=sensitivity_bins,
412
+ min_value=0.05,
413
+ max_value=0.95,
414
+ fill_value=gammas["gh_score"].max(),
415
+ percentile=100 * (1 - args.energy_dependent_gh_efficiency),
416
+ smoothing=None,
417
+ min_events=100,
418
+ )
419
+
420
+ gh_cuts = table.QTable(
421
+ {
422
+ "low": reco_energy_bins[:-1],
423
+ "high": reco_energy_bins[1:],
424
+ "center": gh_center,
425
+ "cut": np.interp(
426
+ np.log10(gh_center / u.TeV),
427
+ np.log10(inter_center / u.TeV),
428
+ gh_cuts_coarse["cut"],
429
+ ),
430
+ }
431
+ )
432
+
433
+ for tab in (gammas, background):
434
+ tab["selected_gh"] = evaluate_binned_cut(
435
+ tab["gh_score"], tab["reco_energy"], gh_cuts, operator.ge
436
+ )
437
+
438
+ gammas["selected_theta"] = evaluate_binned_cut(
439
+ gammas["theta"], gammas["reco_energy"], theta_cuts, operator.le
440
+ )
441
+ gammas["selected"] = gammas["selected_theta"] & gammas["selected_gh"]
442
+
443
+ sensitivities = []
444
+
445
+ gammas_hist = create_histogram_table(
446
+ gammas[gammas["selected"]], sensitivity_bins, "reco_energy"
447
+ )
448
+
449
+ background_hist = estimate_background(
450
+ events=background[background["selected_gh"]],
451
+ reco_energy_bins=sensitivity_bins,
452
+ theta_cuts=theta_cuts,
453
+ alpha=ALPHA,
454
+ fov_offset_min=FOV_OFFSET_MIN,
455
+ fov_offset_max=FOV_OFFSET_MAX,
456
+ )
457
+
458
+ sensitivity_coarse = calculate_sensitivity(
459
+ gammas_hist, background_hist, alpha=ALPHA
460
+ )
461
+ sensitivities.append(sensitivity_coarse)
462
+
463
+ sensitivity = sensitivities[0].copy()
464
+ for bin_id in range(len(sensitivity_bins) - 1):
465
+ sensitivities_bin = [
466
+ s["relative_sensitivity"][bin_id] for s in sensitivities
467
+ ]
468
+
469
+ if not np.all(np.isnan(sensitivities_bin)):
470
+ # nanargmin won't return the index of nan entries
471
+ best = np.nanargmin(sensitivities_bin)
472
+ else:
473
+ # if all are invalid, just use the first one
474
+ best = 0
475
+
476
+ sensitivity[bin_id] = sensitivities[best][bin_id]
477
+
478
+ elif args.global_gh_cut and bool(args.energy_dependent_gh_efficiency):
479
+ log.info(
480
+ "Both a global G/H cut and a energy dependant G/H cut were selected. The energy dependant G/H cut was performed"
481
+ )
482
+
483
+ elif args.global_gh_cut and not bool(args.energy_dependent_gh_efficiency):
484
+
485
+ log.info("Using a global G/H cut of " f"{args.global_gh_cut}")
486
+
487
+ for tab in (gammas, background):
488
+ tab["selected_gh"] = tab["gh_score"] > args.global_gh_cut
489
+
490
+ gh_cuts = table.QTable(
491
+ {
492
+ "low": reco_energy_bins[:-1],
493
+ "high": reco_energy_bins[1:],
494
+ "center": gh_center,
495
+ "cut": np.full(len(reco_energy_bins) - 1, args.global_gh_cut),
496
+ }
497
+ )
498
+
499
+ gammas["selected_theta"] = evaluate_binned_cut(
500
+ gammas["theta"], gammas["reco_energy"], theta_cuts, operator.le
501
+ )
502
+ gammas["selected"] = gammas["selected_theta"] & gammas["selected_gh"]
503
+
504
+ sensitivities = []
505
+
506
+ gammas_hist = create_histogram_table(
507
+ gammas[gammas["selected"]], sensitivity_bins, "reco_energy"
508
+ )
509
+
510
+ background_hist = estimate_background(
511
+ events=background[background["selected_gh"]],
512
+ reco_energy_bins=sensitivity_bins,
513
+ theta_cuts=theta_cuts,
514
+ alpha=ALPHA,
515
+ fov_offset_min=FOV_OFFSET_MIN,
516
+ fov_offset_max=FOV_OFFSET_MAX,
517
+ )
518
+
519
+ sensitivity_coarse = calculate_sensitivity(
520
+ gammas_hist, background_hist, alpha=ALPHA
521
+ )
522
+ sensitivities.append(sensitivity_coarse)
523
+
524
+ sensitivity = sensitivities[0].copy()
525
+ for bin_id in range(len(sensitivity_bins) - 1):
526
+ sensitivities_bin = [
527
+ s["relative_sensitivity"][bin_id] for s in sensitivities
528
+ ]
529
+
530
+ if not np.all(np.isnan(sensitivities_bin)):
531
+ # nanargmin won't return the index of nan entries
532
+ best = np.nanargmin(sensitivities_bin)
533
+ else:
534
+ # if all are invalid, just use the first one
535
+ best = 0
536
+
537
+ sensitivity[bin_id] = sensitivities[best][bin_id]
538
+
539
+ else:
540
+ log.info("Optimizing G/H separation cut for best sensitivity")
541
+ gh_cut_efficiencies = np.arange(
542
+ GH_CUT_EFFICIENCY_STEP,
543
+ MAX_GH_CUT_EFFICIENCY + GH_CUT_EFFICIENCY_STEP / 2,
544
+ GH_CUT_EFFICIENCY_STEP,
545
+ )
546
+ sensitivity, gh_cuts = optimize_gh_cut(
547
+ gammas,
548
+ background,
549
+ reco_energy_bins=sensitivity_bins,
550
+ gh_cut_efficiencies=gh_cut_efficiencies,
551
+ op=operator.ge,
552
+ theta_cuts=theta_cuts,
553
+ alpha=ALPHA,
554
+ fov_offset_min=FOV_OFFSET_MIN,
555
+ fov_offset_max=FOV_OFFSET_MAX,
556
+ )
557
+
558
+ # now that we have the optimized gh cuts, we recalculate the theta
559
+ # cut as 68 percent containment on the events surviving these cuts.
560
+ log.info("Recalculating theta cut for optimized GH Cuts")
561
+ for tab in (gammas, background):
562
+ tab["selected_gh"] = evaluate_binned_cut(
563
+ tab["gh_score"], tab["reco_energy"], gh_cuts, operator.ge
564
+ )
565
+
566
+ gammas["selected_theta"] = evaluate_binned_cut(
567
+ gammas["theta"], gammas["reco_energy"], theta_cuts, operator.le
568
+ )
569
+ gammas["selected"] = gammas["selected_theta"] & gammas["selected_gh"]
570
+
571
+ # scale relative sensitivity by Crab flux to get the flux sensitivity
572
+ spectrum = particles[0]["target_spectrum"]
573
+ sensitivity["flux_sensitivity"] = sensitivity["relative_sensitivity"] * spectrum(
574
+ sensitivity["reco_energy_center"]
575
+ )
576
+
577
+ log.info("Calculating IRFs")
578
+ hdus = [
579
+ fits.PrimaryHDU(),
580
+ fits.BinTableHDU(sensitivity, name="SENSITIVITY"),
581
+ fits.BinTableHDU(theta_cuts, name="THETA_CUTS"),
582
+ fits.BinTableHDU(gh_cuts, name="GH_CUTS"),
583
+ ]
584
+
585
+ masks = {
586
+ "": gammas["selected"],
587
+ "_NO_CUTS": slice(None),
588
+ "_ONLY_GH": gammas["selected_gh"],
589
+ "_ONLY_THETA": gammas["selected_theta"],
590
+ }
591
+
592
+ fov_offset_bins = [0, 0.5] * u.deg
593
+ source_offset_bins = np.arange(0, 1 + 1e-4, 1e-3) * u.deg
594
+ energy_migration_bins = np.geomspace(0.2, 5, 200)
595
+
596
+ for label, mask in masks.items():
597
+ effective_area = effective_area_per_energy(
598
+ gammas[mask],
599
+ particles[0]["simulation_info"],
600
+ true_energy_bins=true_energy_bins,
601
+ )
602
+ hdus.append(
603
+ create_aeff2d_hdu(
604
+ effective_area[..., np.newaxis], # add one dimension for FOV offset
605
+ true_energy_bins,
606
+ fov_offset_bins,
607
+ extname="EFFECTIVE_AREA" + label,
608
+ )
609
+ )
610
+ edisp = energy_dispersion(
611
+ gammas[mask],
612
+ true_energy_bins=true_energy_bins,
613
+ fov_offset_bins=fov_offset_bins,
614
+ migration_bins=energy_migration_bins,
615
+ )
616
+ hdus.append(
617
+ create_energy_dispersion_hdu(
618
+ edisp,
619
+ true_energy_bins=true_energy_bins,
620
+ migration_bins=energy_migration_bins,
621
+ fov_offset_bins=fov_offset_bins,
622
+ extname="ENERGY_DISPERSION" + label,
623
+ )
624
+ )
625
+
626
+ bias_resolution = energy_bias_resolution(
627
+ gammas[gammas["selected"]], reco_energy_bins, energy_type="reco"
628
+ )
629
+ ang_res = angular_resolution(
630
+ gammas[gammas["selected_gh"]], reco_energy_bins, energy_type="reco"
631
+ )
632
+ psf = psf_table(
633
+ gammas[gammas["selected_gh"]],
634
+ true_energy_bins,
635
+ fov_offset_bins=fov_offset_bins,
636
+ source_offset_bins=source_offset_bins,
637
+ )
638
+
639
+ background_rate = background_2d(
640
+ background[background["selected_gh"]],
641
+ reco_energy_bins,
642
+ fov_offset_bins=np.arange(0, 11) * u.deg,
643
+ t_obs=T_OBS,
644
+ )
645
+
646
+ hdus.append(
647
+ create_background_2d_hdu(
648
+ background_rate,
649
+ reco_energy_bins,
650
+ fov_offset_bins=np.arange(0, 11) * u.deg,
651
+ )
652
+ )
653
+ hdus.append(
654
+ create_psf_table_hdu(
655
+ psf,
656
+ true_energy_bins,
657
+ source_offset_bins,
658
+ fov_offset_bins,
659
+ )
660
+ )
661
+ hdus.append(
662
+ create_rad_max_hdu(
663
+ theta_cuts["cut"][:, np.newaxis], theta_bins, fov_offset_bins
664
+ )
665
+ )
666
+ hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION"))
667
+ hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION"))
668
+
669
+ if not args.output.endswith(".fits.gz"):
670
+ args.output += ".fits.gz"
671
+ log.info(f"Writing outputfile in {args.output}")
672
+ fits.HDUList(hdus).writeto(args.output, overwrite=True)
673
+
674
+
675
+ if __name__ == "__main__":
676
+ main()