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

Effective Area 3D #281

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
14b050e
Add 2 methods to calculate effective area in energy and 2 spacial dim…
Feb 12, 2024
3d80615
Add methods to calculate n_showers in energy and 2 spacial dimensions
Feb 12, 2024
bca9226
Merge branch 'cta-observatory:main' into effective_area_3d
luca-dib Feb 12, 2024
6b6ec8d
Add util for calculating FOV position angle
Feb 13, 2024
52f4e44
Merge branch 'effective_area_3d' of https://github.com/luca-dib/pyirf…
Feb 13, 2024
8056f6e
Fix effective_area_3d and poition angle util
Feb 13, 2024
7e0a1d3
Fix missing comma
Feb 13, 2024
4d5df74
Fix typos causing error when calling calculate_n_showers_3d_*
Feb 15, 2024
34ecab9
Remove redundant argument breaking calculate_n_showers_3d call
Feb 15, 2024
2a6d047
Fix formatting and issues from pull request Effective Area 3D (no. 28…
Feb 15, 2024
8e4da8b
Add functions to transform from sky coordinates to FOV coordinates
Mar 1, 2024
5e170e4
Update calculate_n_showers_3d functions and add tests
Mar 1, 2024
98da953
Fix position angle function, add rectangle solid angle function and t…
Mar 1, 2024
f27eef8
Update effective area 3D functions and add corresponding tests
Mar 1, 2024
777c0b1
Fix code review issues
Apr 15, 2024
44018bc
Update function names in __all__
Apr 18, 2024
c80ef58
Fix all changed occurences of gadf coordinate function calls
Apr 18, 2024
643958a
Merge branch 'cta-observatory:main' into effective_area_3d
luca-dib Apr 30, 2024
d74bd18
Change az/alt to lon/lat due to support of both AltAz and ICRS
Apr 30, 2024
554ef57
Merge branch 'effective_area_3d' of https://github.com/luca-dib/pyirf…
Apr 30, 2024
8ff9e70
Add newsfragment
Apr 30, 2024
be14460
Properly handle viewcone_min > 0, add docstring
May 2, 2024
af8a9d1
Add missing condition to all_outside check
May 14, 2024
af76243
Merge branch 'cta-observatory:main' into effective_area_3d
luca-dib May 17, 2024
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
2 changes: 2 additions & 0 deletions docs/changes/281.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

Add 3D effective area functions for lon/lat and theta/phi coordinates and some necessary utiliy functions.
72 changes: 72 additions & 0 deletions pyirf/coordinates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import astropy.units as u
from astropy.coordinates import SkyCoord, SkyOffsetFrame, angular_separation, position_angle

__all__ = [
'gadf_fov_coords_lon_lat',
'gadf_fov_coords_theta_phi',
]


def gadf_fov_coords_lon_lat(lon, lat, pointing_lon, pointing_lat):
"""Transform sky coordinates to field-of-view longitude-latitude coordinates in accordance with
the definitions laid out by the Gamma Astro Data Format.

GADF documentation here:
https://gamma-astro-data-formats.readthedocs.io/en/latest/general/coordinates.html

Parameters
----------
lon, lat : `~astropy.units.Quantity`
Sky coordinate to be transformed.
pointing_lon, pointing_lat : `~astropy.units.Quantity`
Coordinate specifying the pointing position.
(i.e. the center of the field of view.)

Returns
-------
lon_t, lat_t : `~astropy.units.Quantity`
Transformed field-of-view coordinate.
"""
# Create a frame that is centered on the pointing position
center = SkyCoord(pointing_lon, pointing_lat)
fov_frame = SkyOffsetFrame(origin=center)

# Define coordinate to be transformed.
target_sky = SkyCoord(lon, lat)

# Transform into FoV-system
target_fov = target_sky.transform_to(fov_frame)

# Switch sign of longitude angle since this axis is
# reversed in our definition of the FoV-system
return -target_fov.lon, target_fov.lat


def gadf_fov_coords_theta_phi(lon, lat, pointing_lon, pointing_lat):
"""Transform sky coordinates to field-of-view theta-phi coordinates in accordance with
the definitions laid out by the Gamma Astro Data Format.

GADF documentation here:
https://gamma-astro-data-formats.readthedocs.io/en/latest/general/coordinates.html

Parameters
----------
lon, lat : `~astropy.units.Quantity`
Sky coordinate to be transformed.
pointing_lon, pointing_lat : `~astropy.units.Quantity`
Coordinate specifying the pointing position.
(i.e. the center of the field of view.)

Returns
-------
theta, phi : `~astropy.units.Quantity`
Transformed field-of-view coordinate.
"""

theta = angular_separation(pointing_lon, pointing_lat, lon, lat)

# astropy defines the position angle as increasing towards east of north
phi = position_angle(pointing_lon, pointing_lat, lon, lat)

# GADF defines FOV PHI opposite to the position angle way so the sign is switched
return theta.to(u.deg), (-phi).wrap_at(360 * u.deg).to(u.deg)
maxnoe marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 4 additions & 0 deletions pyirf/irf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
effective_area,
effective_area_per_energy_and_fov,
effective_area_per_energy,
effective_area_3d_polar,
effective_area_3d_lonlat,
)
from .energy_dispersion import energy_dispersion
from .psf import psf_table
Expand All @@ -11,6 +13,8 @@
"effective_area",
"effective_area_per_energy",
"effective_area_per_energy_and_fov",
"effective_area_3d_polar",
"effective_area_3d_lonlat",
"energy_dispersion",
"psf_table",
"background_2d",
Expand Down
103 changes: 103 additions & 0 deletions pyirf/irf/effective_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"effective_area",
"effective_area_per_energy",
"effective_area_per_energy_and_fov",
"effective_area_3d_polar",
"effective_area_3d_lonlat",
]


Expand Down Expand Up @@ -85,3 +87,104 @@ def effective_area_per_energy_and_fov(
)

return effective_area(hist_selected, hist_simulated, area)


def effective_area_3d_polar(
selected_events,
simulation_info,
true_energy_bins,
fov_offset_bins,
fov_position_angle_bins,
):
"""
Calculate effective area in bins of true energy, field of view offset, and field of view position angle.

Parameters
----------
selected_events: astropy.table.QTable
DL2 events table, required columns for this function:
- `true_energy`
- `true_source_fov_offset`
- `true_source_fov_position_angle`
simulation_info: pyirf.simulations.SimulatedEventsInfo
The overall statistics of the simulated events
true_energy_bins: astropy.units.Quantity[energy]
The true energy bin edges in which to calculate effective area.
fov_offset_bins: astropy.units.Quantity[angle]
The field of view radial bin edges in which to calculate effective area.
fov_position_angle_bins: astropy.units.Quantity[radian]
The field of view azimuthal bin edges in which to calculate effective area.
Copy link

@kosack kosack Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this could lead to confusion, but a 2D azimuthal coordinate and position angle are not quite the same: the polar azimuth starts on the positive X-axis and goes counter-clockwise, while position angle starts on the positive Y-axis and goes clockwise, so there is a 90 deg phase shift and different direction. Perhaps just to avoid this, say "Field-of-view azimuthal (from Y-axis, toward positive x) bin edges...". Probably this should be shown as a figure somewhere in the general PyIRF documentation...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going into the same direction as the comment above about being clear how we define these coordinate systems and adding tests for the cases spelled out by GADF.

One confusing thing here is that the position angle is defined relative to the original coordinate system axes (AltAz or RaDec) but the FoV lon has the opposite direction to the original lon. Which I think makes the polar angle go counter-clockwise when looking at in a common plot of field of view coordinates where the cartesian like axes are FoV lon/lat...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand it correctly the GADF FOV position angle definition is exactly the same as the usual definition of the azimuthal angle in polar coordinates, right? (Angle increasing counter-clockwise)
So following this definition there shouldn't technically be a difference between the two, although I understand that just calling it the azimuthal angle here might still cause confusion.

Since we are following the GADF conventions anyways, would it be agreeable to just change "field of view azimuthal bin edges" to "field of view position angle bin edges" and maybe showing a figure somewhere to clarify as suggested?

"""
area = np.pi * simulation_info.max_impact**2

hist_simulated = simulation_info.calculate_n_showers_3d_polar(
true_energy_bins, fov_offset_bins, fov_position_angle_bins
)

hist_selected, _ = np.histogramdd(
np.column_stack(
[
selected_events["true_energy"].to_value(u.TeV),
selected_events["true_source_fov_offset"].to_value(u.deg),
selected_events["true_source_fov_position_angle"].to_value(u.rad),
]
),
bins=(
true_energy_bins.to_value(u.TeV),
fov_offset_bins.to_value(u.deg),
fov_position_angle_bins.to_value(u.rad),
),
)

return effective_area(hist_selected, hist_simulated, area)


def effective_area_3d_lonlat(
selected_events,
simulation_info,
true_energy_bins,
fov_longitude_bins,
fov_latitude_bins,
subpixels=20,
):
"""
Calculate effective area in bins of true energy, field of view longitude, and field of view latitude.

Parameters
----------
selected_events: astropy.table.QTable
DL2 events table, required columns for this function:
- `true_energy`
- `true_source_fov_lon`
- `true_source_fov_lat`
simulation_info: pyirf.simulations.SimulatedEventsInfo
The overall statistics of the simulated events
true_energy_bins: astropy.units.Quantity[energy]
maxnoe marked this conversation as resolved.
Show resolved Hide resolved
The true energy bin edges in which to calculate effective area.
fov_longitude_bins: astropy.units.Quantity[angle]
The field of view longitude bin edges in which to calculate effective area.
fov_latitude_bins: astropy.units.Quantity[angle]
The field of view latitude bin edges in which to calculate effective area.
"""
area = np.pi * simulation_info.max_impact**2

hist_simulated = simulation_info.calculate_n_showers_3d_lonlat(
true_energy_bins, fov_longitude_bins, fov_latitude_bins, subpixels=subpixels
)

selected_columns = np.column_stack(
[
selected_events["true_energy"].to_value(u.TeV),
selected_events["true_source_fov_lon"].to_value(u.deg),
selected_events["true_source_fov_lat"].to_value(u.deg),
]
)
bins = (
true_energy_bins.to_value(u.TeV),
fov_longitude_bins.to_value(u.deg),
fov_latitude_bins.to_value(u.deg),
)

hist_selected, _ = np.histogramdd(selected_columns, bins=bins)

return effective_area(hist_selected, hist_simulated, area)
145 changes: 145 additions & 0 deletions pyirf/irf/tests/test_effective_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,148 @@ def test_effective_area_energy_fov():
assert area.unit == u.m ** 2
assert u.allclose(area[:, 0], [200, 20] * u.m ** 2)
assert u.allclose(area[:, 1], [100, 10] * u.m ** 2)


def test_effective_area_3d_polar():
from pyirf.irf import effective_area_3d_polar
from pyirf.simulations import SimulatedEventsInfo

true_energy_bins = [0.1, 1.0, 10.0] * u.TeV
# choose edges so that half are in each bin in fov
fov_offset_bins = [0, np.arccos(0.98), np.arccos(0.96)] * u.rad
# choose edges so that they are equal size and cover the whole circle
fov_pa_bins = [0, np.pi, 2*np.pi] * u.rad
center_1_o, center_2_o = 0.5 * (fov_offset_bins[:-1] + fov_offset_bins[1:]).to_value(
u.deg
)
center_1_pa, center_2_pa = 0.5 * (fov_pa_bins[:-1] + fov_pa_bins[1:]).to_value(
u.deg
)

selected_events = QTable(
{
"true_energy": np.concatenate(
[
np.full(1000, 0.5),
np.full(10, 5),
np.full(500, 0.5),
np.full(5, 5),
np.full(1000, 0.5),
np.full(10, 5),
np.full(500, 0.5),
np.full(5, 5),
]
)
* u.TeV,
"true_source_fov_offset": np.concatenate(
[
np.full(1010, center_1_o),
np.full(505, center_2_o),
np.full(1010, center_1_o),
np.full(505, center_2_o),
]
)
* u.deg,
"true_source_fov_position_angle": np.append(
np.full(1515, center_1_pa), np.full(1515, center_2_pa)
)
* u.deg,
}
)

# this should give 100000 events in the first bin and 10000 in the second
simulation_info = SimulatedEventsInfo(
n_showers=110000,
energy_min=true_energy_bins[0],
energy_max=true_energy_bins[-1],
max_impact=100 / np.sqrt(np.pi) * u.m, # this should give a nice round area
spectral_index=-2,
viewcone_min=0 * u.deg,
viewcone_max=fov_offset_bins[-1],
)

area = effective_area_3d_polar(
selected_events, simulation_info, true_energy_bins, fov_offset_bins, fov_pa_bins
)

assert area.shape == (
len(true_energy_bins) - 1, len(fov_offset_bins) - 1, len(fov_pa_bins) - 1
)
assert area.unit == u.m ** 2
assert u.allclose(area[:, 0, :], [[400, 400],[40,40]] * u.m ** 2)
assert u.allclose(area[:, 1, :], [[200, 200],[20,20]] * u.m ** 2)


def test_effective_area_3d_lonlat():
from pyirf.irf import effective_area_3d_lonlat
from pyirf.simulations import SimulatedEventsInfo

true_energy_bins = [0.1, 1.0, 10.0] * u.TeV
# choose edges so that a quarter are in each bin in fov
fov_lon_bins = [-1.0, 0, 1.0] * u.deg
fov_lat_bins = [-1.0, 0, 1.0] * u.deg
center_1_lon, center_2_lon = 0.5 * (fov_lon_bins[:-1] + fov_lon_bins[1:]).to_value(
u.deg
)
center_1_lat, center_2_lat = 0.5 * (fov_lat_bins[:-1] + fov_lat_bins[1:]).to_value(
u.deg
)

selected_events = QTable(
{
"true_energy": np.concatenate(
[
np.full(1000, 0.5),
np.full(10, 5),
np.full(500, 0.5),
np.full(5, 5),
np.full(1000, 0.5),
np.full(10, 5),
np.full(500, 0.5),
np.full(5, 5),
]
)
* u.TeV,
"true_source_fov_lon": np.concatenate(
[
np.full(1010, center_1_lon),
np.full(505, center_2_lon),
np.full(1010, center_1_lon),
np.full(505, center_2_lon),
]
)
* u.deg,
"true_source_fov_lat": np.append(
np.full(1515, center_1_lat), np.full(1515, center_2_lat)
)
* u.deg,
}
)

# this should give 100000 events in the first bin and 10000 in the second
simulation_info = SimulatedEventsInfo(
n_showers=110000,
energy_min=true_energy_bins[0],
energy_max=true_energy_bins[-1],
max_impact=100 / np.sqrt(np.pi) * u.m, # this should give a nice round area
spectral_index=-2,
viewcone_min=0 * u.deg,
viewcone_max=fov_lon_bins[-1],
)

area = effective_area_3d_lonlat(
selected_events,
simulation_info,
true_energy_bins,
fov_lon_bins,
fov_lat_bins,
subpixels=20,
)

assert area.shape == (
len(true_energy_bins) - 1, len(fov_lon_bins) - 1, len(fov_lat_bins) - 1
)
assert area.unit == u.m ** 2
# due to inexact approximation of the circular FOV area in the lon-lat bins tolerance of 1%
assert u.allclose(area[:, 0, :], [[400, 400],[40,40]] * u.m ** 2,rtol=1e-2)
assert u.allclose(area[:, 1, :], [[200, 200],[20,20]] * u.m ** 2,rtol=1e-2)
Loading
Loading