From 75a1068e8b223b75d5e7af01a8af8087bbf4da66 Mon Sep 17 00:00:00 2001 From: Larry Bradley Date: Fri, 12 May 2023 13:59:21 -0400 Subject: [PATCH 1/5] Change API for ProfileBase --- photutils/profiles/core.py | 72 +++++++++++++++----------------------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/photutils/profiles/core.py b/photutils/profiles/core.py index 244fa7519..3edda7be7 100644 --- a/photutils/profiles/core.py +++ b/photutils/profiles/core.py @@ -4,7 +4,6 @@ """ import abc -import math import warnings import numpy as np @@ -28,14 +27,12 @@ class ProfileBase(metaclass=abc.ABCMeta): xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. - min_radius : float - The minimum radius for the profile. - - max_radius : float - The maximum radius for the profile. - - radius_step : float - The radial step size in pixels. + radii : 1D float `numpy.ndarray` + An array of radii defining the edges of the radial bins. + ``radii`` must be strictly increasing with a minimum value + greater than or equal to zero, and contain at least 2 values. + The radial spacing does not need to be constant. The output + `radius` attribute will be defined at the bin centers. error : 2D `numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed @@ -78,34 +75,36 @@ class ProfileBase(metaclass=abc.ABCMeta): ``method='subpixel'``. """ - def __init__(self, data, xycen, min_radius, max_radius, radius_step, *, - error=None, mask=None, method='exact', subpixels=5): + def __init__(self, data, xycen, radii, *, error=None, mask=None, + method='exact', subpixels=5): (data, error), unit = process_quantities((data, error), ('data', 'error')) if error is not None and error.shape != data.shape: - raise ValueError('error must have the same same as data') + raise ValueError('error must have the same shape as data') self.data = data - self.error = error - self.mask = self._compute_mask(data, error, mask) self.unit = unit self.xycen = xycen + self.radii = self._validate_radii(radii) + self.error = error + self.mask = self._compute_mask(data, error, mask) self.method = method self.subpixels = subpixels - if (min_radius is not None or max_radius is not None - or radius_step is not None): - if min_radius < 0 or max_radius < 0: - raise ValueError('min_radius and max_radius must be >= 0') - if min_radius >= max_radius: - raise ValueError('max_radius must be greater than min_radius') - if radius_step <= 0: - raise ValueError('radius_step must be > 0') - self.min_radius = min_radius - self.max_radius = max_radius - self.radius_step = radius_step + def _validate_radii(self, edge_radii): + edge_radii = np.array(edge_radii) + if edge_radii.ndim != 1 or edge_radii.size < 2: + raise ValueError('edge_radii must be a 1D array and have at ' + 'least two values') + if edge_radii.min() < 0: + raise ValueError('minimum edge_radii must be >= 0') + + if not np.all(edge_radii[1:] > edge_radii[:-1]): + raise ValueError('edge_radii must be strictly increasing') + + return edge_radii def _compute_mask(self, data, error, mask): """ @@ -117,7 +116,7 @@ def _compute_mask(self, data, error, mask): badmask |= ~np.isfinite(error) if mask is not None: if mask.shape != data.shape: - raise ValueError('mask must have the same same as data') + raise ValueError('mask must have the same shape as data') badmask &= ~mask # non-finite values not in input mask mask |= badmask # all masked pixels else: @@ -135,19 +134,7 @@ def radius(self): """ The profile radius in pixels as a 1D `~numpy.ndarray`. """ - nsteps = int(math.floor((self.max_radius - self.min_radius) - / self.radius_step)) - max_radius = self.min_radius + (nsteps * self.radius_step) - return np.linspace(self.min_radius, max_radius, nsteps + 1) - - @property - @abc.abstractmethod - def _circular_radii(self): - """ - The circular aperture radii for the radial bin edges (inner and - outer annulus radii). - """ - raise NotImplementedError('Needs to be implemented in a subclass.') + return self.radii @property @abc.abstractmethod @@ -175,12 +162,11 @@ def _circular_apertures(self): from photutils.aperture import CircularAperture apertures = [] - for radius in self._circular_radii: + for radius in self.radii: if radius <= 0.0: - aper = None + apertures.append(None) else: - aper = CircularAperture(self.xycen, radius) - apertures.append(aper) + apertures.append(CircularAperture(self.xycen, radius)) return apertures @lazyproperty From 88e22c99182ff3391496cb7a62c8bf6f2a695d90 Mon Sep 17 00:00:00 2001 From: Larry Bradley Date: Fri, 12 May 2023 13:59:41 -0400 Subject: [PATCH 2/5] Change API for CurveOfGrowth --- photutils/profiles/curve_of_growth.py | 82 ++++++------- .../profiles/tests/test_curve_of_growth.py | 112 +++++++----------- 2 files changed, 77 insertions(+), 117 deletions(-) diff --git a/photutils/profiles/curve_of_growth.py b/photutils/profiles/curve_of_growth.py index f2b3f08f6..7347ada07 100644 --- a/photutils/profiles/curve_of_growth.py +++ b/photutils/profiles/curve_of_growth.py @@ -2,7 +2,7 @@ """ This module provides tools for generating curves of growth. """ - +import numpy as np from astropy.utils import lazyproperty from photutils.profiles.core import ProfileBase @@ -28,15 +28,11 @@ class CurveOfGrowth(ProfileBase): xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. - min_radius : float - The minimum radius for the profile. Must be greater than or - equal to zero. - - max_radius : float - The maximum radius for the profile. - - radius_step : float - The radial step size in pixels. + radii : 1D float `numpy.ndarray` + An array of the circular radii. ``radii`` must be strictly + increasing with a minimum value greater than zero, and contain + at least 2 values. The radial spacing does not need to be + constant. error : 2D `numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed @@ -101,31 +97,26 @@ class CurveOfGrowth(ProfileBase): Create the curve of growth. >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) - >>> min_radius = 0.0 - >>> max_radius = 25.0 - >>> radius_step = 1.0 - >>> cog = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - ... error=error, mask=None) + >>> radii = np.arange(1, 26) + >>> cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) >>> print(cog.radius) # doctest: +FLOAT_CMP - [ 0. 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. - 18. 19. 20. 21. 22. 23. 24. 25.] + [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 + 25] >>> print(cog.profile) # doctest: +FLOAT_CMP - [ 0. 130.57472018 501.34744442 1066.59182074 1760.50163608 - 2502.13955554 3218.50667597 3892.81448231 4455.36403436 4869.66609313 - 5201.99745378 5429.02043984 5567.28370644 5659.24831854 5695.06577065 - 5783.46217755 5824.01080702 5825.59003768 5818.22316662 5866.52307412 - 5896.96917375 5948.92254787 5968.30540534 5931.15611704 5941.94457249 - 5942.06535486] + [ 130.57472018 501.34744442 1066.59182074 1760.50163608 2502.13955554 + 3218.50667597 3892.81448231 4455.36403436 4869.66609313 5201.99745378 + 5429.02043984 5567.28370644 5659.24831854 5695.06577065 5783.46217755 + 5824.01080702 5825.59003768 5818.22316662 5866.52307412 5896.96917375 + 5948.92254787 5968.30540534 5931.15611704 5941.94457249 5942.06535486] >>> print(cog.profile_error) # doctest: +FLOAT_CMP - [ 0. 5.32777186 9.37111012 13.41750992 16.62928904 - 21.7350922 25.39862532 30.3867526 34.11478867 39.28263973 - 43.96047829 48.11931395 52.00967328 55.7471834 60.48824739 - 64.81392778 68.71042311 72.71899201 76.54959872 81.33806741 - 85.98568713 91.34841248 95.5173253 99.22190499 102.51980185 - 106.83601366] + [ 5.32777186 9.37111012 13.41750992 16.62928904 21.7350922 + 25.39862532 30.3867526 34.11478867 39.28263973 43.96047829 + 48.11931395 52.00967328 55.7471834 60.48824739 64.81392778 + 68.71042311 72.71899201 76.54959872 81.33806741 85.98568713 + 91.34841248 95.5173253 99.22190499 102.51980185 106.83601366] Plot the curve of growth. @@ -151,11 +142,8 @@ class CurveOfGrowth(ProfileBase): xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - cog = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + radii = np.arange(1, 26) + cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the curve of growth cog.plot() @@ -185,11 +173,8 @@ class CurveOfGrowth(ProfileBase): xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - cog = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + radii = np.arange(1, 26) + cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the curve of growth cog.normalize() @@ -220,11 +205,8 @@ class CurveOfGrowth(ProfileBase): xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - cog = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + radii = np.arange(1, 26) + cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) @@ -233,9 +215,15 @@ class CurveOfGrowth(ProfileBase): cog.apertures[10].plot(color='C1', lw=2) """ - @lazyproperty - def _circular_radii(self): - return self.radius + def __init__(self, data, xycen, radii, *, error=None, mask=None, + method='exact', subpixels=5): + + radii = np.array(radii) + if radii.min() <= 0: + raise ValueError('radii must be > 0') + + super().__init__(data, xycen, radii, error=error, mask=mask, + method=method, subpixels=subpixels) @lazyproperty def apertures(self): diff --git a/photutils/profiles/tests/test_curve_of_growth.py b/photutils/profiles/tests/test_curve_of_growth.py index 8e94ce88c..3b75f8691 100644 --- a/photutils/profiles/tests/test_curve_of_growth.py +++ b/photutils/profiles/tests/test_curve_of_growth.py @@ -38,27 +38,20 @@ def fixture_profile_data(): def test_curve_of_growth(profile_data): xycen, data, _, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - cg1 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + radii = np.arange(1, 37) + cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) - assert_equal(cg1.radius, np.arange(36)) + assert_equal(cg1.radius, radii) assert cg1.area.shape == (36,) assert cg1.profile.shape == (36,) assert cg1.profile_error.shape == (0,) - assert cg1.area[0] == 0.0 + assert_allclose(cg1.area[0], np.pi) assert len(cg1.apertures) == 36 - assert cg1.apertures[0] is None - assert isinstance(cg1.apertures[1], CircularAperture) - - min_radius = 1.0 - max_radius = 35.0 - radius_step = 1.0 - cg2 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + assert isinstance(cg1.apertures[0], CircularAperture) + + radii = np.arange(1, 36) + cg2 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) assert cg2.area[0] > 0.0 assert isinstance(cg2.apertures[0], CircularAperture) @@ -66,44 +59,34 @@ def test_curve_of_growth(profile_data): def test_curve_of_growth_units(profile_data): xycen, data, error, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 + radii = np.arange(1, 36) unit = u.Jy - cg1 = CurveOfGrowth(data << unit, xycen, min_radius, max_radius, - radius_step, error=error << unit, mask=None) + cg1 = CurveOfGrowth(data << unit, xycen, radii, error=error << unit, + mask=None) assert cg1.profile.unit == unit assert cg1.profile_error.unit == unit with pytest.raises(ValueError): - CurveOfGrowth(data << unit, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + CurveOfGrowth(data << unit, xycen, radii, error=error, mask=None) def test_curve_of_growth_error(profile_data): xycen, data, error, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - cg1 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + radii = np.arange(1, 36) + cg1 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) - assert cg1.profile.shape == (36,) - assert cg1.profile_error.shape == (36,) + assert cg1.profile.shape == (35,) + assert cg1.profile_error.shape == (35,) def test_curve_of_growth_mask(profile_data): xycen, data, error, mask = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - cg1 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) - cg2 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=mask) + radii = np.arange(1, 36) + cg1 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) + cg2 = CurveOfGrowth(data, xycen, radii, error=error, mask=mask) assert cg1.profile.sum() > cg2.profile.sum() assert np.sum(cg1.profile_error**2) > np.sum(cg2.profile_error**2) @@ -112,11 +95,8 @@ def test_curve_of_growth_mask(profile_data): def test_curve_of_growth_normalize(profile_data): xycen, data, _, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - cg1 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + radii = np.arange(1, 36) + cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) profile1 = cg1.profile cg1.normalize() @@ -139,49 +119,41 @@ def test_curve_of_growth_normalize(profile_data): def test_curve_of_growth_inputs(profile_data): xycen, data, error, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 + msg = 'radii must be > 0' + with pytest.raises(ValueError, match=msg): + radii = np.arange(10) + CurveOfGrowth(data, xycen, radii, error=None, mask=None) - with pytest.raises(ValueError): - CurveOfGrowth(data, xycen, -1, max_radius, radius_step, error=None, + msg = 'radii must be a 1D array and have at least two values' + with pytest.raises(ValueError, match=msg): + CurveOfGrowth(data, xycen, [1], error=None, mask=None) + with pytest.raises(ValueError, match=msg): + CurveOfGrowth(data, xycen, np.arange(1, 7).reshape(2, 3), error=None, mask=None) - with pytest.raises(ValueError): - CurveOfGrowth(data, xycen, 10.0, 1.0, radius_step, error=None, - mask=None) - with pytest.raises(ValueError): - CurveOfGrowth(data, xycen, min_radius, max_radius, -1.0, error=None, - mask=None) - with pytest.raises(ValueError): - CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=np.ones((3, 3)), mask=None) - with pytest.raises(ValueError): - CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=np.ones((3, 3))) + + msg = 'radii must be strictly increasing' + with pytest.raises(ValueError, match=msg): + radii = np.arange(1, 10)[::-1] + CurveOfGrowth(data, xycen, radii, error=None, mask=None) with pytest.raises(ValueError): unit1 = u.Jy unit2 = u.km - CurveOfGrowth(data << unit1, xycen, min_radius, max_radius, - radius_step, error=error << unit2) + radii = np.arange(1, 36) + CurveOfGrowth(data << unit1, xycen, radii, error=error << unit2) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_curve_of_growth_plot(profile_data): xycen, data, error, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - - cg1 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + radii = np.arange(1, 36) + cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg1.plot() with pytest.warns(AstropyUserWarning, match='Errors were not input'): cg1.plot_error() - cg2 = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + cg2 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) cg2.plot() pc1 = cg2.plot_error() assert_allclose(pc1.get_facecolor(), [[0.5, 0.5, 0.5, 0.3]]) @@ -189,7 +161,7 @@ def test_curve_of_growth_plot(profile_data): assert_allclose(pc2.get_facecolor(), [[0, 0, 1, 1]]) unit = u.Jy - cg3 = CurveOfGrowth(data << unit, xycen, min_radius, max_radius, - radius_step, error=error << unit, mask=None) + cg3 = CurveOfGrowth(data << unit, xycen, radii, error=error << unit, + mask=None) cg3.plot() cg3.plot_error() From db995657018e97e72186a8dfa1c21fc7cba00680 Mon Sep 17 00:00:00 2001 From: Larry Bradley Date: Fri, 12 May 2023 14:00:25 -0400 Subject: [PATCH 3/5] Remove EdgeRadialProfile; change API of RadialProfile --- photutils/profiles/radial_profile.py | 427 +++--------------- .../profiles/tests/test_radial_profile.py | 134 ++---- 2 files changed, 100 insertions(+), 461 deletions(-) diff --git a/photutils/profiles/radial_profile.py b/photutils/profiles/radial_profile.py index ded9d5fce..e36b84ca3 100644 --- a/photutils/profiles/radial_profile.py +++ b/photutils/profiles/radial_profile.py @@ -3,7 +3,6 @@ This module provides tools for generating radial profiles. """ -import math import warnings import numpy as np @@ -14,9 +13,9 @@ from photutils.profiles.core import ProfileBase -__all__ = ['RadialProfile', 'EdgeRadialProfile'] +__all__ = ['RadialProfile'] -__doctest_requires__ = {('RadialProfile', 'EdgeRadialProfile'): ['scipy']} +__doctest_requires__ = {('RadialProfile'): ['scipy']} class RadialProfile(ProfileBase): @@ -27,6 +26,10 @@ class RadialProfile(ProfileBase): The radial profile represents the azimuthally-averaged flux in circular annuli apertures as a function of radius. + For this class, the input radii represent the edges of the radial + bins. This differs from the `RadialProfile` class, where the inputs + represent the centers of the radial bins. + Parameters ---------- data : 2D `numpy.ndarray` @@ -37,16 +40,12 @@ class RadialProfile(ProfileBase): xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. - min_radius : float - The minimum radius for the profile. This radius is the minimum - radial bin center, not the edge. - - max_radius : float - The maximum radius for the profile. This radius is the maximum - radial bin center, not the edge. - - radius_step : float - The radial step size in pixels. + edge_radii : 1D float `numpy.ndarray` + An array of radii defining the edges of the radial bins. + ``edge_radii`` must be strictly increasing with a minimum value + greater than or equal to zero, and contain at least 2 values. + The radial spacing does not need to be constant. The output + `radius` attribute will be defined at the bin centers. error : 2D `numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed @@ -92,20 +91,13 @@ class RadialProfile(ProfileBase): See Also -------- - EdgeRadialProfile : Allows input of the radial edges. + RadialProfile Notes ----- - Note that the ``min_radius``, ``max_radius``, and ``radius_step`` - define the radial bin centers, not the edges. As a consequence, - if the ``min_radius`` is less than or equal to half the - ``radius_step``, then a circular aperture with radius equal to - ``min_radius + 0.5 * radius_step`` will be used for the innermost - aperture. - - The `EdgeRadialProfile` class can be used to input an array of the - radial bin edges. For that class, the radial spacing does not need - to be constant. + If the minimum of ``edge_radii`` is zero, then a circular aperture + with radius equal to ``edge_radii[1]`` will be used for the + innermost aperture. Examples -------- @@ -128,31 +120,28 @@ class RadialProfile(ProfileBase): Create the radial profile. >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) - >>> min_radius = 0.0 - >>> max_radius = 25.0 - >>> radius_step = 1.0 - >>> rp = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - ... error=error, mask=None) + >>> edge_radii = np.arange(26) + >>> rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) >>> print(rp.radius) # doctest: +FLOAT_CMP - [ 0. 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. - 18. 19. 20. 21. 22. 23. 24. 25.] + [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 + 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5 24.5] >>> print(rp.profile) # doctest: +FLOAT_CMP - [ 4.27430150e+01 4.02150658e+01 3.81601146e+01 3.38116846e+01 - 2.89343205e+01 2.34250297e+01 1.84368533e+01 1.44310461e+01 - 9.55543388e+00 6.55415896e+00 4.49693014e+00 2.56010523e+00 - 1.50362911e+00 7.35389056e-01 6.04663625e-01 8.08820954e-01 - 2.31751912e-01 -1.39063329e-01 1.25181410e-01 4.84601845e-01 - 1.94567871e-01 4.49109676e-01 -2.00995374e-01 -7.74387397e-02 - 5.70302749e-02 -3.27578439e-02] + [ 4.15632243e+01 3.93402079e+01 3.59845746e+01 3.15540506e+01 + 2.62300757e+01 2.07297033e+01 1.65106801e+01 1.19376723e+01 + 7.75743772e+00 5.56759777e+00 3.44112671e+00 1.91350281e+00 + 1.17092981e+00 4.22261078e-01 9.70256904e-01 4.16355795e-01 + 1.52328707e-02 -6.69985111e-02 4.15522650e-01 2.48494731e-01 + 4.03348112e-01 1.43482678e-01 -2.62777461e-01 7.30653622e-02 + 7.84616804e-04] >>> print(rp.profile_error) # doctest: +FLOAT_CMP - [2.95008692 1.17855895 0.6610777 0.51902503 0.47524302 0.43072819 - 0.39770113 0.37667594 0.33909996 0.35356048 0.30377721 0.29455808 - 0.25670656 0.26599511 0.27354232 0.2430305 0.22910334 0.22204777 - 0.22327174 0.23816561 0.2343794 0.2232632 0.19893783 0.17888776 - 0.18228289 0.19680823] + [1.69588246 0.81797694 0.61132694 0.44670831 0.49499835 0.38025361 + 0.40844702 0.32906672 0.36466713 0.33059274 0.29661894 0.27314739 + 0.25551933 0.27675376 0.25553986 0.23421017 0.22966813 0.21747036 + 0.23654884 0.22760386 0.23941711 0.20661313 0.18999134 0.17469024 + 0.19527558] Plot the radial profile. @@ -178,11 +167,8 @@ class RadialProfile(ProfileBase): xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - rp = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + edge_radii = np.arange(26) + rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.plot() @@ -212,11 +198,8 @@ class RadialProfile(ProfileBase): xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - rp = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + edge_radii = np.arange(26) + rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.normalize() @@ -247,11 +230,8 @@ class RadialProfile(ProfileBase): xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - rp = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + edge_radii = np.arange(26) + rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) @@ -263,10 +243,10 @@ class RadialProfile(ProfileBase): model. >>> rp.gaussian_fit # doctest: +FLOAT_CMP - + >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP - 11.04709589620093 + 11.09260130738712 Plot the fitted 1D Gaussian on the radial profile. @@ -292,11 +272,8 @@ class RadialProfile(ProfileBase): xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - rp = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + edge_radii = np.arange(26) + rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.normalize() @@ -307,18 +284,13 @@ class RadialProfile(ProfileBase): """ @lazyproperty - def _circular_radii(self): + def radius(self): """ - The circular aperture radii for the radial bin edges (inner and - outer annulus radii). + The profile radius (bin centers) in pixels as a 1D + `~numpy.ndarray`. """ - shift = self.radius_step / 2 - min_radius = self.min_radius - shift - max_radius = self.max_radius + shift - nsteps = int(math.floor((max_radius - min_radius) - / self.radius_step)) - max_radius = min_radius + (nsteps * self.radius_step) - return np.linspace(min_radius, max_radius, nsteps + 1) + # define the radial bin centers from the radial bin edges + return (self.radii[:-1] + self.radii[1:]) / 2 @lazyproperty def apertures(self): @@ -334,13 +306,13 @@ def apertures(self): from photutils.aperture import CircularAnnulus, CircularAperture apertures = [] - for i in range(len(self._circular_radii) - 1): + for i in range(len(self.radii) - 1): try: - aperture = CircularAnnulus(self.xycen, self._circular_radii[i], - self._circular_radii[i + 1]) + aperture = CircularAnnulus(self.xycen, self.radii[i], + self.radii[i + 1]) except ValueError: # zero radius aperture = CircularAperture(self.xycen, - self._circular_radii[i + 1]) + self.radii[i + 1]) apertures.append(aperture) return apertures @@ -427,300 +399,3 @@ def gaussian_fwhm(self): Gaussian fitted to the radial profile. """ return self.gaussian_fit.stddev.value * gaussian_sigma_to_fwhm - - -class EdgeRadialProfile(RadialProfile): - """ - Class to create a radial profile using concentric circular - apertures. - - The radial profile represents the azimuthally-averaged flux in - circular annuli apertures as a function of radius. - - For this class, the input radii represent the edges of the radial - bins. This differs from the `RadialProfile` class, where the inputs - represent the centers of the radial bins. - - Parameters - ---------- - data : 2D `numpy.ndarray` - The 2D data array. The data should be background-subtracted. - Non-finite values (e.g., NaN or inf) in the ``data`` or - ``error`` array are automatically masked. - - xycen : tuple of 2 floats - The ``(x, y)`` pixel coordinate of the source center. - - edge_radii : 1D float `numpy.ndarray` - An array of radii defining the edges of the radial bins. - ``edge_radii`` must be strictly increasing with a minimum value - greater than or equal to zero, and contain at least 2 values. - The radial spacing does not need to be constant. The output - `radius` attribute will be defined at the bin centers. - - error : 2D `numpy.ndarray`, optional - The 1-sigma errors of the input ``data``. ``error`` is assumed - to include all sources of error, including the Poisson error - of the sources (see `~photutils.utils.calc_total_error`) . - ``error`` must have the same shape as the input ``data``. - Non-finite values (e.g., NaN or inf) in the ``data`` or - ``error`` array are automatically masked. - - mask : 2D bool `numpy.ndarray`, optional - A boolean mask with the same shape as ``data`` where a `True` - value indicates the corresponding element of ``data`` is masked. - Masked data are excluded from all calculations. - - method : {'exact', 'center', 'subpixel'}, optional - The method used to determine the overlap of the aperture on the - pixel grid: - - * ``'exact'`` (default): - The the exact fractional overlap of the aperture and each - pixel is calculated. The aperture weights will contain - values between 0 and 1. - - * ``'center'``: - A pixel is considered to be entirely in or out of the - aperture depending on whether its center is in or out of - the aperture. The aperture weights will contain values - only of 0 (out) and 1 (in). - - * ``'subpixel'``: - A pixel is divided into subpixels (see the ``subpixels`` - keyword), each of which are considered to be entirely in - or out of the aperture depending on whether its center is - in or out of the aperture. If ``subpixels=1``, this method - is equivalent to ``'center'``. The aperture weights will - contain values between 0 and 1. - - subpixels : int, optional - For the ``'subpixel'`` method, resample pixels by this factor - in each dimension. That is, each pixel is divided into - ``subpixels**2`` subpixels. This keyword is ignored unless - ``method='subpixel'``. - - See Also - -------- - RadialProfile - - Notes - ----- - If the minimum of ``edge_radii`` is zero, then a circular aperture - with radius equal to ``edge_radii[1]`` will be used for the - innermost aperture. - - Examples - -------- - >>> import numpy as np - >>> from astropy.modeling.models import Gaussian2D - >>> from astropy.visualization import simple_norm - >>> from photutils.centroids import centroid_quadratic - >>> from photutils.datasets import make_noise_image - >>> from photutils.profiles import EdgeRadialProfile - - Create an artificial single source. Note that this image does not - have any background. - - >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) - >>> yy, xx = np.mgrid[0:100, 0:100] - >>> data = gmodel(xx, yy) - >>> error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) - >>> data += error - - Create the radial profile. - - >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) - >>> edge_radii = np.arange(26) - >>> rp = EdgeRadialProfile(data, xycen, edge_radii, error=error, mask=None) - - >>> print(rp.radius) # doctest: +FLOAT_CMP - [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 - 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5 24.5] - - >>> print(rp.profile) # doctest: +FLOAT_CMP - [ 4.15632243e+01 3.93402079e+01 3.59845746e+01 3.15540506e+01 - 2.62300757e+01 2.07297033e+01 1.65106801e+01 1.19376723e+01 - 7.75743772e+00 5.56759777e+00 3.44112671e+00 1.91350281e+00 - 1.17092981e+00 4.22261078e-01 9.70256904e-01 4.16355795e-01 - 1.52328707e-02 -6.69985111e-02 4.15522650e-01 2.48494731e-01 - 4.03348112e-01 1.43482678e-01 -2.62777461e-01 7.30653622e-02 - 7.84616804e-04] - - >>> print(rp.profile_error) # doctest: +FLOAT_CMP - [1.69588246 0.81797694 0.61132694 0.44670831 0.49499835 0.38025361 - 0.40844702 0.32906672 0.36466713 0.33059274 0.29661894 0.27314739 - 0.25551933 0.27675376 0.25553986 0.23421017 0.22966813 0.21747036 - 0.23654884 0.22760386 0.23941711 0.20661313 0.18999134 0.17469024 - 0.19527558] - - Plot the radial profile. - - .. plot:: - - import matplotlib.pyplot as plt - import numpy as np - from astropy.modeling.models import Gaussian2D - from astropy.visualization import simple_norm - - from photutils.centroids import centroid_quadratic - from photutils.datasets import make_noise_image - from photutils.profiles import EdgeRadialProfile - - # create an artificial single source - gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) - yy, xx = np.mgrid[0:100, 0:100] - data = gmodel(xx, yy) - error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) - data += error - - # find the source centroid - xycen = centroid_quadratic(data, xpeak=48, ypeak=52) - - # create the radial profile - edge_radii = np.arange(26) - rp = EdgeRadialProfile(data, xycen, edge_radii, error=error, mask=None) - - # plot the radial profile - rp.plot() - rp.plot_error() - - Normalize the profile and plot the normalized radial profile. - - .. plot:: - - import matplotlib.pyplot as plt - import numpy as np - from astropy.modeling.models import Gaussian2D - from astropy.visualization import simple_norm - - from photutils.centroids import centroid_quadratic - from photutils.datasets import make_noise_image - from photutils.profiles import EdgeRadialProfile - - # create an artificial single source - gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) - yy, xx = np.mgrid[0:100, 0:100] - data = gmodel(xx, yy) - error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) - data += error - - # find the source centroid - xycen = centroid_quadratic(data, xpeak=48, ypeak=52) - - # create the radial profile - edge_radii = np.arange(26) - rp = EdgeRadialProfile(data, xycen, edge_radii, error=error, mask=None) - - # plot the radial profile - rp.normalize() - rp.plot() - rp.plot_error() - - Plot two of the annulus apertures on the data. - - .. plot:: - - import matplotlib.pyplot as plt - import numpy as np - from astropy.modeling.models import Gaussian2D - from astropy.visualization import simple_norm - - from photutils.centroids import centroid_quadratic - from photutils.datasets import make_noise_image - from photutils.profiles import EdgeRadialProfile - - # create an artificial single source - gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) - yy, xx = np.mgrid[0:100, 0:100] - data = gmodel(xx, yy) - error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) - data += error - - # find the source centroid - xycen = centroid_quadratic(data, xpeak=48, ypeak=52) - - # create the radial profile - edge_radii = np.arange(26) - rp = EdgeRadialProfile(data, xycen, edge_radii, error=error, mask=None) - - norm = simple_norm(data, 'sqrt') - plt.figure(figsize=(5, 5)) - plt.imshow(data, norm=norm) - rp.apertures[5].plot(color='C0', lw=2) - rp.apertures[10].plot(color='C1', lw=2) - - Fit a 1D Gaussian to the radial profile and return the Gaussian - model. - - >>> rp.gaussian_fit # doctest: +FLOAT_CMP - - - >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP - 11.09260130738712 - - Plot the fitted 1D Gaussian on the radial profile. - - .. plot:: - - import matplotlib.pyplot as plt - import numpy as np - from astropy.modeling.models import Gaussian2D - from astropy.visualization import simple_norm - - from photutils.centroids import centroid_quadratic - from photutils.datasets import make_noise_image - from photutils.profiles import EdgeRadialProfile - - # create an artificial single source - gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) - yy, xx = np.mgrid[0:100, 0:100] - data = gmodel(xx, yy) - error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) - data += error - - # find the source centroid - xycen = centroid_quadratic(data, xpeak=48, ypeak=52) - - # create the radial profile - edge_radii = np.arange(26) - rp = EdgeRadialProfile(data, xycen, edge_radii, error=error, mask=None) - - # plot the radial profile - rp.normalize() - rp.plot(label='Radial Profile') - rp.plot_error() - plt.plot(rp.radius, rp.gaussian_profile, label='Gaussian Fit') - plt.legend() - """ - - def __init__(self, data, xycen, edge_radii, *, error=None, mask=None, - method='exact', subpixels=5): - super().__init__(data, xycen, None, None, None, error=error, mask=mask, - method=method, subpixels=subpixels) - - self.edge_radii = self._validate_edge_radii(edge_radii) - - def _validate_edge_radii(self, edge_radii): - edge_radii = np.array(edge_radii) - if edge_radii.ndim != 1 or edge_radii.size < 2: - raise ValueError('edge_radii must be a 1D array and have at ' - 'least two values') - if edge_radii.min() < 0: - raise ValueError('minimum edge_radii must be >= 0') - - if not np.all(edge_radii[1:] > edge_radii[:-1]): - raise ValueError('edge_radii must be strictly increasing') - - return edge_radii - - @lazyproperty - def radius(self): - """ - The profile radius in pixels as a 1D `~numpy.ndarray`. - """ - return (self.edge_radii[:-1] + self.edge_radii[1:]) / 2 - - @lazyproperty - def _circular_radii(self): - return self.edge_radii diff --git a/photutils/profiles/tests/test_radial_profile.py b/photutils/profiles/tests/test_radial_profile.py index 66545ec27..0e0fad6da 100644 --- a/photutils/profiles/tests/test_radial_profile.py +++ b/photutils/profiles/tests/test_radial_profile.py @@ -11,7 +11,7 @@ from numpy.testing import assert_allclose, assert_equal from photutils.aperture import CircularAnnulus, CircularAperture -from photutils.profiles import EdgeRadialProfile, RadialProfile +from photutils.profiles import RadialProfile from photutils.utils._optional_deps import HAS_SCIPY @@ -38,127 +38,101 @@ def fixture_profile_data(): def test_radial_profile(profile_data): xycen, data, _, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + edge_radii = np.arange(36) + rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) - assert_equal(rp1.radius, np.arange(36)) - assert rp1.area.shape == (36,) - assert rp1.profile.shape == (36,) + assert_equal(rp1.radius, np.arange(35) + 0.5) + assert rp1.area.shape == (35,) + assert rp1.profile.shape == (35,) assert rp1.profile_error.shape == (0,) assert rp1.area[0] > 0.0 - assert len(rp1.apertures) == 36 + assert len(rp1.apertures) == 35 assert isinstance(rp1.apertures[0], CircularAperture) assert isinstance(rp1.apertures[1], CircularAnnulus) - min_radius = 0.7 - max_radius = 35.0 - radius_step = 1.0 - rp2 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + edge_radii = np.arange(36) + 0.1 + rp2 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp2.apertures[0], CircularAnnulus) -def test_edge_radial_profile(profile_data): - xycen, data, _, _ = profile_data - - edge_radii = np.arange(37) - rp1 = EdgeRadialProfile(data, xycen, edge_radii, error=None, mask=None) - - assert_equal(rp1.radius, np.arange(36) + 0.5) - assert rp1.area.shape == (36,) - assert rp1.profile.shape == (36,) - assert rp1.profile_error.shape == (0,) - assert_allclose(rp1.area[0], np.pi) - - assert len(rp1.apertures) == 36 - assert isinstance(rp1.apertures[0], CircularAperture) - assert isinstance(rp1.apertures[1], CircularAnnulus) - - edge_radii = np.arange(1, 36) - rp2 = EdgeRadialProfile(data, xycen, edge_radii, error=None, mask=None) - assert isinstance(rp2.apertures[0], CircularAnnulus) - - -def test_edge_radial_profile_inputs(profile_data): +def test_radial_profile_inputs(profile_data): xycen, data, _, _ = profile_data msg = 'minimum edge_radii must be >= 0' with pytest.raises(ValueError, match=msg): edge_radii = np.arange(-1, 10) - EdgeRadialProfile(data, xycen, edge_radii, error=None, mask=None) + RadialProfile(data, xycen, edge_radii, error=None, mask=None) msg = 'edge_radii must be a 1D array and have at least two values' with pytest.raises(ValueError, match=msg): edge_radii = [1] - EdgeRadialProfile(data, xycen, edge_radii, error=None, mask=None) + RadialProfile(data, xycen, edge_radii, error=None, mask=None) with pytest.raises(ValueError, match=msg): edge_radii = np.arange(6).reshape(2, 3) - EdgeRadialProfile(data, xycen, edge_radii, error=None, mask=None) + RadialProfile(data, xycen, edge_radii, error=None, mask=None) msg = 'edge_radii must be strictly increasing' with pytest.raises(ValueError, match=msg): edge_radii = np.arange(10)[::-1] - EdgeRadialProfile(data, xycen, edge_radii, error=None, mask=None) + RadialProfile(data, xycen, edge_radii, error=None, mask=None) + + msg = 'error must have the same shape as data' + with pytest.raises(ValueError, match=msg): + edge_radii = np.arange(10) + RadialProfile(data, xycen, edge_radii, error=np.ones(3), mask=None) + + msg = 'mask must have the same shape as data' + with pytest.raises(ValueError, match=msg): + edge_radii = np.arange(10) + mask = np.ones(3, dtype=bool) + RadialProfile(data, xycen, edge_radii, error=None, mask=mask) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_radial_profile_gaussian(profile_data): xycen, data, _, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + edge_radii = np.arange(36) + rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp1.gaussian_fit, Gaussian1D) - assert rp1.gaussian_profile.shape == (36,) + assert rp1.gaussian_profile.shape == (35,) assert rp1.gaussian_fwhm < 23.6 - max_radius = 200 - rp2 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=None) + edge_radii = np.arange(201) + rp2 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp2.gaussian_fit, Gaussian1D) - assert rp2.gaussian_profile.shape == (201,) + assert rp2.gaussian_profile.shape == (200,) assert rp2.gaussian_fwhm < 23.6 def test_radial_profile_unit(profile_data): xycen, data, error, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 + edge_radii = np.arange(36) unit = u.Jy - rp1 = RadialProfile(data << unit, xycen, min_radius, max_radius, - radius_step, error=error << unit, mask=None) + rp1 = RadialProfile(data << unit, xycen, edge_radii, error=error << unit, + mask=None) assert rp1.profile.unit == unit assert rp1.profile_error.unit == unit with pytest.raises(ValueError): - RadialProfile(data << unit, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + RadialProfile(data << unit, xycen, edge_radii, error=error, mask=None) def test_radial_profile_error(profile_data): xycen, data, error, _ = profile_data - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + edge_radii = np.arange(36) + rp1 = RadialProfile(data, xycen, edge_radii, error=error, mask=None) - assert_equal(rp1.radius, np.arange(36)) - assert rp1.area.shape == (36,) - assert rp1.profile.shape == (36,) - assert rp1.profile_error.shape == (36,) + assert_equal(rp1.radius, np.arange(35) + 0.5) + assert rp1.area.shape == (35,) + assert rp1.profile.shape == (35,) + assert rp1.profile_error.shape == (35,) - assert len(rp1.apertures) == 36 + assert len(rp1.apertures) == 35 assert isinstance(rp1.apertures[0], CircularAperture) assert isinstance(rp1.apertures[1], CircularAnnulus) @@ -170,11 +144,8 @@ def test_radial_profile_normalize_nan(profile_data): """ xycen, data, _, _ = profile_data - min_radius = 0.0 - max_radius = 100.0 - radius_step = 1.0 - - rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step) + edge_radii = np.arange(101) + rp1 = RadialProfile(data, xycen, edge_radii) rp1.normalize() assert not np.isnan(rp1.profile[0]) @@ -185,26 +156,19 @@ def test_radial_profile_nonfinite(profile_data): data2[40, 40] = np.nan mask = ~np.isfinite(data2) - min_radius = 0.0 - max_radius = 35.0 - radius_step = 1.0 - - rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=None, mask=mask) + edge_radii = np.arange(36) + rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=mask) - rp2 = RadialProfile(data2, xycen, min_radius, max_radius, radius_step, - error=error, mask=mask) + rp2 = RadialProfile(data2, xycen, edge_radii, error=error, mask=mask) assert_allclose(rp1.profile, rp2.profile) msg = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=msg): - rp3 = RadialProfile(data2, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + rp3 = RadialProfile(data2, xycen, edge_radii, error=error, mask=None) assert_allclose(rp1.profile, rp3.profile) error2 = error.copy() error2[40, 40] = np.inf with pytest.warns(AstropyUserWarning, match=msg): - rp4 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error2, mask=None) + rp4 = RadialProfile(data, xycen, edge_radii, error=error2, mask=None) assert_allclose(rp1.profile, rp4.profile) From 7b665836f8155852eb0702e6293dbf28ee5ef6ac Mon Sep 17 00:00:00 2001 From: Larry Bradley Date: Fri, 12 May 2023 14:10:42 -0400 Subject: [PATCH 4/5] Update narrative docs for profile subpackage --- docs/profiles.rst | 183 ++++++++++++++++------------------------------ 1 file changed, 61 insertions(+), 122 deletions(-) diff --git a/docs/profiles.rst b/docs/profiles.rst index bb3a880f8..e97291d82 100644 --- a/docs/profiles.rst +++ b/docs/profiles.rst @@ -7,7 +7,7 @@ Introduction ------------ `photutils.profiles` provides tools to calculate radial profiles and -curves of growth using concentric apertures. +curves of growth using concentric circular apertures. Preliminaries @@ -50,14 +50,10 @@ data before creating a radial profile or curve of growth. Creating a Radial Profile ------------------------- -Photutils provides two classes for computing radial profiles. The -classes have the same functionality, but differ in how the radial bins -are input. The `~photutils.profiles.RadialProfile` radial bins are -computed using inputs (minimum, maximum, and step size) defined as -the radial bin centers. The `~photutils.profiles.EdgeRadialProfile` -class allows the user to directly input the radial bin edges. -Also, the radial spacing does not need to be constant for -`~photutils.profiles.EdgeRadialProfile` class. +Photutils provides the :class:`~photutils.profiles.RadialProfile` class +for computing radial profiles. The radial bins are defined by inputting +a 1D array of radial edges. The radial spacing does not need to be +constant. First, we'll use the `~photutils.centroids.centroid_quadratic` function to find the source centroid:: @@ -67,53 +63,35 @@ to find the source centroid:: >>> print(xycen) # doctest: +FLOAT_CMP [47.61226319 52.04668132] -Now, let's create radial profiles using both classes. The radial -profiles will be centered at our centroid position computed above. - -For the `~photutils.profiles.RadialProfile` class the profile will be -computed over the radial range from ``min_radius`` to ``max_radius`` -with steps of ``radius_step`` (note these are the centers of the radial -bins):: +Now, let's create a radial profile. The radial profile will be centered +at our centroid position computed above. >>> from photutils.profiles import RadialProfile - >>> min_radius = 0.0 - >>> max_radius = 25.0 - >>> radius_step = 1.0 - >>> rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - ... error=error, mask=None) - -For the `~photutils.profiles.EdgeRadialProfile` class the profile will be -computed using the input edge radii:: - - >>> from photutils.profiles import EdgeRadialProfile - >>> edge_radii = np.arange(26) - >>> rp2 = EdgeRadialProfile(data, xycen, edge_radii, error=error, - ... mask=None) + >>> edge_radii = np.arange(25) + >>> rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) -The `~photutils.profiles.RadialProfile.radius`, +The `~photutils.profiles.RadialProfile.radius` (radial bin centers), `~photutils.profiles.RadialProfile.profile`, and `~photutils.profiles.RadialProfile.profile_error` attributes contain the output 1D `~numpy.ndarray` objects:: - >>> print(rp1.radius) # doctest: +FLOAT_CMP - [ 0. 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. - 18. 19. 20. 21. 22. 23. 24. 25.] - - >>> print(rp1.profile) # doctest: +FLOAT_CMP - [ 4.27430150e+01 4.02150658e+01 3.81601146e+01 3.38116846e+01 - 2.89343205e+01 2.34250297e+01 1.84368533e+01 1.44310461e+01 - 9.55543388e+00 6.55415896e+00 4.49693014e+00 2.56010523e+00 - 1.50362911e+00 7.35389056e-01 6.04663625e-01 8.08820954e-01 - 2.31751912e-01 -1.39063329e-01 1.25181410e-01 4.84601845e-01 - 1.94567871e-01 4.49109676e-01 -2.00995374e-01 -7.74387397e-02 - 5.70302749e-02 -3.27578439e-02] - - >>> print(rp1.profile_error) # doctest: +FLOAT_CMP - [2.95008692 1.17855895 0.6610777 0.51902503 0.47524302 0.43072819 - 0.39770113 0.37667594 0.33909996 0.35356048 0.30377721 0.29455808 - 0.25670656 0.26599511 0.27354232 0.2430305 0.22910334 0.22204777 - 0.22327174 0.23816561 0.2343794 0.2232632 0.19893783 0.17888776 - 0.18228289 0.19680823] + >>> print(rp.radius) # doctest: +FLOAT_CMP + [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 + 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5] + + >>> print(rp.profile) # doctest: +FLOAT_CMP + [ 4.15632243e+01 3.93402079e+01 3.59845746e+01 3.15540506e+01 + 2.62300757e+01 2.07297033e+01 1.65106801e+01 1.19376723e+01 + 7.75743772e+00 5.56759777e+00 3.44112671e+00 1.91350281e+00 + 1.17092981e+00 4.22261078e-01 9.70256904e-01 4.16355795e-01 + 1.52328707e-02 -6.69985111e-02 4.15522650e-01 2.48494731e-01 + 4.03348112e-01 1.43482678e-01 -2.62777461e-01 7.30653622e-02] + + >>> print(rp.profile_error) # doctest: +FLOAT_CMP + [1.69588246 0.81797694 0.61132694 0.44670831 0.49499835 0.38025361 + 0.40844702 0.32906672 0.36466713 0.33059274 0.29661894 0.27314739 + 0.25551933 0.27675376 0.25553986 0.23421017 0.22966813 0.21747036 + 0.23654884 0.22760386 0.23941711 0.20661313 0.18999134 0.17469024] If desired, the radial profiles can be normalized using the :meth:`~photutils.profiles.RadialProfile.normalize` method. @@ -123,10 +101,8 @@ error: .. doctest-skip:: - >>> rp1.plot(label='RadialProfile') - >>> rp1.plot_error() - >>> rp2.plot(label='EdgeRadialProfile') - >>> rp2.plot_error() + >>> rp.plot(label='Radial Profile') + >>> rp.plot_error() .. plot:: @@ -135,7 +111,7 @@ error: from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image - from photutils.profiles import EdgeRadialProfile, RadialProfile + from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) @@ -148,20 +124,12 @@ error: xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) - edge_radii = np.arange(26) - rp2 = EdgeRadialProfile(data, xycen, edge_radii, error=error, mask=None) + rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile - rp1.plot(label='RadialProfile') - rp1.plot_error() - rp2.plot(label='EdgeRadialProfile') - rp2.plot_error() + rp.plot(label='Radial Profile') + rp.plot_error() plt.legend() The `~photutils.profiles.RadialProfile.apertures` attribute contains a @@ -190,11 +158,8 @@ list of the apertures. Let's plot two of the annulus apertures for the xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - rp = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + edge_radii = np.arange(26) + rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) @@ -209,12 +174,7 @@ profile and return the Gaussian model using the .. doctest-requires:: scipy - >>> rp1.gaussian_fit # doctest: +FLOAT_CMP - - -.. doctest-requires:: scipy - - >>> rp2.gaussian_fit # doctest: +FLOAT_CMP + >>> rp.gaussian_fit # doctest: +FLOAT_CMP The FWHM of the fitted 1D Gaussian model is stored in the @@ -222,12 +182,7 @@ The FWHM of the fitted 1D Gaussian model is stored in the .. doctest-requires:: scipy - >>> print(rp1.gaussian_fwhm) # doctest: +FLOAT_CMP - 11.04709589620093 - -.. doctest-requires:: scipy - - >>> print(rp2.gaussian_fwhm) # doctest: +FLOAT_CMP + >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.09260130738712 Finally, let's plot the fitted 1D Gaussian model for the @@ -254,16 +209,13 @@ class:`~photutils.profiles.RadialProfile` radial profile: xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - rp1 = RadialProfile(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + edge_radii = np.arange(26) + rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile - rp1.plot(label='Radial Profile') - rp1.plot_error() - plt.plot(rp1.radius, rp1.gaussian_profile, label='Gaussian Fit') + rp.plot(label='Radial Profile') + rp.plot_error() + plt.plot(rp.radius, rp.gaussian_profile, label='Gaussian Fit') plt.legend() @@ -276,16 +228,11 @@ Now let's create a curve of growth using the defined above and the same source centroid. The curve of growth will be centered at our centroid position. It will -be computed over the radial range from ``min_radius`` to ``max_radius`` -with steps of ``radius_step``:: +be computed over the radial range given by the input ``radii`` array:: >>> from photutils.profiles import CurveOfGrowth - >>> min_radius = 0.0 - >>> max_radius = 25.0 - >>> radius_step = 1.0 - >>> cog = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - ... error=error, mask=None) - + >>> radii = np.arange(1, 26) + >>> cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) The `~photutils.profiles.CurveOfGrowth` instance has `~photutils.profiles.CurveOfGrowth.radius`, @@ -294,24 +241,22 @@ has `~photutils.profiles.CurveOfGrowth.radius`, contain 1D `~numpy.ndarray` objects:: >>> print(cog.radius) # doctest: +FLOAT_CMP - [ 0. 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. - 18. 19. 20. 21. 22. 23. 24. 25.] + [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 + 25] >>> print(cog.profile) # doctest: +FLOAT_CMP - [ 0. 130.57472018 501.34744442 1066.59182074 1760.50163608 - 2502.13955554 3218.50667597 3892.81448231 4455.36403436 4869.66609313 - 5201.99745378 5429.02043984 5567.28370644 5659.24831854 5695.06577065 - 5783.46217755 5824.01080702 5825.59003768 5818.22316662 5866.52307412 - 5896.96917375 5948.92254787 5968.30540534 5931.15611704 5941.94457249 - 5942.06535486] + [ 130.57472018 501.34744442 1066.59182074 1760.50163608 2502.13955554 + 3218.50667597 3892.81448231 4455.36403436 4869.66609313 5201.99745378 + 5429.02043984 5567.28370644 5659.24831854 5695.06577065 5783.46217755 + 5824.01080702 5825.59003768 5818.22316662 5866.52307412 5896.96917375 + 5948.92254787 5968.30540534 5931.15611704 5941.94457249 5942.06535486] >>> print(cog.profile_error) # doctest: +FLOAT_CMP - [ 0. 5.32777186 9.37111012 13.41750992 16.62928904 - 21.7350922 25.39862532 30.3867526 34.11478867 39.28263973 - 43.96047829 48.11931395 52.00967328 55.7471834 60.48824739 - 64.81392778 68.71042311 72.71899201 76.54959872 81.33806741 - 85.98568713 91.34841248 95.5173253 99.22190499 102.51980185 - 106.83601366] + [ 5.32777186 9.37111012 13.41750992 16.62928904 21.7350922 + 25.39862532 30.3867526 34.11478867 39.28263973 43.96047829 + 48.11931395 52.00967328 55.7471834 60.48824739 64.81392778 + 68.71042311 72.71899201 76.54959872 81.33806741 85.98568713 + 91.34841248 95.5173253 99.22190499 102.51980185 106.83601366] If desired, the curve of growth profile can be normalized using the :meth:`~photutils.profiles.RadialProfile.normalize` method. @@ -344,11 +289,8 @@ error: xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - cog = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + radii = np.arange(1, 26) + cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the radial profile cog.plot() @@ -379,11 +321,8 @@ list of the apertures. Let's plot a couple of the apertures on the data: xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile - min_radius = 0.0 - max_radius = 25.0 - radius_step = 1.0 - cog = CurveOfGrowth(data, xycen, min_radius, max_radius, radius_step, - error=error, mask=None) + radii = np.arange(1, 26) + cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) From 9534fae7addf0a9ecad4b7b36e55b1c3576da57b Mon Sep 17 00:00:00 2001 From: Larry Bradley Date: Fri, 12 May 2023 15:04:01 -0400 Subject: [PATCH 5/5] Add changelog entry --- CHANGES.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9cf36ee14..14a7e448f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,11 +17,6 @@ General New Features ^^^^^^^^^^^^ -- ``photutils.profiles`` - - - Added ``EdgeRadialProfile`` class defined using radial bin edges. - [#1538] - Bug Fixes ^^^^^^^^^ @@ -40,6 +35,13 @@ API Changes - Removed the ``ApertureStats`` ``unpack_nddata`` method. [#1537] +- ``photutils.profiles`` + + - The API for defining the radial bins for the ``RadialProfile`` and + ``CurveOfGrowth`` classes was changed. While the new API allows for + more flexibility, unfortunately, it is not backwards-compatible. + [#1540] + - ``photutils.segmentation`` - Removed the deprecated ``kernel`` keyword from ``detect_sources``