Skip to content

Commit 074173d

Browse files
authored
Merge pull request #2629 from cta-observatory/hillas_psi_uncertainty_ruo
Hillas psi uncertainty ruo
2 parents 42d1ba6 + 8f56d36 commit 074173d

File tree

6 files changed

+134
-12
lines changed

6 files changed

+134
-12
lines changed

docs/changes/2629.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add two new fields in the Hillas parameters computations:
2+
1. psi_uncertainty (uncertainty on the psi angle of the image)
3+
2. transverse_cog_uncertainty (uncertainty on the center of gravity along the transverse axis of the image)

src/ctapipe/containers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,12 @@ class CameraHillasParametersContainer(BaseHillasParametersContainer):
310310
width = Field(nan * u.m, "standard spread along the minor-axis", unit=u.m)
311311
width_uncertainty = Field(nan * u.m, "uncertainty of width", unit=u.m)
312312
psi = Field(nan * u.deg, "rotation angle of ellipse", unit=u.deg)
313+
psi_uncertainty = Field(nan * u.deg, "uncertainty of psi", unit=u.deg)
314+
transverse_cog_uncertainty = Field(
315+
nan * u.m,
316+
"uncertainty on the center of gravity along the transverse axis of the image",
317+
unit=u.m,
318+
)
313319

314320

315321
class HillasParametersContainer(BaseHillasParametersContainer):
@@ -338,6 +344,12 @@ class HillasParametersContainer(BaseHillasParametersContainer):
338344
width = Field(nan * u.deg, "standard spread along the minor-axis", unit=u.deg)
339345
width_uncertainty = Field(nan * u.deg, "uncertainty of width", unit=u.deg)
340346
psi = Field(nan * u.deg, "rotation angle of ellipse", unit=u.deg)
347+
psi_uncertainty = Field(nan * u.deg, "uncertainty of psi", unit=u.deg)
348+
transverse_cog_uncertainty = Field(
349+
nan * u.deg,
350+
"uncertainty on the center of gravity along the transverse axis of the image",
351+
unit=u.deg,
352+
)
341353

342354

343355
class LeakageContainer(Container):

src/ctapipe/image/hillas.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Hillas-style moment-based shower image parametrization.
55
"""
66

7+
78
import astropy.units as u
89
import numpy as np
910
from astropy.coordinates import Angle
@@ -87,6 +88,7 @@ def hillas_parameters(geom, image):
8788
unit = geom.pix_x.unit
8889
pix_x = geom.pix_x.to_value(unit)
8990
pix_y = geom.pix_y.to_value(unit)
91+
pix_area = geom.pix_area.to_value(unit**2)
9092
image = np.asanyarray(image, dtype=np.float64)
9193

9294
if isinstance(image, np.ma.masked_array):
@@ -131,22 +133,41 @@ def hillas_parameters(geom, image):
131133
# avoid divide by 0 warnings
132134
# psi will be consistently defined in the range (-pi/2, pi/2)
133135
if length == 0:
134-
psi = skewness_long = kurtosis_long = np.nan
136+
psi = (
137+
psi_uncert
138+
) = transverse_cog_uncert = skewness_long = kurtosis_long = np.nan
135139
else:
136140
if vx != 0:
137141
psi = np.arctan(vy / vx)
138142
else:
139143
psi = np.pi / 2
140144

141145
# calculate higher order moments along shower axes
142-
longitudinal = delta_x * np.cos(psi) + delta_y * np.sin(psi)
146+
cos_psi = np.cos(psi)
147+
sin_psi = np.sin(psi)
148+
longi = delta_x * cos_psi + delta_y * sin_psi
149+
trans = delta_x * -sin_psi + delta_y * cos_psi
143150

144-
m3_long = np.average(longitudinal**3, weights=image)
151+
m3_long = np.average(longi**3, weights=image)
145152
skewness_long = m3_long / length**3
146153

147-
m4_long = np.average(longitudinal**4, weights=image)
154+
m4_long = np.average(longi**4, weights=image)
148155
kurtosis_long = m4_long / length**4
149156

157+
# lsq solution to determine uncertainty on phi
158+
W = np.diag(image / pix_area)
159+
X = np.column_stack([longi, np.ones_like(longi)])
160+
lsq_cov = np.linalg.inv(X.T @ W @ X)
161+
p = lsq_cov @ X.T @ W @ trans
162+
# p[0] is the psi angle in the rotated frame, which should be zero.
163+
# Now we add the non-zero residual psi angle in the rotated frame to psi uncertainty
164+
# We also add additional uncertainty to account for elongation of the image (i.e. width / length)
165+
psi_uncert = np.sqrt(lsq_cov[0, 0] + p[0] * p[0]) * (
166+
1.0 + pow(np.tan(width / length * np.pi / 2.0), 2)
167+
)
168+
sin_p0 = np.sin(p[0])
169+
transverse_cog_uncert = np.sqrt(lsq_cov[1, 1] * (1.0 + sin_p0 * sin_p0))
170+
150171
# Compute of the Hillas parameters uncertainties.
151172
# Implementation described in [hillas_uncertainties]_ This is an internal MAGIC document
152173
# not generally accessible.
@@ -190,6 +211,8 @@ def hillas_parameters(geom, image):
190211
width=u.Quantity(width, unit),
191212
width_uncertainty=u.Quantity(width_uncertainty, unit),
192213
psi=Angle(psi, unit=u.rad),
214+
psi_uncertainty=Angle(psi_uncert, unit=u.rad),
215+
transverse_cog_uncertainty=u.Quantity(transverse_cog_uncert, unit),
193216
skewness=skewness_long,
194217
kurtosis=kurtosis_long,
195218
)
@@ -204,6 +227,8 @@ def hillas_parameters(geom, image):
204227
width=u.Quantity(width, unit),
205228
width_uncertainty=u.Quantity(width_uncertainty, unit),
206229
psi=Angle(psi, unit=u.rad),
230+
psi_uncertainty=Angle(psi_uncert, unit=u.rad),
231+
transverse_cog_uncertainty=u.Quantity(transverse_cog_uncert, unit),
207232
skewness=skewness_long,
208233
kurtosis=kurtosis_long,
209234
)

src/ctapipe/image/tests/test_hillas.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,69 @@ def test_single_pixel():
253253
assert hillas.length.value == 0
254254
assert hillas.width.value == 0
255255
assert np.isnan(hillas.psi)
256+
assert np.isnan(hillas.psi_uncertainty)
257+
258+
259+
def test_psi_uncertainty():
260+
x = y = np.arange(3)
261+
x, y = np.meshgrid(x, y)
262+
263+
geom = CameraGeometry(
264+
name="testcam",
265+
pix_id=np.arange(9),
266+
pix_x=x.ravel() * u.cm,
267+
pix_y=y.ravel() * u.cm,
268+
pix_type="rectangular",
269+
pix_area=np.full(9, 1.0) * u.cm**2,
270+
)
271+
272+
image = np.zeros((3, 3))
273+
image[0, 0] = 2
274+
image[0, 1] = 0
275+
image[0, 2] = 0
276+
image[1, 0] = 0
277+
image[1, 1] = 5
278+
image[1, 2] = 0
279+
image[2, 0] = 0
280+
image[2, 1] = 0
281+
image[2, 2] = 2
282+
image = image.ravel()
283+
hillas = hillas_parameters(geom, image)
284+
assert np.isclose(
285+
hillas.psi_uncertainty.to_value(u.deg), 20.25711711353489, rtol=1e-05
286+
)
287+
288+
image = np.zeros((3, 3))
289+
image[0, 0] = 5
290+
image[0, 1] = 0
291+
image[0, 2] = 0
292+
image[1, 0] = 0
293+
image[1, 1] = 10
294+
image[1, 2] = 0
295+
image[2, 0] = 0
296+
image[2, 1] = 0
297+
image[2, 2] = 5
298+
image = image.ravel()
299+
hillas = hillas_parameters(geom, image)
300+
assert np.isclose(
301+
hillas.psi_uncertainty.to_value(u.deg), 12.811725781509189, rtol=1e-05
302+
)
303+
304+
image = np.zeros((3, 3))
305+
image[0, 0] = 5
306+
image[0, 1] = 0
307+
image[0, 2] = 2
308+
image[1, 0] = 0
309+
image[1, 1] = 10
310+
image[1, 2] = 0
311+
image[2, 0] = 0
312+
image[2, 1] = 2
313+
image[2, 2] = 5
314+
image = image.ravel()
315+
hillas = hillas_parameters(geom, image)
316+
assert np.isclose(
317+
hillas.psi_uncertainty.to_value(u.deg), 23.560635267712282, rtol=1e-05
318+
)
256319

257320

258321
def test_reconstruction_in_telescope_frame(prod5_lst):

src/ctapipe/visualization/mpl_camera.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,24 @@ def overlay_moments(
511511
**kwargs,
512512
)
513513

514+
for sign in (-1, 1):
515+
for direction in (-1, 1):
516+
angle = params["psi_rad"] + sign * params["psi_uncert_rad"]
517+
(line,) = self.axes.plot(
518+
[
519+
params["cog_x"],
520+
params["cog_x"]
521+
+ direction * np.cos(angle) * 3 * params["length"],
522+
],
523+
[
524+
params["cog_y"],
525+
params["cog_y"]
526+
+ direction * np.sin(angle) * 3 * params["length"],
527+
],
528+
**kwargs,
529+
)
530+
self._axes_overlays.append(line)
531+
514532
self._axes_overlays.append(el)
515533

516534
if with_label:

src/ctapipe/visualization/utils.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ def build_hillas_overlay(hillas, unit, with_label=True, n_sigma=1):
3131
psi_deg = Angle(hillas.psi).wrap_at(180 * u.deg).to_value(u.deg)
3232

3333
ret = dict(
34-
cog_x=cog_x,
35-
cog_y=cog_y,
36-
width=width,
37-
length=length,
38-
psi_rad=psi_rad,
34+
cog_x=float(cog_x),
35+
cog_y=float(cog_y),
36+
width=float(width),
37+
length=float(length),
38+
psi_rad=float(psi_rad),
39+
psi_uncert_rad=float(hillas.psi_uncertainty.to_value(u.rad)),
3940
)
4041

4142
if not with_label:
@@ -67,9 +68,9 @@ def build_hillas_overlay(hillas, unit, with_label=True, n_sigma=1):
6768
label_y = cog_y - r * np.sin(psi_rad)
6869
rotation = psi_deg + 90
6970

70-
ret["rotation"] = rotation
71-
ret["label_x"] = label_x
72-
ret["label_y"] = label_y
71+
ret["rotation"] = float(rotation)
72+
ret["label_x"] = float(label_x)
73+
ret["label_y"] = float(label_y)
7374

7475
if unit == u.deg:
7576
sep = ""

0 commit comments

Comments
 (0)