Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding muon ring analysis #37

Merged
merged 6 commits into from
Apr 4, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions magicctapipe/image/muons/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .muon_analysis import (
perform_muon_analysis,
)

__all__ = [
'perform_muon_analysis',
]
386 changes: 386 additions & 0 deletions magicctapipe/image/muons/muon_analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,386 @@
import numpy as np
from lstchain.image.muon import tag_pix_thr, fill_muon_event

__all__ = [
'perform_muon_analysis',
]


def perform_muon_analysis(muon_parameters, event, telescope_id, telescope_name, image, subarray,
r1_dl1_calibrator_for_muon_rings, good_ring_config, event_time=np.nan,
min_pe_for_muon_t_calc=10., data_type='mc'):
"""

Parameters
----------
muon_parameters: dict
Container for the parameters of all muon rings
event: ctapipe event container
telescope_id: int
Id of the telescope
telescope_name: string
Name of teh telescope
image: `np.ndarray`
Number of photoelectrons in each pixel
subarray: `ctapipe.instrument.subarray.SubarrayDescription`
r1_dl1_calibrator_for_muon_rings: `ctapipe.calib.camera.CameraCalibrator`
good_ring_config: dict
Set of parameters used to perform the muon ring analysis and select good rings
event_time: float
min_pe_for_muon_t_calc: float
Minimum pixel brightness used to search for the waveform maximum time
data_type: string
'obs' or 'mc'

"""
if data_type == 'obs':
bad_pixels = event.mon.tel[telescope_id].calibration.unusable_pixels[0]
# Set to 0 unreliable pixels:
image = image * (~bad_pixels)
# process only promising events, in terms of # of pixels with large signals:
thr_low = good_ring_config['thr_low'] if 'thr_low' in good_ring_config else 50
if tag_pix_thr(image, thr_low=thr_low):
# re-calibrate r1 to obtain new dl1, using a more adequate pulse integrator for muon rings
numsamples = event.r1.tel[telescope_id].waveform.shape[1] # not necessarily the same as in r0!
if data_type == 'obs':
bad_pixels_hg = event.mon.tel[telescope_id].calibration.unusable_pixels[0]
bad_pixels_lg = event.mon.tel[telescope_id].calibration.unusable_pixels[1]
# Now set to 0 all samples in unreliable pixels. Important for global peak
# integrator in case of crazy pixels! TBD: can this be done in a simpler
# way?
bad_pixels = bad_pixels_hg | bad_pixels_lg
bad_waveform = np.transpose(np.array(numsamples * [bad_pixels]))

# print('hg bad pixels:',np.where(bad_pixels_hg))
# print('lg bad pixels:',np.where(bad_pixels_lg))

event.r1.tel[telescope_id].waveform *= ~bad_waveform
image = image * (~bad_pixels)
r1_dl1_calibrator_for_muon_rings(event)

# Check again: with the extractor for muon rings (most likely GlobalPeakWindowSum)
# perhaps the event is no longer promising (e.g. if it has a large time evolution)
if not tag_pix_thr(image, thr_low=thr_low):
good_ring = False
else:
event_id = event.index.event_id
muonintensityparam, dist_mask, \
ring_size, size_outside_ring, muonringparam, \
good_ring, radial_distribution, \
mean_pixel_charge_around_ring, \
muonpars = \
analyze_muon_event(subarray, telescope_id, event_id,
image, good_ring_config,
plot_rings=False, plots_path='')
# plot_rings=True, plots_path='../../../../data/'+telescope_name+'/')
# (test) plot muon rings as png files

# Now we want to obtain the waveform sample (in HG & LG) at which the ring light peaks:
bright_pixels = image > min_pe_for_muon_t_calc
selected_gain = event.r1.tel[telescope_id].selected_gain_channel
mask_hg = bright_pixels & (selected_gain == 0)
mask_lg = bright_pixels & (selected_gain == 1)

bright_pixels_waveforms_hg = event.r1.tel[telescope_id].waveform[mask_hg, :]
bright_pixels_waveforms_lg = event.r1.tel[telescope_id].waveform[mask_lg, :]
stacked_waveforms_hg = np.sum(bright_pixels_waveforms_hg, axis=0)
stacked_waveforms_lg = np.sum(bright_pixels_waveforms_lg, axis=0)

# stacked waveforms from all bright pixels; shape (ngains, nsamples)
hg_peak_sample = np.argmax(stacked_waveforms_hg, axis=-1)
lg_peak_sample = np.argmax(stacked_waveforms_lg, axis=-1)

if good_ring:
fill_muon_event(-1,
muon_parameters,
good_ring,
event.index.event_id,
event_time,
muonintensityparam,
dist_mask,
muonringparam,
radial_distribution,
ring_size,
size_outside_ring,
mean_pixel_charge_around_ring,
muonpars,
hg_peak_sample, lg_peak_sample)
muon_parameters['telescope_name'].append(telescope_name)


# TODO replace this copy of dev version of lstchain (last release tag v0.9.4) by an import once v0.9.5+ is out
import matplotlib.pyplot as plt
import astropy.units as u
from astropy.coordinates import SkyCoord
from ctapipe.containers import (
MuonEfficiencyContainer,
MuonParametersContainer,
)
from ctapipe.coordinates import (
CameraFrame,
TelescopeFrame,
)
from ctapipe.image.muon import (
MuonIntensityFitter
)
from ctapipe.image.muon.features import (
ring_completeness,
ring_containment,
)
from lstchain.image.muon.muon_analysis import (
fit_muon,
radial_light_distribution
)
from lstchain.image.muon import pixel_coords_to_telescope, plot_muon_event


def update_parameters(config, n_pixels):
"""
Create the parameters used to select good muon rings and perform the muon analysis.
Parameters
----------
config: `dict` or None
Subset of parameters to be updated
n_pixels: `int`
Number of pixels of the camera
Returns
-------
params: `dict`
Dictionary of parameters used for the muon analysis
"""
params = {
'tailcuts': [10, 5], # Thresholds used for the tail_cut cleaning
'min_pix': 0.08, # minimum fraction of the number of pixels in the ring with >0 signal
'min_pix_fraction_after_cleaning': 0.1, # minimum fraction of the ring pixels that must be above tailcuts[0]
'min_ring_radius': 0.8 * u.deg, # minimum ring radius
'max_ring_radius': 1.5 * u.deg, # maximum ring radius
'max_radial_stdev': 0.1 * u.deg, # maximum standard deviation of the light distribution along ring radius
'max_radial_excess_kurtosis': 1., # maximum excess kurtosis
'min_impact_parameter': 0.2, # in fraction of mirror radius
'max_impact_parameter': 0.9, # in fraction of mirror radius
'ring_integration_width': 0.25, # +/- integration range along ring radius,
# in fraction of ring radius (was 0.4 until 20200326)
'outer_ring_width': 0.2, # in fraction of ring radius, width of ring just outside the
# integrated muon ring, used to check pedestal bias
'ring_completeness_threshold': 30, # Threshold in p.e. for pixels used in the ring completeness estimation
}
if config is not None:
for key in config.keys():
params[key] = config[key]
params['min_pix'] = int(n_pixels * params['min_pix'])

return params


def analyze_muon_event(subarray, tel_id, event_id, image, good_ring_config, plot_rings, plots_path):
"""
Analyze an event to fit a muon ring
Parameters
----------
subarray: `ctapipe.instrument.subarray.SubarrayDescription`
Telescopes subarray
tel_id : `int`
Id of the telescope used
event_id : `int`
Id of the analyzed event
image : `np.ndarray`
Number of photoelectrons in each pixel
good_ring_config : `dict` or None
Set of parameters used to identify good muon rings to update LST-1 defaults
plot_rings : `bool`
Plot the muon ring
plots_path : `string`
Path to store the figures
Returns
-------
muonintensityoutput : `MuonEfficiencyContainer`
dist_mask : `ndarray`
Pixels used in ring intensity likelihood fit
ring_size : `float`
Total intensity in ring in photoelectrons
size_outside_ring : `float`
Intensity outside the muon ting in photoelectrons
to check for "shower contamination"
muonringparam : `MuonParametersContainer`
good_ring : `bool`
It determines whether the ring can be used for analysis or not
radial_distribution : `dict`
Return of function radial_light_distribution
mean_pixel_charge_around_ring : float
Charge "just outside" ring, to check the possible signal extractor bias
muonparameters : `MuonParametersContainer`
"""

tel_description = subarray.tels[tel_id]

cam_rad = (
tel_description.camera.geometry.guess_radius() / tel_description.optics.equivalent_focal_length
) * u.rad
geom = tel_description.camera.geometry
equivalent_focal_length = tel_description.optics.equivalent_focal_length
mirror_area = tel_description.optics.mirror_area

# some parameters for analysis and cuts for good ring selection:
params = update_parameters(good_ring_config, geom.n_pixels)

x, y = pixel_coords_to_telescope(geom, equivalent_focal_length)
muonringparam, clean_mask, dist, image_clean = fit_muon(x, y, image, geom,
params['tailcuts'])

mirror_radius = np.sqrt(mirror_area / np.pi) # meters
dist_mask = np.abs(dist - muonringparam.radius
) < muonringparam.radius * params['ring_integration_width']
pix_ring = image * dist_mask
pix_outside_ring = image * ~dist_mask

# mask to select pixels just outside the ring that will be integrated to obtain the ring's intensity:
dist_mask_2 = np.logical_and(~dist_mask,
np.abs(dist - muonringparam.radius) <
muonringparam.radius *
(params['ring_integration_width'] + params['outer_ring_width']))
pix_ring_2 = image[dist_mask_2]

# nom_dist = np.sqrt(np.power(muonringparam.center_x,2)
# + np.power(muonringparam.center_y, 2))

muonparameters = MuonParametersContainer()
muonparameters.containment = ring_containment(
muonringparam.radius,
muonringparam.center_x, muonringparam.center_y, cam_rad)

radial_distribution = radial_light_distribution(
muonringparam.center_x,
muonringparam.center_y,
x[clean_mask], y[clean_mask],
image[clean_mask])

# Do complicated calculations (minuit-based max likelihood ring fit) only for selected rings:
candidate_clean_ring = all(
[radial_distribution['standard_dev'] < params['max_radial_stdev'],
radial_distribution['excess_kurtosis'] < params['max_radial_excess_kurtosis'],
(pix_ring > params['tailcuts'][0]).sum() >
params['min_pix_fraction_after_cleaning'] * params['min_pix'],
np.count_nonzero(pix_ring) > params['min_pix'],
muonringparam.radius < params['max_ring_radius'],
muonringparam.radius > params['min_ring_radius']
])

if candidate_clean_ring:
intensity_fitter = MuonIntensityFitter(subarray, hole_radius_m=0.308)

# Use same hard-coded value for pedestal fluctuations as the previous
# version of ctapipe:
pedestal_stddev = 1.1 * np.ones(len(image))

muonintensityoutput = \
intensity_fitter(tel_id,
muonringparam.center_x,
muonringparam.center_y,
muonringparam.radius,
image,
pedestal_stddev,
dist_mask)

dist_ringwidth_mask = np.abs(dist - muonringparam.radius) < \
muonintensityoutput.width

# We do the calculation of the ring completeness (i.e. fraction of whole circle) using the pixels
# within the "width" fitted using MuonIntensityFitter
muonparameters.completeness = ring_completeness(
x[dist_ringwidth_mask], y[dist_ringwidth_mask],
image[dist_ringwidth_mask],
muonringparam.radius,
muonringparam.center_x,
muonringparam.center_y,
threshold=params['ring_completeness_threshold'],
bins=30)

# No longer existing in ctapipe 0.8:
# pix_ringwidth_im = image[dist_ringwidth_mask]
# muonintensityoutput.ring_pix_completeness = \
# (pix_ringwidth_im > tailcuts[0]).sum() / len(pix_ringwidth_im)

else:
# just to have the default values with units:
muonintensityoutput = MuonEfficiencyContainer()
muonintensityoutput.width = u.Quantity(np.nan, u.deg)
muonintensityoutput.impact = u.Quantity(np.nan, u.m)
muonintensityoutput.impact_x = u.Quantity(np.nan, u.m)
muonintensityoutput.impact_y = u.Quantity(np.nan, u.m)

# muonintensityoutput.mask = dist_mask # no longer there in ctapipe 0.8
ring_size = np.sum(pix_ring)
size_outside_ring = np.sum(pix_outside_ring * clean_mask)

# This is just mean charge per pixel in pixels just around the ring
# (on the outer side):
mean_pixel_charge_around_ring = np.sum(pix_ring_2) / len(pix_ring_2)

if candidate_clean_ring:
print("Impact parameter={:.3f}, ring_width={:.3f}, ring radius={:.3f}, "
"ring completeness={:.3f}".format(
muonintensityoutput.impact,
muonintensityoutput.width,
muonringparam.radius,
muonparameters.completeness, ))
# Now add the conditions based on the detailed muon ring fit:
conditions = [
candidate_clean_ring,
muonintensityoutput.impact < params['max_impact_parameter'] * mirror_radius,
muonintensityoutput.impact > params['min_impact_parameter'] * mirror_radius,

# TODO: To be applied when we have decent optics.
# muonintensityoutput.width
# < 0.08,
# NOTE: inside "candidate_clean_ring" cuts there is already a cut in
# the std dev of light distribution along ring radius, which is also
# a measure of the ring width

# muonintensityoutput.width
# > 0.04
]

if all(conditions):
good_ring = True
else:
good_ring = False

if (plot_rings and plots_path and good_ring):
focal_length = equivalent_focal_length
ring_telescope = SkyCoord(muonringparam.center_x,
muonringparam.center_y,
TelescopeFrame())

ring_camcoord = ring_telescope.transform_to(CameraFrame(
focal_length=focal_length,
rotation=geom.cam_rotation,
))
centroid = (ring_camcoord.x.value, ring_camcoord.y.value)
radius = muonringparam.radius
width = muonintensityoutput.width
ringrad_camcoord = 2 * radius.to(u.rad) * focal_length
ringwidthfrac = width / radius
ringrad_inner = ringrad_camcoord * (1. - ringwidthfrac)
ringrad_outer = ringrad_camcoord * (1. + ringwidthfrac)

fig, ax = plt.subplots(figsize=(10, 10))
plot_muon_event(ax, geom, image * clean_mask, centroid,
ringrad_camcoord, ringrad_inner, ringrad_outer,
event_id)

plt.figtext(0.15, 0.20, 'radial std dev: {0:.3f}'. \
format(radial_distribution['standard_dev']))
plt.figtext(0.15, 0.18, 'radial excess kurtosis: {0:.3f}'. \
format(radial_distribution['excess_kurtosis']))
plt.figtext(0.15, 0.16, 'fitted ring width: {0:.3f}'.format(width))
plt.figtext(0.15, 0.14, 'ring completeness: {0:.3f}'. \
format(muonparameters.completeness))

fig.savefig('{}/Event_{}_fitted.png'.format(plots_path, event_id))

if (plot_rings and not plots_path):
print("You are trying to plot without giving a path!")

return muonintensityoutput, dist_mask, ring_size, size_outside_ring, \
muonringparam, good_ring, radial_distribution, \
mean_pixel_charge_around_ring, muonparameters
Loading