|
| 1 | +from collections import defaultdict |
| 2 | + |
1 | 3 | import numpy as np |
2 | 4 | from numba import njit |
| 5 | +from traitlets import default |
3 | 6 |
|
| 7 | +from ..containers import EventType |
4 | 8 | from ..core import TelescopeComponent |
5 | 9 | from ..core.env import CTAPIPE_DISABLE_NUMBA_CACHE |
6 | | -from ..core.traits import BoolTelescopeParameter, FloatTelescopeParameter, Int |
| 10 | +from ..core.traits import ( |
| 11 | + Bool, |
| 12 | + BoolTelescopeParameter, |
| 13 | + FloatTelescopeParameter, |
| 14 | + Int, |
| 15 | + Path, |
| 16 | +) |
7 | 17 | from ..instrument import SubarrayDescription |
| 18 | +from ..io import EventSource |
| 19 | +from ..utils import EventTypeFilter |
8 | 20 |
|
9 | 21 | __all__ = [ |
10 | 22 | "ImageModifier", |
@@ -100,6 +112,317 @@ def _smear_psf_randomly( |
100 | 112 | return new_image |
101 | 113 |
|
102 | 114 |
|
| 115 | +class NoiseEventTypeFilter(EventTypeFilter): |
| 116 | + """ |
| 117 | + Event filter to select noise events for MC tuning |
| 118 | + By default it selects SKY_PEDESTAL events |
| 119 | + """ |
| 120 | + |
| 121 | + @default("allowed_types") |
| 122 | + def allowed_types_default(self): |
| 123 | + return {EventType.SKY_PEDESTAL} |
| 124 | + |
| 125 | + |
| 126 | +@njit(cache=not CTAPIPE_DISABLE_NUMBA_CACHE) |
| 127 | +def build_wf_noise_pixelwise( |
| 128 | + waveforms, n_noise_realizations, nsb_level, rng, sample_pixels_independently |
| 129 | +): |
| 130 | + """ |
| 131 | + Combine "elemental noise waveforms" into total noise waveforms by |
| 132 | + combining a given number of them, chosen randomly |
| 133 | +
|
| 134 | + Parameters |
| 135 | + ---------- |
| 136 | + waveforms: array (nevents, ngains, npixels, nsamples), the elemental noise |
| 137 | + waveforms |
| 138 | +
|
| 139 | + n_noise_realizations: int |
| 140 | + the number of total noise waveforms we want to generate |
| 141 | +
|
| 142 | + nsb_level: int |
| 143 | + the number of elemental noise waveforms we combine to produce each total |
| 144 | + noise waveform |
| 145 | +
|
| 146 | + rng: random number generator |
| 147 | +
|
| 148 | + sample_pixels_independently: bool |
| 149 | + if True, each pixel will use a different random combination of the elemental |
| 150 | + noise events to build the noise to be added to its waveform. |
| 151 | + if False, the waveform for each pixel in any given noise realization comes |
| 152 | + from the same random combination of the input elemental noise events. |
| 153 | +
|
| 154 | +
|
| 155 | + Returns |
| 156 | + ------- |
| 157 | + array (n_noise, ngains, npixels, nsamples), total noise waveforms |
| 158 | +
|
| 159 | + """ |
| 160 | + n_events, n_gains, n_pixels, n_samples = waveforms.shape |
| 161 | + noise = np.zeros( |
| 162 | + (n_noise_realizations, n_gains, n_pixels, n_samples), dtype=np.float32 |
| 163 | + ) |
| 164 | + |
| 165 | + for i in range(n_noise_realizations): |
| 166 | + if sample_pixels_independently: |
| 167 | + for pixel in range(n_pixels): |
| 168 | + chosen = rng.permutation(n_events)[:nsb_level] |
| 169 | + # The line above is slower (especially for n_events much |
| 170 | + # larger than nsb_level) than rng.choice(n_events, nsb_level) |
| 171 | + # Unfortunately rng.choice does not currently work with numba. |
| 172 | + |
| 173 | + for event in chosen: |
| 174 | + noise[i, :, pixel] += waveforms[event, :, pixel] |
| 175 | + else: |
| 176 | + chosen = rng.permutation(n_events)[:nsb_level] |
| 177 | + for event in chosen: |
| 178 | + noise[i] += waveforms[event] |
| 179 | + |
| 180 | + return noise |
| 181 | + |
| 182 | + |
| 183 | +class WaveformModifier(TelescopeComponent): |
| 184 | + """ |
| 185 | + Component to add NSB noise to R1 waveforms. |
| 186 | +
|
| 187 | + This component in principle to be applied on MC shower simulations, to make |
| 188 | + them closer to real data in terms of noise level. |
| 189 | +
|
| 190 | + There are two possibilities: |
| 191 | + 1. The "showers MC" file has dark-NSB settings and electronic noise ( |
| 192 | + waveform baseline fluctuations), and the input NSB file is a |
| 193 | + dedicated sim_telarray file, which must be produced with the same |
| 194 | + telescope array configuration (and other simulation settings) as the |
| 195 | + showers MC to which the noise is to be added, but containing only NSB |
| 196 | + noise (electronic fluctuations of the baseline should be switched off) |
| 197 | +
|
| 198 | + 2. The showers MC file is produced with no noise (baseline |
| 199 | + fluctuations) of any kind (electronic or NSB), just the Cherenkov |
| 200 | + signal (with the appropriate single-p.e.-response fluctuations), |
| 201 | + whereas the nsb file is a real data DL0 file from which only the |
| 202 | + interleaved pedestals are used (all gain channels must be present |
| 203 | + for all pixels). In that case, nsb_level must be =1 (to |
| 204 | + match the MC to the data) and sample_pixels_independently=False |
| 205 | + (we do not want e.g. to duplicate stars in the FoV). |
| 206 | +
|
| 207 | +
|
| 208 | + In case (1), the number of available noise events per telescope in the NSB |
| 209 | + file must be at least twice the number of waveforms ("nsb_level") from |
| 210 | + that file that we want to add up. If the NSB file is produced with a |
| 211 | + level of 25% of dark NSB, and we want to simulate 10x dark NSB, |
| 212 | + then nsb_level=40 (=10/0.25) and the file must contain at least 80 |
| 213 | + events. This is to guarantee that the different noise waveforms are not |
| 214 | + too correlated |
| 215 | +
|
| 216 | +
|
| 217 | + """ |
| 218 | + |
| 219 | + nsb_file = Path( |
| 220 | + default_value=None, |
| 221 | + help="Path to a dedicated NSB-only file (e.g. from sim_telarray)", |
| 222 | + ).tag(config=True) |
| 223 | + |
| 224 | + nsb_level = Int( |
| 225 | + default_value=1, |
| 226 | + help=( |
| 227 | + "Number of random instances of the NSB waveforms from the " |
| 228 | + "NSB file to be added up to the waveform" |
| 229 | + ), |
| 230 | + ).tag(config=True) |
| 231 | + |
| 232 | + n_noise_realizations = Int( |
| 233 | + default_value=100, |
| 234 | + help=( |
| 235 | + "Number of different realizations of the total NSB waveform to " |
| 236 | + "be created per pixel" |
| 237 | + ), |
| 238 | + ).tag(config=True) |
| 239 | + |
| 240 | + sample_pixels_independently = Bool( |
| 241 | + default_value=True, |
| 242 | + help=( |
| 243 | + "If True, each pixel uses a different " |
| 244 | + "random combination of the input noise " |
| 245 | + "events" |
| 246 | + "If False, all pixels use the same random " |
| 247 | + "combination of noise events. That is, noise " |
| 248 | + "events will be combined as full cameras" |
| 249 | + ), |
| 250 | + ).tag(config=True) |
| 251 | + |
| 252 | + rng_seed = Int(default_value=1, help="Seed for the random number generator").tag( |
| 253 | + config=True |
| 254 | + ) |
| 255 | + |
| 256 | + def __init__( |
| 257 | + self, |
| 258 | + subarray: SubarrayDescription, |
| 259 | + config=None, |
| 260 | + parent=None, |
| 261 | + **kwargs, |
| 262 | + ): |
| 263 | + """ |
| 264 | +
|
| 265 | + Parameters |
| 266 | + ---------- |
| 267 | + subarray: SubarrayDescription |
| 268 | + Description of the subarray. Provides information about the |
| 269 | + camera which are useful in calibration. Also required for |
| 270 | + configuring the TelescopeParameter traitlets. |
| 271 | + config: traitlets.loader.Config |
| 272 | + Configuration specified by config file or cmdline arguments. |
| 273 | + Used to set traitlet values. |
| 274 | + This is mutually exclusive with passing a ``parent``. |
| 275 | + parent: ctapipe.core.Component or ctapipe.core.Tool |
| 276 | + Parent of this component in the configuration hierarchy, |
| 277 | + this is mutually exclusive with passing ``config`` |
| 278 | +
|
| 279 | + """ |
| 280 | + |
| 281 | + super().__init__(subarray=subarray, config=config, parent=parent, **kwargs) |
| 282 | + self.rng = np.random.default_rng(self.rng_seed) |
| 283 | + |
| 284 | + self.event_type_filter = NoiseEventTypeFilter(parent=self) |
| 285 | + |
| 286 | + self.total_noise = dict() |
| 287 | + # One key per tel_id, and each of them is an array of shape |
| 288 | + # [n_noise_realizations, ngains, npixels, nsamples] |
| 289 | + |
| 290 | + # Read in the waveforms in the NSB-only file. Store in a dictionary |
| 291 | + # with one key per telescope, containing an array [n_events, n_gains, |
| 292 | + # n_pixels, n_samples] |
| 293 | + nsb_database = self.read_nsb_database() |
| 294 | + |
| 295 | + # Check if noise statistics is sufficient: |
| 296 | + stats_ok = self.check_noise_statistics(nsb_database) |
| 297 | + if not stats_ok: |
| 298 | + raise ValueError("Please use an input nsb_file with more events!") |
| 299 | + |
| 300 | + # Now shift the waveforms so that they have mean=0 and do not introduce |
| 301 | + # any bias (just fluctuations) |
| 302 | + self.zero_baseline(nsb_database) |
| 303 | + |
| 304 | + # Add up waveforms selected at random to obtain different |
| 305 | + # realizations of the total noise that will be added |
| 306 | + for tel_id in nsb_database: |
| 307 | + self.total_noise[tel_id] = build_wf_noise_pixelwise( |
| 308 | + nsb_database[tel_id], |
| 309 | + self.n_noise_realizations, |
| 310 | + self.nsb_level, |
| 311 | + self.rng, |
| 312 | + self.sample_pixels_independently, |
| 313 | + ) |
| 314 | + |
| 315 | + def read_nsb_database(self): |
| 316 | + """ |
| 317 | + Reads in R1 noise waveforms from an input file self.nsb_file |
| 318 | +
|
| 319 | + Returns |
| 320 | + ------- |
| 321 | + nsb_database : dict |
| 322 | + Dictionary with one key per telescope, containing an array [n_events, |
| 323 | + n_gains, n_pixels, n_samples] (noise waveforms) |
| 324 | +
|
| 325 | + """ |
| 326 | + nsb_database = defaultdict(list) |
| 327 | + with EventSource( |
| 328 | + input_url=self.nsb_file, skip_calibration_events=False |
| 329 | + ) as source: |
| 330 | + for event in source: |
| 331 | + if not self.event_type_filter(event): |
| 332 | + continue |
| 333 | + for tel_id, r1 in event.r1.tel.items(): |
| 334 | + nsb_database[tel_id].append(r1.waveform) |
| 335 | + |
| 336 | + nsb_database = { |
| 337 | + tel_id: np.stack(waveforms) for tel_id, waveforms in nsb_database.items() |
| 338 | + } |
| 339 | + |
| 340 | + return nsb_database |
| 341 | + |
| 342 | + def check_noise_statistics(self, nsb_database): |
| 343 | + """ |
| 344 | + Check that we have enough NSB-only events for all telescopes. We |
| 345 | + require that the number of NSB events for any telescope is at |
| 346 | + least two times the number of waveforms (=nsb_level) that we will add |
| 347 | + up. This is to avoid excessive correlation among the waveforms. |
| 348 | +
|
| 349 | + Parameters |
| 350 | + ---------- |
| 351 | + nsb_database: dict |
| 352 | + Dictionary with one key per telescope, containing an array [n_events, |
| 353 | + n_gains, n_pixels, n_samples] (noise waveforms) |
| 354 | +
|
| 355 | + Returns |
| 356 | + ------- |
| 357 | + stats_ok : bool |
| 358 | + True if statistics of noise events is considered sufficient |
| 359 | + """ |
| 360 | + |
| 361 | + stats_ok = True |
| 362 | + for tel_id in nsb_database: |
| 363 | + nevents = nsb_database[tel_id].shape[0] |
| 364 | + if nevents >= 2 * self.nsb_level: |
| 365 | + continue |
| 366 | + self.log.error( |
| 367 | + f"Not enough NSB events available for tel_" |
| 368 | + f"id {tel_id}. " |
| 369 | + f"For nsb_level = {self.nsb_level}, at least " |
| 370 | + f"{2 * self.nsb_level} events are needed ({nevents} " |
| 371 | + f"were found)." |
| 372 | + ) |
| 373 | + stats_ok = False |
| 374 | + |
| 375 | + return stats_ok |
| 376 | + |
| 377 | + def zero_baseline(self, nsb_database): |
| 378 | + """ |
| 379 | + For each telescope and gain we average the waveform values for all |
| 380 | + pixels, and subtract those averages from the waveforms. |
| 381 | + In this way we make sure we do not introduce any net average charge, |
| 382 | + but only increase the fluctuations. |
| 383 | +
|
| 384 | + Parameters |
| 385 | + ---------- |
| 386 | + nsb_database: dict |
| 387 | + Dictionary with one key per telescope, containing an array [n_events, |
| 388 | + n_gains, n_pixels, n_samples] (noise waveforms) |
| 389 | +
|
| 390 | + Returns |
| 391 | + ------- |
| 392 | + nsb_database: dict |
| 393 | + Dictionary, same as above but after baseline zeroing |
| 394 | + """ |
| 395 | + |
| 396 | + for tel_id in nsb_database: |
| 397 | + for channel in range(nsb_database[tel_id].shape[1]): |
| 398 | + mean = np.mean(nsb_database[tel_id][:, channel, :, :]) |
| 399 | + nsb_database[tel_id][:, channel, :, :] -= mean |
| 400 | + |
| 401 | + def __call__(self, tel_id, waveforms, selected_gain_channel=None): |
| 402 | + """ |
| 403 | + Parameters |
| 404 | + ---------- |
| 405 | + tel_id |
| 406 | + waveforms: ndarray [ngains, npixels, nsamples] (ngains=1 if |
| 407 | + gain-selected) |
| 408 | + selected_gain_channel: ndarray[npixels] or None if no gain selection |
| 409 | +
|
| 410 | + Returns |
| 411 | + ------- |
| 412 | + ndarray, same shape as waveforms: original waveforms plus added NSB |
| 413 | +
|
| 414 | + """ |
| 415 | + |
| 416 | + # Note: MC waveforms passed to this function should contain data in all |
| 417 | + # pixels (not DVR'ed - obviously DVR depends on noise level, it does not |
| 418 | + # make sense to add the noise after DVR was applied) |
| 419 | + noise = self.total_noise[tel_id][self.rng.integers(self.n_noise_realizations)] |
| 420 | + if selected_gain_channel is not None: |
| 421 | + noise = noise[selected_gain_channel, np.arange(waveforms.shape[1])] |
| 422 | + |
| 423 | + return waveforms + noise |
| 424 | + |
| 425 | + |
103 | 426 | class ImageModifier(TelescopeComponent): |
104 | 427 | """ |
105 | 428 | Component to tune simulated background to |
|
0 commit comments