Skip to content

Commit 2a4b881

Browse files
moralejokosackmaxnoeHckjs
authored
Waveform noise adder (#2847)
* Class to add noise in waveforms * Completed init function * Added preliminary __call__ function * Fixed case of gain-selected R1 * Added documentation * Added test function * total_noise not a traitlet * Simplified code * Added filter to use only pedestal events, and some comments on the possible use of real data as source of NSB waveforms. * Use EventTypeFilter * Event types are now configurable * Merge strings * parent=self in EventTypeFilter * Use of context manager in test function Co-authored-by: Karl Kosack <kosack@users.noreply.github.com> * Use context manager in WaveformModifier::init * Improved docstring * Use rng.choice instead of permutation. Updated test std dev values * Class NoiseEventTypeFilter so that we can set the proper default * Introduced @maxnoe's suggestion for doing the pixel-wise noise generation using numba. Now this is the default option, but one can choose to shuffle "full cameras" of noise (like before), which is about 5 times faster. Implemented other reviewers' suggestions * Added comment * Fixed typo & improved class description * Three new functions to do the init stuff * Simplified code in WaveformModifier.__call__ * numba cache Co-authored-by: Maximilian Linhoff <maximilian.linhoff@cta-observatory.org> * Merge remote-tracking branch 'origin/main' into waveform_noise_adder * Changelog * Typo * Add changelog * Introduce needs_atmosphere_profile property to reconstructors * Removed obsolete needs_atmosphere_profile (it was back there after an attempt to clean the history of this branch) * Changed variable shuffle_full_cameras to sample_pixels_independently, which shows its meaning more clearly --------- Co-authored-by: Karl Kosack <kosack@users.noreply.github.com> Co-authored-by: Maximilian Linhoff <maximilian.linhoff@cta-observatory.org> Co-authored-by: Jonas Hackfeld <jonas.hackfeld@ruhr-uni-bochum.de>
1 parent fe66fd2 commit 2a4b881

4 files changed

Lines changed: 391 additions & 3 deletions

File tree

docs/changes/2847.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Component to add noise in waveforms, intended to adapt the noise level in simulations to that in a given real observation.

src/ctapipe/image/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@
3737
from .image_processor import ImageProcessor
3838
from .invalid_pixels import InvalidPixelHandler, NeighborAverage
3939
from .leakage import leakage_parameters
40-
from .modifications import ImageModifier
40+
from .modifications import (
41+
ImageModifier,
42+
NoiseEventTypeFilter,
43+
WaveformModifier,
44+
)
4145
from .morphology import (
4246
brightest_island,
4347
largest_island,
@@ -69,6 +73,8 @@
6973
from .timing import timing_parameters
7074

7175
__all__ = [
76+
"NoiseEventTypeFilter",
77+
"WaveformModifier",
7278
"ImageModifier",
7379
"ImageProcessor",
7480
"hillas_parameters",

src/ctapipe/image/modifications.py

Lines changed: 324 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
from collections import defaultdict
2+
13
import numpy as np
24
from numba import njit
5+
from traitlets import default
36

7+
from ..containers import EventType
48
from ..core import TelescopeComponent
59
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+
)
717
from ..instrument import SubarrayDescription
18+
from ..io import EventSource
19+
from ..utils import EventTypeFilter
820

921
__all__ = [
1022
"ImageModifier",
@@ -100,6 +112,317 @@ def _smear_psf_randomly(
100112
return new_image
101113

102114

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+
103426
class ImageModifier(TelescopeComponent):
104427
"""
105428
Component to tune simulated background to

0 commit comments

Comments
 (0)